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:
parent
20b8d41212
commit
26dcd1d2de
|
|
@ -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(标注合成来源)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() ?? '请求失败');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue