fix(pre-planting): 修复购买省市名称存储及多项购买失败问题

== 问题修复 ==

1. 购买失败:NestJS 返回数组 message 导致 Flutter 类型转换错误
   - 症状:List<dynamic> is not a subtype of String
   - 原因:ValidationPipe 校验失败时 message 字段为 List<String>(每条字段错误一条),
     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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-28 08:02:14 -08:00
parent 20b8d41212
commit 26dcd1d2de
6 changed files with 162 additions and 6 deletions

View File

@ -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<string, {
levelDecrements: Map<number, Decimal>;
bonusDecrements: Map<number, Decimal>;
}>();
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<string, any> = {
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标注合成来源

View File

@ -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);

View File

@ -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)

View File

@ -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)

View File

@ -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<String>
// { message: ["provinceCode should not be empty", "portionCount must be an integer"] }
// message String Dart
// "List<dynamic> is not a subtype of String"
final message = rawMsg is List
? (rawMsg as List).join(', ')
: (rawMsg?.toString() ?? '请求失败');

View File

@ -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');