fix(wallet-service): 修复 settleToBalance 方法缺少事务保护的严重 Bug

问题原因:
settleToBalance 方法先执行 wallet.save() 更新账户余额,再执行
ledgerRepo.save() 写入流水记录。两个操作不在同一个事务中。

当流水写入失败时(如 memo 字段超过 VarChar(500) 限制),账户余额
已经被修改,但流水记录未写入,导致数据不一致。

具体案例:
用户 D25122700023 点击结算时,memo 内容超长(66笔奖励详情),
wallet-service 先把 settleable_usdt 转入 usdt_available,然后
写流水失败。账户余额被改但没有对应流水。

修复内容:
1. settleToBalance: 使用 prisma.$transaction 确保原子性
   - 账户余额更新和流水记录在同一事务中
   - 任一操作失败整个事务回滚
2. schema: memo 字段从 VarChar(500) 改为 Text 类型,无长度限制

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-06 06:40:09 -08:00
parent 573e58c89b
commit 5204d24c88
3 changed files with 69 additions and 33 deletions

View File

@ -592,7 +592,11 @@
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 添加待办操作轮询机制\n\n解决老版本 App 升级后不重启导致无法激活待办事项的问题。\n\n- 新增 PendingActionPollingService 定时轮询服务每4秒检查\n- App启动时无待办则启动轮询有待办则直接进入待办页面\n- 轮询检测到待办后自动停止并跳转,防止重入问题\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 用户资料页添加\"同伴认种\"标题和快捷标签\n\n- 在统计卡片上方添加\"同伴认种\"标题(紫色)\n- 在统计卡片下方添加\"引荐\"、\"同伴\"、\"本人\"快捷标签\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 用户资料页术语修改\n\n- 直推 → 引荐\n- 伞下 → 同伴\n- 个人认种 → 本人认种\n- 团队认种 → 同伴认种\n- 推荐人 → 引荐人\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 已结算数据改为从流水统计API获取\n\n- 从 wallet-service 的 getLedgerStatistics\\(\\) 获取 REWARD_SETTLED 类型的总金额\n- 与流水明细中的结算记录统计来自同一数据源,确保数据一致性\n- 添加调试日志对比 summary 和流水统计的数据\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 已结算数据改为从流水统计API获取\n\n- 从 wallet-service 的 getLedgerStatistics\\(\\) 获取 REWARD_SETTLED 类型的总金额\n- 与流水明细中的结算记录统计来自同一数据源,确保数据一致性\n- 添加调试日志对比 summary 和流水统计的数据\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(authorization\\): 火柴人排名过滤已撤销授权的考核记录\n\n- findRankingsByMonthAndRegion 和 findRankingsByMonthAndRoleType 增加过滤条件\n- 排除 authorization.status = ''REVOKED'' 的记录\n- 解决同一用户因有多条授权记录(含已撤销)而重复显示的问题\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(reward-service\\): 修复 WalletServiceClient 未正确解析 wallet-service 响应格式的 Bug\n\n问题原因:\nwallet-service 使用全局 TransformInterceptor 拦截器,会将所有响应包装成:\n{ success: true, data: { success: boolean, ... }, timestamp: \"...\" }\n\n原代码直接读取外层的 success 字段(始终为 true导致即使业务失败\n内层 data.success = false也被误判为成功。\n\n具体案例:\n用户 D25122700024 点击结算时wallet-service 因余额不足返回:\n{ success: true, data: { success: false, error: \"Insufficient...\" }, ... }\nreward-service 误读为成功,导致奖励被标记为 SETTLED 但钱包余额未变更。\n\n修复内容:\n1. settleToBalance: 解析 response_data.data 获取真实业务结果\n2. confirmPlantingDeduction: 同上\n3. allocateFunds: 同上\n\n所有方法现在会:\n- 使用 response_data.data || response_data 兼容包装和非包装格式\n- 严格检查 data.success !== true 来判断业务是否成功\n- 失败时记录详细错误日志\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 统一奖励分配到 settleable_usdt与 reward-service 保持一致\n\n问题原因:\nwallet-service 对不同类型奖励的分配方式不一致:\n- SHARE_RIGHT: 正确使用 addSettleableReward\\(\\) → settleable_usdt\n- CITY_TEAM_RIGHT/COMMUNITY_RIGHT: 错误使用 addAvailableBalance\\(\\) → usdt_available\n\n这导致 reward-service 记录的 SETTLEABLE 奖励总额与 wallet-service 的\nsettleable_usdt 字段不匹配。用户 D25122700024 的案例中:\n- reward-service: 3条奖励共 4464 USDT \\(SHARE_RIGHT 3600 + CITY_TEAM_RIGHT 288 + COMMUNITY_RIGHT 576\\)\n- wallet-service: settleable_usdt = 3600 \\(仅 SHARE_RIGHT\\)\n差额 864 USDT 被错误地放入了 usdt_available\n\n修复内容:\n1. allocateCommunityRight: 改用 addSettleableReward\\(\\) 替代 addAvailableBalance\\(\\)\n2. allocateToRegionAccount: 改用 addSettleableReward\\(\\) 替代 addAvailableBalance\\(\\)\n3. 流水类型统一使用 REWARD_TO_SETTLEABLE 替代 SYSTEM_ALLOCATION\n4. 日志和备注更新以反映新的分配方式\n\n设计原则:\n- reward-service 是奖励的权威来源\n- wallet-service 应跟随 reward-service 的设计\n- 所有奖励都应进入 settleable_usdt用户主动结算后才转入 usdt_available\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(ls \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\reward-service\\\\prisma\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservicesreward-serviceprisma\"\")"
],
"deny": [],
"ask": []

View File

@ -96,8 +96,8 @@ model LedgerEntry {
refOrderId String? @map("ref_order_id") @db.VarChar(100)
refTxHash String? @map("ref_tx_hash") @db.VarChar(100)
// 备注
memo String? @map("memo") @db.VarChar(500)
// 备注 (使用 Text 类型,无长度限制)
memo String? @map("memo") @db.Text
// 扩展数据
payloadJson Json? @map("payload_json")

View File

@ -909,42 +909,74 @@ export class WalletApplicationService {
}> {
this.logger.log(`Settling ${params.usdtAmount} USDT to balance for ${params.accountSequence}`);
// 生成结算ID
const settlementId = `STL_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
let balanceAfter = 0;
let userId: bigint = BigInt(0);
try {
// 1. 查找钱包
const wallet = await this.walletRepo.findByAccountSequence(params.accountSequence);
if (!wallet) {
throw new WalletNotFoundError(`accountSequence: ${params.accountSequence}`);
}
// 使用事务确保账户余额更新和流水记录的原子性
await this.prisma.$transaction(async (tx) => {
// 1. 查找钱包
const walletRecord = await tx.walletAccount.findUnique({
where: { accountSequence: params.accountSequence },
});
if (!walletRecord) {
throw new WalletNotFoundError(`accountSequence: ${params.accountSequence}`);
}
const usdtAmount = Money.USDT(params.usdtAmount);
const userId = wallet.userId.value;
userId = walletRecord.userId;
const Decimal = (await import('decimal.js')).default;
const usdtAmountDecimal = new Decimal(params.usdtAmount);
const currentSettleable = new Decimal(walletRecord.settleableUsdt.toString());
const currentAvailable = new Decimal(walletRecord.usdtAvailable.toString());
const currentSettledTotal = new Decimal(walletRecord.settledTotalUsdt.toString());
// 2. 生成结算ID
const settlementId = `STL_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 2. 验证可结算余额足够
if (currentSettleable.lessThan(usdtAmountDecimal)) {
throw new Error(`Insufficient settleable balance: ${currentSettleable} < ${usdtAmountDecimal}`);
}
// 3. 执行钱包结算
wallet.settleToBalance(usdtAmount, settlementId);
await this.walletRepo.save(wallet);
// 3. 计算新余额
const newSettleable = currentSettleable.minus(usdtAmountDecimal);
const newAvailable = currentAvailable.plus(usdtAmountDecimal);
const newSettledTotal = currentSettledTotal.plus(usdtAmountDecimal);
balanceAfter = newAvailable.toNumber();
// 4. 记录账本流水(含详细来源信息)
const ledgerEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: UserId.create(userId),
entryType: LedgerEntryType.REWARD_SETTLED,
amount: usdtAmount,
balanceAfter: wallet.balances.usdt.available,
refOrderId: settlementId,
memo: params.memo || `结算 ${params.usdtAmount} 绿积分到钱包余额`,
payloadJson: {
settlementType: 'SETTLE_TO_BALANCE',
rewardEntryIds: params.rewardEntryIds,
rewardCount: params.rewardEntryIds.length,
breakdown: params.breakdown,
},
// 4. 更新钱包账户(在事务内)
await tx.walletAccount.update({
where: { accountSequence: params.accountSequence },
data: {
settleableUsdt: newSettleable.toFixed(8),
usdtAvailable: newAvailable.toFixed(8),
settledTotalUsdt: newSettledTotal.toFixed(8),
updatedAt: new Date(),
},
});
// 5. 创建流水记录(在事务内)
await tx.ledgerEntry.create({
data: {
accountSequence: params.accountSequence,
userId: walletRecord.userId,
entryType: LedgerEntryType.REWARD_SETTLED,
amount: usdtAmountDecimal.toFixed(8),
assetType: 'USDT',
balanceAfter: newAvailable.toFixed(8),
refOrderId: settlementId,
memo: params.memo || `结算 ${params.usdtAmount} 绿积分到钱包余额`,
payloadJson: {
settlementType: 'SETTLE_TO_BALANCE',
rewardEntryIds: params.rewardEntryIds,
rewardCount: params.rewardEntryIds.length,
breakdown: params.breakdown,
},
createdAt: new Date(),
},
});
});
await this.ledgerRepo.save(ledgerEntry);
// 5. 使缓存失效
// 6. 使缓存失效(在事务外,事务成功后执行)
await this.walletCacheService.invalidateWallet(userId);
this.logger.log(`Successfully settled ${params.usdtAmount} USDT to balance for ${params.accountSequence}`);
@ -953,7 +985,7 @@ export class WalletApplicationService {
success: true,
settlementId,
settledAmount: params.usdtAmount,
balanceAfter: wallet.balances.usdt.available.value,
balanceAfter,
};
} catch (error) {
this.logger.error(`Failed to settle to balance for ${params.accountSequence}: ${error.message}`);