feat(wallet/blockchain): 热钱包余额预检查及接收方钱包自动创建

1. blockchain-service: 新增热钱包 dUSDT 余额定时更新调度器
   - 每 5 秒查询热钱包在 KAVA 链上的 dUSDT 余额
   - 更新到 Redis DB 0,key 格式: hot_wallet:dusdt_balance:{chainType}
   - TTL 30 秒,服务故障时缓存自动过期

2. wallet-service: 新增热钱包余额缓存服务
   - 从 Redis DB 0 读取热钱包余额缓存
   - 严格模式:无法获取余额或余额不足时拒绝转账
   - 提示信息:"财务系统审计中,请稍后再试"

3. wallet-service: 转账确认时自动创建接收方钱包
   - 解决接收方钱包不存在导致入账失败的问题
   - 使用 upsert 避免并发创建冲突
   - 在同一事务中完成创建和入账

🤖 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-05 04:31:52 -08:00
parent 191b37a5de
commit ac0e73afac
8 changed files with 296 additions and 44 deletions

View File

@ -12,6 +12,7 @@ import {
} from './services';
import { MpcKeygenCompletedHandler, WithdrawalRequestedHandler } from './event-handlers';
import { DepositAckConsumerService } from '@/infrastructure/kafka/deposit-ack-consumer.service';
import { HotWalletBalanceScheduler } from './schedulers';
@Module({
imports: [InfrastructureModule, DomainModule],
@ -31,6 +32,9 @@ import { DepositAckConsumerService } from '@/infrastructure/kafka/deposit-ack-co
// 事件处理器
MpcKeygenCompletedHandler,
WithdrawalRequestedHandler,
// 定时任务
HotWalletBalanceScheduler,
],
exports: [
AddressDerivationService,

View File

@ -0,0 +1,94 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { Erc20TransferService } from '@/domain/services/erc20-transfer.service';
import { ChainTypeEnum } from '@/domain/enums';
import Redis from 'ioredis';
/**
* dUSDT (绿)
*
* 5 dUSDT Redis
* wallet-service
*
* 使 Redis DB 0便
*
* Redis Key 格式: hot_wallet:dusdt_balance:{chainType}
* Redis Value: 余额字符串 "10000.00"
* TTL: 30
*/
@Injectable()
export class HotWalletBalanceScheduler implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(HotWalletBalanceScheduler.name);
// Redis key 前缀
private readonly REDIS_KEY_PREFIX = 'hot_wallet:dusdt_balance:';
// 缓存过期时间(秒)
private readonly CACHE_TTL_SECONDS = 30;
// 支持的链类型
private readonly SUPPORTED_CHAINS = [ChainTypeEnum.KAVA, ChainTypeEnum.BSC];
// 使用独立的 Redis 连接,连接到 DB 0公共数据库
private readonly sharedRedis: Redis;
constructor(
private readonly configService: ConfigService,
private readonly transferService: Erc20TransferService,
) {
// 创建连接到 DB 0 的 Redis 客户端(公共数据库,所有服务可读取)
this.sharedRedis = new Redis({
host: this.configService.get<string>('redis.host') || 'localhost',
port: this.configService.get<number>('redis.port') || 6379,
password: this.configService.get<string>('redis.password') || undefined,
db: 0, // 使用 DB 0 作为公共数据库
});
this.sharedRedis.on('connect', () => {
this.logger.log('[REDIS] Connected to shared Redis DB 0 for hot wallet balance');
});
this.sharedRedis.on('error', (err) => {
this.logger.error('[REDIS] Shared Redis connection error', err);
});
}
onModuleDestroy() {
this.sharedRedis.disconnect();
}
async onModuleInit() {
this.logger.log('[INIT] HotWalletBalanceScheduler initialized');
// 启动时立即执行一次
await this.updateHotWalletBalances();
}
/**
* 5 Redis
*/
@Cron('*/5 * * * * *') // 每 5 秒执行
async updateHotWalletBalances(): Promise<void> {
for (const chainType of this.SUPPORTED_CHAINS) {
try {
// 检查该链是否已配置
if (!this.transferService.isConfigured(chainType)) {
this.logger.debug(`[SKIP] Chain ${chainType} not configured, skipping balance update`);
continue;
}
// 查询链上余额
const balance = await this.transferService.getHotWalletBalance(chainType);
// 更新到 Redis DB 0
const redisKey = `${this.REDIS_KEY_PREFIX}${chainType}`;
await this.sharedRedis.setex(redisKey, this.CACHE_TTL_SECONDS, balance);
this.logger.debug(`[UPDATE] ${chainType} hot wallet dUSDT balance: ${balance}`);
} catch (error) {
this.logger.error(`[ERROR] Failed to update ${chainType} hot wallet balance`, error);
// 单链失败不影响其他链的更新
}
}
}
}

View File

@ -0,0 +1 @@
export * from './hot-wallet-balance.scheduler';

View File

@ -224,52 +224,84 @@ export class WithdrawalStatusHandler implements OnModuleInit {
});
}
if (toWalletRecord) {
const transferAmount = new Decimal(orderRecord.amount.toString());
const toCurrentAvailable = new Decimal(toWalletRecord.usdtAvailable.toString());
const toNewAvailable = toCurrentAvailable.add(transferAmount);
const toCurrentVersion = toWalletRecord.version;
// 更新接收方余额
const toUpdateResult = await tx.walletAccount.updateMany({
where: {
id: toWalletRecord.id,
version: toCurrentVersion,
},
data: {
usdtAvailable: toNewAvailable,
version: toCurrentVersion + 1,
updatedAt: new Date(),
},
});
if (toUpdateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for receiver wallet ${toWalletRecord.id}`);
}
// 给接收方记录 TRANSFER_IN 流水
await tx.ledgerEntry.create({
data: {
// 如果接收方钱包不存在,自动创建(使用 upsert 避免并发问题)
if (!toWalletRecord) {
this.logger.log(`[CONFIRMED] Receiver wallet not found, auto-creating for: ${orderRecord.toAccountSequence}`);
toWalletRecord = await tx.walletAccount.upsert({
where: { accountSequence: orderRecord.toAccountSequence },
create: {
accountSequence: orderRecord.toAccountSequence,
userId: orderRecord.toUserId,
entryType: LedgerEntryType.TRANSFER_IN,
amount: transferAmount,
assetType: 'USDT',
balanceAfter: toNewAvailable,
refOrderId: orderRecord.orderNo,
refTxHash: payload.txHash,
memo: `来自 ${orderRecord.accountSequence} 的转账`,
payloadJson: {
fromAccountSequence: orderRecord.accountSequence,
fromUserId: orderRecord.userId.toString(),
},
usdtAvailable: new Decimal(0),
usdtFrozen: new Decimal(0),
dstAvailable: new Decimal(0),
dstFrozen: new Decimal(0),
bnbAvailable: new Decimal(0),
bnbFrozen: new Decimal(0),
ogAvailable: new Decimal(0),
ogFrozen: new Decimal(0),
rwadAvailable: new Decimal(0),
rwadFrozen: new Decimal(0),
hashpower: new Decimal(0),
pendingUsdt: new Decimal(0),
pendingHashpower: new Decimal(0),
settleableUsdt: new Decimal(0),
settleableHashpower: new Decimal(0),
settledTotalUsdt: new Decimal(0),
settledTotalHashpower: new Decimal(0),
expiredTotalUsdt: new Decimal(0),
expiredTotalHashpower: new Decimal(0),
status: 'ACTIVE',
hasPlanted: false,
version: 0,
},
update: {}, // 如果已存在,不做任何更新
});
this.logger.log(`[CONFIRMED] Internal transfer: ${orderRecord.accountSequence} -> ${orderRecord.toAccountSequence}, amount: ${transferAmount.toString()}`);
} else {
this.logger.error(`[CONFIRMED] Receiver wallet not found: ${orderRecord.toAccountSequence}`);
this.logger.log(`[CONFIRMED] Auto-created/found wallet for receiver: ${orderRecord.toAccountSequence} (id=${toWalletRecord.id})`);
}
const transferAmount = new Decimal(orderRecord.amount.toString());
const toCurrentAvailable = new Decimal(toWalletRecord.usdtAvailable.toString());
const toNewAvailable = toCurrentAvailable.add(transferAmount);
const toCurrentVersion = toWalletRecord.version;
// 更新接收方余额
const toUpdateResult = await tx.walletAccount.updateMany({
where: {
id: toWalletRecord.id,
version: toCurrentVersion,
},
data: {
usdtAvailable: toNewAvailable,
version: toCurrentVersion + 1,
updatedAt: new Date(),
},
});
if (toUpdateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for receiver wallet ${toWalletRecord.id}`);
}
// 给接收方记录 TRANSFER_IN 流水
await tx.ledgerEntry.create({
data: {
accountSequence: orderRecord.toAccountSequence,
userId: orderRecord.toUserId,
entryType: LedgerEntryType.TRANSFER_IN,
amount: transferAmount,
assetType: 'USDT',
balanceAfter: toNewAvailable,
refOrderId: orderRecord.orderNo,
refTxHash: payload.txHash,
memo: `来自 ${orderRecord.accountSequence} 的转账`,
payloadJson: {
fromAccountSequence: orderRecord.accountSequence,
fromUserId: orderRecord.userId.toString(),
},
},
});
this.logger.log(`[CONFIRMED] Internal transfer: ${orderRecord.accountSequence} -> ${orderRecord.toAccountSequence}, amount: ${transferAmount.toString()}`);
}
} else {
// 普通提现:记录 WITHDRAWAL

View File

@ -20,7 +20,7 @@ import {
} from '@/application/commands';
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
import { DuplicateTransactionError, WalletNotFoundError, OptimisticLockError } from '@/shared/exceptions/domain.exception';
import { WalletCacheService } from '@/infrastructure/redis';
import { WalletCacheService, HotWalletCacheService } from '@/infrastructure/redis';
import { EventPublisherService } from '@/infrastructure/kafka';
import { WithdrawalRequestedEvent } from '@/domain/events';
import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories';
@ -91,6 +91,7 @@ export class WalletApplicationService {
@Inject(PENDING_REWARD_REPOSITORY)
private readonly pendingRewardRepo: IPendingRewardRepository,
private readonly walletCacheService: WalletCacheService,
private readonly hotWalletCacheService: HotWalletCacheService,
private readonly eventPublisher: EventPublisherService,
private readonly prisma: PrismaService,
private readonly feeConfigRepo: FeeConfigRepositoryImpl,
@ -1518,6 +1519,16 @@ export class WalletApplicationService {
throw new Error(`最小提现金额为 ${this.MIN_WITHDRAWAL_AMOUNT} USDT`);
}
// 检查热钱包余额是否足够(预检查,防止用户资金被冻结后链上执行失败)
const hotWalletCheck = await this.hotWalletCacheService.checkSufficientBalance(
command.chainType,
amount.toDecimal(),
);
if (!hotWalletCheck.sufficient) {
this.logger.warn(`[WITHDRAWAL] Hot wallet balance check failed for ${command.chainType}: ${hotWalletCheck.error}`);
throw new BadRequestException(hotWalletCheck.error || '财务系统审计中,请稍后再试');
}
// 优先按 accountSequence 查找,如果未找到则按 userId 查找
let wallet = await this.walletRepo.findByAccountSequence(command.userId);
if (!wallet) {

View File

@ -0,0 +1,108 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import Decimal from 'decimal.js';
/**
* dUSDT (绿)
*
* Redis DB 0 blockchain-service dUSDT
*
*
* Redis Key 格式: hot_wallet:dusdt_balance:{chainType}
*
* Redis null
*
*/
@Injectable()
export class HotWalletCacheService implements OnModuleDestroy {
private readonly logger = new Logger(HotWalletCacheService.name);
// Redis key 前缀(与 blockchain-service 保持一致)
private readonly REDIS_KEY_PREFIX = 'hot_wallet:dusdt_balance:';
// 使用独立的 Redis 连接,连接到 DB 0公共数据库
private readonly sharedRedis: Redis;
constructor(private readonly configService: ConfigService) {
// 创建连接到 DB 0 的 Redis 客户端(公共数据库)
this.sharedRedis = new Redis({
host: this.configService.get<string>('REDIS_HOST') || 'localhost',
port: this.configService.get<number>('REDIS_PORT') || 6379,
password: this.configService.get<string>('REDIS_PASSWORD') || undefined,
db: 0, // 使用 DB 0 作为公共数据库
});
this.sharedRedis.on('connect', () => {
this.logger.log('[REDIS] Connected to shared Redis DB 0 for hot wallet balance cache');
});
this.sharedRedis.on('error', (err) => {
this.logger.error('[REDIS] Shared Redis connection error', err);
});
}
onModuleDestroy() {
this.sharedRedis.disconnect();
}
/**
*
*
* @param chainType (KAVA, BSC)
* @returns Decimal null
*/
async getHotWalletBalance(chainType: string): Promise<Decimal | null> {
try {
const redisKey = `${this.REDIS_KEY_PREFIX}${chainType.toUpperCase()}`;
const balance = await this.sharedRedis.get(redisKey);
if (balance === null) {
this.logger.warn(`[CACHE] Hot wallet balance not found for ${chainType}`);
return null;
}
const balanceDecimal = new Decimal(balance);
this.logger.debug(`[CACHE] Hot wallet dUSDT balance for ${chainType}: ${balanceDecimal.toString()}`);
return balanceDecimal;
} catch (error) {
this.logger.error(`[CACHE] Failed to get hot wallet balance for ${chainType}`, error);
return null;
}
}
/**
*
*
* @param chainType
* @param requiredAmount
* @returns { sufficient: boolean, balance: Decimal | null, error?: string }
*/
async checkSufficientBalance(
chainType: string,
requiredAmount: Decimal,
): Promise<{ sufficient: boolean; balance: Decimal | null; error?: string }> {
const balance = await this.getHotWalletBalance(chainType);
if (balance === null) {
return {
sufficient: false,
balance: null,
error: '财务系统审计中,请稍后再试',
};
}
if (balance.lessThan(requiredAmount)) {
this.logger.warn(
`[CHECK] Insufficient hot wallet balance for ${chainType}: need ${requiredAmount.toString()}, have ${balance.toString()}`,
);
return {
sufficient: false,
balance,
error: '财务系统审计中,请稍后再试',
};
}
return { sufficient: true, balance };
}
}

View File

@ -1,3 +1,4 @@
export * from './redis.service';
export * from './redis.module';
export * from './wallet-cache.service';
export * from './hot-wallet-cache.service';

View File

@ -2,11 +2,12 @@ import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { RedisService } from './redis.service';
import { WalletCacheService } from './wallet-cache.service';
import { HotWalletCacheService } from './hot-wallet-cache.service';
@Global()
@Module({
imports: [ConfigModule],
providers: [RedisService, WalletCacheService],
exports: [RedisService, WalletCacheService],
providers: [RedisService, WalletCacheService, HotWalletCacheService],
exports: [RedisService, WalletCacheService, HotWalletCacheService],
})
export class RedisModule {}