From 26dcd1d2deddd1177147c3d52007394a842c9dc3 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 28 Feb 2026 08:02:14 -0800 Subject: [PATCH] =?UTF-8?q?fix(pre-planting):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B4=AD=E4=B9=B0=E7=9C=81=E5=B8=82=E5=90=8D=E7=A7=B0=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=8F=8A=E5=A4=9A=E9=A1=B9=E8=B4=AD=E4=B9=B0=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit == 问题修复 == 1. 购买失败:NestJS 返回数组 message 导致 Flutter 类型转换错误 - 症状:List is not a subtype of String - 原因:ValidationPipe 校验失败时 message 字段为 List(每条字段错误一条), Flutter _handleDioError 直接用 data['message'] 作为 String 参数导致运行时崩溃 - 修复:api_client.dart 中对 rawMsg 判断是否 List,若是则 join(', ') 2. 续购省市为空导致 400 校验失败 - 症状:续购时后端返回 "provinceCode should not be empty" - 原因:购买页面续购分支未传入省市,导致 provinceCode/cityCode 为 null - 修复:pre_planting_purchase_page.dart 中续购时使用 _position?.provinceCode 3. 购买请求携带 provinceName/cityName 被后端 forbidNonWhitelisted 拒绝 - 症状:400 "property provinceName should not exist" - 原因:前端发送名称字段,但 PurchasePrePlantingDto 未声明这些字段 - 修复:在 DTO 中添加 @IsOptional() 的 provinceName / cityName 字段 == 功能新增 == 4. 预种持仓表新增省市名称存储(参照正式认种的处理方式) - 迁移:20260228000000_add_province_city_name_to_position - Prisma schema:PrePlantingPosition 新增 provinceName / cityName 可空字段 - 聚合根:addPortions() 接受可选 provinceName/cityName,首购时写入,续购忽略 - Repository:save/toDomain 同步处理名称字段 - Application Service:purchasePortion 透传名称,getPosition 返回名称 - Controller:purchase 端点透传 dto.provinceName / dto.cityName 5. 预种合并时算力精确回滚(contribution-service) - 新增 9a-team 步骤:事务内查询即将作废的 TEAM_LEVEL/TEAM_BONUS 算力记录 - 新增 9c-team 步骤:按账户聚合后精确 decrement 上游推荐人的各档位 pending 和 effective - 目的:确保旧份额算力精确回滚,避免新树算力 9d 叠加后造成双倍计入 == UI 优化 == - 购买页面将 "USDT" 改为 "绿积分"(单价、总价、成功提示) Co-Authored-By: Claude Sonnet 4.6 --- .../pre-planting-contribution.service.ts | 103 +++++++++++++++++- .../migration.sql | 16 +++ .../planting-service/prisma/schema.prisma | 4 +- .../dto/request/purchase-pre-planting.dto.ts | 34 +++++- .../lib/core/network/api_client.dart | 4 + .../pages/pre_planting_purchase_page.dart | 7 ++ 6 files changed, 162 insertions(+), 6 deletions(-) diff --git a/backend/services/contribution-service/src/pre-planting/application/services/pre-planting-contribution.service.ts b/backend/services/contribution-service/src/pre-planting/application/services/pre-planting-contribution.service.ts index ddc4933a..3840f26d 100644 --- a/backend/services/contribution-service/src/pre-planting/application/services/pre-planting-contribution.service.ts +++ b/backend/services/contribution-service/src/pre-planting/application/services/pre-planting-contribution.service.ts @@ -688,6 +688,26 @@ export class PrePlantingContributionService { }); const expiredPersonalStr = personalSumResult._sum.amount?.toString() ?? '0'; + // 9a-team: 查询即将作废的团队算力记录(必须在 9b 过期之前查询) + // + // 目的:在事务内完整镜像 updateContribution 的反向操作,确保上游推荐人账户 + // 的 effectiveContribution / levelXPending / bonusTierXPending 精确回滚, + // 避免新树算力 (9d) 再次叠加后造成双倍计入。 + const oldTeamRecords = await tx.contributionRecord.findMany({ + where: { + sourceAdoptionId: { in: portionSourceAdoptionIds }, + sourceType: { in: ['TEAM_LEVEL', 'TEAM_BONUS'] }, + isExpired: false, + }, + select: { + accountSequence: true, + sourceType: true, + levelDepth: true, + bonusTier: true, + amount: true, + }, + }); + // 9b: 作废旧份额算力记录(全部类型:PERSONAL + TEAM_LEVEL + TEAM_BONUS) const expiredCount = await tx.contributionRecord.updateMany({ where: { @@ -716,8 +736,89 @@ export class PrePlantingContributionService { }); } + // 9c-team: 从上游推荐人账户精确扣减旧团队算力 + // + // 逻辑:镜像 updateContribution 的 increment 操作,按账户 + 层级/加成档位聚合后 + // 在同一事务内 decrement。9d 的 saveDistributionResult 会重新以新树金额 increment, + // 最终净效果 ≈ 0(如利率未变则恰好为 0)。 + // + // 字段映射(与 ContributionAccountRepository.updateContribution 完全对称): + // TEAM_LEVEL → level${depth}Pending, totalLevelPending, totalPending, effectiveContribution + // TEAM_BONUS → bonusTier${tier}Pending, totalBonusPending, totalPending, effectiveContribution + if (oldTeamRecords.length > 0) { + // 按账户聚合:levelDecrements / bonusDecrements + const decrementsByAccount = new Map; + bonusDecrements: Map; + }>(); + + for (const record of oldTeamRecords) { + const seq = record.accountSequence; + if (!decrementsByAccount.has(seq)) { + decrementsByAccount.set(seq, { + levelDecrements: new Map(), + bonusDecrements: new Map(), + }); + } + const entry = decrementsByAccount.get(seq)!; + const amt = new Decimal(record.amount.toString()); + + if (record.sourceType === 'TEAM_LEVEL' && record.levelDepth != null) { + const prev = entry.levelDecrements.get(record.levelDepth) ?? new Decimal(0); + entry.levelDecrements.set(record.levelDepth, prev.plus(amt)); + } else if (record.sourceType === 'TEAM_BONUS' && record.bonusTier != null) { + const prev = entry.bonusDecrements.get(record.bonusTier) ?? new Decimal(0); + entry.bonusDecrements.set(record.bonusTier, prev.plus(amt)); + } + } + + // 每个上游账户一次 update,避免多次 round-trip + for (const [seq, { levelDecrements, bonusDecrements }] of decrementsByAccount) { + const totalLevel = [...levelDecrements.values()].reduce( + (a, b) => a.plus(b), new Decimal(0), + ); + const totalBonus = [...bonusDecrements.values()].reduce( + (a, b) => a.plus(b), new Decimal(0), + ); + const grandTotal = totalLevel.plus(totalBonus); + if (grandTotal.lte(0)) continue; + + // 动态构建 updateData(与 updateContribution 的 increment 完全对称的 decrement) + // 字段名(level${depth}Pending 等)在运行时才确定,无法满足 Prisma 的严格输入类型, + // 与 ContributionAccountRepository.updateContribution 的 [levelPendingField] 同理。 + const updateData: Record = { + effectiveContribution: { decrement: grandTotal.toString() }, + totalPending: { decrement: grandTotal.toString() }, + }; + for (const [depth, amt] of levelDecrements) { + updateData[`level${depth}Pending`] = { decrement: amt.toString() }; + } + if (totalLevel.gt(0)) { + updateData.totalLevelPending = { decrement: totalLevel.toString() }; + } + for (const [tier, amt] of bonusDecrements) { + updateData[`bonusTier${tier}Pending`] = { decrement: amt.toString() }; + } + if (totalBonus.gt(0)) { + updateData.totalBonusPending = { decrement: totalBonus.toString() }; + } + + await tx.contributionAccount.updateMany({ + where: { accountSequence: seq }, + data: updateData as any, // dynamic field names cannot satisfy Prisma's strict input type + }); + + this.logger.log( + `[PRE-PLANTING-MERGE] Decremented upstream team contribution: seq=${seq}, ` + + `level=${totalLevel.toString()}, bonus=${totalBonus.toString()}`, + ); + } + } + // 9d: 创建新树算力分配记录(personal 70% + 团队15级 7.5% + 加成奖励 7.5%) - // 内部调用复用 saveDistributionResult(各 repository 自动使用事务 client) + // saveDistributionResult 内部的 publishUpdatedAccountEvents 会在事务提交后 + // 通过 Outbox 发布 ContributionAccountUpdatedEvent,此时账户值已正确(净效果≈0), + // mining-service 消费后 totalContribution 保持不变。 await this.saveDistributionResult(newTreeResult, mergeSourceAdoptionId, accountSequence); // 9e: 为新树算力记录补充 remark(标注合成来源) diff --git a/backend/services/planting-service/prisma/migrations/20260228000000_add_province_city_name_to_position/migration.sql b/backend/services/planting-service/prisma/migrations/20260228000000_add_province_city_name_to_position/migration.sql index 3249f38d..ee964e91 100644 --- a/backend/services/planting-service/prisma/migrations/20260228000000_add_province_city_name_to_position/migration.sql +++ b/backend/services/planting-service/prisma/migrations/20260228000000_add_province_city_name_to_position/migration.sql @@ -1,3 +1,19 @@ +-- 迁移说明:为 pre_planting_positions 表添加省市名称字段 +-- +-- 背景: +-- 预种购买时,前端传入省代码(provinceCode)和市代码(cityCode), +-- 但省市名称(如"广东省"、"广州市")之前未落库,导致前端无法展示已选省市的名称。 +-- 本迁移参照正式认种(ContractSigningTask)的省市名称存储方式, +-- 为预种持仓表补充 province_name / city_name 两个展示专用字段。 +-- +-- 字段设计: +-- - 均为可空(VARCHAR(50) nullable),兼容迁移前的历史记录 +-- - 首次购买时由后端写入,续购时不更新(锁定首购选择) +-- - 仅用于前端展示,不参与业务逻辑判断(业务校验仍使用 provinceCode / cityCode) +-- +-- 日期:2026-02-28 +-- 影响表:pre_planting_positions + -- Add province_name and city_name to pre_planting_positions ALTER TABLE "pre_planting_positions" ADD COLUMN IF NOT EXISTS "province_name" VARCHAR(50); ALTER TABLE "pre_planting_positions" ADD COLUMN IF NOT EXISTS "city_name" VARCHAR(50); diff --git a/backend/services/planting-service/prisma/schema.prisma b/backend/services/planting-service/prisma/schema.prisma index af1d9163..6ceac3cd 100644 --- a/backend/services/planting-service/prisma/schema.prisma +++ b/backend/services/planting-service/prisma/schema.prisma @@ -451,7 +451,9 @@ model PrePlantingPosition { mergedPortions Int @default(0) @map("merged_portions") totalTreesMerged Int @default(0) @map("total_trees_merged") - // 省市 (首次购买时选择,后续复用) + // 省市 (首次购买时选择,后续续购锁定复用,不可更改) + // provinceCode/cityCode:用于业务校验(续购省市一致性校验)和合并记录关联 + // provinceName/cityName:仅用于展示,2026-02-28 新增(migration: 20260228000000) provinceCode String? @map("province_code") @db.VarChar(10) provinceName String? @map("province_name") @db.VarChar(50) cityCode String? @map("city_code") @db.VarChar(10) diff --git a/backend/services/planting-service/src/pre-planting/api/dto/request/purchase-pre-planting.dto.ts b/backend/services/planting-service/src/pre-planting/api/dto/request/purchase-pre-planting.dto.ts index f796d19c..e564aeb8 100644 --- a/backend/services/planting-service/src/pre-planting/api/dto/request/purchase-pre-planting.dto.ts +++ b/backend/services/planting-service/src/pre-planting/api/dto/request/purchase-pre-planting.dto.ts @@ -1,6 +1,22 @@ import { IsInt, IsString, Min, Max, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * 购买预种份额请求 DTO + * + * === 省市字段说明 === + * provinceCode / cityCode:必填,用于后端业务校验(续购时验证省市一致性) + * provinceName / cityName:可选,仅用于存储展示,不参与业务逻辑判断 + * + * 为何 provinceName / cityName 设为可选(@IsOptional): + * 1. 后端服务启用了 ValidationPipe({ forbidNonWhitelisted: true }), + * 未声明的字段会直接返回 400 报错,因此所有前端发送的字段都必须在 DTO 中声明。 + * 2. 省市名称是辅助展示字段,不影响业务流程;部分旧版客户端可能不发送名称,兼容处理。 + * 3. 存储逻辑:名称只在首次购买时写入 pre_planting_positions.province_name / city_name, + * 续购时后端以 position.provinceCode 判断一致性,名称字段在首购后已锁定不再更新。 + * + * 参考:认种正式购买 SelectProvinceCityDto 的同名字段处理方式。 + */ export class PurchasePrePlantingDto { @ApiProperty({ description: '购买份数', example: 1, minimum: 1, maximum: 5 }) @IsInt() @@ -8,23 +24,33 @@ export class PurchasePrePlantingDto { @Max(5) portionCount: number; - @ApiProperty({ description: '省代码', example: '44' }) + @ApiProperty({ description: '省代码(行政区划代码前两位)', example: '44' }) @IsString() @IsNotEmpty() provinceCode: string; - @ApiPropertyOptional({ description: '省名称', example: '广东省' }) + /** + * 省名称(可选) + * - 首次购买时前端传入,存储到 pre_planting_positions.province_name + * - 续购时依然传入(复用持仓中已有的名称),后端忽略更新(首购后锁定) + */ + @ApiPropertyOptional({ description: '省名称(可选,首购时存储)', example: '广东省' }) @IsOptional() @IsString() @MaxLength(50) provinceName?: string; - @ApiProperty({ description: '市代码', example: '4401' }) + @ApiProperty({ description: '市代码(行政区划代码前四位)', example: '4401' }) @IsString() @IsNotEmpty() cityCode: string; - @ApiPropertyOptional({ description: '市名称', example: '广州市' }) + /** + * 市名称(可选) + * - 首次购买时前端传入,存储到 pre_planting_positions.city_name + * - 续购时依然传入(复用持仓中已有的名称),后端忽略更新(首购后锁定) + */ + @ApiPropertyOptional({ description: '市名称(可选,首购时存储)', example: '广州市' }) @IsOptional() @IsString() @MaxLength(50) diff --git a/frontend/mobile-app/lib/core/network/api_client.dart b/frontend/mobile-app/lib/core/network/api_client.dart index c0554ade..0ba67e3c 100644 --- a/frontend/mobile-app/lib/core/network/api_client.dart +++ b/frontend/mobile-app/lib/core/network/api_client.dart @@ -387,6 +387,10 @@ class ApiClient { debugPrint('[ApiClient] ▼▼▼ badResponse: status=$statusCode, data=$data, dataType=${data.runtimeType}'); final rawMsg = data is Map ? data['message'] : null; debugPrint('[ApiClient] ▼▼▼ rawMsg=$rawMsg, rawMsgType=${rawMsg.runtimeType}'); + // NestJS ValidationPipe 校验失败时 message 字段为 List(每条字段错误一个元素), + // 例如:{ message: ["provinceCode should not be empty", "portionCount must be an integer"] } + // 而业务异常的 message 为 String,因此必须区分处理,否则 Dart 运行时会抛出 + // "List is not a subtype of String" 类型错误。 final message = rawMsg is List ? (rawMsg as List).join(', ') : (rawMsg?.toString() ?? '请求失败'); diff --git a/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_purchase_page.dart b/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_purchase_page.dart index 6318ab32..e6177a97 100644 --- a/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_purchase_page.dart +++ b/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_purchase_page.dart @@ -348,6 +348,13 @@ class _PrePlantingPurchasePageState final prePlantingService = ref.read(prePlantingServiceProvider); // 创建预种订单(后端自动完成:扣款 → 分配权益 → 检查合并) + // + // === 续购省市复用逻辑 === + // 首次购买:用户已在页面选择省市,使用 _selectedProvinceCode / _selectedCityCode + // 续购:省市在首购时已锁定到持仓记录,必须使用 _position?.provinceCode 复用, + // 不能传 null,否则后端 @IsNotEmpty() 校验失败返回 400。 + // 名称字段(provinceName/cityName):可选,后端首购时存储,续购时后端忽略更新, + // 但仍须传入避免 forbidNonWhitelisted 拦截(前端发送任何未声明字段都会被拒绝)。 final pc = _isFirstPurchase ? _selectedProvinceCode : _position?.provinceCode; final cc = _isFirstPurchase ? _selectedCityCode : _position?.cityCode; debugPrint('[PrePlantingPurchase] ★ createOrder: qty=$_quantity, isFirst=$_isFirstPurchase, provinceCode=$pc, cityCode=$cc');