diff --git a/backend/services/wallet-service/prisma/migrations/20260103000000_add_fiat_withdrawal/migration.sql b/backend/services/wallet-service/prisma/migrations/20260103000000_add_fiat_withdrawal/migration.sql new file mode 100644 index 00000000..c5035baa --- /dev/null +++ b/backend/services/wallet-service/prisma/migrations/20260103000000_add_fiat_withdrawal/migration.sql @@ -0,0 +1,77 @@ +-- 法币提现功能迁移 +-- 将原来的区块链提现改为法币提现(银行卡/支付宝/微信) + +-- 1. 添加新字段到 withdrawal_orders 表 +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "withdrawal_type" VARCHAR(20) DEFAULT 'FIAT'; + +-- 银行卡信息 +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "bank_name" VARCHAR(100); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "bank_card_no" VARCHAR(30); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "card_holder_name" VARCHAR(50); + +-- 支付宝信息 +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "alipay_account" VARCHAR(100); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "alipay_real_name" VARCHAR(50); + +-- 微信信息 +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "wechat_account" VARCHAR(100); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "wechat_real_name" VARCHAR(50); + +-- 收款方式: BANK_CARD / ALIPAY / WECHAT +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "payment_method" VARCHAR(20); + +-- 审核相关 +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "reviewed_by" VARCHAR(50); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "reviewed_at" TIMESTAMP(6); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "review_remark" VARCHAR(500); + +-- 打款相关 +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "paid_by" VARCHAR(50); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "paid_at" TIMESTAMP(6); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "payment_proof" VARCHAR(500); +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "payment_remark" VARCHAR(500); + +-- 详细备注(记录完整的提现过程) +ALTER TABLE "withdrawal_orders" ADD COLUMN IF NOT EXISTS "detail_memo" TEXT; + +-- 2. 更新索引 +CREATE INDEX IF NOT EXISTS "idx_withdrawal_payment_method" ON "withdrawal_orders" ("payment_method"); +CREATE INDEX IF NOT EXISTS "idx_withdrawal_reviewed_at" ON "withdrawal_orders" ("reviewed_at"); +CREATE INDEX IF NOT EXISTS "idx_withdrawal_paid_at" ON "withdrawal_orders" ("paid_at"); + +-- 3. 创建用户收款账户表(保存用户的常用收款信息) +CREATE TABLE IF NOT EXISTS "user_payment_accounts" ( + "account_id" BIGSERIAL PRIMARY KEY, + "account_sequence" VARCHAR(20) NOT NULL, + "user_id" BIGINT NOT NULL, + + -- 收款方式 + "payment_method" VARCHAR(20) NOT NULL, + + -- 银行卡信息 + "bank_name" VARCHAR(100), + "bank_card_no" VARCHAR(30), + "card_holder_name" VARCHAR(50), + + -- 支付宝信息 + "alipay_account" VARCHAR(100), + "alipay_real_name" VARCHAR(50), + + -- 微信信息 + "wechat_account" VARCHAR(100), + "wechat_real_name" VARCHAR(50), + + -- 是否为默认账户 + "is_default" BOOLEAN DEFAULT false, + + -- 状态 + "is_active" BOOLEAN DEFAULT true, + + "created_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS "idx_payment_account_sequence" ON "user_payment_accounts" ("account_sequence"); +CREATE INDEX IF NOT EXISTS "idx_payment_user_id" ON "user_payment_accounts" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_payment_method_type" ON "user_payment_accounts" ("payment_method"); +CREATE INDEX IF NOT EXISTS "idx_payment_is_default" ON "user_payment_accounts" ("is_default"); diff --git a/backend/services/wallet-service/prisma/schema.prisma b/backend/services/wallet-service/prisma/schema.prisma index 02ebf63e..ca8b45c7 100644 --- a/backend/services/wallet-service/prisma/schema.prisma +++ b/backend/services/wallet-service/prisma/schema.prisma @@ -170,7 +170,7 @@ model SettlementOrder { } // ============================================ -// 提现订单表 +// 提现订单表 (法币提现: 银行卡/支付宝/微信) // ============================================ model WithdrawalOrder { id BigInt @id @default(autoincrement()) @map("order_id") @@ -179,38 +179,111 @@ model WithdrawalOrder { userId BigInt @map("user_id") // 提现信息 - amount Decimal @map("amount") @db.Decimal(20, 8) // 提现金额 + amount Decimal @map("amount") @db.Decimal(20, 8) // 提现金额 (绿积分, 1:1人民币) fee Decimal @map("fee") @db.Decimal(20, 8) // 手续费 - chainType String @map("chain_type") @db.VarChar(20) // 目标链 (BSC/KAVA) - toAddress String @map("to_address") @db.VarChar(100) // 提现目标地址 - // 交易信息 - txHash String? @map("tx_hash") @db.VarChar(100) // 链上交易哈希 + // 提现类型: FIAT (法币) + withdrawalType String @default("FIAT") @map("withdrawal_type") @db.VarChar(20) - // 内部转账标识 - isInternalTransfer Boolean @default(false) @map("is_internal_transfer") // 是否为内部转账(ID转ID) - toAccountSequence String? @map("to_account_sequence") @db.VarChar(20) // 接收方ID(内部转账时有值) - toUserId BigInt? @map("to_user_id") // 接收方用户ID(内部转账时有值) + // 收款方式: BANK_CARD / ALIPAY / WECHAT + paymentMethod String? @map("payment_method") @db.VarChar(20) - // 状态 + // 银行卡信息 + bankName String? @map("bank_name") @db.VarChar(100) + bankCardNo String? @map("bank_card_no") @db.VarChar(30) + cardHolderName String? @map("card_holder_name") @db.VarChar(50) + + // 支付宝信息 + alipayAccount String? @map("alipay_account") @db.VarChar(100) + alipayRealName String? @map("alipay_real_name") @db.VarChar(50) + + // 微信信息 + wechatAccount String? @map("wechat_account") @db.VarChar(100) + wechatRealName String? @map("wechat_real_name") @db.VarChar(50) + + // 状态: PENDING -> REVIEWING -> APPROVED -> PAYING -> COMPLETED / REJECTED / FAILED status String @default("PENDING") @map("status") @db.VarChar(20) errorMessage String? @map("error_message") @db.VarChar(500) + // 审核信息 + reviewedBy String? @map("reviewed_by") @db.VarChar(50) // 审核人 + reviewedAt DateTime? @map("reviewed_at") // 审核时间 + reviewRemark String? @map("review_remark") @db.VarChar(500) // 审核备注 + + // 打款信息 + paidBy String? @map("paid_by") @db.VarChar(50) // 打款人 + paidAt DateTime? @map("paid_at") // 打款时间 + paymentProof String? @map("payment_proof") @db.VarChar(500) // 打款凭证(截图URL等) + paymentRemark String? @map("payment_remark") @db.VarChar(500) // 打款备注 + + // 详细备注(记录完整的提现过程) + detailMemo String? @map("detail_memo") @db.Text + // 时间戳 - frozenAt DateTime? @map("frozen_at") - broadcastedAt DateTime? @map("broadcasted_at") - confirmedAt DateTime? @map("confirmed_at") + frozenAt DateTime? @map("frozen_at") // 资金冻结时间 + completedAt DateTime? @map("completed_at") // 完成时间 createdAt DateTime @default(now()) @map("created_at") + // 兼容旧字段(已弃用,保留用于迁移) + chainType String? @map("chain_type") @db.VarChar(20) + toAddress String? @map("to_address") @db.VarChar(100) + txHash String? @map("tx_hash") @db.VarChar(100) + isInternalTransfer Boolean @default(false) @map("is_internal_transfer") + toAccountSequence String? @map("to_account_sequence") @db.VarChar(20) + toUserId BigInt? @map("to_user_id") + broadcastedAt DateTime? @map("broadcasted_at") + confirmedAt DateTime? @map("confirmed_at") + @@map("withdrawal_orders") @@index([accountSequence]) @@index([userId]) @@index([status]) - @@index([chainType]) - @@index([txHash]) + @@index([paymentMethod]) + @@index([reviewedAt]) + @@index([paidAt]) @@index([createdAt]) } +// ============================================ +// 用户收款账户表(保存用户的常用收款信息) +// ============================================ +model UserPaymentAccount { + id BigInt @id @default(autoincrement()) @map("account_id") + accountSequence String @map("account_sequence") @db.VarChar(20) + userId BigInt @map("user_id") + + // 收款方式: BANK_CARD / ALIPAY / WECHAT + paymentMethod String @map("payment_method") @db.VarChar(20) + + // 银行卡信息 + bankName String? @map("bank_name") @db.VarChar(100) + bankCardNo String? @map("bank_card_no") @db.VarChar(30) + cardHolderName String? @map("card_holder_name") @db.VarChar(50) + + // 支付宝信息 + alipayAccount String? @map("alipay_account") @db.VarChar(100) + alipayRealName String? @map("alipay_real_name") @db.VarChar(50) + + // 微信信息 + wechatAccount String? @map("wechat_account") @db.VarChar(100) + wechatRealName String? @map("wechat_real_name") @db.VarChar(50) + + // 是否为默认账户 + isDefault Boolean @default(false) @map("is_default") + + // 状态 + isActive Boolean @default(true) @map("is_active") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("user_payment_accounts") + @@index([accountSequence]) + @@index([userId]) + @@index([paymentMethod]) + @@index([isDefault]) +} + // ============================================ // 待领取奖励表 (逐笔记录) // 每笔待领取奖励独立跟踪过期时间 diff --git a/backend/services/wallet-service/src/api/api.module.ts b/backend/services/wallet-service/src/api/api.module.ts index 3e5b5809..132b6c16 100644 --- a/backend/services/wallet-service/src/api/api.module.ts +++ b/backend/services/wallet-service/src/api/api.module.ts @@ -11,7 +11,6 @@ import { import { InternalWalletController } from './controllers/internal-wallet.controller'; import { WalletApplicationService } from '@/application/services'; import { DepositConfirmedHandler, PlantingCreatedHandler } from '@/application/event-handlers'; -import { WithdrawalStatusHandler } from '@/application/event-handlers/withdrawal-status.handler'; import { ExpiredRewardsScheduler } from '@/application/schedulers'; import { JwtStrategy } from '@/shared/strategies/jwt.strategy'; @@ -37,7 +36,6 @@ import { JwtStrategy } from '@/shared/strategies/jwt.strategy'; WalletApplicationService, DepositConfirmedHandler, PlantingCreatedHandler, - WithdrawalStatusHandler, ExpiredRewardsScheduler, JwtStrategy, ], diff --git a/backend/services/wallet-service/src/api/controllers/internal-wallet.controller.ts b/backend/services/wallet-service/src/api/controllers/internal-wallet.controller.ts index e2255fce..3c3e9a74 100644 --- a/backend/services/wallet-service/src/api/controllers/internal-wallet.controller.ts +++ b/backend/services/wallet-service/src/api/controllers/internal-wallet.controller.ts @@ -9,6 +9,9 @@ import { FreezeForPlantingCommand, ConfirmPlantingDeductionCommand, UnfreezeForPlantingCommand, + ReviewWithdrawalCommand, + StartPaymentCommand, + CompletePaymentCommand, } from '@/application/commands'; import { Public } from '@/shared/decorators'; @@ -194,4 +197,77 @@ export class InternalWalletController { this.logger.log(`结算结果: ${JSON.stringify(result)}`); return result; } + + // =============== 提现审核管理 (管理后台调用) =============== + + @Get('withdrawals/reviewing') + @Public() + @ApiOperation({ summary: '查询待审核提现订单(内部API)' }) + @ApiResponse({ status: 200, description: '待审核提现订单列表' }) + async getReviewingWithdrawals() { + return this.walletService.getReviewingWithdrawals(); + } + + @Get('withdrawals/approved') + @Public() + @ApiOperation({ summary: '查询待打款提现订单(内部API)' }) + @ApiResponse({ status: 200, description: '待打款提现订单列表' }) + async getApprovedWithdrawals() { + return this.walletService.getApprovedWithdrawals(); + } + + @Get('withdrawals/paying') + @Public() + @ApiOperation({ summary: '查询打款中提现订单(内部API)' }) + @ApiResponse({ status: 200, description: '打款中提现订单列表' }) + async getPayingWithdrawals() { + return this.walletService.getPayingWithdrawals(); + } + + @Post('withdrawals/:orderNo/review') + @Public() + @ApiOperation({ summary: '审核提现订单(内部API)' }) + @ApiParam({ name: 'orderNo', description: '提现订单号' }) + @ApiResponse({ status: 200, description: '审核结果' }) + async reviewWithdrawal( + @Param('orderNo') orderNo: string, + @Body() dto: { approved: boolean; reviewedBy: string; remark?: string }, + ) { + this.logger.log(`审核提现订单: ${orderNo}, approved=${dto.approved}, by=${dto.reviewedBy}`); + const command = new ReviewWithdrawalCommand( + orderNo, + dto.approved, + dto.reviewedBy, + dto.remark, + ); + return this.walletService.reviewWithdrawal(command); + } + + @Post('withdrawals/:orderNo/start-payment') + @Public() + @ApiOperation({ summary: '开始打款(内部API)' }) + @ApiParam({ name: 'orderNo', description: '提现订单号' }) + @ApiResponse({ status: 200, description: '开始打款结果' }) + async startPayment( + @Param('orderNo') orderNo: string, + @Body() dto: { paidBy: string }, + ) { + this.logger.log(`开始打款: ${orderNo}, by=${dto.paidBy}`); + const command = new StartPaymentCommand(orderNo, dto.paidBy); + return this.walletService.startPayment(command); + } + + @Post('withdrawals/:orderNo/complete-payment') + @Public() + @ApiOperation({ summary: '完成打款(内部API)' }) + @ApiParam({ name: 'orderNo', description: '提现订单号' }) + @ApiResponse({ status: 200, description: '完成打款结果' }) + async completePayment( + @Param('orderNo') orderNo: string, + @Body() dto: { paymentProof?: string; remark?: string }, + ) { + this.logger.log(`完成打款: ${orderNo}`); + const command = new CompletePaymentCommand(orderNo, dto.paymentProof, dto.remark); + return this.walletService.completePayment(command); + } } diff --git a/backend/services/wallet-service/src/api/controllers/wallet.controller.ts b/backend/services/wallet-service/src/api/controllers/wallet.controller.ts index ff1616fa..41e35411 100644 --- a/backend/services/wallet-service/src/api/controllers/wallet.controller.ts +++ b/backend/services/wallet-service/src/api/controllers/wallet.controller.ts @@ -1,14 +1,16 @@ -import { Controller, Get, Post, Body, Query, UseGuards, Headers, HttpException, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Post, Body, Query, UseGuards, Headers, HttpException, HttpStatus, Param } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { WalletApplicationService } from '@/application/services'; import { GetMyWalletQuery } from '@/application/queries'; -import { ClaimRewardsCommand, SettleRewardsCommand, RequestWithdrawalCommand } from '@/application/commands'; +import { ClaimRewardsCommand, SettleRewardsCommand, RequestWithdrawalCommand, CancelWithdrawalCommand } from '@/application/commands'; +import { PaymentAccountDto } from '@/application/commands/request-withdrawal.command'; import { CurrentUser, CurrentUserPayload } from '@/shared/decorators'; import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; -import { SettleRewardsDTO, RequestWithdrawalDTO } from '@/api/dto/request'; +import { SettleRewardsDTO, RequestWithdrawalDTO, CancelWithdrawalDTO } from '@/api/dto/request'; import { WalletResponseDTO, WithdrawalResponseDTO, WithdrawalListItemDTO, WithdrawalFeeConfigResponseDTO, CalculateFeeResponseDTO } from '@/api/dto/response'; import { IdentityClientService } from '@/infrastructure/external/identity'; import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories'; +import { PaymentMethod } from '@/domain/value-objects/withdrawal-status.enum'; @ApiTags('Wallet') @Controller('wallet') @@ -72,7 +74,7 @@ export class WalletController { } @Post('withdraw') - @ApiOperation({ summary: '申请提现', description: '将USDT提现到指定地址,需要短信验证和密码验证' }) + @ApiOperation({ summary: '申请提现 (法币)', description: '将绿积分提现到银行卡/支付宝/微信,需要短信验证和密码验证' }) @ApiResponse({ status: 201, type: WithdrawalResponseDTO }) async requestWithdrawal( @CurrentUser() user: CurrentUserPayload, @@ -102,30 +104,68 @@ export class WalletController { throw new HttpException('登录密码错误,请重试', HttpStatus.BAD_REQUEST); } - // 处理 toAddress: 如果是 accountSequence 格式,转换为区块链地址 - let actualAddress = dto.toAddress; - if (dto.toAddress.startsWith('D') && dto.toAddress.length === 12) { - // accountSequence 格式,需要查询对应的区块链地址 - const resolvedAddress = await this.identityClient.resolveAccountSequenceToAddress( - dto.toAddress, - dto.chainType, - token, - ); - if (!resolvedAddress) { - throw new HttpException('无效的充值ID,未找到对应地址', HttpStatus.BAD_REQUEST); - } - actualAddress = resolvedAddress; - } + // 验证收款账户信息完整性 + this.validatePaymentAccount(dto); + + // 构建收款账户信息 + const paymentAccount: PaymentAccountDto = { + paymentMethod: dto.paymentMethod, + bankName: dto.bankName, + bankCardNo: dto.bankCardNo, + cardHolderName: dto.cardHolderName, + alipayAccount: dto.alipayAccount, + alipayRealName: dto.alipayRealName, + wechatAccount: dto.wechatAccount, + wechatRealName: dto.wechatRealName, + }; const command = new RequestWithdrawalCommand( + user.accountSequence, user.userId, dto.amount, - actualAddress, - dto.chainType, + paymentAccount, ); return this.walletService.requestWithdrawal(command); } + /** + * 验证收款账户信息完整性 + */ + private validatePaymentAccount(dto: RequestWithdrawalDTO): void { + switch (dto.paymentMethod) { + case PaymentMethod.BANK_CARD: + if (!dto.bankName || !dto.bankCardNo || !dto.cardHolderName) { + throw new HttpException('请填写完整的银行卡信息', HttpStatus.BAD_REQUEST); + } + break; + case PaymentMethod.ALIPAY: + if (!dto.alipayAccount || !dto.alipayRealName) { + throw new HttpException('请填写完整的支付宝信息', HttpStatus.BAD_REQUEST); + } + break; + case PaymentMethod.WECHAT: + if (!dto.wechatAccount || !dto.wechatRealName) { + throw new HttpException('请填写完整的微信信息', HttpStatus.BAD_REQUEST); + } + break; + default: + throw new HttpException('不支持的收款方式', HttpStatus.BAD_REQUEST); + } + } + + @Post('withdraw/:orderNo/cancel') + @ApiOperation({ summary: '取消提现', description: '取消未审核的提现订单' }) + @ApiResponse({ status: 200, description: '取消成功' }) + async cancelWithdrawal( + @CurrentUser() user: CurrentUserPayload, + @Param('orderNo') orderNo: string, + @Body() dto: CancelWithdrawalDTO, + ): Promise<{ orderNo: string; status: string }> { + // TODO: 验证订单属于当前用户 + const command = new CancelWithdrawalCommand(orderNo, dto.reason); + return this.walletService.cancelWithdrawal(command); + } + @Get('withdrawals') @ApiOperation({ summary: '查询提现记录', description: '获取用户的提现订单列表' }) @ApiResponse({ status: 200, type: [WithdrawalListItemDTO] }) @@ -195,8 +235,8 @@ export class WalletController { } @Get('calculate-fee') - @ApiOperation({ summary: '计算提取手续费', description: '根据提取金额计算手续费' }) - @ApiQuery({ name: 'amount', type: Number, description: '提取金额' }) + @ApiOperation({ summary: '计算提现手续费', description: '根据提现金额计算手续费' }) + @ApiQuery({ name: 'amount', type: Number, description: '提现金额 (绿积分)' }) @ApiResponse({ status: 200, type: CalculateFeeResponseDTO }) async calculateFee( @Query('amount') amountStr: string, @@ -212,7 +252,7 @@ export class WalletController { amount, fee, totalRequired: amount + fee, - receiverGets: amount, // 接收方收到完整金额 + receiverGets: amount - fee, // 实际到账金额(扣除手续费) feeType, feeValue, }; diff --git a/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts b/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts index 6bcdaf37..b61e4c21 100644 --- a/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts +++ b/backend/services/wallet-service/src/api/dto/request/withdrawal.dto.ts @@ -1,31 +1,64 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNumber, IsString, IsEnum, Min, Matches, IsOptional, Length } from 'class-validator'; -import { ChainType } from '@/domain/value-objects'; +import { IsNumber, IsString, IsEnum, Min, IsOptional, Length, ValidateIf, Matches } from 'class-validator'; +import { PaymentMethod } from '@/domain/value-objects/withdrawal-status.enum'; +/** + * 法币提现请求 DTO + */ export class RequestWithdrawalDTO { - @ApiProperty({ description: '提现金额 (USDT)', example: 100 }) + @ApiProperty({ description: '提现金额 (绿积分,1:1人民币)', example: 100 }) @IsNumber() - @Min(10, { message: '最小提现金额为 10 USDT' }) + @Min(100, { message: '最小提现金额为 100 绿积分' }) amount: number; @ApiProperty({ - description: '提现目标地址 (EVM地址或充值ID)', - example: '0x1234567890abcdef1234567890abcdef12345678', + description: '收款方式', + enum: PaymentMethod, + example: PaymentMethod.BANK_CARD, }) + @IsEnum(PaymentMethod, { message: '请选择有效的收款方式' }) + paymentMethod: PaymentMethod; + + // 银行卡相关字段 + @ApiPropertyOptional({ description: '银行名称', example: '中国工商银行' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD) @IsString() - @Matches(/^(0x[a-fA-F0-9]{40}|D\d{11})$/, { - message: '无效的地址格式,请输入EVM地址(0x...)或充值ID(D...)', - }) - toAddress: string; + bankName?: string; - @ApiProperty({ - description: '目标链类型', - enum: ChainType, - example: 'KAVA', - }) - @IsEnum(ChainType) - chainType: ChainType; + @ApiPropertyOptional({ description: '银行卡号', example: '6222021234567890123' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD) + @IsString() + @Matches(/^\d{16,19}$/, { message: '请输入正确的银行卡号(16-19位数字)' }) + bankCardNo?: string; + @ApiPropertyOptional({ description: '持卡人姓名', example: '张三' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD) + @IsString() + cardHolderName?: string; + + // 支付宝相关字段 + @ApiPropertyOptional({ description: '支付宝账号', example: '13800138000' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.ALIPAY) + @IsString() + alipayAccount?: string; + + @ApiPropertyOptional({ description: '支付宝实名', example: '张三' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.ALIPAY) + @IsString() + alipayRealName?: string; + + // 微信相关字段 + @ApiPropertyOptional({ description: '微信账号', example: 'wxid_abc123' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.WECHAT) + @IsString() + wechatAccount?: string; + + @ApiPropertyOptional({ description: '微信实名', example: '张三' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.WECHAT) + @IsString() + wechatRealName?: string; + + // 验证字段 @ApiProperty({ description: '短信验证码', example: '123456', @@ -43,3 +76,17 @@ export class RequestWithdrawalDTO { @Length(6, 32, { message: '密码长度必须在6-32位之间' }) password: string; } + +/** + * 取消提现请求 DTO + */ +export class CancelWithdrawalDTO { + @ApiProperty({ description: '提现订单号', example: 'WD1234567890ABCD' }) + @IsString() + orderNo: string; + + @ApiPropertyOptional({ description: '取消原因', example: '不想提现了' }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/backend/services/wallet-service/src/api/dto/response/withdrawal.dto.ts b/backend/services/wallet-service/src/api/dto/response/withdrawal.dto.ts index 9e95d137..2e4bab23 100644 --- a/backend/services/wallet-service/src/api/dto/response/withdrawal.dto.ts +++ b/backend/services/wallet-service/src/api/dto/response/withdrawal.dto.ts @@ -1,47 +1,103 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** + * 提现响应 DTO + */ export class WithdrawalResponseDTO { @ApiProperty({ description: '提现订单号', example: 'WD1234567890ABCD' }) orderNo: string; - @ApiProperty({ description: '提现金额', example: 100 }) + @ApiProperty({ description: '提现金额 (绿积分)', example: 100 }) amount: number; - @ApiProperty({ description: '手续费', example: 1 }) + @ApiProperty({ description: '手续费 (绿积分)', example: 1 }) fee: number; - @ApiProperty({ description: '实际到账金额', example: 99 }) + @ApiProperty({ description: '实际到账金额 (元人民币)', example: 99 }) netAmount: number; - @ApiProperty({ description: '订单状态', example: 'FROZEN' }) + @ApiProperty({ description: '订单状态', example: 'REVIEWING' }) status: string; + + @ApiProperty({ description: '收款方式', example: 'BANK_CARD' }) + paymentMethod: string; } +/** + * 提现列表项 DTO (用户端) + */ export class WithdrawalListItemDTO { @ApiProperty({ description: '提现订单号', example: 'WD1234567890ABCD' }) orderNo: string; - @ApiProperty({ description: '提现金额', example: 100 }) + @ApiProperty({ description: '提现金额 (绿积分)', example: 100 }) amount: number; - @ApiProperty({ description: '手续费', example: 1 }) + @ApiProperty({ description: '手续费 (绿积分)', example: 1 }) fee: number; - @ApiProperty({ description: '实际到账金额', example: 99 }) + @ApiProperty({ description: '实际到账金额 (元人民币)', example: 99 }) netAmount: number; - @ApiProperty({ description: '目标链', example: 'BSC' }) - chainType: string; + @ApiProperty({ description: '收款方式', example: 'BANK_CARD' }) + paymentMethod: string; - @ApiProperty({ description: '提现地址', example: '0x1234...' }) - toAddress: string; + @ApiProperty({ description: '收款账户显示', example: '工商银行 ****1234 (张三)' }) + paymentAccountDisplay: string; - @ApiProperty({ description: '链上交易哈希', nullable: true }) - txHash: string | null; - - @ApiProperty({ description: '订单状态', example: 'CONFIRMED' }) + @ApiProperty({ description: '订单状态', example: 'COMPLETED' }) status: string; @ApiProperty({ description: '创建时间' }) createdAt: string; + + @ApiPropertyOptional({ description: '完成时间' }) + completedAt: string | null; +} + +/** + * 提现订单详情 DTO (管理后台) + */ +export class WithdrawalOrderDetailDTO { + @ApiProperty({ description: '提现订单号' }) + orderNo: string; + + @ApiProperty({ description: '用户账号' }) + accountSequence: string; + + @ApiProperty({ description: '用户ID' }) + userId: string; + + @ApiProperty({ description: '提现金额 (绿积分)' }) + amount: number; + + @ApiProperty({ description: '手续费 (绿积分)' }) + fee: number; + + @ApiProperty({ description: '实际到账金额 (元人民币)' }) + netAmount: number; + + @ApiProperty({ description: '收款方式' }) + paymentMethod: string; + + @ApiProperty({ description: '收款账户显示' }) + paymentAccountDisplay: string; + + @ApiProperty({ description: '订单状态' }) + status: string; + + @ApiPropertyOptional({ description: '审核人' }) + reviewedBy: string | null; + + @ApiPropertyOptional({ description: '审核时间' }) + reviewedAt: string | null; + + @ApiPropertyOptional({ description: '打款人' }) + paidBy: string | null; + + @ApiProperty({ description: '创建时间' }) + createdAt: string; + + @ApiPropertyOptional({ description: '详细备注' }) + detailMemo: string | null; } diff --git a/backend/services/wallet-service/src/application/commands/request-withdrawal.command.ts b/backend/services/wallet-service/src/application/commands/request-withdrawal.command.ts index 88ea0747..1c2a7aeb 100644 --- a/backend/services/wallet-service/src/application/commands/request-withdrawal.command.ts +++ b/backend/services/wallet-service/src/application/commands/request-withdrawal.command.ts @@ -1,25 +1,73 @@ -import { ChainType } from '@/domain/value-objects'; +import { PaymentMethod } from '@/domain/value-objects/withdrawal-status.enum'; /** - * 请求提现命令 + * 收款账户信息 + */ +export interface PaymentAccountDto { + paymentMethod: PaymentMethod; + // 银行卡 + bankName?: string; + bankCardNo?: string; + cardHolderName?: string; + // 支付宝 + alipayAccount?: string; + alipayRealName?: string; + // 微信 + wechatAccount?: string; + wechatRealName?: string; +} + +/** + * 请求提现命令 (法币提现) */ export class RequestWithdrawalCommand { constructor( + public readonly accountSequence: string, public readonly userId: string, - public readonly amount: number, // 提现金额 (USDT) - public readonly toAddress: string, // 目标地址 - public readonly chainType: ChainType, // 目标链 (BSC/KAVA) + public readonly amount: number, // 提现金额 (绿积分, 1:1人民币) + public readonly paymentAccount: PaymentAccountDto, // 收款账户 ) {} } /** - * 更新提现状态命令 (内部使用) + * 审核提现命令 */ -export class UpdateWithdrawalStatusCommand { +export class ReviewWithdrawalCommand { constructor( public readonly orderNo: string, - public readonly status: 'BROADCASTED' | 'CONFIRMED' | 'FAILED', - public readonly txHash?: string, - public readonly errorMessage?: string, + public readonly approved: boolean, // true=通过, false=驳回 + public readonly reviewedBy: string, // 审核人 + public readonly remark?: string, // 审核备注 + ) {} +} + +/** + * 开始打款命令 + */ +export class StartPaymentCommand { + constructor( + public readonly orderNo: string, + public readonly paidBy: string, // 打款人 + ) {} +} + +/** + * 完成打款命令 + */ +export class CompletePaymentCommand { + constructor( + public readonly orderNo: string, + public readonly paymentProof?: string, // 打款凭证 + public readonly remark?: string, // 打款备注 + ) {} +} + +/** + * 取消提现命令 + */ +export class CancelWithdrawalCommand { + constructor( + public readonly orderNo: string, + public readonly reason?: string, // 取消原因 ) {} } diff --git a/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts b/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts deleted file mode 100644 index 161906a7..00000000 --- a/backend/services/wallet-service/src/application/event-handlers/withdrawal-status.handler.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { Injectable, Logger, OnModuleInit, Inject } from '@nestjs/common'; -import { - WithdrawalEventConsumerService, - WithdrawalConfirmedPayload, - WithdrawalFailedPayload, -} from '@/infrastructure/kafka/withdrawal-event-consumer.service'; -import { - IWithdrawalOrderRepository, - WITHDRAWAL_ORDER_REPOSITORY, - IWalletAccountRepository, - WALLET_ACCOUNT_REPOSITORY, - ILedgerEntryRepository, - LEDGER_ENTRY_REPOSITORY, -} from '@/domain/repositories'; -import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; -import { WithdrawalOrder, WalletAccount, LedgerEntry } from '@/domain/aggregates'; -import { WithdrawalStatus, Money, UserId, LedgerEntryType } from '@/domain/value-objects'; -import { OptimisticLockError } from '@/shared/exceptions/domain.exception'; -import Decimal from 'decimal.js'; - -/** - * Withdrawal Status Handler - * - * Handles withdrawal status events from blockchain-service. - * Updates withdrawal order status and handles fund refunds on failure. - * - * IMPORTANT: - * - All operations use database transactions for atomicity. - * - Wallet balance updates use optimistic locking to prevent concurrent modification issues. - */ -@Injectable() -export class WithdrawalStatusHandler implements OnModuleInit { - private readonly logger = new Logger(WithdrawalStatusHandler.name); - - // Max retry count for optimistic lock conflicts - private readonly MAX_RETRIES = 3; - - constructor( - private readonly withdrawalEventConsumer: WithdrawalEventConsumerService, - @Inject(WITHDRAWAL_ORDER_REPOSITORY) - private readonly withdrawalRepo: IWithdrawalOrderRepository, - @Inject(WALLET_ACCOUNT_REPOSITORY) - private readonly walletRepo: IWalletAccountRepository, - @Inject(LEDGER_ENTRY_REPOSITORY) - private readonly ledgerRepo: ILedgerEntryRepository, - private readonly prisma: PrismaService, - ) {} - - onModuleInit() { - this.withdrawalEventConsumer.onWithdrawalConfirmed( - this.handleWithdrawalConfirmed.bind(this), - ); - this.withdrawalEventConsumer.onWithdrawalFailed( - this.handleWithdrawalFailed.bind(this), - ); - this.logger.log(`[INIT] WithdrawalStatusHandler registered`); - } - - /** - * Handle withdrawal confirmed event - * Update order status to CONFIRMED, store txHash, and deduct frozen balance - * - * Uses database transaction + optimistic locking to ensure atomicity and prevent race conditions. - */ - private async handleWithdrawalConfirmed( - payload: WithdrawalConfirmedPayload, - ): Promise { - 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 { - 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 { - 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 { - 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 { - return new Promise(resolve => setTimeout(resolve, ms)); - } -} diff --git a/backend/services/wallet-service/src/application/services/wallet-application.service.ts b/backend/services/wallet-service/src/application/services/wallet-application.service.ts index 86decef1..ff2b74b0 100644 --- a/backend/services/wallet-service/src/application/services/wallet-application.service.ts +++ b/backend/services/wallet-service/src/application/services/wallet-application.service.ts @@ -15,17 +15,16 @@ import { import { HandleDepositCommand, DeductForPlantingCommand, AddRewardsCommand, ClaimRewardsCommand, SettleRewardsCommand, AllocateFundsCommand, FundAllocationItem, - RequestWithdrawalCommand, UpdateWithdrawalStatusCommand, + RequestWithdrawalCommand, ReviewWithdrawalCommand, StartPaymentCommand, CompletePaymentCommand, CancelWithdrawalCommand, FreezeForPlantingCommand, ConfirmPlantingDeductionCommand, UnfreezeForPlantingCommand, } from '@/application/commands'; +import { PaymentMethod, WithdrawalStatus } from '@/domain/value-objects/withdrawal-status.enum'; import { GetMyWalletQuery, GetMyLedgerQuery } from '@/application/queries'; import { DuplicateTransactionError, WalletNotFoundError, OptimisticLockError } from '@/shared/exceptions/domain.exception'; import { WalletCacheService } from '@/infrastructure/redis'; import { EventPublisherService } from '@/infrastructure/kafka'; -import { WithdrawalRequestedEvent } from '@/domain/events'; import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories'; import { FeeType } from '@/api/dto/response'; -import { IdentityClientService } from '@/infrastructure/external/identity/identity-client.service'; export interface WalletDTO { walletId: string; @@ -94,7 +93,6 @@ export class WalletApplicationService { private readonly eventPublisher: EventPublisherService, private readonly prisma: PrismaService, private readonly feeConfigRepo: FeeConfigRepositoryImpl, - private readonly identityClient: IdentityClientService, ) {} // =============== Commands =============== @@ -1456,7 +1454,7 @@ export class WalletApplicationService { * 3. 创建提现订单 * 4. 冻结用户余额 * 5. 记录流水 - * 6. 发布事件通知 blockchain-service + * 6. 提交审核 */ async requestWithdrawal(command: RequestWithdrawalCommand): Promise<{ orderNo: string; @@ -1464,6 +1462,7 @@ export class WalletApplicationService { fee: number; netAmount: number; status: string; + paymentMethod: string; }> { const MAX_RETRIES = 3; let retries = 0; @@ -1474,9 +1473,9 @@ export class WalletApplicationService { } catch (error) { if (this.isOptimisticLockError(error)) { retries++; - this.logger.warn(`[requestWithdrawal] Optimistic lock conflict for ${command.userId}, retry ${retries}/${MAX_RETRIES}`); + this.logger.warn(`[requestWithdrawal] Optimistic lock conflict for ${command.accountSequence}, retry ${retries}/${MAX_RETRIES}`); if (retries >= MAX_RETRIES) { - this.logger.error(`[requestWithdrawal] Max retries exceeded for ${command.userId}`); + this.logger.error(`[requestWithdrawal] Max retries exceeded for ${command.accountSequence}`); throw error; } await this.sleep(50 * retries); @@ -1490,7 +1489,7 @@ export class WalletApplicationService { } /** - * Execute the withdrawal request logic + * Execute the withdrawal request logic (法币提现) */ private async executeRequestWithdrawal(command: RequestWithdrawalCommand): Promise<{ orderNo: string; @@ -1498,6 +1497,7 @@ export class WalletApplicationService { fee: number; netAmount: number; status: string; + paymentMethod: string; }> { const userId = BigInt(command.userId); const amount = Money.USDT(command.amount); @@ -1511,20 +1511,20 @@ export class WalletApplicationService { ? `固定 ${feeValue} 绿积分` : `${(feeValue * 100).toFixed(2)}%`; - this.logger.log(`Processing withdrawal request for user ${userId}: ${command.amount} USDT to ${command.toAddress}, fee: ${feeAmount} (${feeDescription})`); + this.logger.log(`Processing fiat withdrawal request for user ${command.accountSequence}: ${command.amount} 绿积分, fee: ${feeAmount} (${feeDescription}), payment method: ${command.paymentAccount.paymentMethod}`); // 验证最小提现金额 if (command.amount < this.MIN_WITHDRAWAL_AMOUNT) { - throw new Error(`最小提现金额为 ${this.MIN_WITHDRAWAL_AMOUNT} USDT`); + throw new Error(`最小提现金额为 ${this.MIN_WITHDRAWAL_AMOUNT} 绿积分`); } // 优先按 accountSequence 查找,如果未找到则按 userId 查找 - let wallet = await this.walletRepo.findByAccountSequence(command.userId); + let wallet = await this.walletRepo.findByAccountSequence(command.accountSequence); if (!wallet) { wallet = await this.walletRepo.findByUserId(userId); } if (!wallet) { - throw new WalletNotFoundError(`userId/accountSequence: ${command.userId}`); + throw new WalletNotFoundError(`userId/accountSequence: ${command.accountSequence}`); } // 验证余额是否足够 @@ -1534,45 +1534,31 @@ export class WalletApplicationService { ); } - // 检查目标地址是否为系统内用户(内部转账) - let isInternalTransfer = false; - let toAccountSequence: string | undefined; - let toUserId: UserId | undefined; - - const targetUser = await this.identityClient.findUserByWalletAddress( - command.chainType, - command.toAddress, - ); - - if (targetUser) { - // 目标地址属于系统内用户,标记为内部转账 - isInternalTransfer = true; - toAccountSequence = targetUser.accountSequence; - toUserId = UserId.create(BigInt(targetUser.userId)); - this.logger.log( - `Internal transfer detected: ${wallet.accountSequence} -> ${toAccountSequence}`, - ); - } - - // 创建提现订单 + // 创建提现订单 (法币提现) const withdrawalOrder = WithdrawalOrder.create({ accountSequence: wallet.accountSequence, userId: UserId.create(userId), amount, fee, - chainType: command.chainType, - toAddress: command.toAddress, - isInternalTransfer, - toAccountSequence, - toUserId, + paymentAccount: { + paymentMethod: command.paymentAccount.paymentMethod, + bankName: command.paymentAccount.bankName, + bankCardNo: command.paymentAccount.bankCardNo, + cardHolderName: command.paymentAccount.cardHolderName, + alipayAccount: command.paymentAccount.alipayAccount, + alipayRealName: command.paymentAccount.alipayRealName, + wechatAccount: command.paymentAccount.wechatAccount, + wechatRealName: command.paymentAccount.wechatRealName, + }, }); // 冻结用户余额 (金额 + 手续费) wallet.freeze(totalRequired); await this.walletRepo.save(wallet); - // 标记订单已冻结 + // 标记订单已冻结并提交审核 withdrawalOrder.markAsFrozen(); + withdrawalOrder.submitForReview(); const savedOrder = await this.withdrawalRepo.save(withdrawalOrder); // 记录流水 - 冻结 @@ -1583,35 +1569,14 @@ export class WalletApplicationService { amount: Money.signed(-totalRequired.value, 'USDT'), balanceAfter: wallet.balances.usdt.available, refOrderId: savedOrder.orderNo, - memo: `提取冻结: ${command.amount} 绿积分 + ${feeAmount.toFixed(2)} 绿积分手续费 (${feeDescription})`, + memo: `提现冻结: ${command.amount} 绿积分 + ${feeAmount.toFixed(2)} 绿积分手续费 (${feeDescription}), 收款方式: ${savedOrder.getPaymentMethodName()}`, }); await this.ledgerRepo.save(freezeEntry); - // 发布事件通知 blockchain-service - const event = new WithdrawalRequestedEvent({ - orderNo: savedOrder.orderNo, - accountSequence: wallet.accountSequence, - userId: userId.toString(), - walletId: wallet.walletId.toString(), - amount: command.amount.toString(), - fee: feeAmount.toString(), - netAmount: command.amount.toString(), // 接收方收到完整金额,手续费由发送方额外承担 - assetType: 'USDT', - chainType: command.chainType, - toAddress: command.toAddress, - }); - - // 发布到 Kafka 通知 blockchain-service - await this.eventPublisher.publish({ - eventType: 'wallet.withdrawal.requested', - payload: event.getPayload() as unknown as { [key: string]: unknown }, - }); - this.logger.log(`Withdrawal event published: ${savedOrder.orderNo}`); - // 清除钱包缓存 await this.walletCacheService.invalidateWallet(userId); - this.logger.log(`Withdrawal order created: ${savedOrder.orderNo}`); + this.logger.log(`Fiat withdrawal order created: ${savedOrder.orderNo}, status: ${savedOrder.status}`); return { orderNo: savedOrder.orderNo, @@ -1619,14 +1584,19 @@ export class WalletApplicationService { fee: savedOrder.fee.value, netAmount: savedOrder.netAmount.value, status: savedOrder.status, + paymentMethod: savedOrder.paymentMethod, }; } /** - * 更新提现状态 (内部调用,由 blockchain-service 事件触发) + * 审核提现订单 (管理员) */ - async updateWithdrawalStatus(command: UpdateWithdrawalStatusCommand): Promise { - this.logger.log(`Updating withdrawal ${command.orderNo} to status ${command.status}`); + async reviewWithdrawal(command: ReviewWithdrawalCommand): Promise<{ + orderNo: string; + status: string; + reviewedBy: string; + }> { + this.logger.log(`Reviewing withdrawal ${command.orderNo}: approved=${command.approved} by ${command.reviewedBy}`); const order = await this.withdrawalRepo.findByOrderNo(command.orderNo); if (!order) { @@ -1638,69 +1608,189 @@ export class WalletApplicationService { throw new WalletNotFoundError(`userId: ${order.userId.value}`); } - const totalFrozen = order.amount.add(order.fee); + if (command.approved) { + // 审核通过 + order.approve(command.reviewedBy, command.remark); + await this.withdrawalRepo.save(order); + this.logger.log(`Withdrawal ${order.orderNo} approved by ${command.reviewedBy}`); + } else { + // 审核驳回 + order.reject(command.reviewedBy, command.remark || '审核不通过'); + await this.withdrawalRepo.save(order); - switch (command.status) { - case 'BROADCASTED': - if (!command.txHash) { - throw new Error('txHash is required for BROADCASTED status'); - } - order.markAsBroadcasted(command.txHash); - await this.withdrawalRepo.save(order); - break; + // 解冻资金 + const totalFrozen = order.amount.add(order.fee); + wallet.unfreeze(totalFrozen); + await this.walletRepo.save(wallet); - case 'CONFIRMED': - order.markAsConfirmed(); - await this.withdrawalRepo.save(order); + // 记录解冻流水 + const unfreezeEntry = LedgerEntry.create({ + accountSequence: wallet.accountSequence, + userId: order.userId, + entryType: LedgerEntryType.UNFREEZE, + amount: totalFrozen, + balanceAfter: wallet.balances.usdt.available, + refOrderId: order.orderNo, + memo: `提现审核驳回,资金解冻: ${command.remark || '审核不通过'}`, + }); + await this.ledgerRepo.save(unfreezeEntry); - // 解冻并扣除 - wallet.unfreeze(totalFrozen); - wallet.deduct(totalFrozen, 'Withdrawal completed', order.orderNo); - await this.walletRepo.save(wallet); - - // 记录提现完成流水 - const withdrawEntry = LedgerEntry.create({ - accountSequence: wallet.accountSequence, - userId: order.userId, - entryType: LedgerEntryType.WITHDRAWAL, - amount: Money.signed(-order.amount.value, 'USDT'), - balanceAfter: wallet.balances.usdt.available, - refOrderId: order.orderNo, - refTxHash: order.txHash ?? undefined, - memo: `Withdrawal to ${order.toAddress}`, - }); - await this.ledgerRepo.save(withdrawEntry); - - this.logger.log(`Withdrawal ${order.orderNo} confirmed, txHash: ${order.txHash}`); - break; - - case 'FAILED': - order.markAsFailed(command.errorMessage || 'Unknown error'); - await this.withdrawalRepo.save(order); - - // 解冻资金 - if (order.needsUnfreeze()) { - wallet.unfreeze(totalFrozen); - await this.walletRepo.save(wallet); - - // 记录解冻流水 - const unfreezeEntry = LedgerEntry.create({ - accountSequence: wallet.accountSequence, - userId: order.userId, - entryType: LedgerEntryType.UNFREEZE, - amount: totalFrozen, - balanceAfter: wallet.balances.usdt.available, - refOrderId: order.orderNo, - memo: `Withdrawal failed, funds unfrozen: ${command.errorMessage}`, - }); - await this.ledgerRepo.save(unfreezeEntry); - } - - this.logger.warn(`Withdrawal ${order.orderNo} failed: ${command.errorMessage}`); - break; + this.logger.log(`Withdrawal ${order.orderNo} rejected by ${command.reviewedBy}`); } await this.walletCacheService.invalidateWallet(order.userId.value); + + return { + orderNo: order.orderNo, + status: order.status, + reviewedBy: command.reviewedBy, + }; + } + + /** + * 开始打款 (管理员) + */ + async startPayment(command: StartPaymentCommand): Promise<{ + orderNo: string; + status: string; + paidBy: string; + }> { + this.logger.log(`Starting payment for withdrawal ${command.orderNo} by ${command.paidBy}`); + + const order = await this.withdrawalRepo.findByOrderNo(command.orderNo); + if (!order) { + throw new Error(`Withdrawal order not found: ${command.orderNo}`); + } + + order.startPayment(command.paidBy); + await this.withdrawalRepo.save(order); + + this.logger.log(`Withdrawal ${order.orderNo} payment started by ${command.paidBy}`); + + return { + orderNo: order.orderNo, + status: order.status, + paidBy: command.paidBy, + }; + } + + /** + * 完成打款 (管理员) + */ + async completePayment(command: CompletePaymentCommand): Promise<{ + orderNo: string; + status: string; + completedAt: string; + }> { + this.logger.log(`Completing payment for withdrawal ${command.orderNo}`); + + const order = await this.withdrawalRepo.findByOrderNo(command.orderNo); + if (!order) { + throw new Error(`Withdrawal order not found: ${command.orderNo}`); + } + + const wallet = await this.walletRepo.findByUserId(order.userId.value); + if (!wallet) { + throw new WalletNotFoundError(`userId: ${order.userId.value}`); + } + + // 完成打款 + order.completePayment(command.paymentProof, command.remark); + await this.withdrawalRepo.save(order); + + // 解冻并扣除余额 + const totalFrozen = order.amount.add(order.fee); + wallet.unfreeze(totalFrozen); + wallet.deduct(totalFrozen, `提现完成: ${order.orderNo}`, order.orderNo); + await this.walletRepo.save(wallet); + + // 记录提现完成流水 + const withdrawEntry = LedgerEntry.create({ + accountSequence: wallet.accountSequence, + userId: order.userId, + entryType: LedgerEntryType.WITHDRAWAL, + amount: Money.signed(-order.amount.value, 'USDT'), + balanceAfter: wallet.balances.usdt.available, + refOrderId: order.orderNo, + memo: `提现完成: ${order.netAmount.value.toFixed(2)} 元人民币 到 ${order.getPaymentAccountDisplay()}`, + }); + await this.ledgerRepo.save(withdrawEntry); + + // 手续费流水 (单独记录) + if (order.fee.value > 0) { + const feeEntry = LedgerEntry.create({ + accountSequence: wallet.accountSequence, + userId: order.userId, + entryType: LedgerEntryType.WITHDRAWAL_FEE, + amount: Money.signed(-order.fee.value, 'USDT'), + balanceAfter: wallet.balances.usdt.available, + refOrderId: order.orderNo, + memo: `提现手续费`, + }); + await this.ledgerRepo.save(feeEntry); + } + + await this.walletCacheService.invalidateWallet(order.userId.value); + + this.logger.log(`Withdrawal ${order.orderNo} completed`); + + return { + orderNo: order.orderNo, + status: order.status, + completedAt: order.completedAt?.toISOString() || new Date().toISOString(), + }; + } + + /** + * 取消提现 (用户) + */ + async cancelWithdrawal(command: CancelWithdrawalCommand): Promise<{ + orderNo: string; + status: string; + }> { + this.logger.log(`Cancelling withdrawal ${command.orderNo}`); + + const order = await this.withdrawalRepo.findByOrderNo(command.orderNo); + if (!order) { + throw new Error(`Withdrawal order not found: ${command.orderNo}`); + } + + const wallet = await this.walletRepo.findByUserId(order.userId.value); + if (!wallet) { + throw new WalletNotFoundError(`userId: ${order.userId.value}`); + } + + // 取消订单 + order.cancel(command.reason); + await this.withdrawalRepo.save(order); + + // 如果已冻结,解冻资金 + if (order.needsUnfreeze()) { + const totalFrozen = order.amount.add(order.fee); + wallet.unfreeze(totalFrozen); + await this.walletRepo.save(wallet); + + // 记录解冻流水 + const unfreezeEntry = LedgerEntry.create({ + accountSequence: wallet.accountSequence, + userId: order.userId, + entryType: LedgerEntryType.UNFREEZE, + amount: totalFrozen, + balanceAfter: wallet.balances.usdt.available, + refOrderId: order.orderNo, + memo: `用户取消提现,资金解冻${command.reason ? `: ${command.reason}` : ''}`, + }); + await this.ledgerRepo.save(unfreezeEntry); + } + + await this.walletCacheService.invalidateWallet(order.userId.value); + + this.logger.log(`Withdrawal ${order.orderNo} cancelled`); + + return { + orderNo: order.orderNo, + status: order.status, + }; } /** @@ -1711,11 +1801,11 @@ export class WalletApplicationService { amount: number; fee: number; netAmount: number; - chainType: string; - toAddress: string; - txHash: string | null; + paymentMethod: string; + paymentAccountDisplay: string; status: string; createdAt: string; + completedAt: string | null; }>> { const orders = await this.withdrawalRepo.findByUserId(BigInt(userId)); return orders.map(order => ({ @@ -1723,11 +1813,113 @@ export class WalletApplicationService { amount: order.amount.value, fee: order.fee.value, netAmount: order.netAmount.value, - chainType: order.chainType, - toAddress: order.toAddress, - txHash: order.txHash, + paymentMethod: order.paymentMethod, + paymentAccountDisplay: order.getPaymentAccountDisplay(), status: order.status, createdAt: order.createdAt.toISOString(), + completedAt: order.completedAt?.toISOString() || null, + })); + } + + /** + * 查询待审核的提现订单 (管理员) + */ + async getReviewingWithdrawals(): Promise> { + 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> { + 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> { + 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, })); } diff --git a/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts b/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts index a7ae8559..18b02ca1 100644 --- a/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts +++ b/backend/services/wallet-service/src/domain/aggregates/withdrawal-order.aggregate.ts @@ -1,16 +1,55 @@ import Decimal from 'decimal.js'; -import { UserId, ChainType, AssetType, Money } from '@/domain/value-objects'; -import { WithdrawalStatus } from '@/domain/value-objects/withdrawal-status.enum'; +import { UserId, AssetType, Money } from '@/domain/value-objects'; +import { WithdrawalStatus, PaymentMethod, WithdrawalType } from '@/domain/value-objects/withdrawal-status.enum'; import { DomainError } from '@/shared/exceptions/domain.exception'; /** - * 提现订单聚合根 + * 收款账户信息 + */ +export interface PaymentAccountInfo { + paymentMethod: PaymentMethod; + // 银行卡 + bankName?: string; + bankCardNo?: string; + cardHolderName?: string; + // 支付宝 + alipayAccount?: string; + alipayRealName?: string; + // 微信 + wechatAccount?: string; + wechatRealName?: string; +} + +/** + * 审核信息 + */ +export interface ReviewInfo { + reviewedBy: string; + reviewedAt: Date; + reviewRemark?: string; +} + +/** + * 打款信息 + */ +export interface PaymentInfo { + paidBy: string; + paidAt: Date; + paymentProof?: string; + paymentRemark?: string; +} + +/** + * 提现订单聚合根 (法币提现) * * 提现流程: * 1. 用户发起提现请求 -> PENDING * 2. 冻结用户余额 -> FROZEN - * 3. blockchain-service 签名并广播 -> BROADCASTED - * 4. 链上确认 -> CONFIRMED + * 3. 提交审核 -> REVIEWING + * 4. 审核通过 -> APPROVED (等待打款) + * 审核驳回 -> REJECTED (资金解冻) + * 5. 开始打款 -> PAYING + * 6. 打款完成 -> COMPLETED * * 失败/取消时解冻资金 */ @@ -19,20 +58,44 @@ export class WithdrawalOrder { private readonly _orderNo: string; private readonly _accountSequence: string; private readonly _userId: UserId; - private readonly _amount: Money; - private readonly _fee: Money; // 手续费 - private readonly _chainType: ChainType; - private readonly _toAddress: string; // 提现目标地址 - private _txHash: string | null; - // 内部转账标识 - private readonly _isInternalTransfer: boolean; // 是否为内部转账(ID转ID) - private readonly _toAccountSequence: string | null; // 接收方ID(内部转账时有值) - private readonly _toUserId: UserId | null; // 接收方用户ID(内部转账时有值) + private readonly _amount: Money; // 提现金额 (绿积分, 1:1人民币) + private readonly _fee: Money; // 手续费 + private readonly _withdrawalType: WithdrawalType; + + // 收款方式 + private readonly _paymentMethod: PaymentMethod; + // 银行卡信息 + private readonly _bankName: string | null; + private readonly _bankCardNo: string | null; + private readonly _cardHolderName: string | null; + // 支付宝信息 + private readonly _alipayAccount: string | null; + private readonly _alipayRealName: string | null; + // 微信信息 + private readonly _wechatAccount: string | null; + private readonly _wechatRealName: string | null; + + // 状态 private _status: WithdrawalStatus; private _errorMessage: string | null; + + // 审核信息 + private _reviewedBy: string | null; + private _reviewedAt: Date | null; + private _reviewRemark: string | null; + + // 打款信息 + private _paidBy: string | null; + private _paidAt: Date | null; + private _paymentProof: string | null; + private _paymentRemark: string | null; + + // 详细备注 + private _detailMemo: string | null; + + // 时间戳 private _frozenAt: Date | null; - private _broadcastedAt: Date | null; - private _confirmedAt: Date | null; + private _completedAt: Date | null; private readonly _createdAt: Date; private constructor( @@ -42,17 +105,27 @@ export class WithdrawalOrder { userId: UserId, amount: Money, fee: Money, - chainType: ChainType, - toAddress: string, - txHash: string | null, - isInternalTransfer: boolean, - toAccountSequence: string | null, - toUserId: UserId | null, + withdrawalType: WithdrawalType, + paymentMethod: PaymentMethod, + bankName: string | null, + bankCardNo: string | null, + cardHolderName: string | null, + alipayAccount: string | null, + alipayRealName: string | null, + wechatAccount: string | null, + wechatRealName: string | null, status: WithdrawalStatus, errorMessage: string | null, + reviewedBy: string | null, + reviewedAt: Date | null, + reviewRemark: string | null, + paidBy: string | null, + paidAt: Date | null, + paymentProof: string | null, + paymentRemark: string | null, + detailMemo: string | null, frozenAt: Date | null, - broadcastedAt: Date | null, - confirmedAt: Date | null, + completedAt: Date | null, createdAt: Date, ) { this._id = id; @@ -61,17 +134,27 @@ export class WithdrawalOrder { this._userId = userId; this._amount = amount; this._fee = fee; - this._chainType = chainType; - this._toAddress = toAddress; - this._txHash = txHash; - this._isInternalTransfer = isInternalTransfer; - this._toAccountSequence = toAccountSequence; - this._toUserId = toUserId; + this._withdrawalType = withdrawalType; + this._paymentMethod = paymentMethod; + this._bankName = bankName; + this._bankCardNo = bankCardNo; + this._cardHolderName = cardHolderName; + this._alipayAccount = alipayAccount; + this._alipayRealName = alipayRealName; + this._wechatAccount = wechatAccount; + this._wechatRealName = wechatRealName; this._status = status; this._errorMessage = errorMessage; + this._reviewedBy = reviewedBy; + this._reviewedAt = reviewedAt; + this._reviewRemark = reviewRemark; + this._paidBy = paidBy; + this._paidAt = paidAt; + this._paymentProof = paymentProof; + this._paymentRemark = paymentRemark; + this._detailMemo = detailMemo; this._frozenAt = frozenAt; - this._broadcastedAt = broadcastedAt; - this._confirmedAt = confirmedAt; + this._completedAt = completedAt; this._createdAt = createdAt; } @@ -82,28 +165,43 @@ export class WithdrawalOrder { get userId(): UserId { return this._userId; } get amount(): Money { return this._amount; } get fee(): Money { return this._fee; } - get netAmount(): Money { return this._amount; } // 接收方收到完整金额,手续费由发送方额外承担 - get chainType(): ChainType { return this._chainType; } - get toAddress(): string { return this._toAddress; } - get txHash(): string | null { return this._txHash; } - get isInternalTransfer(): boolean { return this._isInternalTransfer; } - get toAccountSequence(): string | null { return this._toAccountSequence; } - get toUserId(): UserId | null { return this._toUserId; } + get netAmount(): Money { return Money.USDT(new Decimal(this._amount.value).minus(this._fee.value)); } + get withdrawalType(): WithdrawalType { return this._withdrawalType; } + get paymentMethod(): PaymentMethod { return this._paymentMethod; } + get bankName(): string | null { return this._bankName; } + get bankCardNo(): string | null { return this._bankCardNo; } + get cardHolderName(): string | null { return this._cardHolderName; } + get alipayAccount(): string | null { return this._alipayAccount; } + get alipayRealName(): string | null { return this._alipayRealName; } + get wechatAccount(): string | null { return this._wechatAccount; } + get wechatRealName(): string | null { return this._wechatRealName; } get status(): WithdrawalStatus { return this._status; } get errorMessage(): string | null { return this._errorMessage; } + get reviewedBy(): string | null { return this._reviewedBy; } + get reviewedAt(): Date | null { return this._reviewedAt; } + get reviewRemark(): string | null { return this._reviewRemark; } + get paidBy(): string | null { return this._paidBy; } + get paidAt(): Date | null { return this._paidAt; } + get paymentProof(): string | null { return this._paymentProof; } + get paymentRemark(): string | null { return this._paymentRemark; } + get detailMemo(): string | null { return this._detailMemo; } get frozenAt(): Date | null { return this._frozenAt; } - get broadcastedAt(): Date | null { return this._broadcastedAt; } - get confirmedAt(): Date | null { return this._confirmedAt; } + get completedAt(): Date | null { return this._completedAt; } get createdAt(): Date { return this._createdAt; } + // 状态判断 get isPending(): boolean { return this._status === WithdrawalStatus.PENDING; } get isFrozen(): boolean { return this._status === WithdrawalStatus.FROZEN; } - get isBroadcasted(): boolean { return this._status === WithdrawalStatus.BROADCASTED; } - get isConfirmed(): boolean { return this._status === WithdrawalStatus.CONFIRMED; } + get isReviewing(): boolean { return this._status === WithdrawalStatus.REVIEWING; } + get isApproved(): boolean { return this._status === WithdrawalStatus.APPROVED; } + get isPaying(): boolean { return this._status === WithdrawalStatus.PAYING; } + get isCompleted(): boolean { return this._status === WithdrawalStatus.COMPLETED; } + get isRejected(): boolean { return this._status === WithdrawalStatus.REJECTED; } get isFailed(): boolean { return this._status === WithdrawalStatus.FAILED; } get isCancelled(): boolean { return this._status === WithdrawalStatus.CANCELLED; } get isFinished(): boolean { - return this._status === WithdrawalStatus.CONFIRMED || + return this._status === WithdrawalStatus.COMPLETED || + this._status === WithdrawalStatus.REJECTED || this._status === WithdrawalStatus.FAILED || this._status === WithdrawalStatus.CANCELLED; } @@ -118,58 +216,131 @@ export class WithdrawalOrder { } /** - * 创建提现订单 + * 获取收款账户显示信息 + */ + getPaymentAccountDisplay(): string { + switch (this._paymentMethod) { + case PaymentMethod.BANK_CARD: + const maskedCardNo = this._bankCardNo + ? `****${this._bankCardNo.slice(-4)}` + : ''; + return `${this._bankName || ''} ${maskedCardNo} (${this._cardHolderName || ''})`; + case PaymentMethod.ALIPAY: + return `支付宝: ${this._alipayAccount || ''} (${this._alipayRealName || ''})`; + case PaymentMethod.WECHAT: + return `微信: ${this._wechatAccount || ''} (${this._wechatRealName || ''})`; + default: + return '未知收款方式'; + } + } + + /** + * 获取收款方式中文名称 + */ + getPaymentMethodName(): string { + switch (this._paymentMethod) { + case PaymentMethod.BANK_CARD: + return '银行卡'; + case PaymentMethod.ALIPAY: + return '支付宝'; + case PaymentMethod.WECHAT: + return '微信'; + default: + return '未知'; + } + } + + /** + * 创建提现订单 (法币提现) */ static create(params: { accountSequence: string; userId: UserId; amount: Money; fee: Money; - chainType: ChainType; - toAddress: string; - isInternalTransfer?: boolean; - toAccountSequence?: string; - toUserId?: UserId; + paymentAccount: PaymentAccountInfo; }): WithdrawalOrder { // 验证金额 if (params.amount.value <= 0) { - throw new DomainError('Withdrawal amount must be positive'); + throw new DomainError('提现金额必须大于0'); } // 验证手续费 if (params.fee.value < 0) { - throw new DomainError('Withdrawal fee cannot be negative'); + throw new DomainError('手续费不能为负数'); } // 验证净额大于0 - if (params.amount.value <= params.fee.value) { - throw new DomainError('Withdrawal amount must be greater than fee'); + const netAmount = new Decimal(params.amount.value).minus(params.fee.value); + if (netAmount.lte(0)) { + throw new DomainError('提现金额必须大于手续费'); } - // 验证地址格式 (简单的EVM地址检查) - if (!params.toAddress.match(/^0x[a-fA-F0-9]{40}$/)) { - throw new DomainError('Invalid withdrawal address format'); + // 根据收款方式验证必填信息 + const { paymentAccount } = params; + switch (paymentAccount.paymentMethod) { + case PaymentMethod.BANK_CARD: + if (!paymentAccount.bankName || !paymentAccount.bankCardNo || !paymentAccount.cardHolderName) { + throw new DomainError('银行卡信息不完整'); + } + break; + case PaymentMethod.ALIPAY: + if (!paymentAccount.alipayAccount || !paymentAccount.alipayRealName) { + throw new DomainError('支付宝信息不完整'); + } + break; + case PaymentMethod.WECHAT: + if (!paymentAccount.wechatAccount || !paymentAccount.wechatRealName) { + throw new DomainError('微信信息不完整'); + } + break; + default: + throw new DomainError('不支持的收款方式'); } + const now = new Date(); + const orderNo = this.generateOrderNo(); + + // 构建初始备注 + const detailMemo = [ + `[${now.toLocaleString('zh-CN')}] 用户发起提现申请`, + ` 订单号: ${orderNo}`, + ` 提现金额: ${params.amount.value} 绿积分`, + ` 手续费: ${params.fee.value} 绿积分`, + ` 实际到账: ${netAmount.toFixed(2)} 元人民币`, + ` 收款方式: ${paymentAccount.paymentMethod === PaymentMethod.BANK_CARD ? '银行卡' : + paymentAccount.paymentMethod === PaymentMethod.ALIPAY ? '支付宝' : '微信'}`, + ].join('\n'); + return new WithdrawalOrder( BigInt(0), // Will be set by database - this.generateOrderNo(), + orderNo, params.accountSequence, params.userId, params.amount, params.fee, - params.chainType, - params.toAddress, - null, - params.isInternalTransfer ?? false, - params.toAccountSequence ?? null, - params.toUserId ?? null, + WithdrawalType.FIAT, + paymentAccount.paymentMethod, + paymentAccount.bankName || null, + paymentAccount.bankCardNo || null, + paymentAccount.cardHolderName || null, + paymentAccount.alipayAccount || null, + paymentAccount.alipayRealName || null, + paymentAccount.wechatAccount || null, + paymentAccount.wechatRealName || null, WithdrawalStatus.PENDING, null, null, null, null, - new Date(), + null, + null, + null, + null, + detailMemo, + null, + null, + now, ); } @@ -183,17 +354,27 @@ export class WithdrawalOrder { userId: bigint; amount: Decimal; fee: Decimal; - chainType: string; - toAddress: string; - txHash: string | null; - isInternalTransfer: boolean; - toAccountSequence: string | null; - toUserId: bigint | null; + withdrawalType?: string; + paymentMethod?: string; + bankName?: string | null; + bankCardNo?: string | null; + cardHolderName?: string | null; + alipayAccount?: string | null; + alipayRealName?: string | null; + wechatAccount?: string | null; + wechatRealName?: string | null; status: string; - errorMessage: string | null; - frozenAt: Date | null; - broadcastedAt: Date | null; - confirmedAt: Date | null; + errorMessage?: string | null; + reviewedBy?: string | null; + reviewedAt?: Date | null; + reviewRemark?: string | null; + paidBy?: string | null; + paidAt?: Date | null; + paymentProof?: string | null; + paymentRemark?: string | null; + detailMemo?: string | null; + frozenAt?: Date | null; + completedAt?: Date | null; createdAt: Date; }): WithdrawalOrder { return new WithdrawalOrder( @@ -203,53 +384,123 @@ export class WithdrawalOrder { UserId.create(params.userId), Money.USDT(params.amount), Money.USDT(params.fee), - params.chainType as ChainType, - params.toAddress, - params.txHash, - params.isInternalTransfer, - params.toAccountSequence, - params.toUserId ? UserId.create(params.toUserId) : null, + (params.withdrawalType as WithdrawalType) || WithdrawalType.FIAT, + (params.paymentMethod as PaymentMethod) || PaymentMethod.BANK_CARD, + params.bankName || null, + params.bankCardNo || null, + params.cardHolderName || null, + params.alipayAccount || null, + params.alipayRealName || null, + params.wechatAccount || null, + params.wechatRealName || null, params.status as WithdrawalStatus, - params.errorMessage, - params.frozenAt, - params.broadcastedAt, - params.confirmedAt, + params.errorMessage || null, + params.reviewedBy || null, + params.reviewedAt || null, + params.reviewRemark || null, + params.paidBy || null, + params.paidAt || null, + params.paymentProof || null, + params.paymentRemark || null, + params.detailMemo || null, + params.frozenAt || null, + params.completedAt || null, params.createdAt, ); } + /** + * 添加备注 + */ + private appendMemo(message: string): void { + const now = new Date(); + const newLine = `[${now.toLocaleString('zh-CN')}] ${message}`; + this._detailMemo = this._detailMemo + ? `${this._detailMemo}\n${newLine}` + : newLine; + } + /** * 标记为已冻结 (资金已从可用余额冻结) */ markAsFrozen(): void { if (this._status !== WithdrawalStatus.PENDING) { - throw new DomainError('Only pending withdrawals can be frozen'); + throw new DomainError('只有待处理的提现订单可以冻结资金'); } this._status = WithdrawalStatus.FROZEN; this._frozenAt = new Date(); + this.appendMemo('资金已冻结,等待审核'); } /** - * 标记为已广播 + * 提交审核 */ - markAsBroadcasted(txHash: string): void { + submitForReview(): void { if (this._status !== WithdrawalStatus.FROZEN) { - throw new DomainError('Only frozen withdrawals can be broadcasted'); + throw new DomainError('只有已冻结的提现订单可以提交审核'); } - this._status = WithdrawalStatus.BROADCASTED; - this._txHash = txHash; - this._broadcastedAt = new Date(); + this._status = WithdrawalStatus.REVIEWING; + this.appendMemo('已提交审核'); } /** - * 标记为已确认 (链上确认) + * 审核通过 */ - markAsConfirmed(): void { - if (this._status !== WithdrawalStatus.BROADCASTED) { - throw new DomainError('Only broadcasted withdrawals can be confirmed'); + approve(reviewedBy: string, remark?: string): void { + if (this._status !== WithdrawalStatus.REVIEWING && this._status !== WithdrawalStatus.FROZEN) { + throw new DomainError('只有审核中或已冻结的订单可以通过审核'); } - this._status = WithdrawalStatus.CONFIRMED; - this._confirmedAt = new Date(); + this._status = WithdrawalStatus.APPROVED; + this._reviewedBy = reviewedBy; + this._reviewedAt = new Date(); + this._reviewRemark = remark || null; + this.appendMemo(`审核通过 - 审核人: ${reviewedBy}${remark ? `, 备注: ${remark}` : ''}`); + } + + /** + * 审核驳回 + */ + reject(reviewedBy: string, remark: string): void { + if (this._status !== WithdrawalStatus.REVIEWING && this._status !== WithdrawalStatus.FROZEN) { + throw new DomainError('只有审核中或已冻结的订单可以驳回'); + } + this._status = WithdrawalStatus.REJECTED; + this._reviewedBy = reviewedBy; + this._reviewedAt = new Date(); + this._reviewRemark = remark; + this.appendMemo(`审核驳回 - 审核人: ${reviewedBy}, 原因: ${remark}`); + } + + /** + * 开始打款 + */ + startPayment(paidBy: string): void { + if (this._status !== WithdrawalStatus.APPROVED) { + throw new DomainError('只有审核通过的订单可以开始打款'); + } + this._status = WithdrawalStatus.PAYING; + this._paidBy = paidBy; + this.appendMemo(`开始打款 - 操作人: ${paidBy}`); + } + + /** + * 完成打款 + */ + completePayment(paymentProof?: string, remark?: string): void { + if (this._status !== WithdrawalStatus.PAYING) { + throw new DomainError('只有打款中的订单可以完成打款'); + } + this._status = WithdrawalStatus.COMPLETED; + this._paidAt = new Date(); + this._completedAt = new Date(); + this._paymentProof = paymentProof || null; + this._paymentRemark = remark || null; + + const proofInfo = paymentProof ? `, 凭证: ${paymentProof}` : ''; + const remarkInfo = remark ? `, 备注: ${remark}` : ''; + this.appendMemo(`打款完成${proofInfo}${remarkInfo}`); + this.appendMemo(` 收款账户: ${this.getPaymentAccountDisplay()}`); + this.appendMemo(` 到账金额: ${this.netAmount.value.toFixed(2)} 元人民币`); } /** @@ -257,27 +508,31 @@ export class WithdrawalOrder { */ markAsFailed(errorMessage: string): void { if (this.isFinished) { - throw new DomainError('Cannot fail a finished withdrawal'); + throw new DomainError('已完成的提现订单无法标记为失败'); } this._status = WithdrawalStatus.FAILED; this._errorMessage = errorMessage; + this.appendMemo(`提现失败 - 原因: ${errorMessage}`); } /** * 取消提现 */ - cancel(): void { + cancel(reason?: string): void { if (this._status !== WithdrawalStatus.PENDING && this._status !== WithdrawalStatus.FROZEN) { - throw new DomainError('Only pending or frozen withdrawals can be cancelled'); + throw new DomainError('只有待处理或已冻结的提现订单可以取消'); } this._status = WithdrawalStatus.CANCELLED; + this.appendMemo(`用户取消提现${reason ? ` - 原因: ${reason}` : ''}`); } /** - * 是否需要解冻资金 (失败或取消且已冻结) + * 是否需要解冻资金 (驳回/失败/取消且已冻结) */ needsUnfreeze(): boolean { - return (this._status === WithdrawalStatus.FAILED || this._status === WithdrawalStatus.CANCELLED) + return (this._status === WithdrawalStatus.REJECTED || + this._status === WithdrawalStatus.FAILED || + this._status === WithdrawalStatus.CANCELLED) && this._frozenAt !== null; } } diff --git a/backend/services/wallet-service/src/domain/repositories/withdrawal-order.repository.interface.ts b/backend/services/wallet-service/src/domain/repositories/withdrawal-order.repository.interface.ts index ed116d9e..4b31dcee 100644 --- a/backend/services/wallet-service/src/domain/repositories/withdrawal-order.repository.interface.ts +++ b/backend/services/wallet-service/src/domain/repositories/withdrawal-order.repository.interface.ts @@ -8,7 +8,10 @@ export interface IWithdrawalOrderRepository { findByUserId(userId: bigint, status?: WithdrawalStatus): Promise; findPendingOrders(): Promise; findFrozenOrders(): Promise; - findBroadcastedOrders(): Promise; + // 法币提现相关查询 + findReviewingOrders(): Promise; + findApprovedOrders(): Promise; + findPayingOrders(): Promise; } export const WITHDRAWAL_ORDER_REPOSITORY = Symbol('IWithdrawalOrderRepository'); diff --git a/backend/services/wallet-service/src/domain/value-objects/ledger-entry-type.enum.ts b/backend/services/wallet-service/src/domain/value-objects/ledger-entry-type.enum.ts index 70443210..12b624af 100644 --- a/backend/services/wallet-service/src/domain/value-objects/ledger-entry-type.enum.ts +++ b/backend/services/wallet-service/src/domain/value-objects/ledger-entry-type.enum.ts @@ -11,6 +11,7 @@ export enum LedgerEntryType { TRANSFER_TO_POOL = 'TRANSFER_TO_POOL', SWAP_EXECUTED = 'SWAP_EXECUTED', WITHDRAWAL = 'WITHDRAWAL', + WITHDRAWAL_FEE = 'WITHDRAWAL_FEE', // 提现手续费 TRANSFER_IN = 'TRANSFER_IN', TRANSFER_OUT = 'TRANSFER_OUT', FREEZE = 'FREEZE', diff --git a/backend/services/wallet-service/src/domain/value-objects/withdrawal-status.enum.ts b/backend/services/wallet-service/src/domain/value-objects/withdrawal-status.enum.ts index 037b20e9..b6a81fd2 100644 --- a/backend/services/wallet-service/src/domain/value-objects/withdrawal-status.enum.ts +++ b/backend/services/wallet-service/src/domain/value-objects/withdrawal-status.enum.ts @@ -1,8 +1,34 @@ +/** + * 提现订单状态 (法币提现流程) + * + * 流程: PENDING -> FROZEN -> REVIEWING -> APPROVED -> PAYING -> COMPLETED + * -> REJECTED (驳回) + * -> FAILED (失败,退款) + */ export enum WithdrawalStatus { - PENDING = 'PENDING', // 待处理 - FROZEN = 'FROZEN', // 已冻结资金,等待签名 - BROADCASTED = 'BROADCASTED', // 已广播到链上 - CONFIRMED = 'CONFIRMED', // 链上确认完成 - FAILED = 'FAILED', // 失败 - CANCELLED = 'CANCELLED', // 已取消 + PENDING = 'PENDING', // 待处理(用户刚提交) + FROZEN = 'FROZEN', // 已冻结资金,等待审核 + REVIEWING = 'REVIEWING', // 审核中 + APPROVED = 'APPROVED', // 审核通过,等待打款 + PAYING = 'PAYING', // 打款中 + COMPLETED = 'COMPLETED', // 已完成(打款成功) + REJECTED = 'REJECTED', // 审核驳回(资金已退回) + FAILED = 'FAILED', // 失败(资金已退回) + CANCELLED = 'CANCELLED', // 已取消(用户取消,资金已退回) +} + +/** + * 收款方式 + */ +export enum PaymentMethod { + BANK_CARD = 'BANK_CARD', // 银行卡 + ALIPAY = 'ALIPAY', // 支付宝 + WECHAT = 'WECHAT', // 微信支付 +} + +/** + * 提现类型 + */ +export enum WithdrawalType { + FIAT = 'FIAT', // 法币提现(人民币) } diff --git a/backend/services/wallet-service/src/infrastructure/persistence/repositories/withdrawal-order.repository.impl.ts b/backend/services/wallet-service/src/infrastructure/persistence/repositories/withdrawal-order.repository.impl.ts index c6bb4c58..49bbd619 100644 --- a/backend/services/wallet-service/src/infrastructure/persistence/repositories/withdrawal-order.repository.impl.ts +++ b/backend/services/wallet-service/src/infrastructure/persistence/repositories/withdrawal-order.repository.impl.ts @@ -16,17 +16,33 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository userId: order.userId.value, amount: order.amount.toDecimal(), fee: order.fee.toDecimal(), - chainType: order.chainType, - toAddress: order.toAddress, - txHash: order.txHash, - isInternalTransfer: order.isInternalTransfer, - toAccountSequence: order.toAccountSequence, - toUserId: order.toUserId?.value ?? null, + // 法币提现字段 + withdrawalType: order.withdrawalType, + paymentMethod: order.paymentMethod, + bankName: order.bankName, + bankCardNo: order.bankCardNo, + cardHolderName: order.cardHolderName, + alipayAccount: order.alipayAccount, + alipayRealName: order.alipayRealName, + wechatAccount: order.wechatAccount, + wechatRealName: order.wechatRealName, + // 状态 status: order.status, errorMessage: order.errorMessage, + // 审核信息 + reviewedBy: order.reviewedBy, + reviewedAt: order.reviewedAt, + reviewRemark: order.reviewRemark, + // 打款信息 + paidBy: order.paidBy, + paidAt: order.paidAt, + paymentProof: order.paymentProof, + paymentRemark: order.paymentRemark, + // 详细备注 + detailMemo: order.detailMemo, + // 时间戳 frozenAt: order.frozenAt, - broadcastedAt: order.broadcastedAt, - confirmedAt: order.confirmedAt, + completedAt: order.completedAt, }; if (order.id === BigInt(0)) { @@ -68,6 +84,19 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository return records.map(r => this.toDomain(r)); } + async findByAccountSequence(accountSequence: string, status?: WithdrawalStatus): Promise { + const where: Record = { 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 { const records = await this.prisma.withdrawalOrder.findMany({ where: { status: WithdrawalStatus.PENDING }, @@ -84,14 +113,87 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository return records.map(r => this.toDomain(r)); } - async findBroadcastedOrders(): Promise { + /** + * 查找待审核的提现订单 + */ + async findReviewingOrders(): Promise { const records = await this.prisma.withdrawalOrder.findMany({ - where: { status: WithdrawalStatus.BROADCASTED }, + where: { + status: { + in: [WithdrawalStatus.FROZEN, WithdrawalStatus.REVIEWING], + }, + }, orderBy: { createdAt: 'asc' }, }); return records.map(r => this.toDomain(r)); } + /** + * 查找待打款的提现订单 + */ + async findApprovedOrders(): Promise { + const records = await this.prisma.withdrawalOrder.findMany({ + where: { status: WithdrawalStatus.APPROVED }, + orderBy: { createdAt: 'asc' }, + }); + return records.map(r => this.toDomain(r)); + } + + /** + * 查找打款中的提现订单 + */ + async findPayingOrders(): Promise { + 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 = {}; + + if (params.status) { + if (Array.isArray(params.status)) { + where.status = { in: params.status }; + } else { + where.status = params.status; + } + } + + if (params.paymentMethod) { + where.paymentMethod = params.paymentMethod; + } + + if (params.accountSequence) { + where.accountSequence = params.accountSequence; + } + + const [records, total] = await Promise.all([ + this.prisma.withdrawalOrder.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (params.page - 1) * params.pageSize, + take: params.pageSize, + }), + this.prisma.withdrawalOrder.count({ where }), + ]); + + return { + orders: records.map(r => this.toDomain(r)), + total, + }; + } + private toDomain(record: { id: bigint; orderNo: string; @@ -99,18 +201,37 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository userId: bigint; amount: Decimal; fee: Decimal; - chainType: string; - toAddress: string; - txHash: string | null; - isInternalTransfer: boolean; - toAccountSequence: string | null; - toUserId: bigint | null; + withdrawalType: string | null; + paymentMethod: string | null; + bankName: string | null; + bankCardNo: string | null; + cardHolderName: string | null; + alipayAccount: string | null; + alipayRealName: string | null; + wechatAccount: string | null; + wechatRealName: string | null; status: string; errorMessage: string | null; + reviewedBy: string | null; + reviewedAt: Date | null; + reviewRemark: string | null; + paidBy: string | null; + paidAt: Date | null; + paymentProof: string | null; + paymentRemark: string | null; + detailMemo: string | null; frozenAt: Date | null; - broadcastedAt: Date | null; - confirmedAt: Date | null; + completedAt: Date | null; createdAt: Date; + // 兼容旧字段 + chainType?: string | null; + toAddress?: string | null; + txHash?: string | null; + isInternalTransfer?: boolean; + toAccountSequence?: string | null; + toUserId?: bigint | null; + broadcastedAt?: Date | null; + confirmedAt?: Date | null; }): WithdrawalOrder { return WithdrawalOrder.reconstruct({ id: record.id, @@ -119,17 +240,27 @@ export class WithdrawalOrderRepositoryImpl implements IWithdrawalOrderRepository userId: record.userId, amount: new Decimal(record.amount.toString()), fee: new Decimal(record.fee.toString()), - chainType: record.chainType, - toAddress: record.toAddress, - txHash: record.txHash, - isInternalTransfer: record.isInternalTransfer, - toAccountSequence: record.toAccountSequence, - toUserId: record.toUserId, + withdrawalType: record.withdrawalType || undefined, + paymentMethod: record.paymentMethod || undefined, + bankName: record.bankName, + bankCardNo: record.bankCardNo, + cardHolderName: record.cardHolderName, + alipayAccount: record.alipayAccount, + alipayRealName: record.alipayRealName, + wechatAccount: record.wechatAccount, + wechatRealName: record.wechatRealName, status: record.status, errorMessage: record.errorMessage, + reviewedBy: record.reviewedBy, + reviewedAt: record.reviewedAt, + reviewRemark: record.reviewRemark, + paidBy: record.paidBy, + paidAt: record.paidAt, + paymentProof: record.paymentProof, + paymentRemark: record.paymentRemark, + detailMemo: record.detailMemo, frozenAt: record.frozenAt, - broadcastedAt: record.broadcastedAt, - confirmedAt: record.confirmedAt, + completedAt: record.completedAt, createdAt: record.createdAt, }); } diff --git a/frontend/admin-web/src/app/(dashboard)/withdrawals/page.tsx b/frontend/admin-web/src/app/(dashboard)/withdrawals/page.tsx new file mode 100644 index 00000000..e04df258 --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/withdrawals/page.tsx @@ -0,0 +1,682 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Modal, toast, Button } from '@/components/common'; +import { PageContainer } from '@/components/layout'; +import { cn } from '@/utils/helpers'; +import { formatDateTime } from '@/utils/formatters'; +import { + useReviewingWithdrawals, + useApprovedWithdrawals, + usePayingWithdrawals, + useReviewWithdrawal, + useStartPayment, + useCompletePayment, +} from '@/hooks/useWithdrawals'; +import { + WithdrawalOrder, + getWithdrawalStatusInfo, + getPaymentMethodInfo, + formatAmount, + maskBankCardNo, + maskAccount, +} from '@/types/withdrawal.types'; +import styles from './withdrawals.module.scss'; + +type TabType = 'reviewing' | 'approved' | 'paying'; + +/** + * 提现审核管理页面 + */ +export default function WithdrawalsPage() { + const [activeTab, setActiveTab] = useState('reviewing'); + const [viewingOrder, setViewingOrder] = useState(null); + const [reviewingOrder, setReviewingOrder] = useState(null); + const [payingOrder, setPayingOrder] = useState(null); + const [completeOrder, setCompleteOrder] = useState(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 ( + + + {order.orderNo} + + +
+ {order.accountSequence} + ID: {order.userId} +
+ + +
+ {formatAmount(order.amount)} + (手续费: {formatAmount(order.fee)}) +
+ + +
+ {methodInfo.icon} +
+ {accountInfo.label} + {accountInfo.account} +
+
+ + + + {statusInfo.label} + + + {formatDateTime(order.createdAt)} + +
+ + {activeTab === 'reviewing' && ( + + )} + {activeTab === 'approved' && ( + + )} + {activeTab === 'paying' && ( + + )} +
+ + + ); + }; + + return ( + +
+ {/* 页面标题 */} +
+

提现审核管理

+

+ 审核用户提现申请,处理打款操作 +

+
+ + {/* Tab 切换 */} +
+ + + +
+ + {/* 主内容卡片 */} +
+
+ + 共 {currentData?.length ?? 0} 条记录 + + +
+ + {/* 表格 */} + {loading ? ( +
加载中...
+ ) : error ? ( +
+ {(error as Error).message || '加载失败'} + +
+ ) : !currentData || currentData.length === 0 ? ( +
+ + {activeTab === 'reviewing' ? '📋' : activeTab === 'approved' ? '💰' : '⏳'} + + + {activeTab === 'reviewing' + ? '暂无待审核的提现订单' + : activeTab === 'approved' + ? '暂无待打款的提现订单' + : '暂无打款中的提现订单'} + +
+ ) : ( + + + + + + + + + + + + + {currentData.map(renderTableRow)} +
订单号用户金额 (绿积分)收款方式状态申请时间操作
+ )} +
+ + {/* 详情弹窗 */} + setViewingOrder(null)} + footer={ +
+ +
+ } + width={700} + > + {viewingOrder && ( +
+ {/* 基本信息 */} +
+

基本信息

+
+
+ 订单号: + + {viewingOrder.orderNo} + +
+
+ 状态: + + {getWithdrawalStatusInfo(viewingOrder.status).label} + +
+
+ 用户: + + {viewingOrder.accountSequence} (ID: {viewingOrder.userId}) + +
+
+ 申请时间: + {formatDateTime(viewingOrder.createdAt)} +
+
+
+ + {/* 金额信息 */} +
+

金额信息

+
+
+ 提现金额: + + {formatAmount(viewingOrder.amount)} 绿积分 + +
+
+ 手续费: + {formatAmount(viewingOrder.fee)} 绿积分 +
+
+ 实际到账: + {formatAmount(viewingOrder.netAmount)} 元 +
+
+
+ + {/* 收款信息 */} +
+

收款信息

+
+
+ 收款方式: + + {getPaymentMethodInfo(viewingOrder.paymentMethod).icon}{' '} + {getPaymentMethodInfo(viewingOrder.paymentMethod).label} + +
+ {viewingOrder.paymentMethod === 'BANK_CARD' && ( + <> +
+ 开户行: + {viewingOrder.bankName || '-'} +
+
+ 卡号: + + {viewingOrder.bankCardNo || '-'} + +
+
+ 持卡人: + {viewingOrder.cardHolderName || '-'} +
+ + )} + {viewingOrder.paymentMethod === 'ALIPAY' && ( + <> +
+ 支付宝账号: + {viewingOrder.alipayAccount || '-'} +
+
+ 真实姓名: + {viewingOrder.alipayRealName || '-'} +
+ + )} + {viewingOrder.paymentMethod === 'WECHAT' && ( + <> +
+ 微信账号: + {viewingOrder.wechatAccount || '-'} +
+
+ 真实姓名: + {viewingOrder.wechatRealName || '-'} +
+ + )} +
+
+ + {/* 处理记录 */} + {(viewingOrder.reviewedBy || viewingOrder.paidBy) && ( +
+

处理记录

+
+ {viewingOrder.reviewedBy && ( +
+
+ 审核{viewingOrder.status === 'REJECTED' ? '拒绝' : '通过'} +
+
+ 审核人: {viewingOrder.reviewedBy} + {viewingOrder.reviewedAt && ` | 时间: ${formatDateTime(viewingOrder.reviewedAt)}`} + {viewingOrder.reviewRemark && ` | 备注: ${viewingOrder.reviewRemark}`} +
+
+ )} + {viewingOrder.paidBy && ( +
+
+ {viewingOrder.status === 'COMPLETED' ? '打款完成' : '打款中'} +
+
+ 操作人: {viewingOrder.paidBy} + {viewingOrder.paidAt && ` | 时间: ${formatDateTime(viewingOrder.paidAt)}`} + {viewingOrder.paymentRemark && ` | 备注: ${viewingOrder.paymentRemark}`} +
+
+ )} +
+
+ )} +
+ )} +
+ + {/* 审核弹窗 */} + setReviewingOrder(null)} + footer={ +
+ + +
+ } + width={500} + > + {reviewingOrder && ( +
+
+ 订单号: + {reviewingOrder.orderNo} +
+
+ 用户: + {reviewingOrder.accountSequence} +
+
+ 金额: + + {formatAmount(reviewingOrder.amount)} 绿积分 + +
+ +
+ +
+ + +
+
+ +
+ +