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:
parent
2dba7633d8
commit
b01277ab7d
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts
vendored
Normal file
97
backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/services/wallet-service/src/infrastructure/external/identity/identity.module.ts
vendored
Normal file
11
backend/services/wallet-service/src/infrastructure/external/identity/identity.module.ts
vendored
Normal 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 {}
|
||||
2
backend/services/wallet-service/src/infrastructure/external/identity/index.ts
vendored
Normal file
2
backend/services/wallet-service/src/infrastructure/external/identity/index.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './identity-client.service';
|
||||
export * from './identity.module';
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue