From c2ff11bd6d341623f90d3e8689998845c5132fd7 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 25 Dec 2025 20:50:39 -0800 Subject: [PATCH] =?UTF-8?q?fix(wallet-service):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7=E4=BF=AE=E5=A4=8D=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=20D25122600004->D25122600006=20=E8=BD=AC=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复因并发修改导致的冻结余额不足问题 - 自动完成内部转账、记录流水、更新订单状态 - 幂等执行,可安全重启 - 部署成功后请删除 otp/ 目录和相关引用 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 4 +- .../infrastructure/infrastructure.module.ts | 4 +- .../otp/transfer-fix.service.ts | 185 ++++++++++++++++++ 3 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 backend/services/wallet-service/src/infrastructure/otp/transfer-fix.service.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3f521bfa..c668435b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -414,7 +414,9 @@ "Bash(node -e \"\nconst { ethers } = require\\(''ethers''\\);\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x184571959d74a6e771ad4e5b2fbe006951dd29ec'';\n\nasync function transfer\\(\\) {\n const provider = new ethers.JsonRpcProvider\\(KAVA_TESTNET_RPC\\);\n const wallet = new ethers.Wallet\\(privateKey, provider\\);\n \n const abi = [''function transfer\\(address to, uint256 amount\\) returns \\(bool\\)'', ''function balanceOf\\(address\\) view returns \\(uint256\\)''];\n const contract = new ethers.Contract\\(USDT_CONTRACT, abi, wallet\\);\n \n // 10,000,000 USDT = 10000000 * 1e6 \\(6 decimals\\)\n const amount = BigInt\\(10000000\\) * BigInt\\(1000000\\);\n \n console.log\\(''Transferring 10,000,000 USDT to'', TO_ADDRESS\\);\n const tx = await contract.transfer\\(TO_ADDRESS, amount, { gasLimit: 100000 }\\);\n console.log\\(''TX Hash:'', tx.hash\\);\n await tx.wait\\(\\);\n \n const newBalance = await contract.balanceOf\\(TO_ADDRESS\\);\n console.log\\(''New balance:'', Number\\(newBalance\\) / 1e6, ''USDT''\\);\n}\n\ntransfer\\(\\).catch\\(e => console.error\\(''Error:'', e.message\\)\\);\n\")", "Bash(node -e \"\nconst { ethers } = require\\(''ethers''\\);\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x184571959d74a6e771ad4e5b2fbe006951dd29ec'';\n\nasync function transfer\\(\\) {\n const provider = new ethers.JsonRpcProvider\\(KAVA_TESTNET_RPC\\);\n const wallet = new ethers.Wallet\\(privateKey, provider\\);\n \n const abi = [''function transfer\\(address to, uint256 amount\\) returns \\(bool\\)'', ''function balanceOf\\(address\\) view returns \\(uint256\\)''];\n const contract = new ethers.Contract\\(USDT_CONTRACT, abi, wallet\\);\n \n // 10,000,000,000 USDT \\(100亿\\) = 10000000000 * 1e6 \\(6 decimals\\)\n const amount = BigInt\\(10000000000\\) * BigInt\\(1000000\\);\n \n console.log\\(''Transferring 10,000,000,000 USDT to'', TO_ADDRESS\\);\n const tx = await contract.transfer\\(TO_ADDRESS, amount, { gasLimit: 100000 }\\);\n console.log\\(''TX Hash:'', tx.hash\\);\n await tx.wait\\(\\);\n \n const newBalance = await contract.balanceOf\\(TO_ADDRESS\\);\n console.log\\(''New balance:'', Number\\(newBalance\\) / 1e6, ''USDT''\\);\n}\n\ntransfer\\(\\).catch\\(e => console.error\\(''Error:'', e.message\\)\\);\n\")", "Bash(node -e \"\nconst { ethers } = require\\(''ethers''\\);\n\nconst KAVA_TESTNET_RPC = ''https://evm.testnet.kava.io'';\nconst privateKey = ''0xd42a6e6021ebd884f3f179d3793a32e97b9f1001db6ff44441ec455d748b9aa6'';\nconst USDT_CONTRACT = ''0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF'';\nconst TO_ADDRESS = ''0x25bc2f6cebb902cb51f7b51bff81e0f776b07b14'';\n\nasync function transfer\\(\\) {\n const provider = new ethers.JsonRpcProvider\\(KAVA_TESTNET_RPC\\);\n const wallet = new ethers.Wallet\\(privateKey, provider\\);\n \n const abi = [''function transfer\\(address to, uint256 amount\\) returns \\(bool\\)'', ''function balanceOf\\(address\\) view returns \\(uint256\\)''];\n const contract = new ethers.Contract\\(USDT_CONTRACT, abi, wallet\\);\n \n // 2,000,000,000 USDT \\(20亿\\) = 2000000000 * 1e6 \\(6 decimals\\)\n const amount = BigInt\\(2000000000\\) * BigInt\\(1000000\\);\n \n console.log\\(''Transferring 2,000,000,000 USDT to'', TO_ADDRESS\\);\n const tx = await contract.transfer\\(TO_ADDRESS, amount, { gasLimit: 100000 }\\);\n console.log\\(''TX Hash:'', tx.hash\\);\n await tx.wait\\(\\);\n \n const newBalance = await contract.balanceOf\\(TO_ADDRESS\\);\n console.log\\(''New balance:'', Number\\(newBalance\\) / 1e6, ''USDT''\\);\n}\n\ntransfer\\(\\).catch\\(e => console.error\\(''Error:'', e.message\\)\\);\n\")", - "Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\blockchain-service\\\\*.prisma\")" + "Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\blockchain-service\\\\*.prisma\")", + "Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 添加钱包乐观锁防止并发修改\n\n- WalletAccount aggregate 添加 version 字段\n- WalletAccountRepositoryImpl 使用 updateMany + version 检查实现乐观锁\n- requestWithdrawal 添加重试机制处理乐观锁冲突\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", + "Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 添加一次性修复脚本 D25122600004->D25122600006 转账\n\n- 修复因并发修改导致的冻结余额不足问题\n- 自动完成内部转账、记录流水、更新订单状态\n- 幂等执行,可安全重启\n- 部署成功后请删除 otp/ 目录和相关引用\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")" ], "deny": [], "ask": [] diff --git a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts index 5f455d77..a6e258cf 100644 --- a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts @@ -20,6 +20,8 @@ import { import { RedisModule } from './redis'; import { KafkaModule } from './kafka'; import { IdentityModule } from './external/identity'; +// OTP: One-Time fix for D25122600004 -> D25122600006 transfer (remove after fix) +import { TransferFixService } from './otp/transfer-fix.service'; const repositories = [ { @@ -52,7 +54,7 @@ const repositories = [ @Global() @Module({ imports: [RedisModule, KafkaModule, IdentityModule], - providers: [PrismaService, ...repositories], + providers: [PrismaService, ...repositories, TransferFixService], // OTP: remove TransferFixService after fix exports: [PrismaService, RedisModule, KafkaModule, IdentityModule, FeeConfigRepositoryImpl, ...repositories], }) export class InfrastructureModule {} diff --git a/backend/services/wallet-service/src/infrastructure/otp/transfer-fix.service.ts b/backend/services/wallet-service/src/infrastructure/otp/transfer-fix.service.ts new file mode 100644 index 00000000..a687bfb8 --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/otp/transfer-fix.service.ts @@ -0,0 +1,185 @@ +/** + * One-Time-Fix: 修复 D25122600004 -> D25122600006 的转账 + * + * 问题:由于并发修改导致冻结余额少了 2 USDT (手续费) + * 解决:修复冻结余额,完成转账,更新订单状态 + * + * 幂等性:检查订单状态,已 CONFIRMED 则跳过 + * 部署后删除此文件和 infrastructure.module.ts 中的引用 + */ + +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { LedgerEntryType, WithdrawalStatus } from '@/domain/value-objects'; +import Decimal from 'decimal.js'; + +@Injectable() +export class TransferFixService implements OnModuleInit { + private readonly logger = new Logger(TransferFixService.name); + + // 需要修复的订单号 + private readonly ORDER_NO = 'WD1766719397843H90GUW'; + private readonly SENDER_ACCOUNT = 'D25122600004'; + private readonly RECEIVER_ACCOUNT = 'D25122600006'; + private readonly MISSING_FEE = new Decimal('2'); // 缺失的手续费 + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit() { + // 延迟 5 秒执行,确保所有服务都已启动 + setTimeout(() => this.executeFixOnce(), 5000); + } + + private async executeFixOnce(): Promise { + this.logger.log('========================================'); + this.logger.log('[OTP-FIX] Starting one-time transfer fix'); + this.logger.log(`[OTP-FIX] Order: ${this.ORDER_NO}`); + this.logger.log(`[OTP-FIX] From: ${this.SENDER_ACCOUNT} -> To: ${this.RECEIVER_ACCOUNT}`); + this.logger.log('========================================'); + + try { + // 检查订单状态 + const order = await this.prisma.withdrawalOrder.findUnique({ + where: { orderNo: this.ORDER_NO }, + }); + + if (!order) { + this.logger.log('[OTP-FIX] Order not found, skipping (may be wrong environment)'); + return; + } + + // 如果已经确认,说明已修复过(幂等性) + if (order.status === WithdrawalStatus.CONFIRMED) { + this.logger.log('[OTP-FIX] Order already CONFIRMED, skipping'); + return; + } + + if (order.status !== WithdrawalStatus.FROZEN) { + this.logger.warn(`[OTP-FIX] Unexpected order status: ${order.status}, skipping`); + return; + } + + // 执行修复 + await this.prisma.$transaction(async (tx) => { + // 1. 获取发送方钱包 + const senderWallet = await tx.walletAccount.findUnique({ + where: { accountSequence: this.SENDER_ACCOUNT }, + }); + + if (!senderWallet) { + throw new Error(`Sender wallet not found: ${this.SENDER_ACCOUNT}`); + } + + // 2. 获取接收方钱包 + const receiverWallet = await tx.walletAccount.findUnique({ + where: { accountSequence: this.RECEIVER_ACCOUNT }, + }); + + if (!receiverWallet) { + throw new Error(`Receiver wallet not found: ${this.RECEIVER_ACCOUNT}`); + } + + const amount = new Decimal(order.amount.toString()); + const fee = new Decimal(order.fee.toString()); + const totalAmount = amount.add(fee); + + this.logger.log(`[OTP-FIX] Amount: ${amount}, Fee: ${fee}, Total: ${totalAmount}`); + + // 3. 修复发送方冻结余额 (加上缺失的手续费) 并同时扣除 + const senderCurrentFrozen = new Decimal(senderWallet.usdtFrozen.toString()); + // 加上缺失的手续费后再扣除全部 + const senderNewFrozen = senderCurrentFrozen.add(this.MISSING_FEE).minus(totalAmount); + + this.logger.log(`[OTP-FIX] Sender frozen: ${senderCurrentFrozen} + ${this.MISSING_FEE} - ${totalAmount} = ${senderNewFrozen}`); + + await tx.walletAccount.update({ + where: { id: senderWallet.id }, + data: { + usdtFrozen: senderNewFrozen, + version: senderWallet.version + 1, + updatedAt: new Date(), + }, + }); + + // 4. 增加接收方余额 + const receiverCurrentAvailable = new Decimal(receiverWallet.usdtAvailable.toString()); + const receiverNewAvailable = receiverCurrentAvailable.add(amount); + + this.logger.log(`[OTP-FIX] Receiver available: ${receiverCurrentAvailable} + ${amount} = ${receiverNewAvailable}`); + + await tx.walletAccount.update({ + where: { id: receiverWallet.id }, + data: { + usdtAvailable: receiverNewAvailable, + version: receiverWallet.version + 1, + updatedAt: new Date(), + }, + }); + + // 5. 创建发送方流水 (TRANSFER_OUT) + await tx.ledgerEntry.create({ + data: { + accountSequence: order.accountSequence, + userId: order.userId, + entryType: LedgerEntryType.TRANSFER_OUT, + amount: amount.negated(), + assetType: 'USDT', + balanceAfter: senderWallet.usdtAvailable, + refOrderId: order.orderNo, + refTxHash: 'OTP-FIX-INTERNAL', + memo: `转账至 ${order.toAccountSequence} (OTP修复)`, + payloadJson: { + toAccountSequence: order.toAccountSequence, + toUserId: order.toUserId?.toString(), + fee: order.fee.toString(), + fixNote: 'One-time fix for concurrent modification issue', + }, + }, + }); + + // 6. 创建接收方流水 (TRANSFER_IN) + await tx.ledgerEntry.create({ + data: { + accountSequence: order.toAccountSequence!, + userId: order.toUserId!, + entryType: LedgerEntryType.TRANSFER_IN, + amount: amount, + assetType: 'USDT', + balanceAfter: receiverNewAvailable, + refOrderId: order.orderNo, + refTxHash: 'OTP-FIX-INTERNAL', + memo: `来自 ${order.accountSequence} 的转账 (OTP修复)`, + payloadJson: { + fromAccountSequence: order.accountSequence, + fromUserId: order.userId.toString(), + fixNote: 'One-time fix for concurrent modification issue', + }, + }, + }); + + // 7. 更新订单状态为 CONFIRMED + await tx.withdrawalOrder.update({ + where: { id: order.id }, + data: { + status: WithdrawalStatus.CONFIRMED, + txHash: 'OTP-FIX-INTERNAL', + broadcastedAt: new Date(), + confirmedAt: new Date(), + }, + }); + + this.logger.log('[OTP-FIX] Transaction completed successfully'); + }); + + this.logger.log('========================================'); + this.logger.log('[OTP-FIX] Transfer fix completed!'); + this.logger.log(`[OTP-FIX] ${this.SENDER_ACCOUNT} -> ${this.RECEIVER_ACCOUNT}: ${this.prisma}`); + this.logger.log('[OTP-FIX] Order status: CONFIRMED'); + this.logger.log('[OTP-FIX] Please remove this file after deployment'); + this.logger.log('========================================'); + + } catch (error) { + this.logger.error('[OTP-FIX] Failed to execute fix', error); + } + } +}