diff --git a/backend/services/blockchain-service/src/application/event-handlers/withdrawal-requested.handler.ts b/backend/services/blockchain-service/src/application/event-handlers/withdrawal-requested.handler.ts index f03222f5..f251331f 100644 --- a/backend/services/blockchain-service/src/application/event-handlers/withdrawal-requested.handler.ts +++ b/backend/services/blockchain-service/src/application/event-handlers/withdrawal-requested.handler.ts @@ -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 { - 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 { + 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; + } } diff --git a/backend/services/blockchain-service/src/domain/domain.module.ts b/backend/services/blockchain-service/src/domain/domain.module.ts index 666c3909..758adab7 100644 --- a/backend/services/blockchain-service/src/domain/domain.module.ts +++ b/backend/services/blockchain-service/src/domain/domain.module.ts @@ -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 {} diff --git a/backend/services/blockchain-service/src/domain/services/erc20-transfer.service.ts b/backend/services/blockchain-service/src/domain/services/erc20-transfer.service.ts new file mode 100644 index 00000000..f8975d07 --- /dev/null +++ b/backend/services/blockchain-service/src/domain/services/erc20-transfer.service.ts @@ -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 = new Map(); + + constructor( + private readonly configService: ConfigService, + private readonly chainConfig: ChainConfigService, + ) { + this.initializeHotWallets(); + } + + private initializeHotWallets(): void { + // 从环境变量获取热钱包私钥 + const hotWalletPrivateKey = this.configService.get('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 { + 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 { + 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); + } +} diff --git a/backend/services/identity-service/prisma/schema.prisma b/backend/services/identity-service/prisma/schema.prisma index b8e5e01d..bbeaace2 100644 --- a/backend/services/identity-service/prisma/schema.prisma +++ b/backend/services/identity-service/prisma/schema.prisma @@ -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") diff --git a/backend/services/identity-service/src/api/api.module.ts b/backend/services/identity-service/src/api/api.module.ts index 1e8f92cc..ebeab3ff 100644 --- a/backend/services/identity-service/src/api/api.module.ts +++ b/backend/services/identity-service/src/api/api.module.ts @@ -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 {} diff --git a/backend/services/identity-service/src/api/controllers/totp.controller.ts b/backend/services/identity-service/src/api/controllers/totp.controller.ts new file mode 100644 index 00000000..59d33150 --- /dev/null +++ b/backend/services/identity-service/src/api/controllers/totp.controller.ts @@ -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 { + 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 { + 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 }; + } +} diff --git a/backend/services/identity-service/src/application/application.module.ts b/backend/services/identity-service/src/application/application.module.ts index 7918f6b0..e2f87041 100644 --- a/backend/services/identity-service/src/application/application.module.ts +++ b/backend/services/identity-service/src/application/application.module.ts @@ -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, diff --git a/backend/services/identity-service/src/application/services/totp.service.ts b/backend/services/identity-service/src/application/services/totp.service.ts new file mode 100644 index 00000000..fa3d90fc --- /dev/null +++ b/backend/services/identity-service/src/application/services/totp.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/services/wallet-service/src/api/api.module.ts b/backend/services/wallet-service/src/api/api.module.ts index 132b6c16..3e5b5809 100644 --- a/backend/services/wallet-service/src/api/api.module.ts +++ b/backend/services/wallet-service/src/api/api.module.ts @@ -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, ], diff --git a/backend/services/wallet-service/src/api/controllers/wallet.controller.ts b/backend/services/wallet-service/src/api/controllers/wallet.controller.ts index 43af5c37..355749b4 100644 --- a/backend/services/wallet-service/src/api/controllers/wallet.controller.ts +++ b/backend/services/wallet-service/src/api/controllers/wallet.controller.ts @@ -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 { + // 提取 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, diff --git a/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts b/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts index ab659026..ad9d30c7 100644 --- a/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts +++ b/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts @@ -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; } diff --git a/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts b/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts new file mode 100644 index 00000000..052c26c9 --- /dev/null +++ b/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts b/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts new file mode 100644 index 00000000..361d1bbd --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts @@ -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('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 { + 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 { + 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; + } + } +} diff --git a/backend/services/wallet-service/src/infrastructure/external/identity/identity.module.ts b/backend/services/wallet-service/src/infrastructure/external/identity/identity.module.ts new file mode 100644 index 00000000..1e16500b --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/external/identity/identity.module.ts @@ -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 {} diff --git a/backend/services/wallet-service/src/infrastructure/external/identity/index.ts b/backend/services/wallet-service/src/infrastructure/external/identity/index.ts new file mode 100644 index 00000000..e636c083 --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/external/identity/index.ts @@ -0,0 +1,2 @@ +export * from './identity-client.service'; +export * from './identity.module'; diff --git a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts index 4c8f1c7c..0ed62351 100644 --- a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts @@ -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 {} diff --git a/backend/services/wallet-service/src/infrastructure/kafka/kafka.module.ts b/backend/services/wallet-service/src/infrastructure/kafka/kafka.module.ts index 5455d093..151b8a9c 100644 --- a/backend/services/wallet-service/src/infrastructure/kafka/kafka.module.ts +++ b/backend/services/wallet-service/src/infrastructure/kafka/kafka.module.ts @@ -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 {} diff --git a/backend/services/wallet-service/src/infrastructure/kafka/withdrawal-event-consumer.service.ts b/backend/services/wallet-service/src/infrastructure/kafka/withdrawal-event-consumer.service.ts new file mode 100644 index 00000000..4d4a4c59 --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/kafka/withdrawal-event-consumer.service.ts @@ -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; +export type WithdrawalFailedHandler = (payload: WithdrawalFailedPayload) => Promise; + +@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('KAFKA_BROKERS')?.split(',') || ['localhost:9092']; + const clientId = this.configService.get('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 { + 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`); + } +} diff --git a/frontend/mobile-app/assets/images/logo/app_icon.png b/frontend/mobile-app/assets/images/logo/app_icon.png index 7fe21e1e..a90ec4d8 100644 Binary files a/frontend/mobile-app/assets/images/logo/app_icon.png and b/frontend/mobile-app/assets/images/logo/app_icon.png differ diff --git a/frontend/mobile-app/lib/core/services/wallet_service.dart b/frontend/mobile-app/lib/core/services/wallet_service.dart index 3bf7f92c..3c27dd86 100644 --- a/frontend/mobile-app/lib/core/services/wallet_service.dart +++ b/frontend/mobile-app/lib/core/services/wallet_service.dart @@ -278,4 +278,185 @@ class WalletService { rethrow; } } + + /// 提取积分 + /// + /// 调用 POST /wallet/withdraw (wallet-service) + /// 将积分提取到指定地址 + Future 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 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; + // 处理可能的嵌套 data 结构 + final data = responseData['data'] as Map? ?? 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) { + final errorData = response.data as Map; + 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> 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; + final dataList = responseData['data'] as List? ?? + (response.data is List ? response.data as List : []); + + final records = dataList + .map((item) => WithdrawRecord.fromJson(item as Map)) + .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 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 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, + ); + } } diff --git a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart index 16537295..0d8f7c34 100644 --- a/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart +++ b/frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_confirm_page.dart @@ -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 { 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 _onSubmit() async { final code = _getCode(); @@ -105,17 +116,16 @@ class _WithdrawConfirmPageState extends ConsumerState { 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 { setState(() { _isSubmitting = false; }); - _showErrorSnackBar('提取失败: ${e.toString()}'); + // 提取更友好的错误信息 + String errorMsg = e.toString(); + if (errorMsg.contains('Exception:')) { + errorMsg = errorMsg.replaceAll('Exception:', '').trim(); + } + _showErrorSnackBar(errorMsg); } } } diff --git a/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift index 3b887e63..87cdbc2b 100644 --- a/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/frontend/mobile-app/pubspec.lock b/frontend/mobile-app/pubspec.lock index d1c2f51e..c2d73878 100644 --- a/frontend/mobile-app/pubspec.lock +++ b/frontend/mobile-app/pubspec.lock @@ -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: