feat(withdrawal): implement fiat withdrawal with bank/alipay/wechat

Add complete fiat withdrawal feature that allows users to withdraw
green credits (绿积分) to their bank card, Alipay, or WeChat account
with 1:1 CNY conversion. Key changes:

Backend (wallet-service):
- Update Prisma schema with fiat withdrawal fields (paymentMethod,
  bankName, bankCardNo, cardHolderName, alipay*, wechat*, review fields)
- Rewrite withdrawal status enum for fiat flow: PENDING → FROZEN →
  REVIEWING → APPROVED → PAYING → COMPLETED (or REJECTED/FAILED)
- Add PaymentMethod enum: BANK_CARD, ALIPAY, WECHAT
- Update WithdrawalOrderAggregate with new fiat withdrawal methods
- Add review/payment workflow methods in WalletApplicationService
- Add internal API endpoints for admin withdrawal management
- Remove blockchain withdrawal event handler (no longer needed)

Frontend (admin-web):
- Add withdrawal review management page at /withdrawals
- Add tabs for reviewing/approved/paying order states
- Add withdrawal service and React Query hooks
- Add types for withdrawal orders and payment methods
- Add sidebar menu item for withdrawal review

Frontend (mobile-app):
- Add withdrawFiat() method to WalletService
- Add PaymentMethod enum with BANK_CARD/ALIPAY/WECHAT
- Create new WithdrawFiatPage for fiat withdrawal input
- Create WithdrawFiatConfirmPage with SMS + password verification
- Add routes for /withdraw/fiat and /withdraw/fiat/confirm
- Keep existing withdraw/usdt (划转) pages unchanged

Note: The existing withdraw_usdt_page.dart is for point-to-point
transfer (划转), which is a different feature from fiat withdrawal.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-03 05:28:05 -08:00
parent 036696878f
commit 288d894746
28 changed files with 4955 additions and 815 deletions

View File

@ -0,0 +1,77 @@
-- 法币提现功能迁移
-- 将原来的区块链提现改为法币提现(银行卡/支付宝/微信)
-- 1. 添加新字段到 withdrawal_orders 表
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "withdrawal_type" VARCHAR(20) DEFAULT 'FIAT';
-- 银行卡信息
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "bank_name" VARCHAR(100);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "bank_card_no" VARCHAR(30);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "card_holder_name" VARCHAR(50);
-- 支付宝信息
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "alipay_account" VARCHAR(100);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "alipay_real_name" VARCHAR(50);
-- 微信信息
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "wechat_account" VARCHAR(100);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "wechat_real_name" VARCHAR(50);
-- 收款方式: BANK_CARD / ALIPAY / WECHAT
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "payment_method" VARCHAR(20);
-- 审核相关
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "reviewed_by" VARCHAR(50);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "reviewed_at" TIMESTAMP(6);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "review_remark" VARCHAR(500);
-- 打款相关
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "paid_by" VARCHAR(50);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "paid_at" TIMESTAMP(6);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "payment_proof" VARCHAR(500);
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "payment_remark" VARCHAR(500);
-- 详细备注(记录完整的提现过程)
ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "detail_memo" TEXT;
-- 2. 更新索引
CREATE INDEX IF NOT EXISTS "idx_withdrawal_payment_method" ON "withdrawal_orders" ("payment_method");
CREATE INDEX IF NOT EXISTS "idx_withdrawal_reviewed_at" ON "withdrawal_orders" ("reviewed_at");
CREATE INDEX IF NOT EXISTS "idx_withdrawal_paid_at" ON "withdrawal_orders" ("paid_at");
-- 3. 创建用户收款账户表(保存用户的常用收款信息)
CREATE TABLE IF NOT EXISTS "user_payment_accounts" (
"account_id" BIGSERIAL PRIMARY KEY,
"account_sequence" VARCHAR(20) NOT NULL,
"user_id" BIGINT NOT NULL,
-- 收款方式
"payment_method" VARCHAR(20) NOT NULL,
-- 银行卡信息
"bank_name" VARCHAR(100),
"bank_card_no" VARCHAR(30),
"card_holder_name" VARCHAR(50),
-- 支付宝信息
"alipay_account" VARCHAR(100),
"alipay_real_name" VARCHAR(50),
-- 微信信息
"wechat_account" VARCHAR(100),
"wechat_real_name" VARCHAR(50),
-- 是否为默认账户
"is_default" BOOLEAN DEFAULT false,
-- 状态
"is_active" BOOLEAN DEFAULT true,
"created_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "idx_payment_account_sequence" ON "user_payment_accounts" ("account_sequence");
CREATE INDEX IF NOT EXISTS "idx_payment_user_id" ON "user_payment_accounts" ("user_id");
CREATE INDEX IF NOT EXISTS "idx_payment_method_type" ON "user_payment_accounts" ("payment_method");
CREATE INDEX IF NOT EXISTS "idx_payment_is_default" ON "user_payment_accounts" ("is_default");

View File

@ -170,7 +170,7 @@ model SettlementOrder {
}
// ============================================
// 提现订单表
// 提现订单表 (法币提现: 银行卡/支付宝/微信)
// ============================================
model WithdrawalOrder {
id BigInt @id @default(autoincrement()) @map("order_id")
@ -179,38 +179,111 @@ model WithdrawalOrder {
userId BigInt @map("user_id")
// 提现信息
amount Decimal @map("amount") @db.Decimal(20, 8) // 提现金额
amount Decimal @map("amount") @db.Decimal(20, 8) // 提现金额 (绿积分, 1:1人民币)
fee Decimal @map("fee") @db.Decimal(20, 8) // 手续费
chainType String @map("chain_type") @db.VarChar(20) // 目标链 (BSC/KAVA)
toAddress String @map("to_address") @db.VarChar(100) // 提现目标地址
// 交易信息
txHash String? @map("tx_hash") @db.VarChar(100) // 链上交易哈希
// 提现类型: FIAT (法币)
withdrawalType String @default("FIAT") @map("withdrawal_type") @db.VarChar(20)
// 内部转账标识
isInternalTransfer Boolean @default(false) @map("is_internal_transfer") // 是否为内部转账ID转ID
toAccountSequence String? @map("to_account_sequence") @db.VarChar(20) // 接收方ID内部转账时有值
toUserId BigInt? @map("to_user_id") // 接收方用户ID内部转账时有值
// 收款方式: BANK_CARD / ALIPAY / WECHAT
paymentMethod String? @map("payment_method") @db.VarChar(20)
// 状态
// 银行卡信息
bankName String? @map("bank_name") @db.VarChar(100)
bankCardNo String? @map("bank_card_no") @db.VarChar(30)
cardHolderName String? @map("card_holder_name") @db.VarChar(50)
// 支付宝信息
alipayAccount String? @map("alipay_account") @db.VarChar(100)
alipayRealName String? @map("alipay_real_name") @db.VarChar(50)
// 微信信息
wechatAccount String? @map("wechat_account") @db.VarChar(100)
wechatRealName String? @map("wechat_real_name") @db.VarChar(50)
// 状态: PENDING -> REVIEWING -> APPROVED -> PAYING -> COMPLETED / REJECTED / FAILED
status String @default("PENDING") @map("status") @db.VarChar(20)
errorMessage String? @map("error_message") @db.VarChar(500)
// 审核信息
reviewedBy String? @map("reviewed_by") @db.VarChar(50) // 审核人
reviewedAt DateTime? @map("reviewed_at") // 审核时间
reviewRemark String? @map("review_remark") @db.VarChar(500) // 审核备注
// 打款信息
paidBy String? @map("paid_by") @db.VarChar(50) // 打款人
paidAt DateTime? @map("paid_at") // 打款时间
paymentProof String? @map("payment_proof") @db.VarChar(500) // 打款凭证(截图URL等)
paymentRemark String? @map("payment_remark") @db.VarChar(500) // 打款备注
// 详细备注(记录完整的提现过程)
detailMemo String? @map("detail_memo") @db.Text
// 时间戳
frozenAt DateTime? @map("frozen_at")
broadcastedAt DateTime? @map("broadcasted_at")
confirmedAt DateTime? @map("confirmed_at")
frozenAt DateTime? @map("frozen_at") // 资金冻结时间
completedAt DateTime? @map("completed_at") // 完成时间
createdAt DateTime @default(now()) @map("created_at")
// 兼容旧字段(已弃用,保留用于迁移)
chainType String? @map("chain_type") @db.VarChar(20)
toAddress String? @map("to_address") @db.VarChar(100)
txHash String? @map("tx_hash") @db.VarChar(100)
isInternalTransfer Boolean @default(false) @map("is_internal_transfer")
toAccountSequence String? @map("to_account_sequence") @db.VarChar(20)
toUserId BigInt? @map("to_user_id")
broadcastedAt DateTime? @map("broadcasted_at")
confirmedAt DateTime? @map("confirmed_at")
@@map("withdrawal_orders")
@@index([accountSequence])
@@index([userId])
@@index([status])
@@index([chainType])
@@index([txHash])
@@index([paymentMethod])
@@index([reviewedAt])
@@index([paidAt])
@@index([createdAt])
}
// ============================================
// 用户收款账户表(保存用户的常用收款信息)
// ============================================
model UserPaymentAccount {
id BigInt @id @default(autoincrement()) @map("account_id")
accountSequence String @map("account_sequence") @db.VarChar(20)
userId BigInt @map("user_id")
// 收款方式: BANK_CARD / ALIPAY / WECHAT
paymentMethod String @map("payment_method") @db.VarChar(20)
// 银行卡信息
bankName String? @map("bank_name") @db.VarChar(100)
bankCardNo String? @map("bank_card_no") @db.VarChar(30)
cardHolderName String? @map("card_holder_name") @db.VarChar(50)
// 支付宝信息
alipayAccount String? @map("alipay_account") @db.VarChar(100)
alipayRealName String? @map("alipay_real_name") @db.VarChar(50)
// 微信信息
wechatAccount String? @map("wechat_account") @db.VarChar(100)
wechatRealName String? @map("wechat_real_name") @db.VarChar(50)
// 是否为默认账户
isDefault Boolean @default(false) @map("is_default")
// 状态
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("user_payment_accounts")
@@index([accountSequence])
@@index([userId])
@@index([paymentMethod])
@@index([isDefault])
}
// ============================================
// 待领取奖励表 (逐笔记录)
// 每笔待领取奖励独立跟踪过期时间

View File

@ -11,7 +11,6 @@ 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';
@ -37,7 +36,6 @@ import { JwtStrategy } from '@/shared/strategies/jwt.strategy';
WalletApplicationService,
DepositConfirmedHandler,
PlantingCreatedHandler,
WithdrawalStatusHandler,
ExpiredRewardsScheduler,
JwtStrategy,
],

View File

@ -9,6 +9,9 @@ import {
FreezeForPlantingCommand,
ConfirmPlantingDeductionCommand,
UnfreezeForPlantingCommand,
ReviewWithdrawalCommand,
StartPaymentCommand,
CompletePaymentCommand,
} from '@/application/commands';
import { Public } from '@/shared/decorators';
@ -194,4 +197,77 @@ export class InternalWalletController {
this.logger.log(`结算结果: ${JSON.stringify(result)}`);
return result;
}
// =============== 提现审核管理 (管理后台调用) ===============
@Get('withdrawals/reviewing')
@Public()
@ApiOperation({ summary: '查询待审核提现订单(内部API)' })
@ApiResponse({ status: 200, description: '待审核提现订单列表' })
async getReviewingWithdrawals() {
return this.walletService.getReviewingWithdrawals();
}
@Get('withdrawals/approved')
@Public()
@ApiOperation({ summary: '查询待打款提现订单(内部API)' })
@ApiResponse({ status: 200, description: '待打款提现订单列表' })
async getApprovedWithdrawals() {
return this.walletService.getApprovedWithdrawals();
}
@Get('withdrawals/paying')
@Public()
@ApiOperation({ summary: '查询打款中提现订单(内部API)' })
@ApiResponse({ status: 200, description: '打款中提现订单列表' })
async getPayingWithdrawals() {
return this.walletService.getPayingWithdrawals();
}
@Post('withdrawals/:orderNo/review')
@Public()
@ApiOperation({ summary: '审核提现订单(内部API)' })
@ApiParam({ name: 'orderNo', description: '提现订单号' })
@ApiResponse({ status: 200, description: '审核结果' })
async reviewWithdrawal(
@Param('orderNo') orderNo: string,
@Body() dto: { approved: boolean; reviewedBy: string; remark?: string },
) {
this.logger.log(`审核提现订单: ${orderNo}, approved=${dto.approved}, by=${dto.reviewedBy}`);
const command = new ReviewWithdrawalCommand(
orderNo,
dto.approved,
dto.reviewedBy,
dto.remark,
);
return this.walletService.reviewWithdrawal(command);
}
@Post('withdrawals/:orderNo/start-payment')
@Public()
@ApiOperation({ summary: '开始打款(内部API)' })
@ApiParam({ name: 'orderNo', description: '提现订单号' })
@ApiResponse({ status: 200, description: '开始打款结果' })
async startPayment(
@Param('orderNo') orderNo: string,
@Body() dto: { paidBy: string },
) {
this.logger.log(`开始打款: ${orderNo}, by=${dto.paidBy}`);
const command = new StartPaymentCommand(orderNo, dto.paidBy);
return this.walletService.startPayment(command);
}
@Post('withdrawals/:orderNo/complete-payment')
@Public()
@ApiOperation({ summary: '完成打款(内部API)' })
@ApiParam({ name: 'orderNo', description: '提现订单号' })
@ApiResponse({ status: 200, description: '完成打款结果' })
async completePayment(
@Param('orderNo') orderNo: string,
@Body() dto: { paymentProof?: string; remark?: string },
) {
this.logger.log(`完成打款: ${orderNo}`);
const command = new CompletePaymentCommand(orderNo, dto.paymentProof, dto.remark);
return this.walletService.completePayment(command);
}
}

View File

@ -1,14 +1,16 @@
import { Controller, Get, Post, Body, Query, UseGuards, Headers, HttpException, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Body, Query, UseGuards, Headers, HttpException, HttpStatus, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { WalletApplicationService } from '@/application/services';
import { GetMyWalletQuery } from '@/application/queries';
import { ClaimRewardsCommand, SettleRewardsCommand, RequestWithdrawalCommand } from '@/application/commands';
import { ClaimRewardsCommand, SettleRewardsCommand, RequestWithdrawalCommand, CancelWithdrawalCommand } from '@/application/commands';
import { PaymentAccountDto } from '@/application/commands/request-withdrawal.command';
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { SettleRewardsDTO, RequestWithdrawalDTO } from '@/api/dto/request';
import { SettleRewardsDTO, RequestWithdrawalDTO, CancelWithdrawalDTO } from '@/api/dto/request';
import { WalletResponseDTO, WithdrawalResponseDTO, WithdrawalListItemDTO, WithdrawalFeeConfigResponseDTO, CalculateFeeResponseDTO } from '@/api/dto/response';
import { IdentityClientService } from '@/infrastructure/external/identity';
import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories';
import { PaymentMethod } from '@/domain/value-objects/withdrawal-status.enum';
@ApiTags('Wallet')
@Controller('wallet')
@ -72,7 +74,7 @@ export class WalletController {
}
@Post('withdraw')
@ApiOperation({ summary: '申请提现', description: '将USDT提现到指定地址,需要短信验证和密码验证' })
@ApiOperation({ summary: '申请提现 (法币)', description: '将绿积分提现到银行卡/支付宝/微信,需要短信验证和密码验证' })
@ApiResponse({ status: 201, type: WithdrawalResponseDTO })
async requestWithdrawal(
@CurrentUser() user: CurrentUserPayload,
@ -102,30 +104,68 @@ export class WalletController {
throw new HttpException('登录密码错误,请重试', HttpStatus.BAD_REQUEST);
}
// 处理 toAddress: 如果是 accountSequence 格式,转换为区块链地址
let actualAddress = dto.toAddress;
if (dto.toAddress.startsWith('D') && dto.toAddress.length === 12) {
// accountSequence 格式,需要查询对应的区块链地址
const resolvedAddress = await this.identityClient.resolveAccountSequenceToAddress(
dto.toAddress,
dto.chainType,
token,
);
if (!resolvedAddress) {
throw new HttpException('无效的充值ID未找到对应地址', HttpStatus.BAD_REQUEST);
}
actualAddress = resolvedAddress;
}
// 验证收款账户信息完整性
this.validatePaymentAccount(dto);
// 构建收款账户信息
const paymentAccount: PaymentAccountDto = {
paymentMethod: dto.paymentMethod,
bankName: dto.bankName,
bankCardNo: dto.bankCardNo,
cardHolderName: dto.cardHolderName,
alipayAccount: dto.alipayAccount,
alipayRealName: dto.alipayRealName,
wechatAccount: dto.wechatAccount,
wechatRealName: dto.wechatRealName,
};
const command = new RequestWithdrawalCommand(
user.accountSequence,
user.userId,
dto.amount,
actualAddress,
dto.chainType,
paymentAccount,
);
return this.walletService.requestWithdrawal(command);
}
/**
*
*/
private validatePaymentAccount(dto: RequestWithdrawalDTO): void {
switch (dto.paymentMethod) {
case PaymentMethod.BANK_CARD:
if (!dto.bankName || !dto.bankCardNo || !dto.cardHolderName) {
throw new HttpException('请填写完整的银行卡信息', HttpStatus.BAD_REQUEST);
}
break;
case PaymentMethod.ALIPAY:
if (!dto.alipayAccount || !dto.alipayRealName) {
throw new HttpException('请填写完整的支付宝信息', HttpStatus.BAD_REQUEST);
}
break;
case PaymentMethod.WECHAT:
if (!dto.wechatAccount || !dto.wechatRealName) {
throw new HttpException('请填写完整的微信信息', HttpStatus.BAD_REQUEST);
}
break;
default:
throw new HttpException('不支持的收款方式', HttpStatus.BAD_REQUEST);
}
}
@Post('withdraw/:orderNo/cancel')
@ApiOperation({ summary: '取消提现', description: '取消未审核的提现订单' })
@ApiResponse({ status: 200, description: '取消成功' })
async cancelWithdrawal(
@CurrentUser() user: CurrentUserPayload,
@Param('orderNo') orderNo: string,
@Body() dto: CancelWithdrawalDTO,
): Promise<{ orderNo: string; status: string }> {
// TODO: 验证订单属于当前用户
const command = new CancelWithdrawalCommand(orderNo, dto.reason);
return this.walletService.cancelWithdrawal(command);
}
@Get('withdrawals')
@ApiOperation({ summary: '查询提现记录', description: '获取用户的提现订单列表' })
@ApiResponse({ status: 200, type: [WithdrawalListItemDTO] })
@ -195,8 +235,8 @@ export class WalletController {
}
@Get('calculate-fee')
@ApiOperation({ summary: '计算提取手续费', description: '根据提取金额计算手续费' })
@ApiQuery({ name: 'amount', type: Number, description: '提取金额' })
@ApiOperation({ summary: '计算提现手续费', description: '根据提现金额计算手续费' })
@ApiQuery({ name: 'amount', type: Number, description: '提现金额 (绿积分)' })
@ApiResponse({ status: 200, type: CalculateFeeResponseDTO })
async calculateFee(
@Query('amount') amountStr: string,
@ -212,7 +252,7 @@ export class WalletController {
amount,
fee,
totalRequired: amount + fee,
receiverGets: amount, // 接收方收到完整金额
receiverGets: amount - fee, // 实际到账金额(扣除手续费)
feeType,
feeValue,
};

View File

@ -1,31 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsString, IsEnum, Min, Matches, IsOptional, Length } from 'class-validator';
import { ChainType } from '@/domain/value-objects';
import { IsNumber, IsString, IsEnum, Min, IsOptional, Length, ValidateIf, Matches } from 'class-validator';
import { PaymentMethod } from '@/domain/value-objects/withdrawal-status.enum';
/**
* DTO
*/
export class RequestWithdrawalDTO {
@ApiProperty({ description: '提现金额 (USDT)', example: 100 })
@ApiProperty({ description: '提现金额 (绿积分1:1人民币)', example: 100 })
@IsNumber()
@Min(10, { message: '最小提现金额为 10 USDT' })
@Min(100, { message: '最小提现金额为 100 绿积分' })
amount: number;
@ApiProperty({
description: '提现目标地址 (EVM地址或充值ID)',
example: '0x1234567890abcdef1234567890abcdef12345678',
description: '收款方式',
enum: PaymentMethod,
example: PaymentMethod.BANK_CARD,
})
@IsEnum(PaymentMethod, { message: '请选择有效的收款方式' })
paymentMethod: PaymentMethod;
// 银行卡相关字段
@ApiPropertyOptional({ description: '银行名称', example: '中国工商银行' })
@ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD)
@IsString()
@Matches(/^(0x[a-fA-F0-9]{40}|D\d{11})$/, {
message: '无效的地址格式请输入EVM地址(0x...)或充值ID(D...)',
})
toAddress: string;
bankName?: string;
@ApiProperty({
description: '目标链类型',
enum: ChainType,
example: 'KAVA',
})
@IsEnum(ChainType)
chainType: ChainType;
@ApiPropertyOptional({ description: '银行卡号', example: '6222021234567890123' })
@ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD)
@IsString()
@Matches(/^\d{16,19}$/, { message: '请输入正确的银行卡号16-19位数字' })
bankCardNo?: string;
@ApiPropertyOptional({ description: '持卡人姓名', example: '张三' })
@ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD)
@IsString()
cardHolderName?: string;
// 支付宝相关字段
@ApiPropertyOptional({ description: '支付宝账号', example: '13800138000' })
@ValidateIf(o => o.paymentMethod === PaymentMethod.ALIPAY)
@IsString()
alipayAccount?: string;
@ApiPropertyOptional({ description: '支付宝实名', example: '张三' })
@ValidateIf(o => o.paymentMethod === PaymentMethod.ALIPAY)
@IsString()
alipayRealName?: string;
// 微信相关字段
@ApiPropertyOptional({ description: '微信账号', example: 'wxid_abc123' })
@ValidateIf(o => o.paymentMethod === PaymentMethod.WECHAT)
@IsString()
wechatAccount?: string;
@ApiPropertyOptional({ description: '微信实名', example: '张三' })
@ValidateIf(o => o.paymentMethod === PaymentMethod.WECHAT)
@IsString()
wechatRealName?: string;
// 验证字段
@ApiProperty({
description: '短信验证码',
example: '123456',
@ -43,3 +76,17 @@ export class RequestWithdrawalDTO {
@Length(6, 32, { message: '密码长度必须在6-32位之间' })
password: string;
}
/**
* DTO
*/
export class CancelWithdrawalDTO {
@ApiProperty({ description: '提现订单号', example: 'WD1234567890ABCD' })
@IsString()
orderNo: string;
@ApiPropertyOptional({ description: '取消原因', example: '不想提现了' })
@IsOptional()
@IsString()
reason?: string;
}

View File

@ -1,47 +1,103 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO
*/
export class WithdrawalResponseDTO {
@ApiProperty({ description: '提现订单号', example: 'WD1234567890ABCD' })
orderNo: string;
@ApiProperty({ description: '提现金额', example: 100 })
@ApiProperty({ description: '提现金额 (绿积分)', example: 100 })
amount: number;
@ApiProperty({ description: '手续费', example: 1 })
@ApiProperty({ description: '手续费 (绿积分)', example: 1 })
fee: number;
@ApiProperty({ description: '实际到账金额', example: 99 })
@ApiProperty({ description: '实际到账金额 (元人民币)', example: 99 })
netAmount: number;
@ApiProperty({ description: '订单状态', example: 'FROZEN' })
@ApiProperty({ description: '订单状态', example: 'REVIEWING' })
status: string;
@ApiProperty({ description: '收款方式', example: 'BANK_CARD' })
paymentMethod: string;
}
/**
* DTO ()
*/
export class WithdrawalListItemDTO {
@ApiProperty({ description: '提现订单号', example: 'WD1234567890ABCD' })
orderNo: string;
@ApiProperty({ description: '提现金额', example: 100 })
@ApiProperty({ description: '提现金额 (绿积分)', example: 100 })
amount: number;
@ApiProperty({ description: '手续费', example: 1 })
@ApiProperty({ description: '手续费 (绿积分)', example: 1 })
fee: number;
@ApiProperty({ description: '实际到账金额', example: 99 })
@ApiProperty({ description: '实际到账金额 (元人民币)', example: 99 })
netAmount: number;
@ApiProperty({ description: '目标链', example: 'BSC' })
chainType: string;
@ApiProperty({ description: '收款方式', example: 'BANK_CARD' })
paymentMethod: string;
@ApiProperty({ description: '提现地址', example: '0x1234...' })
toAddress: string;
@ApiProperty({ description: '收款账户显示', example: '工商银行 ****1234 (张三)' })
paymentAccountDisplay: string;
@ApiProperty({ description: '链上交易哈希', nullable: true })
txHash: string | null;
@ApiProperty({ description: '订单状态', example: 'CONFIRMED' })
@ApiProperty({ description: '订单状态', example: 'COMPLETED' })
status: string;
@ApiProperty({ description: '创建时间' })
createdAt: string;
@ApiPropertyOptional({ description: '完成时间' })
completedAt: string | null;
}
/**
* DTO ()
*/
export class WithdrawalOrderDetailDTO {
@ApiProperty({ description: '提现订单号' })
orderNo: string;
@ApiProperty({ description: '用户账号' })
accountSequence: string;
@ApiProperty({ description: '用户ID' })
userId: string;
@ApiProperty({ description: '提现金额 (绿积分)' })
amount: number;
@ApiProperty({ description: '手续费 (绿积分)' })
fee: number;
@ApiProperty({ description: '实际到账金额 (元人民币)' })
netAmount: number;
@ApiProperty({ description: '收款方式' })
paymentMethod: string;
@ApiProperty({ description: '收款账户显示' })
paymentAccountDisplay: string;
@ApiProperty({ description: '订单状态' })
status: string;
@ApiPropertyOptional({ description: '审核人' })
reviewedBy: string | null;
@ApiPropertyOptional({ description: '审核时间' })
reviewedAt: string | null;
@ApiPropertyOptional({ description: '打款人' })
paidBy: string | null;
@ApiProperty({ description: '创建时间' })
createdAt: string;
@ApiPropertyOptional({ description: '详细备注' })
detailMemo: string | null;
}

View File

@ -1,25 +1,73 @@
import { ChainType } from '@/domain/value-objects';
import { PaymentMethod } from '@/domain/value-objects/withdrawal-status.enum';
/**
*
*
*/
export interface PaymentAccountDto {
paymentMethod: PaymentMethod;
// 银行卡
bankName?: string;
bankCardNo?: string;
cardHolderName?: string;
// 支付宝
alipayAccount?: string;
alipayRealName?: string;
// 微信
wechatAccount?: string;
wechatRealName?: string;
}
/**
* ()
*/
export class RequestWithdrawalCommand {
constructor(
public readonly accountSequence: string,
public readonly userId: string,
public readonly amount: number, // 提现金额 (USDT)
public readonly toAddress: string, // 目标地址
public readonly chainType: ChainType, // 目标链 (BSC/KAVA)
public readonly amount: number, // 提现金额 (绿积分, 1:1人民币)
public readonly paymentAccount: PaymentAccountDto, // 收款账户
) {}
}
/**
* (使)
*
*/
export class UpdateWithdrawalStatusCommand {
export class ReviewWithdrawalCommand {
constructor(
public readonly orderNo: string,
public readonly status: 'BROADCASTED' | 'CONFIRMED' | 'FAILED',
public readonly txHash?: string,
public readonly errorMessage?: string,
public readonly approved: boolean, // true=通过, false=驳回
public readonly reviewedBy: string, // 审核人
public readonly remark?: string, // 审核备注
) {}
}
/**
*
*/
export class StartPaymentCommand {
constructor(
public readonly orderNo: string,
public readonly paidBy: string, // 打款人
) {}
}
/**
*
*/
export class CompletePaymentCommand {
constructor(
public readonly orderNo: string,
public readonly paymentProof?: string, // 打款凭证
public readonly remark?: string, // 打款备注
) {}
}
/**
*
*/
export class CancelWithdrawalCommand {
constructor(
public readonly orderNo: string,
public readonly reason?: string, // 取消原因
) {}
}

View File

@ -1,461 +0,0 @@
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,
ILedgerEntryRepository,
LEDGER_ENTRY_REPOSITORY,
} from '@/domain/repositories';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { WithdrawalOrder, WalletAccount, LedgerEntry } from '@/domain/aggregates';
import { WithdrawalStatus, Money, UserId, LedgerEntryType } from '@/domain/value-objects';
import { OptimisticLockError } from '@/shared/exceptions/domain.exception';
import Decimal from 'decimal.js';
/**
* Withdrawal Status Handler
*
* Handles withdrawal status events from blockchain-service.
* Updates withdrawal order status and handles fund refunds on failure.
*
* IMPORTANT:
* - All operations use database transactions for atomicity.
* - Wallet balance updates use optimistic locking to prevent concurrent modification issues.
*/
@Injectable()
export class WithdrawalStatusHandler implements OnModuleInit {
private readonly logger = new Logger(WithdrawalStatusHandler.name);
// Max retry count for optimistic lock conflicts
private readonly MAX_RETRIES = 3;
constructor(
private readonly withdrawalEventConsumer: WithdrawalEventConsumerService,
@Inject(WITHDRAWAL_ORDER_REPOSITORY)
private readonly withdrawalRepo: IWithdrawalOrderRepository,
@Inject(WALLET_ACCOUNT_REPOSITORY)
private readonly walletRepo: IWalletAccountRepository,
@Inject(LEDGER_ENTRY_REPOSITORY)
private readonly ledgerRepo: ILedgerEntryRepository,
private readonly prisma: PrismaService,
) {}
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, store txHash, and deduct frozen balance
*
* Uses database transaction + optimistic locking to ensure atomicity and prevent race conditions.
*/
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}`);
let retries = 0;
while (retries < this.MAX_RETRIES) {
try {
await this.executeWithdrawalConfirmed(payload);
return; // Success, exit
} catch (error) {
if (this.isOptimisticLockError(error)) {
retries++;
this.logger.warn(`[CONFIRMED] Optimistic lock conflict for ${payload.orderNo}, retry ${retries}/${this.MAX_RETRIES}`);
if (retries >= this.MAX_RETRIES) {
this.logger.error(`[CONFIRMED] Max retries exceeded for ${payload.orderNo}`);
throw error;
}
// Brief delay before retry
await this.sleep(50 * retries);
} else {
throw error;
}
}
}
}
/**
* Execute the withdrawal confirmed logic within a transaction
*/
private async executeWithdrawalConfirmed(
payload: WithdrawalConfirmedPayload,
): Promise<void> {
try {
// Use transaction to ensure atomicity
await this.prisma.$transaction(async (tx) => {
// Find the withdrawal order
const orderRecord = await tx.withdrawalOrder.findUnique({
where: { orderNo: payload.orderNo },
});
if (!orderRecord) {
this.logger.error(`[CONFIRMED] Order not found: ${payload.orderNo}`);
return;
}
// Check if already confirmed (idempotency)
if (orderRecord.status === WithdrawalStatus.CONFIRMED) {
this.logger.log(`[CONFIRMED] Order ${payload.orderNo} already confirmed, skipping`);
return;
}
// Determine new status based on current status
let newStatus = orderRecord.status;
let txHash = orderRecord.txHash;
let broadcastedAt = orderRecord.broadcastedAt;
let confirmedAt = orderRecord.confirmedAt;
// FROZEN -> BROADCASTED -> CONFIRMED
if (orderRecord.status === WithdrawalStatus.FROZEN) {
newStatus = WithdrawalStatus.BROADCASTED;
txHash = payload.txHash;
broadcastedAt = new Date();
}
if (newStatus === WithdrawalStatus.BROADCASTED || orderRecord.status === WithdrawalStatus.BROADCASTED) {
newStatus = WithdrawalStatus.CONFIRMED;
confirmedAt = new Date();
}
// Update order status
await tx.withdrawalOrder.update({
where: { id: orderRecord.id },
data: {
status: newStatus,
txHash,
broadcastedAt,
confirmedAt,
},
});
// Find wallet and deduct frozen balance with optimistic lock
let walletRecord = await tx.walletAccount.findUnique({
where: { accountSequence: orderRecord.accountSequence },
});
if (!walletRecord) {
walletRecord = await tx.walletAccount.findUnique({
where: { userId: orderRecord.userId },
});
}
if (walletRecord) {
// Deduct the total frozen amount (amount + fee)
const totalAmount = new Decimal(orderRecord.amount.toString()).add(new Decimal(orderRecord.fee.toString()));
const currentFrozen = new Decimal(walletRecord.usdtFrozen.toString());
if (currentFrozen.lessThan(totalAmount)) {
this.logger.error(`[CONFIRMED] Insufficient frozen balance: have ${currentFrozen}, need ${totalAmount}`);
throw new Error(`Insufficient frozen balance for withdrawal ${payload.orderNo}`);
}
const newFrozen = currentFrozen.minus(totalAmount);
const currentVersion = walletRecord.version;
// Optimistic lock: update only if version matches
const updateResult = await tx.walletAccount.updateMany({
where: {
id: walletRecord.id,
version: currentVersion, // Optimistic lock condition
},
data: {
usdtFrozen: newFrozen,
version: currentVersion + 1, // Increment version
updatedAt: new Date(),
},
});
if (updateResult.count === 0) {
// Version mismatch - another transaction modified the record
throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`);
}
this.logger.log(`[CONFIRMED] Deducted ${totalAmount.toString()} USDT from frozen balance for ${orderRecord.accountSequence} (version: ${currentVersion} -> ${currentVersion + 1})`);
// 记录流水:根据是否内部转账决定流水类型
if (orderRecord.isInternalTransfer && orderRecord.toAccountSequence) {
// 内部转账:给转出方记录 TRANSFER_OUT
await tx.ledgerEntry.create({
data: {
accountSequence: orderRecord.accountSequence,
userId: orderRecord.userId,
entryType: LedgerEntryType.TRANSFER_OUT,
amount: new Decimal(orderRecord.amount.toString()).negated(),
assetType: 'USDT',
balanceAfter: walletRecord.usdtAvailable, // 冻结余额扣除后可用余额不变
refOrderId: orderRecord.orderNo,
refTxHash: payload.txHash,
memo: `转账至 ${orderRecord.toAccountSequence}`,
payloadJson: {
toAccountSequence: orderRecord.toAccountSequence,
toUserId: orderRecord.toUserId?.toString(),
fee: orderRecord.fee.toString(),
},
},
});
// 内部转账:给接收方记录 TRANSFER_IN 并增加余额
if (orderRecord.toUserId) {
// 查找接收方钱包
let toWalletRecord = await tx.walletAccount.findUnique({
where: { accountSequence: orderRecord.toAccountSequence },
});
if (!toWalletRecord) {
toWalletRecord = await tx.walletAccount.findUnique({
where: { userId: orderRecord.toUserId },
});
}
if (toWalletRecord) {
const transferAmount = new Decimal(orderRecord.amount.toString());
const toCurrentAvailable = new Decimal(toWalletRecord.usdtAvailable.toString());
const toNewAvailable = toCurrentAvailable.add(transferAmount);
const toCurrentVersion = toWalletRecord.version;
// 更新接收方余额
const toUpdateResult = await tx.walletAccount.updateMany({
where: {
id: toWalletRecord.id,
version: toCurrentVersion,
},
data: {
usdtAvailable: toNewAvailable,
version: toCurrentVersion + 1,
updatedAt: new Date(),
},
});
if (toUpdateResult.count === 0) {
throw new OptimisticLockError(`Optimistic lock conflict for receiver wallet ${toWalletRecord.id}`);
}
// 给接收方记录 TRANSFER_IN 流水
await tx.ledgerEntry.create({
data: {
accountSequence: orderRecord.toAccountSequence,
userId: orderRecord.toUserId,
entryType: LedgerEntryType.TRANSFER_IN,
amount: transferAmount,
assetType: 'USDT',
balanceAfter: toNewAvailable,
refOrderId: orderRecord.orderNo,
refTxHash: payload.txHash,
memo: `来自 ${orderRecord.accountSequence} 的转账`,
payloadJson: {
fromAccountSequence: orderRecord.accountSequence,
fromUserId: orderRecord.userId.toString(),
},
},
});
this.logger.log(`[CONFIRMED] Internal transfer: ${orderRecord.accountSequence} -> ${orderRecord.toAccountSequence}, amount: ${transferAmount.toString()}`);
} else {
this.logger.error(`[CONFIRMED] Receiver wallet not found: ${orderRecord.toAccountSequence}`);
}
}
} else {
// 普通提现:记录 WITHDRAWAL
await tx.ledgerEntry.create({
data: {
accountSequence: orderRecord.accountSequence,
userId: orderRecord.userId,
entryType: LedgerEntryType.WITHDRAWAL,
amount: new Decimal(orderRecord.amount.toString()).negated(),
assetType: 'USDT',
balanceAfter: walletRecord.usdtAvailable,
refOrderId: orderRecord.orderNo,
refTxHash: payload.txHash,
memo: `提现至 ${orderRecord.toAddress}`,
payloadJson: {
toAddress: orderRecord.toAddress,
chainType: orderRecord.chainType,
fee: orderRecord.fee.toString(),
},
},
});
}
} else {
this.logger.error(`[CONFIRMED] Wallet not found for accountSequence: ${orderRecord.accountSequence}, userId: ${orderRecord.userId}`);
}
});
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 (amount + fee)
*
* Uses database transaction + optimistic locking to ensure atomicity and prevent race conditions.
*/
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}`);
let retries = 0;
while (retries < this.MAX_RETRIES) {
try {
await this.executeWithdrawalFailed(payload);
return; // Success, exit
} catch (error) {
if (this.isOptimisticLockError(error)) {
retries++;
this.logger.warn(`[FAILED] Optimistic lock conflict for ${payload.orderNo}, retry ${retries}/${this.MAX_RETRIES}`);
if (retries >= this.MAX_RETRIES) {
this.logger.error(`[FAILED] Max retries exceeded for ${payload.orderNo}`);
throw error;
}
// Brief delay before retry
await this.sleep(50 * retries);
} else {
throw error;
}
}
}
}
/**
* Execute the withdrawal failed logic within a transaction
*/
private async executeWithdrawalFailed(
payload: WithdrawalFailedPayload,
): Promise<void> {
try {
// Use transaction to ensure atomicity
await this.prisma.$transaction(async (tx) => {
// Find the withdrawal order
const orderRecord = await tx.withdrawalOrder.findUnique({
where: { orderNo: payload.orderNo },
});
if (!orderRecord) {
this.logger.error(`[FAILED] Order not found: ${payload.orderNo}`);
return;
}
// Check if already in terminal state (idempotency)
if (orderRecord.status === WithdrawalStatus.CONFIRMED ||
orderRecord.status === WithdrawalStatus.FAILED ||
orderRecord.status === WithdrawalStatus.CANCELLED) {
this.logger.log(`[FAILED] Order ${payload.orderNo} already in terminal state: ${orderRecord.status}, skipping`);
return;
}
// Check if needs unfreeze (was frozen)
const needsUnfreeze = orderRecord.frozenAt !== null;
// Update order status to FAILED
await tx.withdrawalOrder.update({
where: { id: orderRecord.id },
data: {
status: WithdrawalStatus.FAILED,
errorMessage: payload.error,
},
});
// Refund frozen funds back to available balance if needed
if (needsUnfreeze) {
let walletRecord = await tx.walletAccount.findUnique({
where: { accountSequence: orderRecord.accountSequence },
});
if (!walletRecord) {
walletRecord = await tx.walletAccount.findUnique({
where: { userId: orderRecord.userId },
});
}
if (walletRecord) {
// Unfreeze the total amount (amount + fee)
const totalAmount = new Decimal(orderRecord.amount.toString()).add(new Decimal(orderRecord.fee.toString()));
const currentFrozen = new Decimal(walletRecord.usdtFrozen.toString());
const currentAvailable = new Decimal(walletRecord.usdtAvailable.toString());
const currentVersion = walletRecord.version;
// Validate frozen balance
let newFrozen: Decimal;
let newAvailable: Decimal;
if (currentFrozen.lessThan(totalAmount)) {
this.logger.warn(`[FAILED] Frozen balance (${currentFrozen}) less than refund amount (${totalAmount}), refunding what's available`);
// Refund whatever is frozen (shouldn't happen in normal flow)
const refundAmount = Decimal.min(currentFrozen, totalAmount);
newFrozen = currentFrozen.minus(refundAmount);
newAvailable = currentAvailable.add(refundAmount);
} else {
newFrozen = currentFrozen.minus(totalAmount);
newAvailable = currentAvailable.add(totalAmount);
}
// Optimistic lock: update only if version matches
const updateResult = await tx.walletAccount.updateMany({
where: {
id: walletRecord.id,
version: currentVersion, // Optimistic lock condition
},
data: {
usdtFrozen: newFrozen,
usdtAvailable: newAvailable,
version: currentVersion + 1, // Increment version
updatedAt: new Date(),
},
});
if (updateResult.count === 0) {
// Version mismatch - another transaction modified the record
throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`);
}
this.logger.log(`[FAILED] Refunded ${totalAmount.toString()} USDT (amount + fee) to account ${orderRecord.accountSequence} (version: ${currentVersion} -> ${currentVersion + 1})`);
} else {
this.logger.error(`[FAILED] Wallet not found for accountSequence: ${orderRecord.accountSequence}, userId: ${orderRecord.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;
}
}
/**
* Check if error is an optimistic lock error
*/
private isOptimisticLockError(error: unknown): boolean {
return error instanceof OptimisticLockError;
}
/**
* Sleep for specified milliseconds
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@ -15,17 +15,16 @@ import {
import {
HandleDepositCommand, DeductForPlantingCommand, AddRewardsCommand,
ClaimRewardsCommand, SettleRewardsCommand, AllocateFundsCommand, FundAllocationItem,
RequestWithdrawalCommand, UpdateWithdrawalStatusCommand,
RequestWithdrawalCommand, ReviewWithdrawalCommand, StartPaymentCommand, CompletePaymentCommand, CancelWithdrawalCommand,
FreezeForPlantingCommand, ConfirmPlantingDeductionCommand, UnfreezeForPlantingCommand,
} from '@/application/commands';
import { PaymentMethod, WithdrawalStatus } from '@/domain/value-objects/withdrawal-status.enum';
import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries';
import { DuplicateTransactionError, WalletNotFoundError, OptimisticLockError } from '@/shared/exceptions/domain.exception';
import { WalletCacheService } from '@/infrastructure/redis';
import { EventPublisherService } from '@/infrastructure/kafka';
import { WithdrawalRequestedEvent } from '@/domain/events';
import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories';
import { FeeType } from '@/api/dto/response';
import { IdentityClientService } from '@/infrastructure/external/identity/identity-client.service';
export interface WalletDTO {
walletId: string;
@ -94,7 +93,6 @@ export class WalletApplicationService {
private readonly eventPublisher: EventPublisherService,
private readonly prisma: PrismaService,
private readonly feeConfigRepo: FeeConfigRepositoryImpl,
private readonly identityClient: IdentityClientService,
) {}
// =============== Commands ===============
@ -1456,7 +1454,7 @@ export class WalletApplicationService {
* 3.
* 4.
* 5.
* 6. blockchain-service
* 6.
*/
async requestWithdrawal(command: RequestWithdrawalCommand): Promise<{
orderNo: string;
@ -1464,6 +1462,7 @@ export class WalletApplicationService {
fee: number;
netAmount: number;
status: string;
paymentMethod: string;
}> {
const MAX_RETRIES = 3;
let retries = 0;
@ -1474,9 +1473,9 @@ export class WalletApplicationService {
} catch (error) {
if (this.isOptimisticLockError(error)) {
retries++;
this.logger.warn(`[requestWithdrawal] Optimistic lock conflict for ${command.userId}, retry ${retries}/${MAX_RETRIES}`);
this.logger.warn(`[requestWithdrawal] Optimistic lock conflict for ${command.accountSequence}, retry ${retries}/${MAX_RETRIES}`);
if (retries >= MAX_RETRIES) {
this.logger.error(`[requestWithdrawal] Max retries exceeded for ${command.userId}`);
this.logger.error(`[requestWithdrawal] Max retries exceeded for ${command.accountSequence}`);
throw error;
}
await this.sleep(50 * retries);
@ -1490,7 +1489,7 @@ export class WalletApplicationService {
}
/**
* Execute the withdrawal request logic
* Execute the withdrawal request logic ()
*/
private async executeRequestWithdrawal(command: RequestWithdrawalCommand): Promise<{
orderNo: string;
@ -1498,6 +1497,7 @@ export class WalletApplicationService {
fee: number;
netAmount: number;
status: string;
paymentMethod: string;
}> {
const userId = BigInt(command.userId);
const amount = Money.USDT(command.amount);
@ -1511,20 +1511,20 @@ export class WalletApplicationService {
? `固定 ${feeValue} 绿积分`
: `${(feeValue * 100).toFixed(2)}%`;
this.logger.log(`Processing withdrawal request for user ${userId}: ${command.amount} USDT to ${command.toAddress}, fee: ${feeAmount} (${feeDescription})`);
this.logger.log(`Processing fiat withdrawal request for user ${command.accountSequence}: ${command.amount} 绿积分, fee: ${feeAmount} (${feeDescription}), payment method: ${command.paymentAccount.paymentMethod}`);
// 验证最小提现金额
if (command.amount < this.MIN_WITHDRAWAL_AMOUNT) {
throw new Error(`最小提现金额为 ${this.MIN_WITHDRAWAL_AMOUNT} USDT`);
throw new Error(`最小提现金额为 ${this.MIN_WITHDRAWAL_AMOUNT} 绿积分`);
}
// 优先按 accountSequence 查找,如果未找到则按 userId 查找
let wallet = await this.walletRepo.findByAccountSequence(command.userId);
let wallet = await this.walletRepo.findByAccountSequence(command.accountSequence);
if (!wallet) {
wallet = await this.walletRepo.findByUserId(userId);
}
if (!wallet) {
throw new WalletNotFoundError(`userId/accountSequence: ${command.userId}`);
throw new WalletNotFoundError(`userId/accountSequence: ${command.accountSequence}`);
}
// 验证余额是否足够
@ -1534,45 +1534,31 @@ export class WalletApplicationService {
);
}
// 检查目标地址是否为系统内用户(内部转账)
let isInternalTransfer = false;
let toAccountSequence: string | undefined;
let toUserId: UserId | undefined;
const targetUser = await this.identityClient.findUserByWalletAddress(
command.chainType,
command.toAddress,
);
if (targetUser) {
// 目标地址属于系统内用户,标记为内部转账
isInternalTransfer = true;
toAccountSequence = targetUser.accountSequence;
toUserId = UserId.create(BigInt(targetUser.userId));
this.logger.log(
`Internal transfer detected: ${wallet.accountSequence} -> ${toAccountSequence}`,
);
}
// 创建提现订单
// 创建提现订单 (法币提现)
const withdrawalOrder = WithdrawalOrder.create({
accountSequence: wallet.accountSequence,
userId: UserId.create(userId),
amount,
fee,
chainType: command.chainType,
toAddress: command.toAddress,
isInternalTransfer,
toAccountSequence,
toUserId,
paymentAccount: {
paymentMethod: command.paymentAccount.paymentMethod,
bankName: command.paymentAccount.bankName,
bankCardNo: command.paymentAccount.bankCardNo,
cardHolderName: command.paymentAccount.cardHolderName,
alipayAccount: command.paymentAccount.alipayAccount,
alipayRealName: command.paymentAccount.alipayRealName,
wechatAccount: command.paymentAccount.wechatAccount,
wechatRealName: command.paymentAccount.wechatRealName,
},
});
// 冻结用户余额 (金额 + 手续费)
wallet.freeze(totalRequired);
await this.walletRepo.save(wallet);
// 标记订单已冻结
// 标记订单已冻结并提交审核
withdrawalOrder.markAsFrozen();
withdrawalOrder.submitForReview();
const savedOrder = await this.withdrawalRepo.save(withdrawalOrder);
// 记录流水 - 冻结
@ -1583,35 +1569,14 @@ export class WalletApplicationService {
amount: Money.signed(-totalRequired.value, 'USDT'),
balanceAfter: wallet.balances.usdt.available,
refOrderId: savedOrder.orderNo,
memo: `冻结: ${command.amount} 绿积分 + ${feeAmount.toFixed(2)} 绿积分手续费 (${feeDescription})`,
memo: `冻结: ${command.amount} 绿积分 + ${feeAmount.toFixed(2)} 绿积分手续费 (${feeDescription}), 收款方式: ${savedOrder.getPaymentMethodName()}`,
});
await this.ledgerRepo.save(freezeEntry);
// 发布事件通知 blockchain-service
const event = new WithdrawalRequestedEvent({
orderNo: savedOrder.orderNo,
accountSequence: wallet.accountSequence,
userId: userId.toString(),
walletId: wallet.walletId.toString(),
amount: command.amount.toString(),
fee: feeAmount.toString(),
netAmount: command.amount.toString(), // 接收方收到完整金额,手续费由发送方额外承担
assetType: 'USDT',
chainType: command.chainType,
toAddress: command.toAddress,
});
// 发布到 Kafka 通知 blockchain-service
await this.eventPublisher.publish({
eventType: 'wallet.withdrawal.requested',
payload: event.getPayload() as unknown as { [key: string]: unknown },
});
this.logger.log(`Withdrawal event published: ${savedOrder.orderNo}`);
// 清除钱包缓存
await this.walletCacheService.invalidateWallet(userId);
this.logger.log(`Withdrawal order created: ${savedOrder.orderNo}`);
this.logger.log(`Fiat withdrawal order created: ${savedOrder.orderNo}, status: ${savedOrder.status}`);
return {
orderNo: savedOrder.orderNo,
@ -1619,14 +1584,19 @@ export class WalletApplicationService {
fee: savedOrder.fee.value,
netAmount: savedOrder.netAmount.value,
status: savedOrder.status,
paymentMethod: savedOrder.paymentMethod,
};
}
/**
* ( blockchain-service )
* ()
*/
async updateWithdrawalStatus(command: UpdateWithdrawalStatusCommand): Promise<void> {
this.logger.log(`Updating withdrawal ${command.orderNo} to status ${command.status}`);
async reviewWithdrawal(command: ReviewWithdrawalCommand): Promise<{
orderNo: string;
status: string;
reviewedBy: string;
}> {
this.logger.log(`Reviewing withdrawal ${command.orderNo}: approved=${command.approved} by ${command.reviewedBy}`);
const order = await this.withdrawalRepo.findByOrderNo(command.orderNo);
if (!order) {
@ -1638,69 +1608,189 @@ export class WalletApplicationService {
throw new WalletNotFoundError(`userId: ${order.userId.value}`);
}
const totalFrozen = order.amount.add(order.fee);
if (command.approved) {
// 审核通过
order.approve(command.reviewedBy, command.remark);
await this.withdrawalRepo.save(order);
this.logger.log(`Withdrawal ${order.orderNo} approved by ${command.reviewedBy}`);
} else {
// 审核驳回
order.reject(command.reviewedBy, command.remark || '审核不通过');
await this.withdrawalRepo.save(order);
switch (command.status) {
case 'BROADCASTED':
if (!command.txHash) {
throw new Error('txHash is required for BROADCASTED status');
}
order.markAsBroadcasted(command.txHash);
await this.withdrawalRepo.save(order);
break;
// 解冻资金
const totalFrozen = order.amount.add(order.fee);
wallet.unfreeze(totalFrozen);
await this.walletRepo.save(wallet);
case 'CONFIRMED':
order.markAsConfirmed();
await this.withdrawalRepo.save(order);
// 记录解冻流水
const unfreezeEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: order.userId,
entryType: LedgerEntryType.UNFREEZE,
amount: totalFrozen,
balanceAfter: wallet.balances.usdt.available,
refOrderId: order.orderNo,
memo: `提现审核驳回,资金解冻: ${command.remark || '审核不通过'}`,
});
await this.ledgerRepo.save(unfreezeEntry);
// 解冻并扣除
wallet.unfreeze(totalFrozen);
wallet.deduct(totalFrozen, 'Withdrawal completed', order.orderNo);
await this.walletRepo.save(wallet);
// 记录提现完成流水
const withdrawEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: order.userId,
entryType: LedgerEntryType.WITHDRAWAL,
amount: Money.signed(-order.amount.value, 'USDT'),
balanceAfter: wallet.balances.usdt.available,
refOrderId: order.orderNo,
refTxHash: order.txHash ?? undefined,
memo: `Withdrawal to ${order.toAddress}`,
});
await this.ledgerRepo.save(withdrawEntry);
this.logger.log(`Withdrawal ${order.orderNo} confirmed, txHash: ${order.txHash}`);
break;
case 'FAILED':
order.markAsFailed(command.errorMessage || 'Unknown error');
await this.withdrawalRepo.save(order);
// 解冻资金
if (order.needsUnfreeze()) {
wallet.unfreeze(totalFrozen);
await this.walletRepo.save(wallet);
// 记录解冻流水
const unfreezeEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: order.userId,
entryType: LedgerEntryType.UNFREEZE,
amount: totalFrozen,
balanceAfter: wallet.balances.usdt.available,
refOrderId: order.orderNo,
memo: `Withdrawal failed, funds unfrozen: ${command.errorMessage}`,
});
await this.ledgerRepo.save(unfreezeEntry);
}
this.logger.warn(`Withdrawal ${order.orderNo} failed: ${command.errorMessage}`);
break;
this.logger.log(`Withdrawal ${order.orderNo} rejected by ${command.reviewedBy}`);
}
await this.walletCacheService.invalidateWallet(order.userId.value);
return {
orderNo: order.orderNo,
status: order.status,
reviewedBy: command.reviewedBy,
};
}
/**
* ()
*/
async startPayment(command: StartPaymentCommand): Promise<{
orderNo: string;
status: string;
paidBy: string;
}> {
this.logger.log(`Starting payment for withdrawal ${command.orderNo} by ${command.paidBy}`);
const order = await this.withdrawalRepo.findByOrderNo(command.orderNo);
if (!order) {
throw new Error(`Withdrawal order not found: ${command.orderNo}`);
}
order.startPayment(command.paidBy);
await this.withdrawalRepo.save(order);
this.logger.log(`Withdrawal ${order.orderNo} payment started by ${command.paidBy}`);
return {
orderNo: order.orderNo,
status: order.status,
paidBy: command.paidBy,
};
}
/**
* ()
*/
async completePayment(command: CompletePaymentCommand): Promise<{
orderNo: string;
status: string;
completedAt: string;
}> {
this.logger.log(`Completing payment for withdrawal ${command.orderNo}`);
const order = await this.withdrawalRepo.findByOrderNo(command.orderNo);
if (!order) {
throw new Error(`Withdrawal order not found: ${command.orderNo}`);
}
const wallet = await this.walletRepo.findByUserId(order.userId.value);
if (!wallet) {
throw new WalletNotFoundError(`userId: ${order.userId.value}`);
}
// 完成打款
order.completePayment(command.paymentProof, command.remark);
await this.withdrawalRepo.save(order);
// 解冻并扣除余额
const totalFrozen = order.amount.add(order.fee);
wallet.unfreeze(totalFrozen);
wallet.deduct(totalFrozen, `提现完成: ${order.orderNo}`, order.orderNo);
await this.walletRepo.save(wallet);
// 记录提现完成流水
const withdrawEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: order.userId,
entryType: LedgerEntryType.WITHDRAWAL,
amount: Money.signed(-order.amount.value, 'USDT'),
balanceAfter: wallet.balances.usdt.available,
refOrderId: order.orderNo,
memo: `提现完成: ${order.netAmount.value.toFixed(2)} 元人民币 到 ${order.getPaymentAccountDisplay()}`,
});
await this.ledgerRepo.save(withdrawEntry);
// 手续费流水 (单独记录)
if (order.fee.value > 0) {
const feeEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: order.userId,
entryType: LedgerEntryType.WITHDRAWAL_FEE,
amount: Money.signed(-order.fee.value, 'USDT'),
balanceAfter: wallet.balances.usdt.available,
refOrderId: order.orderNo,
memo: `提现手续费`,
});
await this.ledgerRepo.save(feeEntry);
}
await this.walletCacheService.invalidateWallet(order.userId.value);
this.logger.log(`Withdrawal ${order.orderNo} completed`);
return {
orderNo: order.orderNo,
status: order.status,
completedAt: order.completedAt?.toISOString() || new Date().toISOString(),
};
}
/**
* ()
*/
async cancelWithdrawal(command: CancelWithdrawalCommand): Promise<{
orderNo: string;
status: string;
}> {
this.logger.log(`Cancelling withdrawal ${command.orderNo}`);
const order = await this.withdrawalRepo.findByOrderNo(command.orderNo);
if (!order) {
throw new Error(`Withdrawal order not found: ${command.orderNo}`);
}
const wallet = await this.walletRepo.findByUserId(order.userId.value);
if (!wallet) {
throw new WalletNotFoundError(`userId: ${order.userId.value}`);
}
// 取消订单
order.cancel(command.reason);
await this.withdrawalRepo.save(order);
// 如果已冻结,解冻资金
if (order.needsUnfreeze()) {
const totalFrozen = order.amount.add(order.fee);
wallet.unfreeze(totalFrozen);
await this.walletRepo.save(wallet);
// 记录解冻流水
const unfreezeEntry = LedgerEntry.create({
accountSequence: wallet.accountSequence,
userId: order.userId,
entryType: LedgerEntryType.UNFREEZE,
amount: totalFrozen,
balanceAfter: wallet.balances.usdt.available,
refOrderId: order.orderNo,
memo: `用户取消提现,资金解冻${command.reason ? `: ${command.reason}` : ''}`,
});
await this.ledgerRepo.save(unfreezeEntry);
}
await this.walletCacheService.invalidateWallet(order.userId.value);
this.logger.log(`Withdrawal ${order.orderNo} cancelled`);
return {
orderNo: order.orderNo,
status: order.status,
};
}
/**
@ -1711,11 +1801,11 @@ export class WalletApplicationService {
amount: number;
fee: number;
netAmount: number;
chainType: string;
toAddress: string;
txHash: string | null;
paymentMethod: string;
paymentAccountDisplay: string;
status: string;
createdAt: string;
completedAt: string | null;
}>> {
const orders = await this.withdrawalRepo.findByUserId(BigInt(userId));
return orders.map(order => ({
@ -1723,11 +1813,113 @@ export class WalletApplicationService {
amount: order.amount.value,
fee: order.fee.value,
netAmount: order.netAmount.value,
chainType: order.chainType,
toAddress: order.toAddress,
txHash: order.txHash,
paymentMethod: order.paymentMethod,
paymentAccountDisplay: order.getPaymentAccountDisplay(),
status: order.status,
createdAt: order.createdAt.toISOString(),
completedAt: order.completedAt?.toISOString() || null,
}));
}
/**
* ()
*/
async getReviewingWithdrawals(): Promise<Array<{
orderNo: string;
accountSequence: string;
userId: string;
amount: number;
fee: number;
netAmount: number;
paymentMethod: string;
paymentAccountDisplay: string;
status: string;
createdAt: string;
detailMemo: string | null;
}>> {
const orders = await this.withdrawalRepo.findReviewingOrders();
return orders.map(order => ({
orderNo: order.orderNo,
accountSequence: order.accountSequence,
userId: order.userId.value.toString(),
amount: order.amount.value,
fee: order.fee.value,
netAmount: order.netAmount.value,
paymentMethod: order.paymentMethod,
paymentAccountDisplay: order.getPaymentAccountDisplay(),
status: order.status,
createdAt: order.createdAt.toISOString(),
detailMemo: order.detailMemo,
}));
}
/**
* ()
*/
async getApprovedWithdrawals(): Promise<Array<{
orderNo: string;
accountSequence: string;
userId: string;
amount: number;
fee: number;
netAmount: number;
paymentMethod: string;
paymentAccountDisplay: string;
status: string;
reviewedBy: string | null;
reviewedAt: string | null;
createdAt: string;
detailMemo: string | null;
}>> {
const orders = await this.withdrawalRepo.findApprovedOrders();
return orders.map(order => ({
orderNo: order.orderNo,
accountSequence: order.accountSequence,
userId: order.userId.value.toString(),
amount: order.amount.value,
fee: order.fee.value,
netAmount: order.netAmount.value,
paymentMethod: order.paymentMethod,
paymentAccountDisplay: order.getPaymentAccountDisplay(),
status: order.status,
reviewedBy: order.reviewedBy,
reviewedAt: order.reviewedAt?.toISOString() || null,
createdAt: order.createdAt.toISOString(),
detailMemo: order.detailMemo,
}));
}
/**
* ()
*/
async getPayingWithdrawals(): Promise<Array<{
orderNo: string;
accountSequence: string;
userId: string;
amount: number;
fee: number;
netAmount: number;
paymentMethod: string;
paymentAccountDisplay: string;
status: string;
paidBy: string | null;
createdAt: string;
detailMemo: string | null;
}>> {
const orders = await this.withdrawalRepo.findPayingOrders();
return orders.map(order => ({
orderNo: order.orderNo,
accountSequence: order.accountSequence,
userId: order.userId.value.toString(),
amount: order.amount.value,
fee: order.fee.value,
netAmount: order.netAmount.value,
paymentMethod: order.paymentMethod,
paymentAccountDisplay: order.getPaymentAccountDisplay(),
status: order.status,
paidBy: order.paidBy,
createdAt: order.createdAt.toISOString(),
detailMemo: order.detailMemo,
}));
}

View File

@ -1,16 +1,55 @@
import Decimal from 'decimal.js';
import { UserId, ChainType, AssetType, Money } from '@/domain/value-objects';
import { WithdrawalStatus } from '@/domain/value-objects/withdrawal-status.enum';
import { UserId, AssetType, Money } from '@/domain/value-objects';
import { WithdrawalStatus, PaymentMethod, WithdrawalType } from '@/domain/value-objects/withdrawal-status.enum';
import { DomainError } from '@/shared/exceptions/domain.exception';
/**
*
*
*/
export interface PaymentAccountInfo {
paymentMethod: PaymentMethod;
// 银行卡
bankName?: string;
bankCardNo?: string;
cardHolderName?: string;
// 支付宝
alipayAccount?: string;
alipayRealName?: string;
// 微信
wechatAccount?: string;
wechatRealName?: string;
}
/**
*
*/
export interface ReviewInfo {
reviewedBy: string;
reviewedAt: Date;
reviewRemark?: string;
}
/**
*
*/
export interface PaymentInfo {
paidBy: string;
paidAt: Date;
paymentProof?: string;
paymentRemark?: string;
}
/**
* ()
*
* :
* 1. -> PENDING
* 2. -> FROZEN
* 3. blockchain-service 广 -> BROADCASTED
* 4. -> CONFIRMED
* 3. -> REVIEWING
* 4. -> APPROVED ()
* -> REJECTED ()
* 5. -> PAYING
* 6. -> COMPLETED
*
* /
*/
@ -19,20 +58,44 @@ export class WithdrawalOrder {
private readonly _orderNo: string;
private readonly _accountSequence: string;
private readonly _userId: UserId;
private readonly _amount: Money;
private readonly _fee: Money; // 手续费
private readonly _chainType: ChainType;
private readonly _toAddress: string; // 提现目标地址
private _txHash: string | null;
// 内部转账标识
private readonly _isInternalTransfer: boolean; // 是否为内部转账ID转ID
private readonly _toAccountSequence: string | null; // 接收方ID内部转账时有值
private readonly _toUserId: UserId | null; // 接收方用户ID内部转账时有值
private readonly _amount: Money; // 提现金额 (绿积分, 1:1人民币)
private readonly _fee: Money; // 手续费
private readonly _withdrawalType: WithdrawalType;
// 收款方式
private readonly _paymentMethod: PaymentMethod;
// 银行卡信息
private readonly _bankName: string | null;
private readonly _bankCardNo: string | null;
private readonly _cardHolderName: string | null;
// 支付宝信息
private readonly _alipayAccount: string | null;
private readonly _alipayRealName: string | null;
// 微信信息
private readonly _wechatAccount: string | null;
private readonly _wechatRealName: string | null;
// 状态
private _status: WithdrawalStatus;
private _errorMessage: string | null;
// 审核信息
private _reviewedBy: string | null;
private _reviewedAt: Date | null;
private _reviewRemark: string | null;
// 打款信息
private _paidBy: string | null;
private _paidAt: Date | null;
private _paymentProof: string | null;
private _paymentRemark: string | null;
// 详细备注
private _detailMemo: string | null;
// 时间戳
private _frozenAt: Date | null;
private _broadcastedAt: Date | null;
private _confirmedAt: Date | null;
private _completedAt: Date | null;
private readonly _createdAt: Date;
private constructor(
@ -42,17 +105,27 @@ export class WithdrawalOrder {
userId: UserId,
amount: Money,
fee: Money,
chainType: ChainType,
toAddress: string,
txHash: string | null,
isInternalTransfer: boolean,
toAccountSequence: string | null,
toUserId: UserId | null,
withdrawalType: WithdrawalType,
paymentMethod: PaymentMethod,
bankName: string | null,
bankCardNo: string | null,
cardHolderName: string | null,
alipayAccount: string | null,
alipayRealName: string | null,
wechatAccount: string | null,
wechatRealName: string | null,
status: WithdrawalStatus,
errorMessage: string | null,
reviewedBy: string | null,
reviewedAt: Date | null,
reviewRemark: string | null,
paidBy: string | null,
paidAt: Date | null,
paymentProof: string | null,
paymentRemark: string | null,
detailMemo: string | null,
frozenAt: Date | null,
broadcastedAt: Date | null,
confirmedAt: Date | null,
completedAt: Date | null,
createdAt: Date,
) {
this._id = id;
@ -61,17 +134,27 @@ export class WithdrawalOrder {
this._userId = userId;
this._amount = amount;
this._fee = fee;
this._chainType = chainType;
this._toAddress = toAddress;
this._txHash = txHash;
this._isInternalTransfer = isInternalTransfer;
this._toAccountSequence = toAccountSequence;
this._toUserId = toUserId;
this._withdrawalType = withdrawalType;
this._paymentMethod = paymentMethod;
this._bankName = bankName;
this._bankCardNo = bankCardNo;
this._cardHolderName = cardHolderName;
this._alipayAccount = alipayAccount;
this._alipayRealName = alipayRealName;
this._wechatAccount = wechatAccount;
this._wechatRealName = wechatRealName;
this._status = status;
this._errorMessage = errorMessage;
this._reviewedBy = reviewedBy;
this._reviewedAt = reviewedAt;
this._reviewRemark = reviewRemark;
this._paidBy = paidBy;
this._paidAt = paidAt;
this._paymentProof = paymentProof;
this._paymentRemark = paymentRemark;
this._detailMemo = detailMemo;
this._frozenAt = frozenAt;
this._broadcastedAt = broadcastedAt;
this._confirmedAt = confirmedAt;
this._completedAt = completedAt;
this._createdAt = createdAt;
}
@ -82,28 +165,43 @@ export class WithdrawalOrder {
get userId(): UserId { return this._userId; }
get amount(): Money { return this._amount; }
get fee(): Money { return this._fee; }
get netAmount(): Money { return this._amount; } // 接收方收到完整金额,手续费由发送方额外承担
get chainType(): ChainType { return this._chainType; }
get toAddress(): string { return this._toAddress; }
get txHash(): string | null { return this._txHash; }
get isInternalTransfer(): boolean { return this._isInternalTransfer; }
get toAccountSequence(): string | null { return this._toAccountSequence; }
get toUserId(): UserId | null { return this._toUserId; }
get netAmount(): Money { return Money.USDT(new Decimal(this._amount.value).minus(this._fee.value)); }
get withdrawalType(): WithdrawalType { return this._withdrawalType; }
get paymentMethod(): PaymentMethod { return this._paymentMethod; }
get bankName(): string | null { return this._bankName; }
get bankCardNo(): string | null { return this._bankCardNo; }
get cardHolderName(): string | null { return this._cardHolderName; }
get alipayAccount(): string | null { return this._alipayAccount; }
get alipayRealName(): string | null { return this._alipayRealName; }
get wechatAccount(): string | null { return this._wechatAccount; }
get wechatRealName(): string | null { return this._wechatRealName; }
get status(): WithdrawalStatus { return this._status; }
get errorMessage(): string | null { return this._errorMessage; }
get reviewedBy(): string | null { return this._reviewedBy; }
get reviewedAt(): Date | null { return this._reviewedAt; }
get reviewRemark(): string | null { return this._reviewRemark; }
get paidBy(): string | null { return this._paidBy; }
get paidAt(): Date | null { return this._paidAt; }
get paymentProof(): string | null { return this._paymentProof; }
get paymentRemark(): string | null { return this._paymentRemark; }
get detailMemo(): string | null { return this._detailMemo; }
get frozenAt(): Date | null { return this._frozenAt; }
get broadcastedAt(): Date | null { return this._broadcastedAt; }
get confirmedAt(): Date | null { return this._confirmedAt; }
get completedAt(): Date | null { return this._completedAt; }
get createdAt(): Date { return this._createdAt; }
// 状态判断
get isPending(): boolean { return this._status === WithdrawalStatus.PENDING; }
get isFrozen(): boolean { return this._status === WithdrawalStatus.FROZEN; }
get isBroadcasted(): boolean { return this._status === WithdrawalStatus.BROADCASTED; }
get isConfirmed(): boolean { return this._status === WithdrawalStatus.CONFIRMED; }
get isReviewing(): boolean { return this._status === WithdrawalStatus.REVIEWING; }
get isApproved(): boolean { return this._status === WithdrawalStatus.APPROVED; }
get isPaying(): boolean { return this._status === WithdrawalStatus.PAYING; }
get isCompleted(): boolean { return this._status === WithdrawalStatus.COMPLETED; }
get isRejected(): boolean { return this._status === WithdrawalStatus.REJECTED; }
get isFailed(): boolean { return this._status === WithdrawalStatus.FAILED; }
get isCancelled(): boolean { return this._status === WithdrawalStatus.CANCELLED; }
get isFinished(): boolean {
return this._status === WithdrawalStatus.CONFIRMED ||
return this._status === WithdrawalStatus.COMPLETED ||
this._status === WithdrawalStatus.REJECTED ||
this._status === WithdrawalStatus.FAILED ||
this._status === WithdrawalStatus.CANCELLED;
}
@ -118,58 +216,131 @@ export class WithdrawalOrder {
}
/**
*
*
*/
getPaymentAccountDisplay(): string {
switch (this._paymentMethod) {
case PaymentMethod.BANK_CARD:
const maskedCardNo = this._bankCardNo
? `****${this._bankCardNo.slice(-4)}`
: '';
return `${this._bankName || ''} ${maskedCardNo} (${this._cardHolderName || ''})`;
case PaymentMethod.ALIPAY:
return `支付宝: ${this._alipayAccount || ''} (${this._alipayRealName || ''})`;
case PaymentMethod.WECHAT:
return `微信: ${this._wechatAccount || ''} (${this._wechatRealName || ''})`;
default:
return '未知收款方式';
}
}
/**
*
*/
getPaymentMethodName(): string {
switch (this._paymentMethod) {
case PaymentMethod.BANK_CARD:
return '银行卡';
case PaymentMethod.ALIPAY:
return '支付宝';
case PaymentMethod.WECHAT:
return '微信';
default:
return '未知';
}
}
/**
* ()
*/
static create(params: {
accountSequence: string;
userId: UserId;
amount: Money;
fee: Money;
chainType: ChainType;
toAddress: string;
isInternalTransfer?: boolean;
toAccountSequence?: string;
toUserId?: UserId;
paymentAccount: PaymentAccountInfo;
}): WithdrawalOrder {
// 验证金额
if (params.amount.value <= 0) {
throw new DomainError('Withdrawal amount must be positive');
throw new DomainError('提现金额必须大于0');
}
// 验证手续费
if (params.fee.value < 0) {
throw new DomainError('Withdrawal fee cannot be negative');
throw new DomainError('手续费不能为负数');
}
// 验证净额大于0
if (params.amount.value <= params.fee.value) {
throw new DomainError('Withdrawal amount must be greater than fee');
const netAmount = new Decimal(params.amount.value).minus(params.fee.value);
if (netAmount.lte(0)) {
throw new DomainError('提现金额必须大于手续费');
}
// 验证地址格式 (简单的EVM地址检查)
if (!params.toAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
throw new DomainError('Invalid withdrawal address format');
// 根据收款方式验证必填信息
const { paymentAccount } = params;
switch (paymentAccount.paymentMethod) {
case PaymentMethod.BANK_CARD:
if (!paymentAccount.bankName || !paymentAccount.bankCardNo || !paymentAccount.cardHolderName) {
throw new DomainError('银行卡信息不完整');
}
break;
case PaymentMethod.ALIPAY:
if (!paymentAccount.alipayAccount || !paymentAccount.alipayRealName) {
throw new DomainError('支付宝信息不完整');
}
break;
case PaymentMethod.WECHAT:
if (!paymentAccount.wechatAccount || !paymentAccount.wechatRealName) {
throw new DomainError('微信信息不完整');
}
break;
default:
throw new DomainError('不支持的收款方式');
}
const now = new Date();
const orderNo = this.generateOrderNo();
// 构建初始备注
const detailMemo = [
`[${now.toLocaleString('zh-CN')}] 用户发起提现申请`,
` 订单号: ${orderNo}`,
` 提现金额: ${params.amount.value} 绿积分`,
` 手续费: ${params.fee.value} 绿积分`,
` 实际到账: ${netAmount.toFixed(2)} 元人民币`,
` 收款方式: ${paymentAccount.paymentMethod === PaymentMethod.BANK_CARD ? '银行卡' :
paymentAccount.paymentMethod === PaymentMethod.ALIPAY ? '支付宝' : '微信'}`,
].join('\n');
return new WithdrawalOrder(
BigInt(0), // Will be set by database
this.generateOrderNo(),
orderNo,
params.accountSequence,
params.userId,
params.amount,
params.fee,
params.chainType,
params.toAddress,
null,
params.isInternalTransfer ?? false,
params.toAccountSequence ?? null,
params.toUserId ?? null,
WithdrawalType.FIAT,
paymentAccount.paymentMethod,
paymentAccount.bankName || null,
paymentAccount.bankCardNo || null,
paymentAccount.cardHolderName || null,
paymentAccount.alipayAccount || null,
paymentAccount.alipayRealName || null,
paymentAccount.wechatAccount || null,
paymentAccount.wechatRealName || null,
WithdrawalStatus.PENDING,
null,
null,
null,
null,
new Date(),
null,
null,
null,
null,
detailMemo,
null,
null,
now,
);
}
@ -183,17 +354,27 @@ export class WithdrawalOrder {
userId: bigint;
amount: Decimal;
fee: Decimal;
chainType: string;
toAddress: string;
txHash: string | null;
isInternalTransfer: boolean;
toAccountSequence: string | null;
toUserId: bigint | null;
withdrawalType?: string;
paymentMethod?: string;
bankName?: string | null;
bankCardNo?: string | null;
cardHolderName?: string | null;
alipayAccount?: string | null;
alipayRealName?: string | null;
wechatAccount?: string | null;
wechatRealName?: string | null;
status: string;
errorMessage: string | null;
frozenAt: Date | null;
broadcastedAt: Date | null;
confirmedAt: Date | null;
errorMessage?: string | null;
reviewedBy?: string | null;
reviewedAt?: Date | null;
reviewRemark?: string | null;
paidBy?: string | null;
paidAt?: Date | null;
paymentProof?: string | null;
paymentRemark?: string | null;
detailMemo?: string | null;
frozenAt?: Date | null;
completedAt?: Date | null;
createdAt: Date;
}): WithdrawalOrder {
return new WithdrawalOrder(
@ -203,53 +384,123 @@ export class WithdrawalOrder {
UserId.create(params.userId),
Money.USDT(params.amount),
Money.USDT(params.fee),
params.chainType as ChainType,
params.toAddress,
params.txHash,
params.isInternalTransfer,
params.toAccountSequence,
params.toUserId ? UserId.create(params.toUserId) : null,
(params.withdrawalType as WithdrawalType) || WithdrawalType.FIAT,
(params.paymentMethod as PaymentMethod) || PaymentMethod.BANK_CARD,
params.bankName || null,
params.bankCardNo || null,
params.cardHolderName || null,
params.alipayAccount || null,
params.alipayRealName || null,
params.wechatAccount || null,
params.wechatRealName || null,
params.status as WithdrawalStatus,
params.errorMessage,
params.frozenAt,
params.broadcastedAt,
params.confirmedAt,
params.errorMessage || null,
params.reviewedBy || null,
params.reviewedAt || null,
params.reviewRemark || null,
params.paidBy || null,
params.paidAt || null,
params.paymentProof || null,
params.paymentRemark || null,
params.detailMemo || null,
params.frozenAt || null,
params.completedAt || null,
params.createdAt,
);
}
/**
*
*/
private appendMemo(message: string): void {
const now = new Date();
const newLine = `[${now.toLocaleString('zh-CN')}] ${message}`;
this._detailMemo = this._detailMemo
? `${this._detailMemo}\n${newLine}`
: newLine;
}
/**
* ()
*/
markAsFrozen(): void {
if (this._status !== WithdrawalStatus.PENDING) {
throw new DomainError('Only pending withdrawals can be frozen');
throw new DomainError('只有待处理的提现订单可以冻结资金');
}
this._status = WithdrawalStatus.FROZEN;
this._frozenAt = new Date();
this.appendMemo('资金已冻结,等待审核');
}
/**
* 广
*
*/
markAsBroadcasted(txHash: string): void {
submitForReview(): void {
if (this._status !== WithdrawalStatus.FROZEN) {
throw new DomainError('Only frozen withdrawals can be broadcasted');
throw new DomainError('只有已冻结的提现订单可以提交审核');
}
this._status = WithdrawalStatus.BROADCASTED;
this._txHash = txHash;
this._broadcastedAt = new Date();
this._status = WithdrawalStatus.REVIEWING;
this.appendMemo('已提交审核');
}
/**
* ()
*
*/
markAsConfirmed(): void {
if (this._status !== WithdrawalStatus.BROADCASTED) {
throw new DomainError('Only broadcasted withdrawals can be confirmed');
approve(reviewedBy: string, remark?: string): void {
if (this._status !== WithdrawalStatus.REVIEWING && this._status !== WithdrawalStatus.FROZEN) {
throw new DomainError('只有审核中或已冻结的订单可以通过审核');
}
this._status = WithdrawalStatus.CONFIRMED;
this._confirmedAt = new Date();
this._status = WithdrawalStatus.APPROVED;
this._reviewedBy = reviewedBy;
this._reviewedAt = new Date();
this._reviewRemark = remark || null;
this.appendMemo(`审核通过 - 审核人: ${reviewedBy}${remark ? `, 备注: ${remark}` : ''}`);
}
/**
*
*/
reject(reviewedBy: string, remark: string): void {
if (this._status !== WithdrawalStatus.REVIEWING && this._status !== WithdrawalStatus.FROZEN) {
throw new DomainError('只有审核中或已冻结的订单可以驳回');
}
this._status = WithdrawalStatus.REJECTED;
this._reviewedBy = reviewedBy;
this._reviewedAt = new Date();
this._reviewRemark = remark;
this.appendMemo(`审核驳回 - 审核人: ${reviewedBy}, 原因: ${remark}`);
}
/**
*
*/
startPayment(paidBy: string): void {
if (this._status !== WithdrawalStatus.APPROVED) {
throw new DomainError('只有审核通过的订单可以开始打款');
}
this._status = WithdrawalStatus.PAYING;
this._paidBy = paidBy;
this.appendMemo(`开始打款 - 操作人: ${paidBy}`);
}
/**
*
*/
completePayment(paymentProof?: string, remark?: string): void {
if (this._status !== WithdrawalStatus.PAYING) {
throw new DomainError('只有打款中的订单可以完成打款');
}
this._status = WithdrawalStatus.COMPLETED;
this._paidAt = new Date();
this._completedAt = new Date();
this._paymentProof = paymentProof || null;
this._paymentRemark = remark || null;
const proofInfo = paymentProof ? `, 凭证: ${paymentProof}` : '';
const remarkInfo = remark ? `, 备注: ${remark}` : '';
this.appendMemo(`打款完成${proofInfo}${remarkInfo}`);
this.appendMemo(` 收款账户: ${this.getPaymentAccountDisplay()}`);
this.appendMemo(` 到账金额: ${this.netAmount.value.toFixed(2)} 元人民币`);
}
/**
@ -257,27 +508,31 @@ export class WithdrawalOrder {
*/
markAsFailed(errorMessage: string): void {
if (this.isFinished) {
throw new DomainError('Cannot fail a finished withdrawal');
throw new DomainError('已完成的提现订单无法标记为失败');
}
this._status = WithdrawalStatus.FAILED;
this._errorMessage = errorMessage;
this.appendMemo(`提现失败 - 原因: ${errorMessage}`);
}
/**
*
*/
cancel(): void {
cancel(reason?: string): void {
if (this._status !== WithdrawalStatus.PENDING && this._status !== WithdrawalStatus.FROZEN) {
throw new DomainError('Only pending or frozen withdrawals can be cancelled');
throw new DomainError('只有待处理或已冻结的提现订单可以取消');
}
this._status = WithdrawalStatus.CANCELLED;
this.appendMemo(`用户取消提现${reason ? ` - 原因: ${reason}` : ''}`);
}
/**
* ()
* (//)
*/
needsUnfreeze(): boolean {
return (this._status === WithdrawalStatus.FAILED || this._status === WithdrawalStatus.CANCELLED)
return (this._status === WithdrawalStatus.REJECTED ||
this._status === WithdrawalStatus.FAILED ||
this._status === WithdrawalStatus.CANCELLED)
&& this._frozenAt !== null;
}
}

View File

@ -8,7 +8,10 @@ export interface IWithdrawalOrderRepository {
findByUserId(userId: bigint, status?: WithdrawalStatus): Promise<WithdrawalOrder[]>;
findPendingOrders(): Promise<WithdrawalOrder[]>;
findFrozenOrders(): Promise<WithdrawalOrder[]>;
findBroadcastedOrders(): Promise<WithdrawalOrder[]>;
// 法币提现相关查询
findReviewingOrders(): Promise<WithdrawalOrder[]>;
findApprovedOrders(): Promise<WithdrawalOrder[]>;
findPayingOrders(): Promise<WithdrawalOrder[]>;
}
export const WITHDRAWAL_ORDER_REPOSITORY = Symbol('IWithdrawalOrderRepository');

View File

@ -11,6 +11,7 @@ export enum LedgerEntryType {
TRANSFER_TO_POOL = 'TRANSFER_TO_POOL',
SWAP_EXECUTED = 'SWAP_EXECUTED',
WITHDRAWAL = 'WITHDRAWAL',
WITHDRAWAL_FEE = 'WITHDRAWAL_FEE', // 提现手续费
TRANSFER_IN = 'TRANSFER_IN',
TRANSFER_OUT = 'TRANSFER_OUT',
FREEZE = 'FREEZE',

View File

@ -1,8 +1,34 @@
/**
* ()
*
* 流程: PENDING -> FROZEN -> REVIEWING -> APPROVED -> PAYING -> COMPLETED
* -> REJECTED ()
* -> FAILED (退)
*/
export enum WithdrawalStatus {
PENDING = 'PENDING', // 待处理
FROZEN = 'FROZEN', // 已冻结资金,等待签名
BROADCASTED = 'BROADCASTED', // 已广播到链上
CONFIRMED = 'CONFIRMED', // 链上确认完成
FAILED = 'FAILED', // 失败
CANCELLED = 'CANCELLED', // 已取消
PENDING = 'PENDING', // 待处理(用户刚提交)
FROZEN = 'FROZEN', // 已冻结资金,等待审核
REVIEWING = 'REVIEWING', // 审核中
APPROVED = 'APPROVED', // 审核通过,等待打款
PAYING = 'PAYING', // 打款中
COMPLETED = 'COMPLETED', // 已完成(打款成功)
REJECTED = 'REJECTED', // 审核驳回(资金已退回)
FAILED = 'FAILED', // 失败(资金已退回)
CANCELLED = 'CANCELLED', // 已取消(用户取消,资金已退回)
}
/**
*
*/
export enum PaymentMethod {
BANK_CARD = 'BANK_CARD', // 银行卡
ALIPAY = 'ALIPAY', // 支付宝
WECHAT = 'WECHAT', // 微信支付
}
/**
*
*/
export enum WithdrawalType {
FIAT = 'FIAT', // 法币提现(人民币)
}

View File

@ -16,17 +16,33 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
userId: order.userId.value,
amount: order.amount.toDecimal(),
fee: order.fee.toDecimal(),
chainType: order.chainType,
toAddress: order.toAddress,
txHash: order.txHash,
isInternalTransfer: order.isInternalTransfer,
toAccountSequence: order.toAccountSequence,
toUserId: order.toUserId?.value ?? null,
// 法币提现字段
withdrawalType: order.withdrawalType,
paymentMethod: order.paymentMethod,
bankName: order.bankName,
bankCardNo: order.bankCardNo,
cardHolderName: order.cardHolderName,
alipayAccount: order.alipayAccount,
alipayRealName: order.alipayRealName,
wechatAccount: order.wechatAccount,
wechatRealName: order.wechatRealName,
// 状态
status: order.status,
errorMessage: order.errorMessage,
// 审核信息
reviewedBy: order.reviewedBy,
reviewedAt: order.reviewedAt,
reviewRemark: order.reviewRemark,
// 打款信息
paidBy: order.paidBy,
paidAt: order.paidAt,
paymentProof: order.paymentProof,
paymentRemark: order.paymentRemark,
// 详细备注
detailMemo: order.detailMemo,
// 时间戳
frozenAt: order.frozenAt,
broadcastedAt: order.broadcastedAt,
confirmedAt: order.confirmedAt,
completedAt: order.completedAt,
};
if (order.id === BigInt(0)) {
@ -68,6 +84,19 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
return records.map(r => this.toDomain(r));
}
async findByAccountSequence(accountSequence: string, status?: WithdrawalStatus): Promise<WithdrawalOrder[]> {
const where: Record<string, unknown> = { accountSequence };
if (status) {
where.status = status;
}
const records = await this.prisma.withdrawalOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
});
return records.map(r => this.toDomain(r));
}
async findPendingOrders(): Promise<WithdrawalOrder[]> {
const records = await this.prisma.withdrawalOrder.findMany({
where: { status: WithdrawalStatus.PENDING },
@ -84,14 +113,87 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
return records.map(r => this.toDomain(r));
}
async findBroadcastedOrders(): Promise<WithdrawalOrder[]> {
/**
*
*/
async findReviewingOrders(): Promise<WithdrawalOrder[]> {
const records = await this.prisma.withdrawalOrder.findMany({
where: { status: WithdrawalStatus.BROADCASTED },
where: {
status: {
in: [WithdrawalStatus.FROZEN, WithdrawalStatus.REVIEWING],
},
},
orderBy: { createdAt: 'asc' },
});
return records.map(r => this.toDomain(r));
}
/**
*
*/
async findApprovedOrders(): Promise<WithdrawalOrder[]> {
const records = await this.prisma.withdrawalOrder.findMany({
where: { status: WithdrawalStatus.APPROVED },
orderBy: { createdAt: 'asc' },
});
return records.map(r => this.toDomain(r));
}
/**
*
*/
async findPayingOrders(): Promise<WithdrawalOrder[]> {
const records = await this.prisma.withdrawalOrder.findMany({
where: { status: WithdrawalStatus.PAYING },
orderBy: { createdAt: 'asc' },
});
return records.map(r => this.toDomain(r));
}
/**
* 使
*/
async findWithPagination(params: {
status?: WithdrawalStatus | WithdrawalStatus[];
paymentMethod?: string;
accountSequence?: string;
page: number;
pageSize: number;
}): Promise<{ orders: WithdrawalOrder[]; total: number }> {
const where: Record<string, unknown> = {};
if (params.status) {
if (Array.isArray(params.status)) {
where.status = { in: params.status };
} else {
where.status = params.status;
}
}
if (params.paymentMethod) {
where.paymentMethod = params.paymentMethod;
}
if (params.accountSequence) {
where.accountSequence = params.accountSequence;
}
const [records, total] = await Promise.all([
this.prisma.withdrawalOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (params.page - 1) * params.pageSize,
take: params.pageSize,
}),
this.prisma.withdrawalOrder.count({ where }),
]);
return {
orders: records.map(r => this.toDomain(r)),
total,
};
}
private toDomain(record: {
id: bigint;
orderNo: string;
@ -99,18 +201,37 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
userId: bigint;
amount: Decimal;
fee: Decimal;
chainType: string;
toAddress: string;
txHash: string | null;
isInternalTransfer: boolean;
toAccountSequence: string | null;
toUserId: bigint | null;
withdrawalType: string | null;
paymentMethod: string | null;
bankName: string | null;
bankCardNo: string | null;
cardHolderName: string | null;
alipayAccount: string | null;
alipayRealName: string | null;
wechatAccount: string | null;
wechatRealName: string | null;
status: string;
errorMessage: string | null;
reviewedBy: string | null;
reviewedAt: Date | null;
reviewRemark: string | null;
paidBy: string | null;
paidAt: Date | null;
paymentProof: string | null;
paymentRemark: string | null;
detailMemo: string | null;
frozenAt: Date | null;
broadcastedAt: Date | null;
confirmedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
// 兼容旧字段
chainType?: string | null;
toAddress?: string | null;
txHash?: string | null;
isInternalTransfer?: boolean;
toAccountSequence?: string | null;
toUserId?: bigint | null;
broadcastedAt?: Date | null;
confirmedAt?: Date | null;
}): WithdrawalOrder {
return WithdrawalOrder.reconstruct({
id: record.id,
@ -119,17 +240,27 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
userId: record.userId,
amount: new Decimal(record.amount.toString()),
fee: new Decimal(record.fee.toString()),
chainType: record.chainType,
toAddress: record.toAddress,
txHash: record.txHash,
isInternalTransfer: record.isInternalTransfer,
toAccountSequence: record.toAccountSequence,
toUserId: record.toUserId,
withdrawalType: record.withdrawalType || undefined,
paymentMethod: record.paymentMethod || undefined,
bankName: record.bankName,
bankCardNo: record.bankCardNo,
cardHolderName: record.cardHolderName,
alipayAccount: record.alipayAccount,
alipayRealName: record.alipayRealName,
wechatAccount: record.wechatAccount,
wechatRealName: record.wechatRealName,
status: record.status,
errorMessage: record.errorMessage,
reviewedBy: record.reviewedBy,
reviewedAt: record.reviewedAt,
reviewRemark: record.reviewRemark,
paidBy: record.paidBy,
paidAt: record.paidAt,
paymentProof: record.paymentProof,
paymentRemark: record.paymentRemark,
detailMemo: record.detailMemo,
frozenAt: record.frozenAt,
broadcastedAt: record.broadcastedAt,
confirmedAt: record.confirmedAt,
completedAt: record.completedAt,
createdAt: record.createdAt,
});
}

View File

@ -0,0 +1,682 @@
'use client';
import { useState, useCallback } from 'react';
import { Modal, toast, Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { formatDateTime } from '@/utils/formatters';
import {
useReviewingWithdrawals,
useApprovedWithdrawals,
usePayingWithdrawals,
useReviewWithdrawal,
useStartPayment,
useCompletePayment,
} from '@/hooks/useWithdrawals';
import {
WithdrawalOrder,
getWithdrawalStatusInfo,
getPaymentMethodInfo,
formatAmount,
maskBankCardNo,
maskAccount,
} from '@/types/withdrawal.types';
import styles from './withdrawals.module.scss';
type TabType = 'reviewing' | 'approved' | 'paying';
/**
*
*/
export default function WithdrawalsPage() {
const [activeTab, setActiveTab] = useState<TabType>('reviewing');
const [viewingOrder, setViewingOrder] = useState<WithdrawalOrder | null>(null);
const [reviewingOrder, setReviewingOrder] = useState<WithdrawalOrder | null>(null);
const [payingOrder, setPayingOrder] = useState<WithdrawalOrder | null>(null);
const [completeOrder, setCompleteOrder] = useState<WithdrawalOrder | null>(null);
// 审核表单
const [reviewForm, setReviewForm] = useState({
approved: true,
remark: '',
});
// 完成打款表单
const [completeForm, setCompleteForm] = useState({
paymentProof: '',
remark: '',
});
// 数据查询
const {
data: reviewingData,
isLoading: reviewingLoading,
error: reviewingError,
refetch: refetchReviewing,
} = useReviewingWithdrawals();
const {
data: approvedData,
isLoading: approvedLoading,
error: approvedError,
refetch: refetchApproved,
} = useApprovedWithdrawals();
const {
data: payingData,
isLoading: payingLoading,
error: payingError,
refetch: refetchPaying,
} = usePayingWithdrawals();
// Mutations
const reviewMutation = useReviewWithdrawal();
const startPaymentMutation = useStartPayment();
const completePaymentMutation = useCompletePayment();
// 获取当前 Tab 数据
const getCurrentData = () => {
switch (activeTab) {
case 'reviewing':
return { data: reviewingData, loading: reviewingLoading, error: reviewingError, refetch: refetchReviewing };
case 'approved':
return { data: approvedData, loading: approvedLoading, error: approvedError, refetch: refetchApproved };
case 'paying':
return { data: payingData, loading: payingLoading, error: payingError, refetch: refetchPaying };
}
};
const { data: currentData, loading, error, refetch } = getCurrentData();
// 刷新所有数据
const handleRefreshAll = useCallback(() => {
refetchReviewing();
refetchApproved();
refetchPaying();
toast.success('数据已刷新');
}, [refetchReviewing, refetchApproved, refetchPaying]);
// 打开审核弹窗
const handleOpenReview = (order: WithdrawalOrder) => {
setReviewingOrder(order);
setReviewForm({ approved: true, remark: '' });
};
// 提交审核
const handleSubmitReview = async () => {
if (!reviewingOrder) return;
try {
await reviewMutation.mutateAsync({
orderNo: reviewingOrder.orderNo,
data: {
approved: reviewForm.approved,
reviewedBy: 'admin', // TODO: 从登录用户获取
remark: reviewForm.remark || undefined,
},
});
toast.success(reviewForm.approved ? '审核通过' : '已拒绝提现');
setReviewingOrder(null);
} catch (err) {
toast.error((err as Error).message || '操作失败');
}
};
// 开始打款
const handleStartPayment = async (order: WithdrawalOrder) => {
setPayingOrder(order);
try {
await startPaymentMutation.mutateAsync({
orderNo: order.orderNo,
data: {
paidBy: 'admin', // TODO: 从登录用户获取
},
});
toast.success('已开始打款');
setPayingOrder(null);
} catch (err) {
toast.error((err as Error).message || '操作失败');
setPayingOrder(null);
}
};
// 打开完成打款弹窗
const handleOpenComplete = (order: WithdrawalOrder) => {
setCompleteOrder(order);
setCompleteForm({ paymentProof: '', remark: '' });
};
// 完成打款
const handleSubmitComplete = async () => {
if (!completeOrder) return;
try {
await completePaymentMutation.mutateAsync({
orderNo: completeOrder.orderNo,
data: {
paymentProof: completeForm.paymentProof || undefined,
remark: completeForm.remark || undefined,
},
});
toast.success('打款完成');
setCompleteOrder(null);
} catch (err) {
toast.error((err as Error).message || '操作失败');
}
};
// 获取收款账户显示信息
const getPaymentAccountDisplay = (order: WithdrawalOrder) => {
switch (order.paymentMethod) {
case 'BANK_CARD':
return {
label: order.bankName || '银行卡',
account: order.bankCardNo ? maskBankCardNo(order.bankCardNo) : '-',
name: order.cardHolderName,
};
case 'ALIPAY':
return {
label: '支付宝',
account: order.alipayAccount ? maskAccount(order.alipayAccount) : '-',
name: order.alipayRealName,
};
case 'WECHAT':
return {
label: '微信',
account: order.wechatAccount ? maskAccount(order.wechatAccount) : '-',
name: order.wechatRealName,
};
default:
return { label: '-', account: '-', name: '-' };
}
};
// 渲染表格行
const renderTableRow = (order: WithdrawalOrder) => {
const statusInfo = getWithdrawalStatusInfo(order.status);
const methodInfo = getPaymentMethodInfo(order.paymentMethod);
const accountInfo = getPaymentAccountDisplay(order);
return (
<tr key={order.id}>
<td>
<span className={styles.withdrawals__orderNo}>{order.orderNo}</span>
</td>
<td>
<div className={styles.withdrawals__user}>
<span className={styles.withdrawals__accountSequence}>{order.accountSequence}</span>
<span className={styles.withdrawals__userId}>ID: {order.userId}</span>
</div>
</td>
<td>
<div>
<span className={styles.withdrawals__amount}>{formatAmount(order.amount)}</span>
<span className={styles.withdrawals__fee}> (: {formatAmount(order.fee)})</span>
</div>
</td>
<td>
<div className={styles.withdrawals__paymentMethod}>
<span className={styles.withdrawals__paymentIcon}>{methodInfo.icon}</span>
<div className={styles.withdrawals__paymentInfo}>
<span className={styles.withdrawals__paymentLabel}>{accountInfo.label}</span>
<span className={styles.withdrawals__paymentAccount}>{accountInfo.account}</span>
</div>
</div>
</td>
<td>
<span
className={styles.withdrawals__statusTag}
style={{
backgroundColor: statusInfo.color,
color: statusInfo.textColor || 'white',
}}
>
{statusInfo.label}
</span>
</td>
<td>{formatDateTime(order.createdAt)}</td>
<td>
<div className={styles.withdrawals__actions}>
<button
className={cn(styles.withdrawals__actionBtn, styles['withdrawals__actionBtn--outline'])}
onClick={() => setViewingOrder(order)}
>
</button>
{activeTab === 'reviewing' && (
<button
className={cn(styles.withdrawals__actionBtn, styles['withdrawals__actionBtn--primary'])}
onClick={() => handleOpenReview(order)}
>
</button>
)}
{activeTab === 'approved' && (
<button
className={cn(styles.withdrawals__actionBtn, styles['withdrawals__actionBtn--success'])}
onClick={() => handleStartPayment(order)}
disabled={startPaymentMutation.isPending && payingOrder?.id === order.id}
>
{startPaymentMutation.isPending && payingOrder?.id === order.id ? '处理中...' : '开始打款'}
</button>
)}
{activeTab === 'paying' && (
<button
className={cn(styles.withdrawals__actionBtn, styles['withdrawals__actionBtn--success'])}
onClick={() => handleOpenComplete(order)}
>
</button>
)}
</div>
</td>
</tr>
);
};
return (
<PageContainer title="提现审核">
<div className={styles.withdrawals}>
{/* 页面标题 */}
<div className={styles.withdrawals__header}>
<h1 className={styles.withdrawals__title}></h1>
<p className={styles.withdrawals__subtitle}>
</p>
</div>
{/* Tab 切换 */}
<div className={styles.withdrawals__tabs}>
<button
className={cn(styles.withdrawals__tab, activeTab === 'reviewing' && styles['withdrawals__tab--active'])}
onClick={() => setActiveTab('reviewing')}
>
{(reviewingData?.length ?? 0) > 0 && (
<span className={styles.withdrawals__tabBadge}>{reviewingData?.length}</span>
)}
</button>
<button
className={cn(styles.withdrawals__tab, activeTab === 'approved' && styles['withdrawals__tab--active'])}
onClick={() => setActiveTab('approved')}
>
{(approvedData?.length ?? 0) > 0 && (
<span className={styles.withdrawals__tabBadge}>{approvedData?.length}</span>
)}
</button>
<button
className={cn(styles.withdrawals__tab, activeTab === 'paying' && styles['withdrawals__tab--active'])}
onClick={() => setActiveTab('paying')}
>
{(payingData?.length ?? 0) > 0 && (
<span className={styles.withdrawals__tabBadge}>{payingData?.length}</span>
)}
</button>
</div>
{/* 主内容卡片 */}
<div className={styles.withdrawals__card}>
<div className={styles.withdrawals__toolbar}>
<span className={styles.withdrawals__toolbarInfo}>
{currentData?.length ?? 0}
</span>
<Button variant="outline" size="sm" onClick={handleRefreshAll}>
</Button>
</div>
{/* 表格 */}
{loading ? (
<div className={styles.withdrawals__loading}>...</div>
) : error ? (
<div className={styles.withdrawals__error}>
<span>{(error as Error).message || '加载失败'}</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
</Button>
</div>
) : !currentData || currentData.length === 0 ? (
<div className={styles.withdrawals__empty}>
<span className={styles.withdrawals__emptyIcon}>
{activeTab === 'reviewing' ? '📋' : activeTab === 'approved' ? '💰' : '⏳'}
</span>
<span>
{activeTab === 'reviewing'
? '暂无待审核的提现订单'
: activeTab === 'approved'
? '暂无待打款的提现订单'
: '暂无打款中的提现订单'}
</span>
</div>
) : (
<table className={styles.withdrawals__table}>
<thead>
<tr>
<th></th>
<th></th>
<th> (绿)</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>{currentData.map(renderTableRow)}</tbody>
</table>
)}
</div>
{/* 详情弹窗 */}
<Modal
visible={!!viewingOrder}
title="提现订单详情"
onClose={() => setViewingOrder(null)}
footer={
<div className={styles.withdrawals__modalFooter}>
<Button variant="outline" onClick={() => setViewingOrder(null)}>
</Button>
</div>
}
width={700}
>
{viewingOrder && (
<div className={styles.withdrawals__detail}>
{/* 基本信息 */}
<div className={styles.withdrawals__detailSection}>
<h4 className={styles.withdrawals__detailTitle}></h4>
<div className={styles.withdrawals__detailGrid}>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={cn(styles.withdrawals__detailValue, styles['withdrawals__detailValue--highlight'])}>
{viewingOrder.orderNo}
</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span
className={styles.withdrawals__statusTag}
style={{
backgroundColor: getWithdrawalStatusInfo(viewingOrder.status).color,
color: getWithdrawalStatusInfo(viewingOrder.status).textColor || 'white',
}}
>
{getWithdrawalStatusInfo(viewingOrder.status).label}
</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>
{viewingOrder.accountSequence} (ID: {viewingOrder.userId})
</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{formatDateTime(viewingOrder.createdAt)}</span>
</div>
</div>
</div>
{/* 金额信息 */}
<div className={styles.withdrawals__detailSection}>
<h4 className={styles.withdrawals__detailTitle}></h4>
<div className={styles.withdrawals__detailGrid}>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={cn(styles.withdrawals__detailValue, styles['withdrawals__detailValue--amount'])}>
{formatAmount(viewingOrder.amount)} 绿
</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{formatAmount(viewingOrder.fee)} 绿</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{formatAmount(viewingOrder.netAmount)} </span>
</div>
</div>
</div>
{/* 收款信息 */}
<div className={styles.withdrawals__detailSection}>
<h4 className={styles.withdrawals__detailTitle}></h4>
<div className={styles.withdrawals__detailGrid}>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>
{getPaymentMethodInfo(viewingOrder.paymentMethod).icon}{' '}
{getPaymentMethodInfo(viewingOrder.paymentMethod).label}
</span>
</div>
{viewingOrder.paymentMethod === 'BANK_CARD' && (
<>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{viewingOrder.bankName || '-'}</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>
{viewingOrder.bankCardNo || '-'}
</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{viewingOrder.cardHolderName || '-'}</span>
</div>
</>
)}
{viewingOrder.paymentMethod === 'ALIPAY' && (
<>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{viewingOrder.alipayAccount || '-'}</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{viewingOrder.alipayRealName || '-'}</span>
</div>
</>
)}
{viewingOrder.paymentMethod === 'WECHAT' && (
<>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{viewingOrder.wechatAccount || '-'}</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{viewingOrder.wechatRealName || '-'}</span>
</div>
</>
)}
</div>
</div>
{/* 处理记录 */}
{(viewingOrder.reviewedBy || viewingOrder.paidBy) && (
<div className={styles.withdrawals__detailSection}>
<h4 className={styles.withdrawals__detailTitle}></h4>
<div className={styles.withdrawals__timeline}>
{viewingOrder.reviewedBy && (
<div className={cn(styles.withdrawals__timelineItem, styles['withdrawals__timelineItem--success'])}>
<div className={styles.withdrawals__timelineTitle}>
{viewingOrder.status === 'REJECTED' ? '拒绝' : '通过'}
</div>
<div className={styles.withdrawals__timelineInfo}>
: {viewingOrder.reviewedBy}
{viewingOrder.reviewedAt && ` | 时间: ${formatDateTime(viewingOrder.reviewedAt)}`}
{viewingOrder.reviewRemark && ` | 备注: ${viewingOrder.reviewRemark}`}
</div>
</div>
)}
{viewingOrder.paidBy && (
<div
className={cn(
styles.withdrawals__timelineItem,
viewingOrder.status === 'COMPLETED'
? styles['withdrawals__timelineItem--success']
: styles['withdrawals__timelineItem--active']
)}
>
<div className={styles.withdrawals__timelineTitle}>
{viewingOrder.status === 'COMPLETED' ? '打款完成' : '打款中'}
</div>
<div className={styles.withdrawals__timelineInfo}>
: {viewingOrder.paidBy}
{viewingOrder.paidAt && ` | 时间: ${formatDateTime(viewingOrder.paidAt)}`}
{viewingOrder.paymentRemark && ` | 备注: ${viewingOrder.paymentRemark}`}
</div>
</div>
)}
</div>
</div>
)}
</div>
)}
</Modal>
{/* 审核弹窗 */}
<Modal
visible={!!reviewingOrder}
title="审核提现订单"
onClose={() => setReviewingOrder(null)}
footer={
<div className={styles.withdrawals__modalFooter}>
<Button variant="outline" onClick={() => setReviewingOrder(null)}>
</Button>
<Button
variant={reviewForm.approved ? 'primary' : 'danger'}
onClick={handleSubmitReview}
loading={reviewMutation.isPending}
>
{reviewForm.approved ? '通过' : '拒绝'}
</Button>
</div>
}
width={500}
>
{reviewingOrder && (
<div className={styles.withdrawals__form}>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{reviewingOrder.orderNo}</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{reviewingOrder.accountSequence}</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={cn(styles.withdrawals__detailValue, styles['withdrawals__detailValue--amount'])}>
{formatAmount(reviewingOrder.amount)} 绿
</span>
</div>
<div className={styles.withdrawals__formGroup}>
<label></label>
<div className={styles.withdrawals__radioGroup}>
<label
className={cn(styles.withdrawals__radioItem, styles['withdrawals__radioItem--approve'])}
>
<input
type="radio"
name="approved"
checked={reviewForm.approved}
onChange={() => setReviewForm({ ...reviewForm, approved: true })}
/>
<span></span>
</label>
<label
className={cn(styles.withdrawals__radioItem, styles['withdrawals__radioItem--reject'])}
>
<input
type="radio"
name="approved"
checked={!reviewForm.approved}
onChange={() => setReviewForm({ ...reviewForm, approved: false })}
/>
<span></span>
</label>
</div>
</div>
<div className={styles.withdrawals__formGroup}>
<label> ()</label>
<textarea
value={reviewForm.remark}
onChange={(e) => setReviewForm({ ...reviewForm, remark: e.target.value })}
placeholder={reviewForm.approved ? '审核通过备注...' : '请填写拒绝原因...'}
/>
</div>
</div>
)}
</Modal>
{/* 完成打款弹窗 */}
<Modal
visible={!!completeOrder}
title="完成打款"
onClose={() => setCompleteOrder(null)}
footer={
<div className={styles.withdrawals__modalFooter}>
<Button variant="outline" onClick={() => setCompleteOrder(null)}>
</Button>
<Button
variant="primary"
onClick={handleSubmitComplete}
loading={completePaymentMutation.isPending}
>
</Button>
</div>
}
width={500}
>
{completeOrder && (
<div className={styles.withdrawals__form}>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{completeOrder.orderNo}</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={styles.withdrawals__detailValue}>{completeOrder.accountSequence}</span>
</div>
<div className={styles.withdrawals__detailRow}>
<span className={styles.withdrawals__detailLabel}>:</span>
<span className={cn(styles.withdrawals__detailValue, styles['withdrawals__detailValue--amount'])}>
{formatAmount(completeOrder.netAmount)}
</span>
</div>
<div className={styles.withdrawals__formGroup}>
<label> ()</label>
<input
type="text"
value={completeForm.paymentProof}
onChange={(e) => setCompleteForm({ ...completeForm, paymentProof: e.target.value })}
placeholder="转账流水号或凭证链接..."
/>
</div>
<div className={styles.withdrawals__formGroup}>
<label> ()</label>
<textarea
value={completeForm.remark}
onChange={(e) => setCompleteForm({ ...completeForm, remark: e.target.value })}
placeholder="打款备注..."
/>
</div>
</div>
)}
</Modal>
</div>
</PageContainer>
);
}

View File

@ -0,0 +1,457 @@
@use '@/styles/variables' as *;
.withdrawals {
display: flex;
flex-direction: column;
gap: 24px;
&__header {
display: flex;
flex-direction: column;
gap: 8px;
}
&__title {
font-size: 24px;
font-weight: 600;
color: $text-primary;
}
&__subtitle {
font-size: 14px;
color: $text-secondary;
}
// Tab 切换
&__tabs {
display: flex;
gap: 4px;
background: #f0f2f5;
padding: 4px;
border-radius: 8px;
width: fit-content;
}
&__tab {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
background: transparent;
color: $text-secondary;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: $text-primary;
}
&--active {
background: white;
color: $primary-color;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
&__tabBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: #ff4d4f;
color: white;
font-size: 12px;
font-weight: 600;
border-radius: 10px;
}
&__card {
background: $card-background;
border-radius: 12px;
padding: 24px;
box-shadow: $shadow-base;
}
&__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
&__toolbarInfo {
font-size: 14px;
color: $text-secondary;
}
// 表格
&__table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
th,
td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid $border-color;
}
th {
font-weight: 600;
color: $text-secondary;
background: #fafafa;
}
td {
color: $text-primary;
}
tbody tr {
transition: background 0.15s;
&:hover {
background: #f5f7fa;
}
}
}
&__orderNo {
font-family: monospace;
font-size: 13px;
color: $primary-color;
}
&__user {
display: flex;
flex-direction: column;
gap: 2px;
}
&__accountSequence {
font-weight: 600;
color: $primary-color;
font-size: 13px;
}
&__userId {
font-size: 12px;
color: $text-secondary;
}
&__amount {
font-weight: 600;
color: $text-primary;
font-size: 15px;
}
&__fee {
font-size: 12px;
color: $text-secondary;
}
&__statusTag {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
&__paymentMethod {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
&__paymentIcon {
font-size: 16px;
}
&__paymentInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
&__paymentLabel {
font-weight: 500;
}
&__paymentAccount {
font-size: 12px;
color: $text-secondary;
font-family: monospace;
}
&__actions {
display: flex;
gap: 8px;
}
&__actionBtn {
padding: 6px 12px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: none;
&--primary {
background: $primary-color;
color: white;
&:hover {
background: darken($primary-color, 10%);
}
}
&--success {
background: #52c41a;
color: white;
&:hover {
background: darken(#52c41a, 10%);
}
}
&--danger {
background: white;
color: #ff4d4f;
border: 1px solid #ff4d4f;
&:hover {
background: #fff1f0;
}
}
&--outline {
background: white;
color: $text-secondary;
border: 1px solid $border-color;
&:hover {
color: $primary-color;
border-color: $primary-color;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// 空状态和加载
&__loading,
&__empty,
&__error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
padding: 60px 20px;
color: $text-secondary;
font-size: 14px;
}
&__emptyIcon {
font-size: 48px;
opacity: 0.5;
}
&__error {
color: $error-color;
}
// 弹窗表单
&__form {
display: flex;
flex-direction: column;
gap: 16px;
}
&__formGroup {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
input,
select,
textarea {
padding: 10px 12px;
border: 1px solid $border-color;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: $primary-color;
}
&::placeholder {
color: $text-disabled;
}
}
textarea {
resize: vertical;
min-height: 80px;
}
}
&__radioGroup {
display: flex;
gap: 16px;
}
&__radioItem {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
input[type='radio'] {
width: 18px;
height: 18px;
cursor: pointer;
}
span {
font-size: 14px;
}
&--approve span {
color: #52c41a;
}
&--reject span {
color: #ff4d4f;
}
}
&__modalFooter {
display: flex;
justify-content: flex-end;
gap: 12px;
}
// 详情弹窗
&__detail {
display: flex;
flex-direction: column;
gap: 20px;
}
&__detailSection {
display: flex;
flex-direction: column;
gap: 12px;
}
&__detailTitle {
font-size: 15px;
font-weight: 600;
color: $text-primary;
padding-bottom: 8px;
border-bottom: 1px solid $border-color;
}
&__detailGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 24px;
}
&__detailRow {
display: flex;
gap: 12px;
}
&__detailLabel {
flex-shrink: 0;
min-width: 80px;
font-size: 13px;
color: $text-secondary;
}
&__detailValue {
font-size: 13px;
color: $text-primary;
word-break: break-all;
&--highlight {
color: $primary-color;
font-weight: 500;
}
&--amount {
font-size: 16px;
font-weight: 600;
color: #ff4d4f;
}
}
&__timeline {
display: flex;
flex-direction: column;
gap: 16px;
padding-left: 20px;
border-left: 2px solid $border-color;
}
&__timelineItem {
position: relative;
padding-left: 20px;
&::before {
content: '';
position: absolute;
left: -26px;
top: 4px;
width: 10px;
height: 10px;
background: $border-color;
border-radius: 50%;
}
&--active::before {
background: $primary-color;
}
&--success::before {
background: #52c41a;
}
&--error::before {
background: #ff4d4f;
}
}
&__timelineTitle {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
&__timelineInfo {
font-size: 12px;
color: $text-secondary;
margin-top: 4px;
}
}

View File

@ -27,6 +27,7 @@ const topMenuItems: MenuItem[] = [
{ key: 'users', icon: '/images/Container2.svg', label: '用户管理', path: '/users' },
{ key: 'leaderboard', icon: '/images/Container3.svg', label: '龙虎榜', path: '/leaderboard' },
{ key: 'authorization', icon: '/images/Container4.svg', label: '授权管理', path: '/authorization' },
{ key: 'withdrawals', icon: '/images/Container4.svg', label: '提现审核', path: '/withdrawals' },
{ key: 'notifications', icon: '/images/Container3.svg', label: '通知管理', path: '/notifications' },
{ key: 'pending-actions', icon: '/images/Container3.svg', label: '待办操作', path: '/pending-actions' },
{ key: 'statistics', icon: '/images/Container5.svg', label: '数据统计', path: '/statistics' },

View File

@ -0,0 +1,110 @@
/**
* React Query hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { withdrawalService } from '@/services/withdrawalService';
import type {
ReviewWithdrawalRequest,
StartPaymentRequest,
CompletePaymentRequest,
} from '@/types/withdrawal.types';
// Query Keys
const QUERY_KEYS = {
reviewing: ['withdrawals', 'reviewing'] as const,
approved: ['withdrawals', 'approved'] as const,
paying: ['withdrawals', 'paying'] as const,
};
/**
*
*/
export function useReviewingWithdrawals() {
return useQuery({
queryKey: QUERY_KEYS.reviewing,
queryFn: () => withdrawalService.getReviewingWithdrawals(),
staleTime: 30000, // 30秒
refetchInterval: 60000, // 1分钟自动刷新
});
}
/**
*
*/
export function useApprovedWithdrawals() {
return useQuery({
queryKey: QUERY_KEYS.approved,
queryFn: () => withdrawalService.getApprovedWithdrawals(),
staleTime: 30000,
refetchInterval: 60000,
});
}
/**
*
*/
export function usePayingWithdrawals() {
return useQuery({
queryKey: QUERY_KEYS.paying,
queryFn: () => withdrawalService.getPayingWithdrawals(),
staleTime: 30000,
refetchInterval: 60000,
});
}
/**
*
*/
export function useReviewWithdrawal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
orderNo,
data,
}: {
orderNo: string;
data: ReviewWithdrawalRequest;
}) => withdrawalService.reviewWithdrawal(orderNo, data),
onSuccess: () => {
// 刷新所有相关列表
queryClient.invalidateQueries({ queryKey: ['withdrawals'] });
},
});
}
/**
*
*/
export function useStartPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ orderNo, data }: { orderNo: string; data: StartPaymentRequest }) =>
withdrawalService.startPayment(orderNo, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['withdrawals'] });
},
});
}
/**
*
*/
export function useCompletePayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
orderNo,
data,
}: {
orderNo: string;
data: CompletePaymentRequest;
}) => withdrawalService.completePayment(orderNo, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['withdrawals'] });
},
});
}

View File

@ -175,4 +175,14 @@ export const API_ENDPOINTS = {
CANCEL: (id: string) => `/v1/admin/pending-actions/${id}/cancel`,
DELETE: (id: string) => `/v1/admin/pending-actions/${id}`,
},
// 提现审核 (wallet-service internal API)
WITHDRAWALS: {
REVIEWING: '/v1/wallets/withdrawals/reviewing',
APPROVED: '/v1/wallets/withdrawals/approved',
PAYING: '/v1/wallets/withdrawals/paying',
REVIEW: (orderNo: string) => `/v1/wallets/withdrawals/${orderNo}/review`,
START_PAYMENT: (orderNo: string) => `/v1/wallets/withdrawals/${orderNo}/start-payment`,
COMPLETE_PAYMENT: (orderNo: string) => `/v1/wallets/withdrawals/${orderNo}/complete-payment`,
},
} as const;

View File

@ -0,0 +1,92 @@
/**
*
*
*/
import apiClient from '@/infrastructure/api/client';
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
import type {
WithdrawalOrder,
ReviewWithdrawalRequest,
StartPaymentRequest,
CompletePaymentRequest,
} from '@/types/withdrawal.types';
/**
*
*/
export const withdrawalService = {
/**
*
*/
async getReviewingWithdrawals(): Promise<WithdrawalOrder[]> {
const response = await apiClient.get(API_ENDPOINTS.WITHDRAWALS.REVIEWING);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (response as any)?.data?.data ?? (response as any)?.data ?? response;
return Array.isArray(result) ? result : [];
},
/**
*
*/
async getApprovedWithdrawals(): Promise<WithdrawalOrder[]> {
const response = await apiClient.get(API_ENDPOINTS.WITHDRAWALS.APPROVED);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (response as any)?.data?.data ?? (response as any)?.data ?? response;
return Array.isArray(result) ? result : [];
},
/**
*
*/
async getPayingWithdrawals(): Promise<WithdrawalOrder[]> {
const response = await apiClient.get(API_ENDPOINTS.WITHDRAWALS.PAYING);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (response as any)?.data?.data ?? (response as any)?.data ?? response;
return Array.isArray(result) ? result : [];
},
/**
*
*/
async reviewWithdrawal(
orderNo: string,
data: ReviewWithdrawalRequest
): Promise<WithdrawalOrder> {
const response = await apiClient.post(
API_ENDPOINTS.WITHDRAWALS.REVIEW(orderNo),
data
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (response as any)?.data?.data ?? (response as any)?.data ?? response;
},
/**
*
*/
async startPayment(orderNo: string, data: StartPaymentRequest): Promise<WithdrawalOrder> {
const response = await apiClient.post(
API_ENDPOINTS.WITHDRAWALS.START_PAYMENT(orderNo),
data
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (response as any)?.data?.data ?? (response as any)?.data ?? response;
},
/**
*
*/
async completePayment(
orderNo: string,
data: CompletePaymentRequest
): Promise<WithdrawalOrder> {
const response = await apiClient.post(
API_ENDPOINTS.WITHDRAWALS.COMPLETE_PAYMENT(orderNo),
data
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (response as any)?.data?.data ?? (response as any)?.data ?? response;
},
};
export default withdrawalService;

View File

@ -0,0 +1,127 @@
/**
*
*/
// 提现状态
export type WithdrawalStatus =
| 'PENDING'
| 'FROZEN'
| 'REVIEWING'
| 'APPROVED'
| 'PAYING'
| 'COMPLETED'
| 'REJECTED'
| 'FAILED'
| 'CANCELLED';
// 支付方式
export type PaymentMethod = 'BANK_CARD' | 'ALIPAY' | 'WECHAT';
// 提现订单
export interface WithdrawalOrder {
id: string;
orderNo: string;
userId: string;
accountSequence: string;
amount: number;
fee: number;
netAmount: number;
currency: string;
status: WithdrawalStatus;
paymentMethod: PaymentMethod;
// 银行卡信息
bankName?: string;
bankCardNo?: string;
cardHolderName?: string;
// 支付宝信息
alipayAccount?: string;
alipayRealName?: string;
// 微信信息
wechatAccount?: string;
wechatRealName?: string;
// 审核信息
reviewedBy?: string;
reviewedAt?: string;
reviewRemark?: string;
// 打款信息
paidBy?: string;
paidAt?: string;
paymentProof?: string;
paymentRemark?: string;
// 时间戳
createdAt: string;
updatedAt: string;
completedAt?: string;
cancelledAt?: string;
cancelReason?: string;
}
// 审核请求
export interface ReviewWithdrawalRequest {
approved: boolean;
reviewedBy: string;
remark?: string;
}
// 开始打款请求
export interface StartPaymentRequest {
paidBy: string;
}
// 完成打款请求
export interface CompletePaymentRequest {
paymentProof?: string;
remark?: string;
}
// 状态显示配置
export const WITHDRAWAL_STATUS_CONFIG: Record<
WithdrawalStatus,
{ label: string; color: string; textColor?: string }
> = {
PENDING: { label: '待处理', color: '#faad14', textColor: '#000' },
FROZEN: { label: '已冻结', color: '#722ed1' },
REVIEWING: { label: '待审核', color: '#1677ff' },
APPROVED: { label: '待打款', color: '#13c2c2' },
PAYING: { label: '打款中', color: '#eb2f96' },
COMPLETED: { label: '已完成', color: '#52c41a' },
REJECTED: { label: '已拒绝', color: '#ff4d4f' },
FAILED: { label: '失败', color: '#ff4d4f' },
CANCELLED: { label: '已取消', color: '#8c8c8c' },
};
// 支付方式显示配置
export const PAYMENT_METHOD_CONFIG: Record<PaymentMethod, { label: string; icon: string }> = {
BANK_CARD: { label: '银行卡', icon: '🏦' },
ALIPAY: { label: '支付宝', icon: '💙' },
WECHAT: { label: '微信', icon: '💚' },
};
// 获取状态信息
export function getWithdrawalStatusInfo(status: WithdrawalStatus) {
return WITHDRAWAL_STATUS_CONFIG[status] || { label: status, color: '#8c8c8c' };
}
// 获取支付方式信息
export function getPaymentMethodInfo(method: PaymentMethod) {
return PAYMENT_METHOD_CONFIG[method] || { label: method, icon: '💳' };
}
// 格式化金额
export function formatAmount(amount: number): string {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// 掩码银行卡号
export function maskBankCardNo(cardNo: string): string {
if (!cardNo || cardNo.length < 8) return cardNo;
return `${cardNo.slice(0, 4)} **** **** ${cardNo.slice(-4)}`;
}
// 掩码账户
export function maskAccount(account: string): string {
if (!account || account.length < 4) return account;
const start = Math.floor(account.length / 3);
const end = account.length - start;
return account.slice(0, start) + '***' + account.slice(end);
}

View File

@ -493,6 +493,91 @@ class WalletService {
}
}
/// //
///
/// POST /wallet/withdraw (wallet-service)
/// 绿//1:1
Future<WithdrawResponse> withdrawFiat({
required double amount,
required PaymentMethod paymentMethod,
//
String? bankName,
String? bankCardNo,
String? cardHolderName,
//
String? alipayAccount,
String? alipayRealName,
//
String? wechatAccount,
String? wechatRealName,
//
required String smsCode,
required String password,
}) async {
try {
debugPrint('[WalletService] ========== 法币提现 ==========');
debugPrint('[WalletService] 请求: POST /wallet/withdraw');
debugPrint('[WalletService] 参数: amount=$amount, paymentMethod=${paymentMethod.value}');
final Map<String, dynamic> data = {
'amount': amount,
'paymentMethod': paymentMethod.value,
'smsCode': smsCode,
'password': password,
};
//
switch (paymentMethod) {
case PaymentMethod.bankCard:
data['bankName'] = bankName;
data['bankCardNo'] = bankCardNo;
data['cardHolderName'] = cardHolderName;
break;
case PaymentMethod.alipay:
data['alipayAccount'] = alipayAccount;
data['alipayRealName'] = alipayRealName;
break;
case PaymentMethod.wechat:
data['wechatAccount'] = wechatAccount;
data['wechatRealName'] = wechatRealName;
break;
}
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 innerData = responseData['data'] as Map<String, dynamic>? ?? responseData;
final result = WithdrawResponse.fromJson(innerData);
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;
}
}
// =============== API ===============
///
@ -668,6 +753,38 @@ class WalletService {
}
}
///
enum PaymentMethod {
bankCard,
alipay,
wechat,
}
///
extension PaymentMethodExtension on PaymentMethod {
String get value {
switch (this) {
case PaymentMethod.bankCard:
return 'BANK_CARD';
case PaymentMethod.alipay:
return 'ALIPAY';
case PaymentMethod.wechat:
return 'WECHAT';
}
}
String get label {
switch (this) {
case PaymentMethod.bankCard:
return '银行卡';
case PaymentMethod.alipay:
return '支付宝';
case PaymentMethod.wechat:
return '微信';
}
}
}
///
class WithdrawResponse {
final String orderNo;
@ -675,8 +792,9 @@ class WithdrawResponse {
final double amount;
final double fee;
final double netAmount;
final String toAddress;
final String chainType;
final String? toAddress;
final String? chainType;
final PaymentMethod? paymentMethod;
final DateTime createdAt;
WithdrawResponse({
@ -685,20 +803,38 @@ class WithdrawResponse {
required this.amount,
required this.fee,
required this.netAmount,
required this.toAddress,
required this.chainType,
this.toAddress,
this.chainType,
this.paymentMethod,
required this.createdAt,
});
factory WithdrawResponse.fromJson(Map<String, dynamic> json) {
PaymentMethod? method;
final methodStr = json['paymentMethod'] as String?;
if (methodStr != null) {
switch (methodStr) {
case 'BANK_CARD':
method = PaymentMethod.bankCard;
break;
case 'ALIPAY':
method = PaymentMethod.alipay;
break;
case 'WECHAT':
method = PaymentMethod.wechat;
break;
}
}
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',
toAddress: json['toAddress'],
chainType: json['chainType'],
paymentMethod: method,
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt']) ?? DateTime.now()
: DateTime.now(),

View File

@ -0,0 +1,894 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'withdraw_fiat_page.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/wallet_service.dart';
///
///
class WithdrawFiatConfirmPage extends ConsumerStatefulWidget {
final WithdrawFiatParams params;
const WithdrawFiatConfirmPage({
super.key,
required this.params,
});
@override
ConsumerState<WithdrawFiatConfirmPage> createState() => _WithdrawFiatConfirmPageState();
}
class _WithdrawFiatConfirmPageState extends ConsumerState<WithdrawFiatConfirmPage> {
///
final List<TextEditingController> _codeControllers = List.generate(
6,
(index) => TextEditingController(),
);
///
final List<FocusNode> _focusNodes = List.generate(
6,
(index) => FocusNode(),
);
///
final TextEditingController _passwordController = TextEditingController();
///
bool _isPasswordVisible = false;
///
bool _isSubmitting = false;
///
bool _isSendingSms = false;
///
int _countdown = 0;
///
String? _maskedPhoneNumber;
///
FeeConfig? _feeConfig;
@override
void initState() {
super.initState();
_loadInitData();
}
///
Future<void> _loadInitData() async {
await Future.wait([
_loadUserPhone(),
_loadFeeConfig(),
]);
}
///
Future<void> _loadFeeConfig() async {
try {
final walletService = ref.read(walletServiceProvider);
final feeConfig = await walletService.getFeeConfig();
if (mounted) {
setState(() {
_feeConfig = feeConfig;
});
}
} catch (e) {
debugPrint('[WithdrawFiatConfirmPage] 加载手续费配置失败: $e');
}
}
///
Future<void> _loadUserPhone() async {
try {
final accountService = ref.read(accountServiceProvider);
final meResponse = await accountService.getMe();
if (meResponse.phoneNumber != null) {
//
final phone = meResponse.phoneNumber!;
if (phone.length >= 7) {
setState(() {
_maskedPhoneNumber = '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
});
}
}
} catch (e) {
debugPrint('[WithdrawFiatConfirmPage] 加载用户手机号失败: $e');
}
}
@override
void dispose() {
for (final controller in _codeControllers) {
controller.dispose();
}
for (final node in _focusNodes) {
node.dispose();
}
_passwordController.dispose();
super.dispose();
}
///
void _goBack() {
context.pop();
}
///
Future<void> _sendSmsCode() async {
if (_isSendingSms || _countdown > 0) return;
setState(() {
_isSendingSms = true;
});
try {
final walletService = ref.read(walletServiceProvider);
await walletService.sendWithdrawSmsCode();
if (mounted) {
setState(() {
_isSendingSms = false;
_countdown = 60;
});
_startCountdown();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('验证码已发送'),
backgroundColor: Color(0xFF4CAF50),
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
debugPrint('[WithdrawFiatConfirmPage] 发送验证码失败: $e');
if (mounted) {
setState(() {
_isSendingSms = false;
});
String errorMsg = e.toString();
if (errorMsg.contains('Exception:')) {
errorMsg = errorMsg.replaceAll('Exception:', '').trim();
}
_showErrorSnackBar(errorMsg);
}
}
}
///
void _startCountdown() {
Future.doWhile(() async {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return false;
setState(() {
_countdown--;
});
return _countdown > 0;
});
}
///
String _getCode() {
return _codeControllers.map((c) => c.text).join();
}
///
double _calculateFee() {
if (_feeConfig != null) {
return _feeConfig!.calculateFee(widget.params.amount);
}
// 2 绿
return 2;
}
///
double _calculateActualAmount() {
final fee = _calculateFee();
return widget.params.amount - fee;
}
///
String _getPaymentMethodName() {
return widget.params.paymentMethod.label;
}
///
String _getPaymentAccountInfo() {
switch (widget.params.paymentMethod) {
case PaymentMethod.bankCard:
final cardNo = widget.params.bankCardNo ?? '';
if (cardNo.length > 8) {
return '${widget.params.bankName ?? ''} **** ${cardNo.substring(cardNo.length - 4)}';
}
return '${widget.params.bankName ?? ''} ${cardNo}';
case PaymentMethod.alipay:
return widget.params.alipayAccount ?? '';
case PaymentMethod.wechat:
return widget.params.wechatAccount ?? '';
}
}
///
String _getPaymentAccountName() {
switch (widget.params.paymentMethod) {
case PaymentMethod.bankCard:
return widget.params.cardHolderName ?? '';
case PaymentMethod.alipay:
return widget.params.alipayRealName ?? '';
case PaymentMethod.wechat:
return widget.params.wechatRealName ?? '';
}
}
///
Future<void> _onSubmit() async {
final code = _getCode();
final password = _passwordController.text.trim();
//
if (code.length != 6) {
_showErrorSnackBar('请输入完整的6位验证码');
return;
}
//
if (password.isEmpty) {
_showErrorSnackBar('请输入登录密码');
return;
}
if (password.length < 6) {
_showErrorSnackBar('密码长度至少6位');
return;
}
setState(() {
_isSubmitting = true;
});
try {
debugPrint('[WithdrawFiatConfirmPage] 开始提现...');
debugPrint('[WithdrawFiatConfirmPage] 金额: ${widget.params.amount} 绿积分');
debugPrint('[WithdrawFiatConfirmPage] 收款方式: ${_getPaymentMethodName()}');
debugPrint('[WithdrawFiatConfirmPage] 验证码: $code');
//
final walletService = ref.read(walletServiceProvider);
final response = await walletService.withdrawFiat(
amount: widget.params.amount,
paymentMethod: widget.params.paymentMethod,
bankName: widget.params.bankName,
bankCardNo: widget.params.bankCardNo,
cardHolderName: widget.params.cardHolderName,
alipayAccount: widget.params.alipayAccount,
alipayRealName: widget.params.alipayRealName,
wechatAccount: widget.params.wechatAccount,
wechatRealName: widget.params.wechatRealName,
smsCode: code,
password: password,
);
debugPrint('[WithdrawFiatConfirmPage] 提现成功: orderNo=${response.orderNo}');
if (mounted) {
setState(() {
_isSubmitting = false;
});
//
_showSuccessDialog();
}
} catch (e) {
debugPrint('[WithdrawFiatConfirmPage] 提现失败: $e');
if (mounted) {
setState(() {
_isSubmitting = false;
});
//
String errorMsg = e.toString();
if (errorMsg.contains('Exception:')) {
errorMsg = errorMsg.replaceAll('Exception:', '').trim();
}
_showErrorSnackBar(errorMsg);
}
}
}
///
void _showSuccessDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF4CAF50),
),
child: const Icon(
Icons.check,
size: 40,
color: Colors.white,
),
),
const SizedBox(height: 16),
const Text(
'提现申请已提交',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 8),
const Text(
'预计 1-3 个工作日内到账',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
color: Color(0xFF8B5A2B),
),
),
const SizedBox(height: 4),
const Text(
'请留意短信通知',
style: TextStyle(
fontSize: 12,
fontFamily: 'Inter',
color: Color(0xFF8B5A2B),
),
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
//
context.go('/trading');
},
child: const Text(
'确定',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Color(0xFFD4AF37),
),
),
),
],
),
);
}
///
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF5E6),
Color(0xFFFFE4B5),
],
),
),
child: SafeArea(
child: Column(
children: [
//
_buildAppBar(),
//
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
_buildDetailsCard(),
const SizedBox(height: 24),
//
_buildAuthenticatorSection(),
const SizedBox(height: 24),
//
_buildPasswordSection(),
const SizedBox(height: 32),
//
_buildSubmitButton(),
],
),
),
),
],
),
),
),
);
}
///
Widget _buildAppBar() {
return Container(
height: 64,
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
child: Row(
children: [
//
GestureDetector(
onTap: _goBack,
child: Container(
width: 48,
height: 48,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back,
size: 24,
color: Color(0xFF5D4037),
),
),
),
//
const Expanded(
child: Text(
'确认提现',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.25,
letterSpacing: -0.27,
color: Color(0xFF5D4037),
),
textAlign: TextAlign.center,
),
),
//
const SizedBox(width: 48),
],
),
);
}
///
Widget _buildDetailsCard() {
final fee = _calculateFee();
final actual = _calculateActualAmount();
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0x80FFFFFF),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0x33D4AF37),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'提现详情',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 16),
_buildDetailRow('收款方式', _getPaymentMethodName()),
const SizedBox(height: 12),
_buildDetailRow('收款账户', _getPaymentAccountInfo()),
const SizedBox(height: 12),
_buildDetailRow('收款人', _getPaymentAccountName()),
const SizedBox(height: 12),
_buildDetailRow('提现金额', '${widget.params.amount.toStringAsFixed(2)} 绿积分'),
const SizedBox(height: 12),
_buildDetailRow('手续费', '${fee.toStringAsFixed(2)} 绿积分'),
const Divider(color: Color(0x33D4AF37), height: 24),
_buildDetailRow(
'实际到账',
'¥ ${actual.toStringAsFixed(2)}',
isHighlight: true,
),
],
),
);
}
///
Widget _buildDetailRow(String label, String value, {bool isHighlight = false}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
color: isHighlight ? const Color(0xFF5D4037) : const Color(0xFF745D43),
fontWeight: isHighlight ? FontWeight.w600 : FontWeight.normal,
),
),
const SizedBox(width: 16),
Flexible(
child: Text(
value,
style: TextStyle(
fontSize: isHighlight ? 18 : 14,
fontFamily: 'Inter',
fontWeight: isHighlight ? FontWeight.w700 : FontWeight.w500,
color: isHighlight ? const Color(0xFFD4AF37) : const Color(0xFF5D4037),
),
textAlign: TextAlign.right,
),
),
],
);
}
///
Widget _buildAuthenticatorSection() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0x80FFFFFF),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0x33D4AF37),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Icon(
Icons.sms_outlined,
size: 24,
color: Color(0xFFD4AF37),
),
SizedBox(width: 8),
Text(
'短信验证',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
],
),
const SizedBox(height: 8),
Text(
_maskedPhoneNumber != null
? '验证码将发送至 $_maskedPhoneNumber'
: '请输入短信验证码',
style: const TextStyle(
fontSize: 14,
fontFamily: 'Inter',
color: Color(0xFF8B5A2B),
),
),
const SizedBox(height: 20),
//
_buildCodeInput(),
const SizedBox(height: 16),
//
_buildSendCodeButton(),
],
),
);
}
///
Widget _buildPasswordSection() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0x80FFFFFF),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0x33D4AF37),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Icon(
Icons.lock_outline,
size: 24,
color: Color(0xFFD4AF37),
),
SizedBox(width: 8),
Text(
'密码验证',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
],
),
const SizedBox(height: 8),
const Text(
'请输入您的登录密码以确认提现操作',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
color: Color(0xFF8B5A2B),
),
),
const SizedBox(height: 16),
//
TextField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
color: Color(0xFF5D4037),
),
decoration: InputDecoration(
hintText: '请输入登录密码',
hintStyle: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
color: Color(0xFF8B5A2B),
),
filled: true,
fillColor: const Color(0xFFFFF5E6),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0x33D4AF37),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0x33D4AF37),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFFD4AF37),
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: const Color(0xFF8B5A2B),
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
),
onChanged: (_) => setState(() {}),
),
],
),
);
}
///
Widget _buildSendCodeButton() {
final canSend = !_isSendingSms && _countdown <= 0;
return GestureDetector(
onTap: canSend ? _sendSmsCode : null,
child: Container(
width: double.infinity,
height: 44,
decoration: BoxDecoration(
color: canSend
? const Color(0xFFD4AF37).withValues(alpha: 0.15)
: const Color(0xFFD4AF37).withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: canSend
? const Color(0xFFD4AF37).withValues(alpha: 0.5)
: const Color(0xFFD4AF37).withValues(alpha: 0.2),
width: 1,
),
),
child: Center(
child: _isSendingSms
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFFD4AF37)),
),
)
: Text(
_countdown > 0 ? '${_countdown}s 后重新发送' : '获取验证码',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
color: canSend
? const Color(0xFFD4AF37)
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
),
),
),
),
);
}
///
Widget _buildCodeInput() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(6, (index) {
return SizedBox(
width: 45,
height: 54,
child: TextField(
controller: _codeControllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
style: const TextStyle(
fontSize: 24,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
color: Color(0xFF5D4037),
),
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: const Color(0xFFFFF5E6),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0x33D4AF37),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0x33D4AF37),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFFD4AF37),
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
onChanged: (value) {
if (value.isNotEmpty && index < 5) {
_focusNodes[index + 1].requestFocus();
}
setState(() {});
},
),
);
}),
);
}
///
Widget _buildSubmitButton() {
final code = _getCode();
final password = _passwordController.text.trim();
final isValid = code.length == 6 && password.length >= 6;
return GestureDetector(
onTap: (isValid && !_isSubmitting) ? _onSubmit : null,
child: Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
color: (isValid && !_isSubmitting)
? const Color(0xFFD4AF37)
: const Color(0x80D4AF37),
borderRadius: BorderRadius.circular(12),
boxShadow: (isValid && !_isSubmitting)
? const [
BoxShadow(
color: Color(0x4DD4AF37),
blurRadius: 14,
offset: Offset(0, 4),
),
]
: null,
),
child: Center(
child: _isSubmitting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'确认提现',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
letterSpacing: 0.24,
color: Colors.white,
),
),
),
),
);
}
}

View File

@ -31,6 +31,8 @@ import '../features/security/presentation/pages/bind_email_page.dart';
import '../features/authorization/presentation/pages/authorization_apply_page.dart';
import '../features/withdraw/presentation/pages/withdraw_usdt_page.dart';
import '../features/withdraw/presentation/pages/withdraw_confirm_page.dart';
import '../features/withdraw/presentation/pages/withdraw_fiat_page.dart';
import '../features/withdraw/presentation/pages/withdraw_fiat_confirm_page.dart';
import '../features/notification/presentation/pages/notification_inbox_page.dart';
import '../features/account/presentation/pages/account_switch_page.dart';
import '../features/kyc/presentation/pages/kyc_entry_page.dart';
@ -365,6 +367,23 @@ final appRouterProvider = Provider<GoRouter>((ref) {
},
),
// Withdraw Fiat Page ()
GoRoute(
path: RoutePaths.withdrawFiat,
name: RouteNames.withdrawFiat,
builder: (context, state) => const WithdrawFiatPage(),
),
// Withdraw Fiat Confirm Page ()
GoRoute(
path: RoutePaths.withdrawFiatConfirm,
name: RouteNames.withdrawFiatConfirm,
builder: (context, state) {
final params = state.extra as WithdrawFiatParams;
return WithdrawFiatConfirmPage(params: params);
},
),
// KYC Entry Page ()
GoRoute(
path: RoutePaths.kycEntry,

View File

@ -41,6 +41,8 @@ class RouteNames {
static const ledgerDetail = 'ledger-detail';
static const withdrawUsdt = 'withdraw-usdt';
static const withdrawConfirm = 'withdraw-confirm';
static const withdrawFiat = 'withdraw-fiat';
static const withdrawFiatConfirm = 'withdraw-fiat-confirm';
// Share
static const share = 'share';

View File

@ -41,6 +41,8 @@ class RoutePaths {
static const ledgerDetail = '/trading/ledger';
static const withdrawUsdt = '/withdraw/usdt';
static const withdrawConfirm = '/withdraw/confirm';
static const withdrawFiat = '/withdraw/fiat';
static const withdrawFiatConfirm = '/withdraw/fiat/confirm';
// Share
static const share = '/share';