Revert "feat(withdrawal): implement fiat withdrawal with bank/alipay/wechat"
This reverts commit 288d894746.
This commit is contained in:
parent
288d894746
commit
d614d18e97
|
|
@ -1,77 +0,0 @@
|
|||
-- 法币提现功能迁移
|
||||
-- 将原来的区块链提现改为法币提现(银行卡/支付宝/微信)
|
||||
|
||||
-- 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");
|
||||
|
|
@ -170,7 +170,7 @@ model SettlementOrder {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// 提现订单表 (法币提现: 银行卡/支付宝/微信)
|
||||
// 提现订单表
|
||||
// ============================================
|
||||
model WithdrawalOrder {
|
||||
id BigInt @id @default(autoincrement()) @map("order_id")
|
||||
|
|
@ -179,111 +179,38 @@ model WithdrawalOrder {
|
|||
userId BigInt @map("user_id")
|
||||
|
||||
// 提现信息
|
||||
amount Decimal @map("amount") @db.Decimal(20, 8) // 提现金额 (绿积分, 1:1人民币)
|
||||
amount Decimal @map("amount") @db.Decimal(20, 8) // 提现金额
|
||||
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) // 提现目标地址
|
||||
|
||||
// 提现类型: FIAT (法币)
|
||||
withdrawalType String @default("FIAT") @map("withdrawal_type") @db.VarChar(20)
|
||||
// 交易信息
|
||||
txHash String? @map("tx_hash") @db.VarChar(100) // 链上交易哈希
|
||||
|
||||
// 收款方式: BANK_CARD / ALIPAY / WECHAT
|
||||
paymentMethod String? @map("payment_method") @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(内部转账时有值)
|
||||
|
||||
// 银行卡信息
|
||||
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") // 资金冻结时间
|
||||
completedAt DateTime? @map("completed_at") // 完成时间
|
||||
frozenAt DateTime? @map("frozen_at")
|
||||
broadcastedAt DateTime? @map("broadcasted_at")
|
||||
confirmedAt DateTime? @map("confirmed_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([paymentMethod])
|
||||
@@index([reviewedAt])
|
||||
@@index([paidAt])
|
||||
@@index([chainType])
|
||||
@@index([txHash])
|
||||
@@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])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 待领取奖励表 (逐笔记录)
|
||||
// 每笔待领取奖励独立跟踪过期时间
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { InternalWalletController } from './controllers/internal-wallet.controller';
|
||||
import { WalletApplicationService } from '@/application/services';
|
||||
import { DepositConfirmedHandler, PlantingCreatedHandler } from '@/application/event-handlers';
|
||||
import { WithdrawalStatusHandler } from '@/application/event-handlers/withdrawal-status.handler';
|
||||
import { ExpiredRewardsScheduler } from '@/application/schedulers';
|
||||
import { JwtStrategy } from '@/shared/strategies/jwt.strategy';
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ import { JwtStrategy } from '@/shared/strategies/jwt.strategy';
|
|||
WalletApplicationService,
|
||||
DepositConfirmedHandler,
|
||||
PlantingCreatedHandler,
|
||||
WithdrawalStatusHandler,
|
||||
ExpiredRewardsScheduler,
|
||||
JwtStrategy,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@ import {
|
|||
FreezeForPlantingCommand,
|
||||
ConfirmPlantingDeductionCommand,
|
||||
UnfreezeForPlantingCommand,
|
||||
ReviewWithdrawalCommand,
|
||||
StartPaymentCommand,
|
||||
CompletePaymentCommand,
|
||||
} from '@/application/commands';
|
||||
import { Public } from '@/shared/decorators';
|
||||
|
||||
|
|
@ -197,77 +194,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import { Controller, Get, Post, Body, Query, UseGuards, Headers, HttpException, HttpStatus, Param } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Query, UseGuards, Headers, HttpException, HttpStatus } 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, CancelWithdrawalCommand } from '@/application/commands';
|
||||
import { PaymentAccountDto } from '@/application/commands/request-withdrawal.command';
|
||||
import { ClaimRewardsCommand, SettleRewardsCommand, RequestWithdrawalCommand } from '@/application/commands';
|
||||
import { CurrentUser, CurrentUserPayload } from '@/shared/decorators';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { SettleRewardsDTO, RequestWithdrawalDTO, CancelWithdrawalDTO } from '@/api/dto/request';
|
||||
import { SettleRewardsDTO, RequestWithdrawalDTO } 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')
|
||||
|
|
@ -74,7 +72,7 @@ export class WalletController {
|
|||
}
|
||||
|
||||
@Post('withdraw')
|
||||
@ApiOperation({ summary: '申请提现 (法币)', description: '将绿积分提现到银行卡/支付宝/微信,需要短信验证和密码验证' })
|
||||
@ApiOperation({ summary: '申请提现', description: '将USDT提现到指定地址,需要短信验证和密码验证' })
|
||||
@ApiResponse({ status: 201, type: WithdrawalResponseDTO })
|
||||
async requestWithdrawal(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
|
|
@ -104,68 +102,30 @@ export class WalletController {
|
|||
throw new HttpException('登录密码错误,请重试', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// 验证收款账户信息完整性
|
||||
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,
|
||||
};
|
||||
// 处理 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;
|
||||
}
|
||||
|
||||
const command = new RequestWithdrawalCommand(
|
||||
user.accountSequence,
|
||||
user.userId,
|
||||
dto.amount,
|
||||
paymentAccount,
|
||||
actualAddress,
|
||||
dto.chainType,
|
||||
);
|
||||
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] })
|
||||
|
|
@ -235,8 +195,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,
|
||||
|
|
@ -252,7 +212,7 @@ export class WalletController {
|
|||
amount,
|
||||
fee,
|
||||
totalRequired: amount + fee,
|
||||
receiverGets: amount - fee, // 实际到账金额(扣除手续费)
|
||||
receiverGets: amount, // 接收方收到完整金额
|
||||
feeType,
|
||||
feeValue,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,64 +1,31 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsString, IsEnum, Min, IsOptional, Length, ValidateIf, Matches } from 'class-validator';
|
||||
import { PaymentMethod } from '@/domain/value-objects/withdrawal-status.enum';
|
||||
import { IsNumber, IsString, IsEnum, Min, Matches, IsOptional, Length } from 'class-validator';
|
||||
import { ChainType } from '@/domain/value-objects';
|
||||
|
||||
/**
|
||||
* 法币提现请求 DTO
|
||||
*/
|
||||
export class RequestWithdrawalDTO {
|
||||
@ApiProperty({ description: '提现金额 (绿积分,1:1人民币)', example: 100 })
|
||||
@ApiProperty({ description: '提现金额 (USDT)', example: 100 })
|
||||
@IsNumber()
|
||||
@Min(100, { message: '最小提现金额为 100 绿积分' })
|
||||
@Min(10, { message: '最小提现金额为 10 USDT' })
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '收款方式',
|
||||
enum: PaymentMethod,
|
||||
example: PaymentMethod.BANK_CARD,
|
||||
description: '提现目标地址 (EVM地址或充值ID)',
|
||||
example: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
})
|
||||
@IsEnum(PaymentMethod, { message: '请选择有效的收款方式' })
|
||||
paymentMethod: PaymentMethod;
|
||||
|
||||
// 银行卡相关字段
|
||||
@ApiPropertyOptional({ description: '银行名称', example: '中国工商银行' })
|
||||
@ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD)
|
||||
@IsString()
|
||||
bankName?: string;
|
||||
@Matches(/^(0x[a-fA-F0-9]{40}|D\d{11})$/, {
|
||||
message: '无效的地址格式,请输入EVM地址(0x...)或充值ID(D...)',
|
||||
})
|
||||
toAddress: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '银行卡号', example: '6222021234567890123' })
|
||||
@ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD)
|
||||
@IsString()
|
||||
@Matches(/^\d{16,19}$/, { message: '请输入正确的银行卡号(16-19位数字)' })
|
||||
bankCardNo?: string;
|
||||
@ApiProperty({
|
||||
description: '目标链类型',
|
||||
enum: ChainType,
|
||||
example: 'KAVA',
|
||||
})
|
||||
@IsEnum(ChainType)
|
||||
chainType: ChainType;
|
||||
|
||||
@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',
|
||||
|
|
@ -76,17 +43,3 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,103 +1,47 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty } 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: 'REVIEWING' })
|
||||
@ApiProperty({ description: '订单状态', example: 'FROZEN' })
|
||||
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: 'BANK_CARD' })
|
||||
paymentMethod: string;
|
||||
@ApiProperty({ description: '目标链', example: 'BSC' })
|
||||
chainType: string;
|
||||
|
||||
@ApiProperty({ description: '收款账户显示', example: '工商银行 ****1234 (张三)' })
|
||||
paymentAccountDisplay: string;
|
||||
@ApiProperty({ description: '提现地址', example: '0x1234...' })
|
||||
toAddress: string;
|
||||
|
||||
@ApiProperty({ description: '订单状态', example: 'COMPLETED' })
|
||||
@ApiProperty({ description: '链上交易哈希', nullable: true })
|
||||
txHash: string | null;
|
||||
|
||||
@ApiProperty({ description: '订单状态', example: 'CONFIRMED' })
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,25 @@
|
|||
import { PaymentMethod } from '@/domain/value-objects/withdrawal-status.enum';
|
||||
import { ChainType } from '@/domain/value-objects';
|
||||
|
||||
/**
|
||||
* 收款账户信息
|
||||
*/
|
||||
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, // 提现金额 (绿积分, 1:1人民币)
|
||||
public readonly paymentAccount: PaymentAccountDto, // 收款账户
|
||||
public readonly amount: number, // 提现金额 (USDT)
|
||||
public readonly toAddress: string, // 目标地址
|
||||
public readonly chainType: ChainType, // 目标链 (BSC/KAVA)
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核提现命令
|
||||
* 更新提现状态命令 (内部使用)
|
||||
*/
|
||||
export class ReviewWithdrawalCommand {
|
||||
export class UpdateWithdrawalStatusCommand {
|
||||
constructor(
|
||||
public readonly orderNo: 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, // 取消原因
|
||||
public readonly status: 'BROADCASTED' | 'CONFIRMED' | 'FAILED',
|
||||
public readonly txHash?: string,
|
||||
public readonly errorMessage?: string,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,461 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -15,16 +15,17 @@ import {
|
|||
import {
|
||||
HandleDepositCommand, DeductForPlantingCommand, AddRewardsCommand,
|
||||
ClaimRewardsCommand, SettleRewardsCommand, AllocateFundsCommand, FundAllocationItem,
|
||||
RequestWithdrawalCommand, ReviewWithdrawalCommand, StartPaymentCommand, CompletePaymentCommand, CancelWithdrawalCommand,
|
||||
RequestWithdrawalCommand, UpdateWithdrawalStatusCommand,
|
||||
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;
|
||||
|
|
@ -93,6 +94,7 @@ export class WalletApplicationService {
|
|||
private readonly eventPublisher: EventPublisherService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly feeConfigRepo: FeeConfigRepositoryImpl,
|
||||
private readonly identityClient: IdentityClientService,
|
||||
) {}
|
||||
|
||||
// =============== Commands ===============
|
||||
|
|
@ -1454,7 +1456,7 @@ export class WalletApplicationService {
|
|||
* 3. 创建提现订单
|
||||
* 4. 冻结用户余额
|
||||
* 5. 记录流水
|
||||
* 6. 提交审核
|
||||
* 6. 发布事件通知 blockchain-service
|
||||
*/
|
||||
async requestWithdrawal(command: RequestWithdrawalCommand): Promise<{
|
||||
orderNo: string;
|
||||
|
|
@ -1462,7 +1464,6 @@ export class WalletApplicationService {
|
|||
fee: number;
|
||||
netAmount: number;
|
||||
status: string;
|
||||
paymentMethod: string;
|
||||
}> {
|
||||
const MAX_RETRIES = 3;
|
||||
let retries = 0;
|
||||
|
|
@ -1473,9 +1474,9 @@ export class WalletApplicationService {
|
|||
} catch (error) {
|
||||
if (this.isOptimisticLockError(error)) {
|
||||
retries++;
|
||||
this.logger.warn(`[requestWithdrawal] Optimistic lock conflict for ${command.accountSequence}, retry ${retries}/${MAX_RETRIES}`);
|
||||
this.logger.warn(`[requestWithdrawal] Optimistic lock conflict for ${command.userId}, retry ${retries}/${MAX_RETRIES}`);
|
||||
if (retries >= MAX_RETRIES) {
|
||||
this.logger.error(`[requestWithdrawal] Max retries exceeded for ${command.accountSequence}`);
|
||||
this.logger.error(`[requestWithdrawal] Max retries exceeded for ${command.userId}`);
|
||||
throw error;
|
||||
}
|
||||
await this.sleep(50 * retries);
|
||||
|
|
@ -1489,7 +1490,7 @@ export class WalletApplicationService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Execute the withdrawal request logic (法币提现)
|
||||
* Execute the withdrawal request logic
|
||||
*/
|
||||
private async executeRequestWithdrawal(command: RequestWithdrawalCommand): Promise<{
|
||||
orderNo: string;
|
||||
|
|
@ -1497,7 +1498,6 @@ 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 fiat withdrawal request for user ${command.accountSequence}: ${command.amount} 绿积分, fee: ${feeAmount} (${feeDescription}), payment method: ${command.paymentAccount.paymentMethod}`);
|
||||
this.logger.log(`Processing withdrawal request for user ${userId}: ${command.amount} USDT to ${command.toAddress}, fee: ${feeAmount} (${feeDescription})`);
|
||||
|
||||
// 验证最小提现金额
|
||||
if (command.amount < this.MIN_WITHDRAWAL_AMOUNT) {
|
||||
throw new Error(`最小提现金额为 ${this.MIN_WITHDRAWAL_AMOUNT} 绿积分`);
|
||||
throw new Error(`最小提现金额为 ${this.MIN_WITHDRAWAL_AMOUNT} USDT`);
|
||||
}
|
||||
|
||||
// 优先按 accountSequence 查找,如果未找到则按 userId 查找
|
||||
let wallet = await this.walletRepo.findByAccountSequence(command.accountSequence);
|
||||
let wallet = await this.walletRepo.findByAccountSequence(command.userId);
|
||||
if (!wallet) {
|
||||
wallet = await this.walletRepo.findByUserId(userId);
|
||||
}
|
||||
if (!wallet) {
|
||||
throw new WalletNotFoundError(`userId/accountSequence: ${command.accountSequence}`);
|
||||
throw new WalletNotFoundError(`userId/accountSequence: ${command.userId}`);
|
||||
}
|
||||
|
||||
// 验证余额是否足够
|
||||
|
|
@ -1534,31 +1534,45 @@ 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,
|
||||
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,
|
||||
},
|
||||
chainType: command.chainType,
|
||||
toAddress: command.toAddress,
|
||||
isInternalTransfer,
|
||||
toAccountSequence,
|
||||
toUserId,
|
||||
});
|
||||
|
||||
// 冻结用户余额 (金额 + 手续费)
|
||||
wallet.freeze(totalRequired);
|
||||
await this.walletRepo.save(wallet);
|
||||
|
||||
// 标记订单已冻结并提交审核
|
||||
// 标记订单已冻结
|
||||
withdrawalOrder.markAsFrozen();
|
||||
withdrawalOrder.submitForReview();
|
||||
const savedOrder = await this.withdrawalRepo.save(withdrawalOrder);
|
||||
|
||||
// 记录流水 - 冻结
|
||||
|
|
@ -1569,14 +1583,35 @@ export class WalletApplicationService {
|
|||
amount: Money.signed(-totalRequired.value, 'USDT'),
|
||||
balanceAfter: wallet.balances.usdt.available,
|
||||
refOrderId: savedOrder.orderNo,
|
||||
memo: `提现冻结: ${command.amount} 绿积分 + ${feeAmount.toFixed(2)} 绿积分手续费 (${feeDescription}), 收款方式: ${savedOrder.getPaymentMethodName()}`,
|
||||
memo: `提取冻结: ${command.amount} 绿积分 + ${feeAmount.toFixed(2)} 绿积分手续费 (${feeDescription})`,
|
||||
});
|
||||
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(`Fiat withdrawal order created: ${savedOrder.orderNo}, status: ${savedOrder.status}`);
|
||||
this.logger.log(`Withdrawal order created: ${savedOrder.orderNo}`);
|
||||
|
||||
return {
|
||||
orderNo: savedOrder.orderNo,
|
||||
|
|
@ -1584,19 +1619,14 @@ export class WalletApplicationService {
|
|||
fee: savedOrder.fee.value,
|
||||
netAmount: savedOrder.netAmount.value,
|
||||
status: savedOrder.status,
|
||||
paymentMethod: savedOrder.paymentMethod,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核提现订单 (管理员)
|
||||
* 更新提现状态 (内部调用,由 blockchain-service 事件触发)
|
||||
*/
|
||||
async reviewWithdrawal(command: ReviewWithdrawalCommand): Promise<{
|
||||
orderNo: string;
|
||||
status: string;
|
||||
reviewedBy: string;
|
||||
}> {
|
||||
this.logger.log(`Reviewing withdrawal ${command.orderNo}: approved=${command.approved} by ${command.reviewedBy}`);
|
||||
async updateWithdrawalStatus(command: UpdateWithdrawalStatusCommand): Promise<void> {
|
||||
this.logger.log(`Updating withdrawal ${command.orderNo} to status ${command.status}`);
|
||||
|
||||
const order = await this.withdrawalRepo.findByOrderNo(command.orderNo);
|
||||
if (!order) {
|
||||
|
|
@ -1608,189 +1638,69 @@ export class WalletApplicationService {
|
|||
throw new WalletNotFoundError(`userId: ${order.userId.value}`);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 解冻资金
|
||||
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.remark || '审核不通过'}`,
|
||||
});
|
||||
await this.ledgerRepo.save(unfreezeEntry);
|
||||
|
||||
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);
|
||||
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;
|
||||
|
||||
// 手续费流水 (单独记录)
|
||||
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);
|
||||
case 'CONFIRMED':
|
||||
order.markAsConfirmed();
|
||||
await this.withdrawalRepo.save(order);
|
||||
|
||||
// 解冻并扣除
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1801,11 +1711,11 @@ export class WalletApplicationService {
|
|||
amount: number;
|
||||
fee: number;
|
||||
netAmount: number;
|
||||
paymentMethod: string;
|
||||
paymentAccountDisplay: string;
|
||||
chainType: string;
|
||||
toAddress: string;
|
||||
txHash: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}>> {
|
||||
const orders = await this.withdrawalRepo.findByUserId(BigInt(userId));
|
||||
return orders.map(order => ({
|
||||
|
|
@ -1813,113 +1723,11 @@ export class WalletApplicationService {
|
|||
amount: order.amount.value,
|
||||
fee: order.fee.value,
|
||||
netAmount: order.netAmount.value,
|
||||
paymentMethod: order.paymentMethod,
|
||||
paymentAccountDisplay: order.getPaymentAccountDisplay(),
|
||||
chainType: order.chainType,
|
||||
toAddress: order.toAddress,
|
||||
txHash: order.txHash,
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,55 +1,16 @@
|
|||
import Decimal from 'decimal.js';
|
||||
import { UserId, AssetType, Money } from '@/domain/value-objects';
|
||||
import { WithdrawalStatus, PaymentMethod, WithdrawalType } from '@/domain/value-objects/withdrawal-status.enum';
|
||||
import { UserId, ChainType, AssetType, Money } from '@/domain/value-objects';
|
||||
import { WithdrawalStatus } 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. 提交审核 -> REVIEWING
|
||||
* 4. 审核通过 -> APPROVED (等待打款)
|
||||
* 审核驳回 -> REJECTED (资金解冻)
|
||||
* 5. 开始打款 -> PAYING
|
||||
* 6. 打款完成 -> COMPLETED
|
||||
* 3. blockchain-service 签名并广播 -> BROADCASTED
|
||||
* 4. 链上确认 -> CONFIRMED
|
||||
*
|
||||
* 失败/取消时解冻资金
|
||||
*/
|
||||
|
|
@ -58,44 +19,20 @@ export class WithdrawalOrder {
|
|||
private readonly _orderNo: string;
|
||||
private readonly _accountSequence: string;
|
||||
private readonly _userId: UserId;
|
||||
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 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 _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 _completedAt: Date | null;
|
||||
private _broadcastedAt: Date | null;
|
||||
private _confirmedAt: Date | null;
|
||||
private readonly _createdAt: Date;
|
||||
|
||||
private constructor(
|
||||
|
|
@ -105,27 +42,17 @@ export class WithdrawalOrder {
|
|||
userId: UserId,
|
||||
amount: Money,
|
||||
fee: Money,
|
||||
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,
|
||||
chainType: ChainType,
|
||||
toAddress: string,
|
||||
txHash: string | null,
|
||||
isInternalTransfer: boolean,
|
||||
toAccountSequence: string | null,
|
||||
toUserId: UserId | 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,
|
||||
completedAt: Date | null,
|
||||
broadcastedAt: Date | null,
|
||||
confirmedAt: Date | null,
|
||||
createdAt: Date,
|
||||
) {
|
||||
this._id = id;
|
||||
|
|
@ -134,27 +61,17 @@ export class WithdrawalOrder {
|
|||
this._userId = userId;
|
||||
this._amount = amount;
|
||||
this._fee = fee;
|
||||
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._chainType = chainType;
|
||||
this._toAddress = toAddress;
|
||||
this._txHash = txHash;
|
||||
this._isInternalTransfer = isInternalTransfer;
|
||||
this._toAccountSequence = toAccountSequence;
|
||||
this._toUserId = toUserId;
|
||||
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._completedAt = completedAt;
|
||||
this._broadcastedAt = broadcastedAt;
|
||||
this._confirmedAt = confirmedAt;
|
||||
this._createdAt = createdAt;
|
||||
}
|
||||
|
||||
|
|
@ -165,43 +82,28 @@ 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 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 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 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 completedAt(): Date | null { return this._completedAt; }
|
||||
get broadcastedAt(): Date | null { return this._broadcastedAt; }
|
||||
get confirmedAt(): Date | null { return this._confirmedAt; }
|
||||
get createdAt(): Date { return this._createdAt; }
|
||||
|
||||
// 状态判断
|
||||
get isPending(): boolean { return this._status === WithdrawalStatus.PENDING; }
|
||||
get isFrozen(): boolean { return this._status === WithdrawalStatus.FROZEN; }
|
||||
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 isBroadcasted(): boolean { return this._status === WithdrawalStatus.BROADCASTED; }
|
||||
get isConfirmed(): boolean { return this._status === WithdrawalStatus.CONFIRMED; }
|
||||
get isFailed(): boolean { return this._status === WithdrawalStatus.FAILED; }
|
||||
get isCancelled(): boolean { return this._status === WithdrawalStatus.CANCELLED; }
|
||||
get isFinished(): boolean {
|
||||
return this._status === WithdrawalStatus.COMPLETED ||
|
||||
this._status === WithdrawalStatus.REJECTED ||
|
||||
return this._status === WithdrawalStatus.CONFIRMED ||
|
||||
this._status === WithdrawalStatus.FAILED ||
|
||||
this._status === WithdrawalStatus.CANCELLED;
|
||||
}
|
||||
|
|
@ -216,131 +118,58 @@ 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;
|
||||
paymentAccount: PaymentAccountInfo;
|
||||
chainType: ChainType;
|
||||
toAddress: string;
|
||||
isInternalTransfer?: boolean;
|
||||
toAccountSequence?: string;
|
||||
toUserId?: UserId;
|
||||
}): WithdrawalOrder {
|
||||
// 验证金额
|
||||
if (params.amount.value <= 0) {
|
||||
throw new DomainError('提现金额必须大于0');
|
||||
throw new DomainError('Withdrawal amount must be positive');
|
||||
}
|
||||
|
||||
// 验证手续费
|
||||
if (params.fee.value < 0) {
|
||||
throw new DomainError('手续费不能为负数');
|
||||
throw new DomainError('Withdrawal fee cannot be negative');
|
||||
}
|
||||
|
||||
// 验证净额大于0
|
||||
const netAmount = new Decimal(params.amount.value).minus(params.fee.value);
|
||||
if (netAmount.lte(0)) {
|
||||
throw new DomainError('提现金额必须大于手续费');
|
||||
if (params.amount.value <= params.fee.value) {
|
||||
throw new DomainError('Withdrawal amount must be greater than fee');
|
||||
}
|
||||
|
||||
// 根据收款方式验证必填信息
|
||||
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('不支持的收款方式');
|
||||
// 验证地址格式 (简单的EVM地址检查)
|
||||
if (!params.toAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
|
||||
throw new DomainError('Invalid withdrawal address format');
|
||||
}
|
||||
|
||||
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
|
||||
orderNo,
|
||||
this.generateOrderNo(),
|
||||
params.accountSequence,
|
||||
params.userId,
|
||||
params.amount,
|
||||
params.fee,
|
||||
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,
|
||||
params.chainType,
|
||||
params.toAddress,
|
||||
null,
|
||||
params.isInternalTransfer ?? false,
|
||||
params.toAccountSequence ?? null,
|
||||
params.toUserId ?? null,
|
||||
WithdrawalStatus.PENDING,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
detailMemo,
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -354,27 +183,17 @@ export class WithdrawalOrder {
|
|||
userId: bigint;
|
||||
amount: Decimal;
|
||||
fee: Decimal;
|
||||
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;
|
||||
chainType: string;
|
||||
toAddress: string;
|
||||
txHash: string | null;
|
||||
isInternalTransfer: boolean;
|
||||
toAccountSequence: string | null;
|
||||
toUserId: bigint | 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;
|
||||
completedAt?: Date | null;
|
||||
errorMessage: string | null;
|
||||
frozenAt: Date | null;
|
||||
broadcastedAt: Date | null;
|
||||
confirmedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}): WithdrawalOrder {
|
||||
return new WithdrawalOrder(
|
||||
|
|
@ -384,123 +203,53 @@ export class WithdrawalOrder {
|
|||
UserId.create(params.userId),
|
||||
Money.USDT(params.amount),
|
||||
Money.USDT(params.fee),
|
||||
(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.chainType as ChainType,
|
||||
params.toAddress,
|
||||
params.txHash,
|
||||
params.isInternalTransfer,
|
||||
params.toAccountSequence,
|
||||
params.toUserId ? UserId.create(params.toUserId) : null,
|
||||
params.status as WithdrawalStatus,
|
||||
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.errorMessage,
|
||||
params.frozenAt,
|
||||
params.broadcastedAt,
|
||||
params.confirmedAt,
|
||||
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('只有待处理的提现订单可以冻结资金');
|
||||
throw new DomainError('Only pending withdrawals can be frozen');
|
||||
}
|
||||
this._status = WithdrawalStatus.FROZEN;
|
||||
this._frozenAt = new Date();
|
||||
this.appendMemo('资金已冻结,等待审核');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交审核
|
||||
* 标记为已广播
|
||||
*/
|
||||
submitForReview(): void {
|
||||
markAsBroadcasted(txHash: string): void {
|
||||
if (this._status !== WithdrawalStatus.FROZEN) {
|
||||
throw new DomainError('只有已冻结的提现订单可以提交审核');
|
||||
throw new DomainError('Only frozen withdrawals can be broadcasted');
|
||||
}
|
||||
this._status = WithdrawalStatus.REVIEWING;
|
||||
this.appendMemo('已提交审核');
|
||||
this._status = WithdrawalStatus.BROADCASTED;
|
||||
this._txHash = txHash;
|
||||
this._broadcastedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过
|
||||
* 标记为已确认 (链上确认)
|
||||
*/
|
||||
approve(reviewedBy: string, remark?: string): void {
|
||||
if (this._status !== WithdrawalStatus.REVIEWING && this._status !== WithdrawalStatus.FROZEN) {
|
||||
throw new DomainError('只有审核中或已冻结的订单可以通过审核');
|
||||
markAsConfirmed(): void {
|
||||
if (this._status !== WithdrawalStatus.BROADCASTED) {
|
||||
throw new DomainError('Only broadcasted withdrawals can be confirmed');
|
||||
}
|
||||
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)} 元人民币`);
|
||||
this._status = WithdrawalStatus.CONFIRMED;
|
||||
this._confirmedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -508,31 +257,27 @@ export class WithdrawalOrder {
|
|||
*/
|
||||
markAsFailed(errorMessage: string): void {
|
||||
if (this.isFinished) {
|
||||
throw new DomainError('已完成的提现订单无法标记为失败');
|
||||
throw new DomainError('Cannot fail a finished withdrawal');
|
||||
}
|
||||
this._status = WithdrawalStatus.FAILED;
|
||||
this._errorMessage = errorMessage;
|
||||
this.appendMemo(`提现失败 - 原因: ${errorMessage}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消提现
|
||||
*/
|
||||
cancel(reason?: string): void {
|
||||
cancel(): void {
|
||||
if (this._status !== WithdrawalStatus.PENDING && this._status !== WithdrawalStatus.FROZEN) {
|
||||
throw new DomainError('只有待处理或已冻结的提现订单可以取消');
|
||||
throw new DomainError('Only pending or frozen withdrawals can be cancelled');
|
||||
}
|
||||
this._status = WithdrawalStatus.CANCELLED;
|
||||
this.appendMemo(`用户取消提现${reason ? ` - 原因: ${reason}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要解冻资金 (驳回/失败/取消且已冻结)
|
||||
* 是否需要解冻资金 (失败或取消且已冻结)
|
||||
*/
|
||||
needsUnfreeze(): boolean {
|
||||
return (this._status === WithdrawalStatus.REJECTED ||
|
||||
this._status === WithdrawalStatus.FAILED ||
|
||||
this._status === WithdrawalStatus.CANCELLED)
|
||||
return (this._status === WithdrawalStatus.FAILED || this._status === WithdrawalStatus.CANCELLED)
|
||||
&& this._frozenAt !== null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ export interface IWithdrawalOrderRepository {
|
|||
findByUserId(userId: bigint, status?: WithdrawalStatus): Promise<WithdrawalOrder[]>;
|
||||
findPendingOrders(): Promise<WithdrawalOrder[]>;
|
||||
findFrozenOrders(): Promise<WithdrawalOrder[]>;
|
||||
// 法币提现相关查询
|
||||
findReviewingOrders(): Promise<WithdrawalOrder[]>;
|
||||
findApprovedOrders(): Promise<WithdrawalOrder[]>;
|
||||
findPayingOrders(): Promise<WithdrawalOrder[]>;
|
||||
findBroadcastedOrders(): Promise<WithdrawalOrder[]>;
|
||||
}
|
||||
|
||||
export const WITHDRAWAL_ORDER_REPOSITORY = Symbol('IWithdrawalOrderRepository');
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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',
|
||||
|
|
|
|||
|
|
@ -1,34 +1,8 @@
|
|||
/**
|
||||
* 提现订单状态 (法币提现流程)
|
||||
*
|
||||
* 流程: PENDING -> FROZEN -> REVIEWING -> APPROVED -> PAYING -> COMPLETED
|
||||
* -> REJECTED (驳回)
|
||||
* -> FAILED (失败,退款)
|
||||
*/
|
||||
export enum WithdrawalStatus {
|
||||
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', // 法币提现(人民币)
|
||||
PENDING = 'PENDING', // 待处理
|
||||
FROZEN = 'FROZEN', // 已冻结资金,等待签名
|
||||
BROADCASTED = 'BROADCASTED', // 已广播到链上
|
||||
CONFIRMED = 'CONFIRMED', // 链上确认完成
|
||||
FAILED = 'FAILED', // 失败
|
||||
CANCELLED = 'CANCELLED', // 已取消
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,33 +16,17 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
|
|||
userId: order.userId.value,
|
||||
amount: order.amount.toDecimal(),
|
||||
fee: order.fee.toDecimal(),
|
||||
// 法币提现字段
|
||||
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,
|
||||
// 状态
|
||||
chainType: order.chainType,
|
||||
toAddress: order.toAddress,
|
||||
txHash: order.txHash,
|
||||
isInternalTransfer: order.isInternalTransfer,
|
||||
toAccountSequence: order.toAccountSequence,
|
||||
toUserId: order.toUserId?.value ?? null,
|
||||
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,
|
||||
completedAt: order.completedAt,
|
||||
broadcastedAt: order.broadcastedAt,
|
||||
confirmedAt: order.confirmedAt,
|
||||
};
|
||||
|
||||
if (order.id === BigInt(0)) {
|
||||
|
|
@ -84,19 +68,6 @@ 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 },
|
||||
|
|
@ -113,87 +84,14 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
|
|||
return records.map(r => this.toDomain(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找待审核的提现订单
|
||||
*/
|
||||
async findReviewingOrders(): Promise<WithdrawalOrder[]> {
|
||||
async findBroadcastedOrders(): Promise<WithdrawalOrder[]> {
|
||||
const records = await this.prisma.withdrawalOrder.findMany({
|
||||
where: {
|
||||
status: {
|
||||
in: [WithdrawalStatus.FROZEN, WithdrawalStatus.REVIEWING],
|
||||
},
|
||||
},
|
||||
where: { status: WithdrawalStatus.BROADCASTED },
|
||||
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;
|
||||
|
|
@ -201,37 +99,18 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
|
|||
userId: bigint;
|
||||
amount: Decimal;
|
||||
fee: Decimal;
|
||||
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;
|
||||
chainType: string;
|
||||
toAddress: string;
|
||||
txHash: string | null;
|
||||
isInternalTransfer: boolean;
|
||||
toAccountSequence: string | null;
|
||||
toUserId: bigint | 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;
|
||||
completedAt: Date | null;
|
||||
broadcastedAt: Date | null;
|
||||
confirmedAt: 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,
|
||||
|
|
@ -240,27 +119,17 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository
|
|||
userId: record.userId,
|
||||
amount: new Decimal(record.amount.toString()),
|
||||
fee: new Decimal(record.fee.toString()),
|
||||
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,
|
||||
chainType: record.chainType,
|
||||
toAddress: record.toAddress,
|
||||
txHash: record.txHash,
|
||||
isInternalTransfer: record.isInternalTransfer,
|
||||
toAccountSequence: record.toAccountSequence,
|
||||
toUserId: record.toUserId,
|
||||
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,
|
||||
completedAt: record.completedAt,
|
||||
broadcastedAt: record.broadcastedAt,
|
||||
confirmedAt: record.confirmedAt,
|
||||
createdAt: record.createdAt,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,682 +0,0 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,457 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,6 @@ 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' },
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
/**
|
||||
* 提现审核管理 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -175,14 +175,4 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* 提现审核管理服务
|
||||
* 用于后台管理员审核和处理用户提现申请
|
||||
*/
|
||||
|
||||
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;
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
/**
|
||||
* 提现管理类型定义
|
||||
*/
|
||||
|
||||
// 提现状态
|
||||
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);
|
||||
}
|
||||
|
|
@ -493,91 +493,6 @@ 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 ===============
|
||||
|
||||
/// 获取账本流水列表
|
||||
|
|
@ -753,38 +668,6 @@ 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;
|
||||
|
|
@ -792,9 +675,8 @@ class WithdrawResponse {
|
|||
final double amount;
|
||||
final double fee;
|
||||
final double netAmount;
|
||||
final String? toAddress;
|
||||
final String? chainType;
|
||||
final PaymentMethod? paymentMethod;
|
||||
final String toAddress;
|
||||
final String chainType;
|
||||
final DateTime createdAt;
|
||||
|
||||
WithdrawResponse({
|
||||
|
|
@ -803,38 +685,20 @@ class WithdrawResponse {
|
|||
required this.amount,
|
||||
required this.fee,
|
||||
required this.netAmount,
|
||||
this.toAddress,
|
||||
this.chainType,
|
||||
this.paymentMethod,
|
||||
required this.toAddress,
|
||||
required this.chainType,
|
||||
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'],
|
||||
paymentMethod: method,
|
||||
toAddress: json['toAddress'] ?? '',
|
||||
chainType: json['chainType'] ?? 'KAVA',
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.tryParse(json['createdAt']) ?? DateTime.now()
|
||||
: DateTime.now(),
|
||||
|
|
|
|||
|
|
@ -1,894 +0,0 @@
|
|||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -31,8 +31,6 @@ 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';
|
||||
|
|
@ -367,23 +365,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -41,8 +41,6 @@ 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';
|
||||
|
|
|
|||
|
|
@ -41,8 +41,6 @@ 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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue