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:
parent
573e58c89b
commit
5204d24c88
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue