feat(withdraw): 实现完整的提取功能 - TOTP验证和KAVA转账

功能实现:
- identity-service: 添加TOTP二次验证功能
  - 新增 UserTotp 数据模型
  - 实现 TotpService (生成/验证/启用/禁用)
  - 添加 /totp/* API端点

- wallet-service: 提取API添加TOTP验证
  - withdrawal.dto 添加可选 totpCode 字段
  - 添加 IdentityClientService 调用identity-service验证TOTP
  - 添加 WithdrawalStatusHandler 处理区块链确认/失败事件

- blockchain-service: 实现真实KAVA ERC20转账
  - 新增 Erc20TransferService (热钱包USDT转账)
  - 更新 withdrawal-requested.handler 执行真实转账
  - 发布确认/失败事件回wallet-service

- 前端: 对接真实提取API
  - withdraw_confirm_page 调用 walletService.withdrawUsdt

环境变量配置:
- TOTP_ENCRYPTION_KEY (identity-service)
- IDENTITY_SERVICE_URL (wallet-service)
- HOT_WALLET_PRIVATE_KEY (blockchain-service)

🤖 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 2025-12-15 05:16:42 -08:00
parent 2dba7633d8
commit b01277ab7d
23 changed files with 1404 additions and 133 deletions

View File

@ -4,18 +4,14 @@ import {
WithdrawalRequestedPayload,
} from '@/infrastructure/kafka/withdrawal-event-consumer.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { Erc20TransferService } from '@/domain/services/erc20-transfer.service';
import { ChainTypeEnum } from '@/domain/enums';
/**
* Withdrawal Requested Event Handler
*
* Handles withdrawal requests from wallet-service.
* For now, logs the event and publishes a status update.
*
* Future implementation will:
* 1. Create TransactionRequest record
* 2. Request MPC signing
* 3. Broadcast to blockchain
* 4. Monitor confirmation
* Executes ERC20 USDT transfers on the specified chain (KAVA/BSC).
*/
@Injectable()
export class WithdrawalRequestedHandler implements OnModuleInit {
@ -24,6 +20,7 @@ export class WithdrawalRequestedHandler implements OnModuleInit {
constructor(
private readonly withdrawalEventConsumer: WithdrawalEventConsumerService,
private readonly eventPublisher: EventPublisherService,
private readonly transferService: Erc20TransferService,
) {}
onModuleInit() {
@ -36,13 +33,16 @@ export class WithdrawalRequestedHandler implements OnModuleInit {
/**
* Handle withdrawal requested event from wallet-service
*
* Current implementation: Log and acknowledge
* TODO: Implement full blockchain transaction flow
* Flow:
* 1. Receive withdrawal request
* 2. Publish "PROCESSING" status
* 3. Execute ERC20 transfer
* 4. Publish final status (CONFIRMED or FAILED)
*/
private async handleWithdrawalRequested(
payload: WithdrawalRequestedPayload,
): Promise<void> {
this.logger.log(`[HANDLE] Received WithdrawalRequested event`);
this.logger.log(`[HANDLE] ========== Withdrawal Request ==========`);
this.logger.log(`[HANDLE] orderNo: ${payload.orderNo}`);
this.logger.log(`[HANDLE] accountSequence: ${payload.accountSequence}`);
this.logger.log(`[HANDLE] userId: ${payload.userId}`);
@ -53,47 +53,57 @@ export class WithdrawalRequestedHandler implements OnModuleInit {
this.logger.log(`[HANDLE] netAmount: ${payload.netAmount}`);
try {
// TODO: Full implementation steps:
// 1. Validate the withdrawal request
// 2. Get system hot wallet address for the chain
// 3. Create TransactionRequest record
// 4. Request MPC signing
// 5. After signed, broadcast to blockchain
// 6. Monitor for confirmation
// 7. Publish status updates back to wallet-service
// Step 1: 验证链类型
const chainType = this.parseChainType(payload.chainType);
if (!chainType) {
throw new Error(`Unsupported chain type: ${payload.chainType}`);
}
// For now, just log that we received it
this.logger.log(
`[PROCESS] Withdrawal ${payload.orderNo} received for processing`,
);
this.logger.log(
`[PROCESS] Chain: ${payload.chainType}, To: ${payload.toAddress}, Amount: ${payload.netAmount} USDT`,
// Step 2: 检查转账服务是否配置
if (!this.transferService.isConfigured(chainType)) {
throw new Error(`Hot wallet not configured for chain: ${chainType}`);
}
// Step 3: 发布处理中状态
this.logger.log(`[PROCESS] Starting withdrawal ${payload.orderNo}`);
await this.publishStatus(payload, 'PROCESSING', 'Withdrawal is being processed');
// Step 4: 执行 ERC20 转账
this.logger.log(`[PROCESS] Executing ERC20 transfer...`);
const result = await this.transferService.transferUsdt(
chainType,
payload.toAddress,
payload.netAmount.toString(),
);
// Publish acknowledgment event (wallet-service can listen for status updates)
await this.eventPublisher.publish({
eventType: 'blockchain.withdrawal.received',
toPayload: () => ({
orderNo: payload.orderNo,
accountSequence: payload.accountSequence,
status: 'RECEIVED',
message: 'Withdrawal request received by blockchain-service',
}),
eventId: `wd-received-${payload.orderNo}-${Date.now()}`,
occurredAt: new Date(),
});
if (result.success && result.txHash) {
// Step 5a: 转账成功,发布确认状态
this.logger.log(`[SUCCESS] Withdrawal ${payload.orderNo} confirmed!`);
this.logger.log(`[SUCCESS] TxHash: ${result.txHash}`);
this.logger.log(`[SUCCESS] Block: ${result.blockNumber}`);
this.logger.log(
`[COMPLETE] Withdrawal ${payload.orderNo} acknowledged`,
);
await this.eventPublisher.publish({
eventType: 'blockchain.withdrawal.confirmed',
toPayload: () => ({
orderNo: payload.orderNo,
accountSequence: payload.accountSequence,
userId: payload.userId,
status: 'CONFIRMED',
txHash: result.txHash,
blockNumber: result.blockNumber,
chainType: payload.chainType,
toAddress: payload.toAddress,
netAmount: payload.netAmount,
}),
eventId: `wd-confirmed-${payload.orderNo}-${Date.now()}`,
occurredAt: new Date(),
});
// NOTE: Actual blockchain transaction implementation would go here
// This would involve:
// - Creating a TransactionRequest aggregate
// - Calling MPC service for signing
// - Broadcasting the signed transaction
// - Monitoring for confirmations
// - Publishing final status (CONFIRMED or FAILED)
this.logger.log(`[COMPLETE] Withdrawal ${payload.orderNo} completed successfully`);
} else {
// Step 5b: 转账失败
throw new Error(result.error || 'Transfer failed');
}
} catch (error) {
this.logger.error(
@ -101,14 +111,18 @@ export class WithdrawalRequestedHandler implements OnModuleInit {
error,
);
// Publish failure event
// 发布失败事件
await this.eventPublisher.publish({
eventType: 'blockchain.withdrawal.failed',
toPayload: () => ({
orderNo: payload.orderNo,
accountSequence: payload.accountSequence,
userId: payload.userId,
status: 'FAILED',
error: error instanceof Error ? error.message : 'Unknown error',
chainType: payload.chainType,
toAddress: payload.toAddress,
netAmount: payload.netAmount,
}),
eventId: `wd-failed-${payload.orderNo}-${Date.now()}`,
occurredAt: new Date(),
@ -117,4 +131,35 @@ export class WithdrawalRequestedHandler implements OnModuleInit {
throw error;
}
}
/**
*
*/
private async publishStatus(
payload: WithdrawalRequestedPayload,
status: string,
message: string,
): Promise<void> {
await this.eventPublisher.publish({
eventType: 'blockchain.withdrawal.status',
toPayload: () => ({
orderNo: payload.orderNo,
accountSequence: payload.accountSequence,
status,
message,
}),
eventId: `wd-status-${payload.orderNo}-${status}-${Date.now()}`,
occurredAt: new Date(),
});
}
/**
*
*/
private parseChainType(chainType: string): ChainTypeEnum | null {
const normalized = chainType.toUpperCase();
if (normalized === 'KAVA') return ChainTypeEnum.KAVA;
if (normalized === 'BSC') return ChainTypeEnum.BSC;
return null;
}
}

View File

@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { ConfirmationPolicyService, ChainConfigService } from './services';
import { Erc20TransferService } from './services/erc20-transfer.service';
@Module({
providers: [ConfirmationPolicyService, ChainConfigService],
exports: [ConfirmationPolicyService, ChainConfigService],
providers: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService],
exports: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService],
})
export class DomainModule {}

View File

@ -0,0 +1,178 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JsonRpcProvider, Wallet, Contract, parseUnits, formatUnits } from 'ethers';
import { ChainConfigService } from './chain-config.service';
import { ChainType } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
// ERC20 ABI for transfer
const ERC20_TRANSFER_ABI = [
'function transfer(address to, uint256 amount) returns (bool)',
'function balanceOf(address owner) view returns (uint256)',
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
];
export interface TransferResult {
success: boolean;
txHash?: string;
error?: string;
gasUsed?: string;
blockNumber?: number;
}
/**
* ERC20
*
* ERC20
* 使 MPC
*/
@Injectable()
export class Erc20TransferService {
private readonly logger = new Logger(Erc20TransferService.name);
private readonly hotWallets: Map<ChainTypeEnum, Wallet> = new Map();
constructor(
private readonly configService: ConfigService,
private readonly chainConfig: ChainConfigService,
) {
this.initializeHotWallets();
}
private initializeHotWallets(): void {
// 从环境变量获取热钱包私钥
const hotWalletPrivateKey = this.configService.get<string>('HOT_WALLET_PRIVATE_KEY');
if (!hotWalletPrivateKey) {
this.logger.warn('[INIT] HOT_WALLET_PRIVATE_KEY not configured, transfers will fail');
return;
}
// 为每条支持的链创建钱包
for (const chainType of this.chainConfig.getSupportedChains()) {
try {
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
const provider = new JsonRpcProvider(config.rpcUrl, config.chainId);
const wallet = new Wallet(hotWalletPrivateKey, provider);
this.hotWallets.set(chainType, wallet);
this.logger.log(`[INIT] Hot wallet initialized for ${chainType}: ${wallet.address}`);
} catch (error) {
this.logger.error(`[INIT] Failed to initialize wallet for ${chainType}`, error);
}
}
}
/**
*
*/
getHotWalletAddress(chainType: ChainTypeEnum): string | null {
const wallet = this.hotWallets.get(chainType);
return wallet?.address ?? null;
}
/**
* USDT
*/
async getHotWalletBalance(chainType: ChainTypeEnum): Promise<string> {
const wallet = this.hotWallets.get(chainType);
if (!wallet) {
throw new Error(`Hot wallet not configured for chain: ${chainType}`);
}
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
const contract = new Contract(config.usdtContract, ERC20_TRANSFER_ABI, wallet.provider);
const balance = await contract.balanceOf(wallet.address);
const decimals = await contract.decimals();
return formatUnits(balance, decimals);
}
/**
* ERC20
*
* @param chainType (KAVA, BSC)
* @param toAddress
* @param amount ( "100.5")
* @returns
*/
async transferUsdt(
chainType: ChainTypeEnum,
toAddress: string,
amount: string,
): Promise<TransferResult> {
this.logger.log(`[TRANSFER] Starting USDT transfer`);
this.logger.log(`[TRANSFER] Chain: ${chainType}`);
this.logger.log(`[TRANSFER] To: ${toAddress}`);
this.logger.log(`[TRANSFER] Amount: ${amount} USDT`);
const wallet = this.hotWallets.get(chainType);
if (!wallet) {
const error = `Hot wallet not configured for chain: ${chainType}`;
this.logger.error(`[TRANSFER] ${error}`);
return { success: false, error };
}
try {
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
const contract = new Contract(config.usdtContract, ERC20_TRANSFER_ABI, wallet);
// 获取代币精度
const decimals = await contract.decimals();
this.logger.log(`[TRANSFER] Token decimals: ${decimals}`);
// 转换金额
const amountInWei = parseUnits(amount, decimals);
this.logger.log(`[TRANSFER] Amount in wei: ${amountInWei.toString()}`);
// 检查余额
const balance = await contract.balanceOf(wallet.address);
this.logger.log(`[TRANSFER] Hot wallet balance: ${formatUnits(balance, decimals)} USDT`);
if (balance < amountInWei) {
const error = 'Insufficient USDT balance in hot wallet';
this.logger.error(`[TRANSFER] ${error}`);
return { success: false, error };
}
// 执行转账
this.logger.log(`[TRANSFER] Sending transaction...`);
const tx = await contract.transfer(toAddress, amountInWei);
this.logger.log(`[TRANSFER] Transaction sent: ${tx.hash}`);
// 等待确认
this.logger.log(`[TRANSFER] Waiting for confirmation...`);
const receipt = await tx.wait();
if (receipt.status === 1) {
this.logger.log(`[TRANSFER] Transaction confirmed!`);
this.logger.log(`[TRANSFER] Block: ${receipt.blockNumber}`);
this.logger.log(`[TRANSFER] Gas used: ${receipt.gasUsed.toString()}`);
return {
success: true,
txHash: tx.hash,
gasUsed: receipt.gasUsed.toString(),
blockNumber: receipt.blockNumber,
};
} else {
const error = 'Transaction failed (reverted)';
this.logger.error(`[TRANSFER] ${error}`);
return { success: false, txHash: tx.hash, error };
}
} catch (error: any) {
this.logger.error(`[TRANSFER] Transfer failed:`, error);
return {
success: false,
error: error.message || 'Unknown error during transfer',
};
}
}
/**
*
*/
isConfigured(chainType: ChainTypeEnum): boolean {
return this.hotWallets.has(chainType);
}
}

View File

@ -263,6 +263,26 @@ model RecoveryMnemonic {
@@map("recovery_mnemonics")
}
// TOTP 二次验证 - 用于敏感操作 (提现、转账等)
model UserTotp {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
// TOTP 密钥 (AES加密存储)
encryptedSecret String @map("encrypted_secret") @db.VarChar(100)
// 状态
isEnabled Boolean @default(false) @map("is_enabled") // 是否已启用
isVerified Boolean @default(false) @map("is_verified") // 用户是否已验证过一次
createdAt DateTime @default(now()) @map("created_at")
enabledAt DateTime? @map("enabled_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([userId], name: "idx_totp_user")
@@map("user_totp")
}
// 推荐链接 - 用于追踪不同渠道的邀请
model ReferralLink {
linkId BigInt @id @default(autoincrement()) @map("link_id")

View File

@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { UserAccountController } from './controllers/user-account.controller';
import { AuthController } from './controllers/auth.controller';
import { ReferralsController } from './controllers/referrals.controller';
import { TotpController } from './controllers/totp.controller';
import { ApplicationModule } from '@/application/application.module';
@Module({
imports: [ApplicationModule],
controllers: [UserAccountController, AuthController, ReferralsController],
controllers: [UserAccountController, AuthController, ReferralsController, TotpController],
})
export class ApiModule {}

View File

@ -0,0 +1,84 @@
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { TotpService } from '@/application/services/totp.service';
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
class SetupTotpResponseDto {
secret: string;
qrCodeUrl: string;
manualEntryKey: string;
}
class TotpStatusResponseDto {
isEnabled: boolean;
isSetup: boolean;
enabledAt: Date | null;
}
class EnableTotpDto {
code: string;
}
class DisableTotpDto {
code: string;
}
class VerifyTotpDto {
code: string;
}
@ApiTags('TOTP')
@Controller('totp')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class TotpController {
constructor(private readonly totpService: TotpService) {}
@Get('status')
@ApiOperation({ summary: '获取 TOTP 状态', description: '查询当前用户的 TOTP 启用状态' })
@ApiResponse({ status: 200, type: TotpStatusResponseDto })
async getStatus(@CurrentUser() user: CurrentUserPayload): Promise<TotpStatusResponseDto> {
return this.totpService.getTotpStatus(BigInt(user.userId));
}
@Post('setup')
@ApiOperation({ summary: '设置 TOTP', description: '生成 TOTP 密钥,返回二维码和手动输入密钥' })
@ApiResponse({ status: 201, type: SetupTotpResponseDto })
async setup(@CurrentUser() user: CurrentUserPayload): Promise<SetupTotpResponseDto> {
return this.totpService.setupTotp(BigInt(user.userId));
}
@Post('enable')
@ApiOperation({ summary: '启用 TOTP', description: '验证码正确后启用 TOTP 二次验证' })
@ApiResponse({ status: 200, description: 'TOTP 已启用' })
async enable(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: EnableTotpDto,
): Promise<{ success: boolean; message: string }> {
await this.totpService.enableTotp(BigInt(user.userId), dto.code);
return { success: true, message: 'TOTP 已启用' };
}
@Post('disable')
@ApiOperation({ summary: '禁用 TOTP', description: '验证码正确后禁用 TOTP 二次验证' })
@ApiResponse({ status: 200, description: 'TOTP 已禁用' })
async disable(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: DisableTotpDto,
): Promise<{ success: boolean; message: string }> {
await this.totpService.disableTotp(BigInt(user.userId), dto.code);
return { success: true, message: 'TOTP 已禁用' };
}
@Post('verify')
@ApiOperation({ summary: '验证 TOTP', description: '验证 TOTP 验证码是否正确' })
@ApiResponse({ status: 200, description: '验证结果' })
async verify(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: VerifyTotpDto,
): Promise<{ valid: boolean }> {
const valid = await this.totpService.verifyTotp(BigInt(user.userId), dto.code);
return { valid };
}
}

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { UserApplicationService } from './services/user-application.service';
import { TokenService } from './services/token.service';
import { TotpService } from './services/totp.service';
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
@ -17,6 +18,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
providers: [
UserApplicationService,
TokenService,
TotpService,
AutoCreateAccountHandler,
RecoverByMnemonicHandler,
RecoverByPhoneHandler,
@ -31,6 +33,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
exports: [
UserApplicationService,
TokenService,
TotpService,
AutoCreateAccountHandler,
RecoverByMnemonicHandler,
RecoverByPhoneHandler,

View File

@ -0,0 +1,352 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import * as crypto from 'crypto';
/**
* TOTP (Time-based One-Time Password)
*
*/
@Injectable()
export class TotpService {
private readonly logger = new Logger(TotpService.name);
// TOTP 配置
private readonly TOTP_DIGITS = 6; // 验证码位数
private readonly TOTP_PERIOD = 30; // 验证码有效期 (秒)
private readonly TOTP_WINDOW = 1; // 允许的时间窗口偏移
private readonly ISSUER = 'RWADurian'; // 应用名称
// AES 加密密钥 (生产环境应从环境变量获取)
private readonly ENCRYPTION_KEY = process.env.TOTP_ENCRYPTION_KEY || 'rwa-durian-totp-secret-key-32ch';
constructor(private readonly prisma: PrismaService) {}
/**
* TOTP
*/
private generateSecret(): string {
// 生成 20 字节随机数,编码为 base32
const buffer = crypto.randomBytes(20);
return this.base32Encode(buffer);
}
/**
* Base32
*/
private base32Encode(buffer: Buffer): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = 0;
let value = 0;
let result = '';
for (let i = 0; i < buffer.length; i++) {
value = (value << 8) | buffer[i];
bits += 8;
while (bits >= 5) {
result += alphabet[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 31];
}
return result;
}
/**
* Base32
*/
private base32Decode(encoded: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const cleanedInput = encoded.toUpperCase().replace(/=+$/, '');
let bits = 0;
let value = 0;
const output: number[] = [];
for (let i = 0; i < cleanedInput.length; i++) {
const char = cleanedInput[i];
const index = alphabet.indexOf(char);
if (index === -1) continue;
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(output);
}
/**
* TOTP
*/
private generateTOTP(secret: string, counter: number): string {
// 将 counter 转换为 8 字节大端序
const counterBuffer = Buffer.alloc(8);
for (let i = 7; i >= 0; i--) {
counterBuffer[i] = counter & 0xff;
counter = Math.floor(counter / 256);
}
// HMAC-SHA1
const key = this.base32Decode(secret);
const hmac = crypto.createHmac('sha1', key);
hmac.update(counterBuffer);
const hash = hmac.digest();
// 动态截断
const offset = hash[hash.length - 1] & 0x0f;
const binary =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
// 取模生成验证码
const otp = binary % Math.pow(10, this.TOTP_DIGITS);
return otp.toString().padStart(this.TOTP_DIGITS, '0');
}
/**
* AES
*/
private encrypt(text: string): string {
const key = crypto.scryptSync(this.ENCRYPTION_KEY, 'salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
/**
* AES
*/
private decrypt(encryptedText: string): string {
const [ivHex, encrypted] = encryptedText.split(':');
const key = crypto.scryptSync(this.ENCRYPTION_KEY, 'salt', 32);
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* TOTP ()
*/
async setupTotp(userId: bigint): Promise<{
secret: string;
qrCodeUrl: string;
manualEntryKey: string;
}> {
this.logger.log(`设置 TOTP: userId=${userId}`);
// 检查是否已有 TOTP 配置
const existing = await this.prisma.userTotp.findUnique({
where: { userId },
});
if (existing?.isEnabled) {
throw new BadRequestException('TOTP 已启用,如需重置请先禁用');
}
// 生成新密钥
const secret = this.generateSecret();
const encryptedSecret = this.encrypt(secret);
// 获取用户信息用于生成 QR 码
const user = await this.prisma.userAccount.findUnique({
where: { userId },
select: { accountSequence: true, nickname: true },
});
const accountName = user?.accountSequence || `user_${userId}`;
// 生成 otpauth URI
const otpauthUrl = `otpauth://totp/${this.ISSUER}:${accountName}?secret=${secret}&issuer=${this.ISSUER}&algorithm=SHA1&digits=${this.TOTP_DIGITS}&period=${this.TOTP_PERIOD}`;
// 保存或更新 TOTP 配置
if (existing) {
await this.prisma.userTotp.update({
where: { userId },
data: {
encryptedSecret,
isEnabled: false,
isVerified: false,
},
});
} else {
await this.prisma.userTotp.create({
data: {
userId,
encryptedSecret,
isEnabled: false,
isVerified: false,
},
});
}
this.logger.log(`TOTP 密钥已生成: userId=${userId}`);
return {
secret,
qrCodeUrl: otpauthUrl,
manualEntryKey: secret,
};
}
/**
* TOTP
*/
async enableTotp(userId: bigint, code: string): Promise<boolean> {
this.logger.log(`启用 TOTP: userId=${userId}`);
const totp = await this.prisma.userTotp.findUnique({
where: { userId },
});
if (!totp) {
throw new BadRequestException('请先设置 TOTP');
}
if (totp.isEnabled) {
throw new BadRequestException('TOTP 已启用');
}
// 验证码验证
const secret = this.decrypt(totp.encryptedSecret);
const isValid = this.verifyCode(secret, code);
if (!isValid) {
throw new BadRequestException('验证码错误');
}
// 启用 TOTP
await this.prisma.userTotp.update({
where: { userId },
data: {
isEnabled: true,
isVerified: true,
enabledAt: new Date(),
},
});
this.logger.log(`TOTP 已启用: userId=${userId}`);
return true;
}
/**
* TOTP
*/
async disableTotp(userId: bigint, code: string): Promise<boolean> {
this.logger.log(`禁用 TOTP: userId=${userId}`);
const totp = await this.prisma.userTotp.findUnique({
where: { userId },
});
if (!totp || !totp.isEnabled) {
throw new BadRequestException('TOTP 未启用');
}
// 验证码验证
const secret = this.decrypt(totp.encryptedSecret);
const isValid = this.verifyCode(secret, code);
if (!isValid) {
throw new BadRequestException('验证码错误');
}
// 禁用 TOTP
await this.prisma.userTotp.update({
where: { userId },
data: {
isEnabled: false,
enabledAt: null,
},
});
this.logger.log(`TOTP 已禁用: userId=${userId}`);
return true;
}
/**
* TOTP
*/
async verifyTotp(userId: bigint, code: string): Promise<boolean> {
this.logger.log(`验证 TOTP: userId=${userId}`);
const totp = await this.prisma.userTotp.findUnique({
where: { userId },
});
if (!totp || !totp.isEnabled) {
// TOTP 未启用,跳过验证
this.logger.log(`TOTP 未启用,跳过验证: userId=${userId}`);
return true;
}
const secret = this.decrypt(totp.encryptedSecret);
const isValid = this.verifyCode(secret, code);
this.logger.log(`TOTP 验证结果: userId=${userId}, valid=${isValid}`);
return isValid;
}
/**
* TOTP
*/
async isTotpEnabled(userId: bigint): Promise<boolean> {
const totp = await this.prisma.userTotp.findUnique({
where: { userId },
select: { isEnabled: true },
});
return totp?.isEnabled ?? false;
}
/**
* TOTP
*/
async getTotpStatus(userId: bigint): Promise<{
isEnabled: boolean;
isSetup: boolean;
enabledAt: Date | null;
}> {
const totp = await this.prisma.userTotp.findUnique({
where: { userId },
});
return {
isEnabled: totp?.isEnabled ?? false,
isSetup: !!totp,
enabledAt: totp?.enabledAt ?? null,
};
}
/**
*
*/
private verifyCode(secret: string, code: string): boolean {
const now = Math.floor(Date.now() / 1000);
const counter = Math.floor(now / this.TOTP_PERIOD);
// 检查当前和前后时间窗口
for (let i = -this.TOTP_WINDOW; i <= this.TOTP_WINDOW; i++) {
const expectedCode = this.generateTOTP(secret, counter + i);
if (expectedCode === code) {
return true;
}
}
return false;
}
}

View File

@ -11,6 +11,7 @@ import {
import { InternalWalletController } from './controllers/internal-wallet.controller';
import { WalletApplicationService } from '@/application/services';
import { DepositConfirmedHandler, PlantingCreatedHandler } from '@/application/event-handlers';
import { WithdrawalStatusHandler } from '@/application/event-handlers/withdrawal-status.handler';
import { ExpiredRewardsScheduler } from '@/application/schedulers';
import { JwtStrategy } from '@/shared/strategies/jwt.strategy';
@ -36,6 +37,7 @@ import { JwtStrategy } from '@/shared/strategies/jwt.strategy';
WalletApplicationService,
DepositConfirmedHandler,
PlantingCreatedHandler,
WithdrawalStatusHandler,
ExpiredRewardsScheduler,
JwtStrategy,
],

View File

@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Body, UseGuards, Headers, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { WalletApplicationService } from '@/application/services';
import { GetMyWalletQuery } from '@/application/queries';
@ -7,13 +7,17 @@ import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { SettleRewardsDTO, RequestWithdrawalDTO } from '@/api/dto/request';
import { WalletResponseDTO, WithdrawalResponseDTO, WithdrawalListItemDTO } from '@/api/dto/response';
import { IdentityClientService } from '@/infrastructure/external/identity';
@ApiTags('Wallet')
@Controller('wallet')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class WalletController {
constructor(private readonly walletService: WalletApplicationService) {}
constructor(
private readonly walletService: WalletApplicationService,
private readonly identityClient: IdentityClientService,
) {}
@Get('my-wallet')
@ApiOperation({ summary: '查询我的钱包', description: '获取当前用户的钱包余额、算力和奖励信息' })
@ -52,12 +56,32 @@ export class WalletController {
}
@Post('withdraw')
@ApiOperation({ summary: '申请提现', description: '将USDT提现到指定地址' })
@ApiOperation({ summary: '申请提现', description: '将USDT提现到指定地址需要TOTP验证如已启用' })
@ApiResponse({ status: 201, type: WithdrawalResponseDTO })
async requestWithdrawal(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: RequestWithdrawalDTO,
@Headers('authorization') authHeader: string,
): Promise<WithdrawalResponseDTO> {
// 提取 JWT token
const token = authHeader?.replace('Bearer ', '') || '';
// 检查用户是否启用了 TOTP
const totpEnabled = await this.identityClient.isTotpEnabled(user.userId, token);
if (totpEnabled) {
// 如果启用了 TOTP必须提供验证码
if (!dto.totpCode) {
throw new HttpException('请输入谷歌验证码', HttpStatus.BAD_REQUEST);
}
// 验证 TOTP 码
const isValid = await this.identityClient.verifyTotp(user.userId, dto.totpCode, token);
if (!isValid) {
throw new HttpException('验证码错误,请重试', HttpStatus.BAD_REQUEST);
}
}
const command = new RequestWithdrawalCommand(
user.userId,
dto.amount,

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsString, IsEnum, Min, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsString, IsEnum, Min, Matches, IsOptional, Length } from 'class-validator';
import { ChainType } from '@/domain/value-objects';
export class RequestWithdrawalDTO {
@ -19,8 +19,18 @@ export class RequestWithdrawalDTO {
@ApiProperty({
description: '目标链类型',
enum: ChainType,
example: 'BSC',
example: 'KAVA',
})
@IsEnum(ChainType)
chainType: ChainType;
@ApiPropertyOptional({
description: 'TOTP 验证码 (如已启用二次验证)',
example: '123456',
})
@IsOptional()
@IsString()
@Length(6, 6, { message: 'TOTP 验证码必须是6位数字' })
@Matches(/^\d{6}$/, { message: 'TOTP 验证码必须是6位数字' })
totpCode?: string;
}

View File

@ -0,0 +1,121 @@
import { Injectable, Logger, OnModuleInit, Inject } from '@nestjs/common';
import {
WithdrawalEventConsumerService,
WithdrawalConfirmedPayload,
WithdrawalFailedPayload,
} from '@/infrastructure/kafka/withdrawal-event-consumer.service';
import {
IWithdrawalOrderRepository,
WITHDRAWAL_ORDER_REPOSITORY,
IWalletAccountRepository,
WALLET_ACCOUNT_REPOSITORY,
} from '@/domain/repositories';
/**
* Withdrawal Status Handler
*
* Handles withdrawal status events from blockchain-service.
* Updates withdrawal order status and handles fund refunds on failure.
*/
@Injectable()
export class WithdrawalStatusHandler implements OnModuleInit {
private readonly logger = new Logger(WithdrawalStatusHandler.name);
constructor(
private readonly withdrawalEventConsumer: WithdrawalEventConsumerService,
@Inject(WITHDRAWAL_ORDER_REPOSITORY)
private readonly withdrawalRepo: IWithdrawalOrderRepository,
@Inject(WALLET_ACCOUNT_REPOSITORY)
private readonly walletRepo: IWalletAccountRepository,
) {}
onModuleInit() {
this.withdrawalEventConsumer.onWithdrawalConfirmed(
this.handleWithdrawalConfirmed.bind(this),
);
this.withdrawalEventConsumer.onWithdrawalFailed(
this.handleWithdrawalFailed.bind(this),
);
this.logger.log(`[INIT] WithdrawalStatusHandler registered`);
}
/**
* Handle withdrawal confirmed event
* Update order status to CONFIRMED and store txHash
*/
private async handleWithdrawalConfirmed(
payload: WithdrawalConfirmedPayload,
): Promise<void> {
this.logger.log(`[CONFIRMED] Processing withdrawal confirmation`);
this.logger.log(`[CONFIRMED] orderNo: ${payload.orderNo}`);
this.logger.log(`[CONFIRMED] txHash: ${payload.txHash}`);
try {
// Find the withdrawal order
const order = await this.withdrawalRepo.findByOrderNo(payload.orderNo);
if (!order) {
this.logger.error(`[CONFIRMED] Order not found: ${payload.orderNo}`);
return;
}
// Update order status: FROZEN -> BROADCASTED -> CONFIRMED
// If still FROZEN, first mark as broadcasted with txHash
if (order.isFrozen) {
order.markAsBroadcasted(payload.txHash);
}
// Then mark as confirmed
if (order.isBroadcasted) {
order.markAsConfirmed();
}
await this.withdrawalRepo.save(order);
this.logger.log(`[CONFIRMED] Order ${payload.orderNo} confirmed successfully`);
} catch (error) {
this.logger.error(`[CONFIRMED] Failed to process confirmation for ${payload.orderNo}`, error);
throw error;
}
}
/**
* Handle withdrawal failed event
* Update order status to FAILED and refund frozen funds
*/
private async handleWithdrawalFailed(
payload: WithdrawalFailedPayload,
): Promise<void> {
this.logger.log(`[FAILED] Processing withdrawal failure`);
this.logger.log(`[FAILED] orderNo: ${payload.orderNo}`);
this.logger.log(`[FAILED] error: ${payload.error}`);
try {
// Find the withdrawal order
const order = await this.withdrawalRepo.findByOrderNo(payload.orderNo);
if (!order) {
this.logger.error(`[FAILED] Order not found: ${payload.orderNo}`);
return;
}
// Mark order as failed
order.markAsFailed(payload.error);
await this.withdrawalRepo.save(order);
// Refund frozen funds back to available balance if needed
if (order.needsUnfreeze()) {
const wallet = await this.walletRepo.findByUserId(order.userId.toString());
if (wallet) {
// Unfreeze the amount (add back to available balance)
wallet.unfreezeUsdt(order.amount.asNumber);
await this.walletRepo.save(wallet);
this.logger.log(`[FAILED] Refunded ${order.amount.asNumber} USDT to user ${order.userId}`);
}
}
this.logger.log(`[FAILED] Order ${payload.orderNo} marked as failed`);
} catch (error) {
this.logger.error(`[FAILED] Failed to process failure for ${payload.orderNo}`, error);
throw error;
}
}
}

View File

@ -0,0 +1,97 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
/**
* Identity Service
* identity-service API
*/
@Injectable()
export class IdentityClientService {
private readonly logger = new Logger(IdentityClientService.name);
private readonly httpClient: AxiosInstance;
constructor(private readonly configService: ConfigService) {
const baseUrl = this.configService.get<string>('IDENTITY_SERVICE_URL', 'http://localhost:3001');
this.httpClient = axios.create({
baseURL: baseUrl,
timeout: 10000,
});
this.logger.log(`Identity client initialized: ${baseUrl}`);
}
/**
* TOTP
*
* @param userId ID
* @param totpCode TOTP
* @param token JWT token ()
* @returns
*/
async verifyTotp(userId: string, totpCode: string, token: string): Promise<boolean> {
try {
this.logger.log(`验证 TOTP: userId=${userId}`);
const response = await this.httpClient.post(
'/totp/verify',
{ code: totpCode },
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
const valid = response.data?.valid ?? false;
this.logger.log(`TOTP 验证结果: userId=${userId}, valid=${valid}`);
return valid;
} catch (error: any) {
this.logger.error(`TOTP 验证失败: userId=${userId}, error=${error.message}`);
// 如果是 identity-service 返回的错误
if (error.response) {
const status = error.response.status;
const message = error.response.data?.message || 'TOTP 验证失败';
if (status === 400 || status === 401) {
throw new HttpException(message, HttpStatus.BAD_REQUEST);
}
}
// 网络错误或其他错误
throw new HttpException('TOTP 验证服务不可用', HttpStatus.SERVICE_UNAVAILABLE);
}
}
/**
* TOTP
*
* @param userId ID
* @param token JWT token
* @returns TOTP
*/
async isTotpEnabled(userId: string, token: string): Promise<boolean> {
try {
this.logger.log(`检查 TOTP 状态: userId=${userId}`);
const response = await this.httpClient.get('/totp/status', {
headers: {
Authorization: `Bearer ${token}`,
},
});
const isEnabled = response.data?.isEnabled ?? false;
this.logger.log(`TOTP 状态: userId=${userId}, enabled=${isEnabled}`);
return isEnabled;
} catch (error: any) {
this.logger.error(`获取 TOTP 状态失败: userId=${userId}, error=${error.message}`);
// 如果获取状态失败,假设未启用 TOTP允许操作继续
return false;
}
}
}

View File

@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { IdentityClientService } from './identity-client.service';
@Global()
@Module({
imports: [ConfigModule],
providers: [IdentityClientService],
exports: [IdentityClientService],
})
export class IdentityModule {}

View File

@ -0,0 +1,2 @@
export * from './identity-client.service';
export * from './identity.module';

View File

@ -18,6 +18,7 @@ import {
} from '@/domain/repositories';
import { RedisModule } from './redis';
import { KafkaModule } from './kafka';
import { IdentityModule } from './external/identity';
const repositories = [
{
@ -48,8 +49,8 @@ const repositories = [
@Global()
@Module({
imports: [RedisModule, KafkaModule],
imports: [RedisModule, KafkaModule, IdentityModule],
providers: [PrismaService, ...repositories],
exports: [PrismaService, RedisModule, KafkaModule, ...repositories],
exports: [PrismaService, RedisModule, KafkaModule, IdentityModule, ...repositories],
})
export class InfrastructureModule {}

View File

@ -4,6 +4,7 @@ import { ClientsModule, Transport } from '@nestjs/microservices';
import { EventPublisherService } from './event-publisher.service';
import { DepositEventConsumerService } from './deposit-event-consumer.service';
import { PlantingEventConsumerService } from './planting-event-consumer.service';
import { WithdrawalEventConsumerService } from './withdrawal-event-consumer.service';
// [已屏蔽] 前端直接从 reward-service 查询,不再订阅 reward-service 消息
// import { RewardEventConsumerController } from './reward-event-consumer.controller';
// import { EventAckPublisher } from './event-ack.publisher';
@ -35,7 +36,7 @@ import { PrismaService } from '../persistence/prisma/prisma.service';
// [已屏蔽] 前端直接从 reward-service 查询,不再订阅 reward-service 消息
// controllers: [RewardEventConsumerController],
controllers: [],
providers: [PrismaService, EventPublisherService, DepositEventConsumerService, PlantingEventConsumerService],
exports: [EventPublisherService, DepositEventConsumerService, PlantingEventConsumerService, ClientsModule],
providers: [PrismaService, EventPublisherService, DepositEventConsumerService, PlantingEventConsumerService, WithdrawalEventConsumerService],
exports: [EventPublisherService, DepositEventConsumerService, PlantingEventConsumerService, WithdrawalEventConsumerService, ClientsModule],
})
export class KafkaModule {}

View File

@ -0,0 +1,180 @@
/**
* Withdrawal Event Consumer Service for Wallet Service
*
* Consumes withdrawal status events from blockchain-service via Kafka.
* Updates withdrawal order status when transactions are confirmed or failed.
*/
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs';
export const WITHDRAWAL_TOPICS = {
BLOCKCHAIN_EVENTS: 'blockchain.events',
} as const;
export interface WithdrawalConfirmedPayload {
orderNo: string;
accountSequence: string;
userId: string;
status: 'CONFIRMED';
txHash: string;
blockNumber?: number;
chainType: string;
toAddress: string;
netAmount: number;
}
export interface WithdrawalFailedPayload {
orderNo: string;
accountSequence: string;
userId: string;
status: 'FAILED';
error: string;
chainType: string;
toAddress: string;
netAmount: number;
}
export type WithdrawalConfirmedHandler = (payload: WithdrawalConfirmedPayload) => Promise<void>;
export type WithdrawalFailedHandler = (payload: WithdrawalFailedPayload) => Promise<void>;
@Injectable()
export class WithdrawalEventConsumerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WithdrawalEventConsumerService.name);
private kafka: Kafka;
private consumer: Consumer;
private isConnected = false;
private withdrawalConfirmedHandler?: WithdrawalConfirmedHandler;
private withdrawalFailedHandler?: WithdrawalFailedHandler;
constructor(private readonly configService: ConfigService) {}
async onModuleInit() {
const brokers = this.configService.get<string>('KAFKA_BROKERS')?.split(',') || ['localhost:9092'];
const clientId = this.configService.get<string>('KAFKA_CLIENT_ID') || 'wallet-service';
const groupId = 'wallet-service-withdrawal-events';
this.logger.log(`[INIT] Withdrawal Event Consumer initializing...`);
this.logger.log(`[INIT] ClientId: ${clientId}`);
this.logger.log(`[INIT] GroupId: ${groupId}`);
this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`);
this.kafka = new Kafka({
clientId,
brokers,
logLevel: logLevel.WARN,
retry: {
initialRetryTime: 100,
retries: 8,
},
});
this.consumer = this.kafka.consumer({
groupId,
sessionTimeout: 30000,
heartbeatInterval: 3000,
});
try {
this.logger.log(`[CONNECT] Connecting Withdrawal Event consumer...`);
await this.consumer.connect();
this.isConnected = true;
this.logger.log(`[CONNECT] Withdrawal Event consumer connected successfully`);
await this.consumer.subscribe({
topics: Object.values(WITHDRAWAL_TOPICS),
fromBeginning: false,
});
this.logger.log(`[SUBSCRIBE] Subscribed to withdrawal topics`);
await this.startConsuming();
} catch (error) {
this.logger.error(`[ERROR] Failed to connect Withdrawal Event consumer`, error);
}
}
async onModuleDestroy() {
if (this.isConnected) {
await this.consumer.disconnect();
this.logger.log('Withdrawal Event consumer disconnected');
}
}
/**
* Register handler for withdrawal confirmed events
*/
onWithdrawalConfirmed(handler: WithdrawalConfirmedHandler): void {
this.withdrawalConfirmedHandler = handler;
this.logger.log(`[REGISTER] WithdrawalConfirmed handler registered`);
}
/**
* Register handler for withdrawal failed events
*/
onWithdrawalFailed(handler: WithdrawalFailedHandler): void {
this.withdrawalFailedHandler = handler;
this.logger.log(`[REGISTER] WithdrawalFailed handler registered`);
}
private async startConsuming(): Promise<void> {
await this.consumer.run({
eachMessage: async ({ topic, partition, message }: EachMessagePayload) => {
const offset = message.offset;
this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`);
try {
const value = message.value?.toString();
if (!value) {
this.logger.warn(`[RECEIVE] Empty message received on ${topic}`);
return;
}
this.logger.debug(`[RECEIVE] Raw message: ${value.substring(0, 500)}...`);
const parsed = JSON.parse(value);
const eventType = parsed.eventType;
const payload = parsed.payload || parsed;
this.logger.log(`[RECEIVE] Event type: ${eventType}`);
if (eventType === 'blockchain.withdrawal.confirmed') {
this.logger.log(`[HANDLE] Processing WithdrawalConfirmed event`);
this.logger.log(`[HANDLE] orderNo: ${payload.orderNo}`);
this.logger.log(`[HANDLE] txHash: ${payload.txHash}`);
this.logger.log(`[HANDLE] blockNumber: ${payload.blockNumber}`);
if (this.withdrawalConfirmedHandler) {
await this.withdrawalConfirmedHandler(payload as WithdrawalConfirmedPayload);
this.logger.log(`[HANDLE] WithdrawalConfirmed handler completed`);
} else {
this.logger.warn(`[HANDLE] No handler registered for WithdrawalConfirmed`);
}
} else if (eventType === 'blockchain.withdrawal.failed') {
this.logger.log(`[HANDLE] Processing WithdrawalFailed event`);
this.logger.log(`[HANDLE] orderNo: ${payload.orderNo}`);
this.logger.log(`[HANDLE] error: ${payload.error}`);
if (this.withdrawalFailedHandler) {
await this.withdrawalFailedHandler(payload as WithdrawalFailedPayload);
this.logger.log(`[HANDLE] WithdrawalFailed handler completed`);
} else {
this.logger.warn(`[HANDLE] No handler registered for WithdrawalFailed`);
}
} else if (eventType === 'blockchain.withdrawal.status' || eventType === 'blockchain.withdrawal.received') {
// Log status updates but don't process them (informational only)
this.logger.log(`[INFO] Withdrawal status update: ${payload.status} for ${payload.orderNo}`);
} else {
// Ignore other event types
this.logger.debug(`[SKIP] Ignoring event type: ${eventType}`);
}
} catch (error) {
this.logger.error(`[ERROR] Error processing withdrawal event from ${topic}`, error);
}
},
});
this.logger.log(`[START] Started consuming withdrawal events`);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 429 KiB

View File

@ -278,4 +278,185 @@ class WalletService {
rethrow;
}
}
///
///
/// POST /wallet/withdraw (wallet-service)
///
Future<WithdrawResponse> withdrawUsdt({
required double amount,
required String toAddress,
required String chainType,
String? totpCode,
}) async {
try {
debugPrint('[WalletService] ========== 提取积分 ==========');
debugPrint('[WalletService] 请求: POST /wallet/withdraw');
debugPrint('[WalletService] 参数: amount=$amount, toAddress=$toAddress, chainType=$chainType');
final Map<String, dynamic> data = {
'amount': amount,
'toAddress': toAddress,
'chainType': chainType,
};
// TOTP
if (totpCode != null && totpCode.isNotEmpty) {
data['totpCode'] = totpCode;
}
final response = await _apiClient.post(
'/wallet/withdraw',
data: data,
);
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
debugPrint('[WalletService] 响应数据: ${response.data}');
if (response.statusCode == 200 || response.statusCode == 201) {
final responseData = response.data as Map<String, dynamic>;
// data
final data = responseData['data'] as Map<String, dynamic>? ?? responseData;
final result = WithdrawResponse.fromJson(data);
debugPrint('[WalletService] 提取成功: orderNo=${result.orderNo}');
debugPrint('[WalletService] ================================');
return result;
}
debugPrint('[WalletService] 提取失败,状态码: ${response.statusCode}');
//
String errorMessage = '提取失败: ${response.statusCode}';
if (response.data is Map<String, dynamic>) {
final errorData = response.data as Map<String, dynamic>;
errorMessage = errorData['message'] ?? errorData['error'] ?? errorMessage;
}
throw Exception(errorMessage);
} catch (e, stackTrace) {
debugPrint('[WalletService] !!!!!!!!!! 提取积分异常 !!!!!!!!!!');
debugPrint('[WalletService] 错误: $e');
debugPrint('[WalletService] 堆栈: $stackTrace');
rethrow;
}
}
///
///
/// GET /wallet/withdrawals (wallet-service)
Future<List<WithdrawRecord>> getWithdrawals() async {
try {
debugPrint('[WalletService] ========== 获取提取记录 ==========');
debugPrint('[WalletService] 请求: GET /wallet/withdrawals');
final response = await _apiClient.get('/wallet/withdrawals');
debugPrint('[WalletService] 响应状态码: ${response.statusCode}');
if (response.statusCode == 200) {
final responseData = response.data as Map<String, dynamic>;
final dataList = responseData['data'] as List<dynamic>? ??
(response.data is List ? response.data as List<dynamic> : []);
final records = dataList
.map((item) => WithdrawRecord.fromJson(item as Map<String, dynamic>))
.toList();
debugPrint('[WalletService] 获取成功: ${records.length} 条记录');
debugPrint('[WalletService] ================================');
return records;
}
debugPrint('[WalletService] 获取失败,状态码: ${response.statusCode}');
throw Exception('获取提取记录失败: ${response.statusCode}');
} catch (e, stackTrace) {
debugPrint('[WalletService] !!!!!!!!!! 获取提取记录异常 !!!!!!!!!!');
debugPrint('[WalletService] 错误: $e');
debugPrint('[WalletService] 堆栈: $stackTrace');
rethrow;
}
}
}
///
class WithdrawResponse {
final String orderNo;
final String status;
final double amount;
final double fee;
final double netAmount;
final String toAddress;
final String chainType;
final DateTime createdAt;
WithdrawResponse({
required this.orderNo,
required this.status,
required this.amount,
required this.fee,
required this.netAmount,
required this.toAddress,
required this.chainType,
required this.createdAt,
});
factory WithdrawResponse.fromJson(Map<String, dynamic> json) {
return WithdrawResponse(
orderNo: json['orderNo'] ?? json['id'] ?? '',
status: json['status'] ?? 'PENDING',
amount: (json['amount'] ?? 0).toDouble(),
fee: (json['fee'] ?? 0).toDouble(),
netAmount: (json['netAmount'] ?? json['amount'] ?? 0).toDouble(),
toAddress: json['toAddress'] ?? '',
chainType: json['chainType'] ?? 'KAVA',
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt']) ?? DateTime.now()
: DateTime.now(),
);
}
}
///
class WithdrawRecord {
final String orderNo;
final String status;
final double amount;
final double fee;
final double netAmount;
final String toAddress;
final String chainType;
final String? txHash;
final DateTime createdAt;
final DateTime? completedAt;
WithdrawRecord({
required this.orderNo,
required this.status,
required this.amount,
required this.fee,
required this.netAmount,
required this.toAddress,
required this.chainType,
this.txHash,
required this.createdAt,
this.completedAt,
});
factory WithdrawRecord.fromJson(Map<String, dynamic> json) {
return WithdrawRecord(
orderNo: json['orderNo'] ?? json['id'] ?? '',
status: json['status'] ?? 'PENDING',
amount: (json['amount'] ?? 0).toDouble(),
fee: (json['fee'] ?? 0).toDouble(),
netAmount: (json['netAmount'] ?? json['amount'] ?? 0).toDouble(),
toAddress: json['toAddress'] ?? '',
chainType: json['chainType'] ?? 'KAVA',
txHash: json['txHash'],
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt']) ?? DateTime.now()
: DateTime.now(),
completedAt: json['completedAt'] != null
? DateTime.tryParse(json['completedAt'])
: null,
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'withdraw_usdt_page.dart';
import '../../../../core/di/injection_container.dart';
///
///
@ -84,6 +85,16 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
return '${address.substring(0, 8)}...${address.substring(address.length - 8)}';
}
///
String _getChainType(WithdrawNetwork network) {
switch (network) {
case WithdrawNetwork.kava:
return 'KAVA';
case WithdrawNetwork.bsc:
return 'BSC';
}
}
///
Future<void> _onSubmit() async {
final code = _getCode();
@ -105,17 +116,16 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
debugPrint('[WithdrawConfirmPage] 网络: ${_getNetworkName(widget.params.network)}');
debugPrint('[WithdrawConfirmPage] 验证码: $code');
// TODO: API
// final walletService = ref.read(walletServiceProvider);
// await walletService.withdrawUsdt(
// amount: widget.params.amount,
// address: widget.params.address,
// network: widget.params.network.name,
// totpCode: code,
// );
//
final walletService = ref.read(walletServiceProvider);
final response = await walletService.withdrawUsdt(
amount: widget.params.amount,
toAddress: widget.params.address,
chainType: _getChainType(widget.params.network),
totpCode: code,
);
//
await Future.delayed(const Duration(seconds: 2));
debugPrint('[WithdrawConfirmPage] 提取成功: orderNo=${response.orderNo}');
if (mounted) {
setState(() {
@ -131,7 +141,12 @@ class _WithdrawConfirmPageState extends ConsumerState<WithdrawConfirmPage> {
setState(() {
_isSubmitting = false;
});
_showErrorSnackBar('提取失败: ${e.toString()}');
//
String errorMsg = e.toString();
if (errorMsg.contains('Exception:')) {
errorMsg = errorMsg.replaceAll('Exception:', '').trim();
}
_showErrorSnackBar(errorMsg);
}
}
}

View File

@ -17,7 +17,6 @@ import share_plus
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
@ -32,5 +31,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
}

View File

@ -249,14 +249,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@ -629,14 +621,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
@ -1241,18 +1225,18 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900"
url: "https://pub.dev"
source: hosted
version: "10.1.4"
version: "7.2.2"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "3.4.0"
shared_preferences:
dependency: "direct main"
description:
@ -1586,46 +1570,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: d74b66f283afff135d5be0ceccca2ca74dff7df1e9b1eaca6bd4699875d3ae60
url: "https://pub.dev"
source: hosted
version: "2.8.22"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: e4d33b79a064498c6eb3a6a492b6a5012573d4943c28d566caf1a6c0840fe78d
url: "https://pub.dev"
source: hosted
version: "2.8.8"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
url: "https://pub.dev"
source: hosted
version: "6.6.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description: