From a609600cd82fe709d1275f11e30bd0e3cf9f358a Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 3 Jan 2026 06:39:11 -0800 Subject: [PATCH] feat(fiat-withdrawal): add complete fiat withdrawal system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现完整的法币提现功能,支持银行卡、支付宝、微信三种收款方式。 此功能与现有的区块链划转功能完全独立,互不影响。 ## 后端 (wallet-service) ### 数据库 - 新增 `fiat_withdrawal_orders` 表存储法币提现订单 - 与现有 `withdrawal_orders` 表(区块链划转)完全分离 - 添加完整索引支持高效查询 ### 领域层 - 新增 `FiatWithdrawalStatus` 枚举(与 WithdrawalStatus 独立) - 流程: PENDING -> FROZEN -> REVIEWING -> APPROVED -> PAYING -> COMPLETED - 或 REJECTED / FAILED / CANCELLED - 新增 `PaymentMethod` 枚举: BANK_CARD / ALIPAY / WECHAT - 新增 `FiatWithdrawalOrder` 聚合根 - 新增 `IFiatWithdrawalOrderRepository` 仓储接口 - 新增 `FIAT_WITHDRAWAL` 账本流水类型 ### 应用层 - 新增 `FiatWithdrawalApplicationService` 处理业务逻辑 - 发送短信验证码 - 申请法币提现(冻结余额) - 提交审核 - 审核通过/驳回 - 开始打款 - 完成打款 ### API层 - 新增 `FiatWithdrawalController` 提供用户端API - POST /wallet/fiat-withdrawal/send-sms - 发送验证码 - POST /wallet/fiat-withdrawal - 申请提现 - GET /wallet/fiat-withdrawal - 获取提现记录 - 新增内部API供管理端调用 - GET /api/v1/wallets/fiat-withdrawals - 查询订单 - POST /api/v1/wallets/fiat-withdrawals/:orderNo/review - 审核 - POST /api/v1/wallets/fiat-withdrawals/:orderNo/start-payment - 开始打款 - POST /api/v1/wallets/fiat-withdrawals/:orderNo/complete-payment - 完成打款 ## 前端 (admin-web) - 新增法币提现审核管理页面 `/withdrawals` - 支持按状态分 Tab 查看订单 - 支持审核通过/驳回 - 支持打款操作 - 支持查看订单详情 ## 前端 (mobile-app) - 新增 `WithdrawFiatPage` 法币提现页面 - 支持选择银行卡/支付宝/微信 - 输入收款账户信息 - 新增 `WithdrawFiatConfirmPage` 确认页面 - 短信验证码验证 - 密码验证 - 在 `WalletService` 中添加法币提现相关方法和模型 ## 重要说明 此功能与现有的区块链划转功能 (withdraw_usdt_page.dart) 完全独立: - 独立的数据库表 - 独立的聚合根 - 独立的状态枚举 - 独立的API端点 - 独立的前端页面 原有的区块链划转功能保持不变,不受任何影响。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 4 +- .../migration.sql | 53 + .../wallet-service/prisma/schema.prisma | 64 + .../wallet-service/src/api/api.module.ts | 5 +- .../controllers/fiat-withdrawal.controller.ts | 133 +++ .../controllers/internal-wallet.controller.ts | 87 +- .../api/dto/request/fiat-withdrawal.dto.ts | 109 ++ .../src/api/dto/request/index.ts | 1 + .../api/dto/response/fiat-withdrawal.dto.ts | 83 ++ .../src/api/dto/response/index.ts | 1 + .../fiat-withdrawal-application.service.ts | 493 ++++++++ .../src/application/services/index.ts | 1 + .../fiat-withdrawal-order.aggregate.ts | 531 +++++++++ .../src/domain/aggregates/index.ts | 1 + ...t-withdrawal-order.repository.interface.ts | 46 + .../src/domain/repositories/index.ts | 1 + .../fiat-withdrawal-status.enum.ts | 34 + .../src/domain/value-objects/index.ts | 1 + .../value-objects/ledger-entry-type.enum.ts | 3 +- .../infrastructure/infrastructure.module.ts | 6 + .../fiat-withdrawal-order.repository.impl.ts | 175 +++ .../persistence/repositories/index.ts | 1 + .../src/app/(dashboard)/withdrawals/page.tsx | 924 ++++++++++++++ .../withdrawals/withdrawals.module.scss | 427 +++++++ .../src/components/layout/Sidebar/Sidebar.tsx | 1 + .../admin-web/src/hooks/useWithdrawals.ts | 110 ++ .../src/infrastructure/api/endpoints.ts | 10 + .../src/services/withdrawalService.ts | 87 ++ frontend/admin-web/src/types/index.ts | 1 + .../admin-web/src/types/withdrawal.types.ts | 143 +++ .../lib/core/services/wallet_service.dart | 327 +++++ .../pages/withdraw_fiat_confirm_page.dart | 659 ++++++++++ .../pages/withdraw_fiat_page.dart | 1060 +++++++++++++++++ .../mobile-app/lib/routes/app_router.dart | 19 + .../mobile-app/lib/routes/route_names.dart | 2 + .../mobile-app/lib/routes/route_paths.dart | 2 + 36 files changed, 5598 insertions(+), 7 deletions(-) create mode 100644 backend/services/wallet-service/prisma/migrations/20260103100000_add_fiat_withdrawal/migration.sql create mode 100644 backend/services/wallet-service/src/api/controllers/fiat-withdrawal.controller.ts create mode 100644 backend/services/wallet-service/src/api/dto/request/fiat-withdrawal.dto.ts create mode 100644 backend/services/wallet-service/src/api/dto/response/fiat-withdrawal.dto.ts create mode 100644 backend/services/wallet-service/src/application/services/fiat-withdrawal-application.service.ts create mode 100644 backend/services/wallet-service/src/domain/aggregates/fiat-withdrawal-order.aggregate.ts create mode 100644 backend/services/wallet-service/src/domain/repositories/fiat-withdrawal-order.repository.interface.ts create mode 100644 backend/services/wallet-service/src/domain/value-objects/fiat-withdrawal-status.enum.ts create mode 100644 backend/services/wallet-service/src/infrastructure/persistence/repositories/fiat-withdrawal-order.repository.impl.ts create mode 100644 frontend/admin-web/src/app/(dashboard)/withdrawals/page.tsx create mode 100644 frontend/admin-web/src/app/(dashboard)/withdrawals/withdrawals.module.scss create mode 100644 frontend/admin-web/src/hooks/useWithdrawals.ts create mode 100644 frontend/admin-web/src/services/withdrawalService.ts create mode 100644 frontend/admin-web/src/types/withdrawal.types.ts create mode 100644 frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_fiat_confirm_page.dart create mode 100644 frontend/mobile-app/lib/features/withdraw/presentation/pages/withdraw_fiat_page.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7d1b1995..74ccfe92 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -574,7 +574,9 @@ "Bash(head:*)", "Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(pending-actions\\): add user pending actions system\n\nAdd a fully optional pending actions system that allows admins to configure\nspecific tasks that users must complete after login.\n\nBackend \\(identity-service\\):\n- Add UserPendingAction model to Prisma schema\n- Add migration for user_pending_actions table\n- Add PendingActionService with full CRUD operations\n- Add user-facing API \\(GET list, POST complete\\)\n- Add admin API \\(CRUD, batch create\\)\n\nAdmin Web:\n- Add pending actions management page\n- Support single/batch create, edit, cancel, delete\n- View action details including completion time\n- Filter by userId, actionCode, status\n\nFlutter Mobile App:\n- Add PendingActionService and PendingActionCheckService\n- Add PendingActionsPage for forced task execution\n- Integrate into splash_page login flow\n- Users must complete all pending tasks in priority order\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", "Bash(npm run type-check:*)", - "Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(settlement\\): implement settle-to-balance with detailed source tracking\n\nAdd complete settlement-to-balance feature that transfers settleable\nearnings directly to wallet USDT balance \\(no currency swap\\). Key changes:\n\nBackend \\(wallet-service\\):\n- Add SettleToBalanceCommand for settlement operations\n- Add settleToBalance method to WalletAccountAggregate\n- Add settleToBalance application service with ledger recording\n- Add internal API endpoint POST /api/v1/wallets/settle-to-balance\n\nBackend \\(reward-service\\):\n- Add settleToBalance client method for wallet-service communication\n- Add settleRewardsToBalance application service method\n- Add user-facing API endpoint POST /rewards/settle-to-balance\n- Build detailed settlement memo with source user tracking per reward\n\nFrontend \\(mobile-app\\):\n- Add SettleToBalanceResult model class\n- Add settleToBalance\\(\\) method to RewardService\n- Update pending_actions_page to handle SETTLE_REWARDS action\n- Add completion detection via settleableUsdt balance check\n\nSettlement memo now includes detailed breakdown by right type with\nsource user accountSequence for each reward entry, e.g.:\n 结算 1000.00 绿积分到钱包余额\n 涉及 5 笔奖励\n - SHARE_RIGHT: 500.00 绿积分\n 来自 D2512120001: 288.00 绿积分\n 来自 D2512120002: 212.00 绿积分\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")" + "Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(settlement\\): implement settle-to-balance with detailed source tracking\n\nAdd complete settlement-to-balance feature that transfers settleable\nearnings directly to wallet USDT balance \\(no currency swap\\). Key changes:\n\nBackend \\(wallet-service\\):\n- Add SettleToBalanceCommand for settlement operations\n- Add settleToBalance method to WalletAccountAggregate\n- Add settleToBalance application service with ledger recording\n- Add internal API endpoint POST /api/v1/wallets/settle-to-balance\n\nBackend \\(reward-service\\):\n- Add settleToBalance client method for wallet-service communication\n- Add settleRewardsToBalance application service method\n- Add user-facing API endpoint POST /rewards/settle-to-balance\n- Build detailed settlement memo with source user tracking per reward\n\nFrontend \\(mobile-app\\):\n- Add SettleToBalanceResult model class\n- Add settleToBalance\\(\\) method to RewardService\n- Update pending_actions_page to handle SETTLE_REWARDS action\n- Add completion detection via settleableUsdt balance check\n\nSettlement memo now includes detailed breakdown by right type with\nsource user accountSequence for each reward entry, e.g.:\n 结算 1000.00 绿积分到钱包余额\n 涉及 5 笔奖励\n - SHARE_RIGHT: 500.00 绿积分\n 来自 D2512120001: 288.00 绿积分\n 来自 D2512120002: 212.00 绿积分\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", + "Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(withdrawal\\): implement fiat withdrawal with bank/alipay/wechat\n\nAdd complete fiat withdrawal feature that allows users to withdraw\ngreen credits \\(绿积分\\) to their bank card, Alipay, or WeChat account\nwith 1:1 CNY conversion. Key changes:\n\nBackend \\(wallet-service\\):\n- Update Prisma schema with fiat withdrawal fields \\(paymentMethod,\n bankName, bankCardNo, cardHolderName, alipay*, wechat*, review fields\\)\n- Rewrite withdrawal status enum for fiat flow: PENDING → FROZEN →\n REVIEWING → APPROVED → PAYING → COMPLETED \\(or REJECTED/FAILED\\)\n- Add PaymentMethod enum: BANK_CARD, ALIPAY, WECHAT\n- Update WithdrawalOrderAggregate with new fiat withdrawal methods\n- Add review/payment workflow methods in WalletApplicationService\n- Add internal API endpoints for admin withdrawal management\n- Remove blockchain withdrawal event handler \\(no longer needed\\)\n\nFrontend \\(admin-web\\):\n- Add withdrawal review management page at /withdrawals\n- Add tabs for reviewing/approved/paying order states\n- Add withdrawal service and React Query hooks\n- Add types for withdrawal orders and payment methods\n- Add sidebar menu item for withdrawal review\n\nFrontend \\(mobile-app\\):\n- Add withdrawFiat\\(\\) method to WalletService\n- Add PaymentMethod enum with BANK_CARD/ALIPAY/WECHAT\n- Create new WithdrawFiatPage for fiat withdrawal input\n- Create WithdrawFiatConfirmPage with SMS + password verification\n- Add routes for /withdraw/fiat and /withdraw/fiat/confirm\n- Keep existing withdraw/usdt \\(划转\\) pages unchanged\n\nNote: The existing withdraw_usdt_page.dart is for point-to-point\ntransfer \\(划转\\), which is a different feature from fiat withdrawal.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", + "Bash(git grep:*)" ], "deny": [], "ask": [] diff --git a/backend/services/wallet-service/prisma/migrations/20260103100000_add_fiat_withdrawal/migration.sql b/backend/services/wallet-service/prisma/migrations/20260103100000_add_fiat_withdrawal/migration.sql new file mode 100644 index 00000000..d5381f15 --- /dev/null +++ b/backend/services/wallet-service/prisma/migrations/20260103100000_add_fiat_withdrawal/migration.sql @@ -0,0 +1,53 @@ +-- CreateTable +CREATE TABLE "fiat_withdrawal_orders" ( + "order_id" BIGSERIAL NOT NULL, + "order_no" VARCHAR(50) NOT NULL, + "account_sequence" VARCHAR(20) NOT NULL, + "user_id" BIGINT NOT NULL, + "amount" DECIMAL(20,8) NOT NULL, + "fee" DECIMAL(20,8) NOT NULL, + "payment_method" VARCHAR(20) NOT NULL, + "bank_name" VARCHAR(100), + "bank_card_no" VARCHAR(50), + "card_holder_name" VARCHAR(100), + "alipay_account" VARCHAR(100), + "alipay_real_name" VARCHAR(100), + "wechat_account" VARCHAR(100), + "wechat_real_name" VARCHAR(100), + "status" VARCHAR(20) NOT NULL DEFAULT 'PENDING', + "error_message" VARCHAR(500), + "reviewed_by" VARCHAR(100), + "reviewed_at" TIMESTAMP(3), + "review_remark" VARCHAR(500), + "paid_by" VARCHAR(100), + "paid_at" TIMESTAMP(3), + "payment_proof" VARCHAR(500), + "payment_remark" VARCHAR(500), + "detail_memo" TEXT, + "frozen_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "fiat_withdrawal_orders_pkey" PRIMARY KEY ("order_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "fiat_withdrawal_orders_order_no_key" ON "fiat_withdrawal_orders"("order_no"); + +-- CreateIndex +CREATE INDEX "fiat_withdrawal_orders_account_sequence_idx" ON "fiat_withdrawal_orders"("account_sequence"); + +-- CreateIndex +CREATE INDEX "fiat_withdrawal_orders_user_id_idx" ON "fiat_withdrawal_orders"("user_id"); + +-- CreateIndex +CREATE INDEX "fiat_withdrawal_orders_status_idx" ON "fiat_withdrawal_orders"("status"); + +-- CreateIndex +CREATE INDEX "fiat_withdrawal_orders_payment_method_idx" ON "fiat_withdrawal_orders"("payment_method"); + +-- CreateIndex +CREATE INDEX "fiat_withdrawal_orders_reviewed_by_idx" ON "fiat_withdrawal_orders"("reviewed_by"); + +-- CreateIndex +CREATE INDEX "fiat_withdrawal_orders_created_at_idx" ON "fiat_withdrawal_orders"("created_at"); diff --git a/backend/services/wallet-service/prisma/schema.prisma b/backend/services/wallet-service/prisma/schema.prisma index 02ebf63e..1164b38a 100644 --- a/backend/services/wallet-service/prisma/schema.prisma +++ b/backend/services/wallet-service/prisma/schema.prisma @@ -248,6 +248,70 @@ model PendingReward { @@index([createdAt]) } +// ============================================ +// 法币提现订单表 +// 用于法币提现 (银行卡/支付宝/微信) +// 与 WithdrawalOrder (区块链划转) 分开管理 +// ============================================ +model FiatWithdrawalOrder { + id BigInt @id @default(autoincrement()) @map("order_id") + orderNo String @unique @map("order_no") @db.VarChar(50) + accountSequence String @map("account_sequence") @db.VarChar(20) + userId BigInt @map("user_id") + + // 提现金额信息 + amount Decimal @map("amount") @db.Decimal(20, 8) // 提现金额 (绿积分, 1:1 人民币) + fee Decimal @map("fee") @db.Decimal(20, 8) // 手续费 + + // 收款方式: 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(50) + cardHolderName String? @map("card_holder_name") @db.VarChar(100) + + // 支付宝信息 + alipayAccount String? @map("alipay_account") @db.VarChar(100) + alipayRealName String? @map("alipay_real_name") @db.VarChar(100) + + // 微信信息 + wechatAccount String? @map("wechat_account") @db.VarChar(100) + wechatRealName String? @map("wechat_real_name") @db.VarChar(100) + + // 状态: PENDING -> FROZEN -> REVIEWING -> APPROVED -> PAYING -> COMPLETED + // 或 REJECTED / FAILED / CANCELLED + status String @default("PENDING") @map("status") @db.VarChar(20) + errorMessage String? @map("error_message") @db.VarChar(500) + + // 审核信息 + reviewedBy String? @map("reviewed_by") @db.VarChar(100) + reviewedAt DateTime? @map("reviewed_at") + reviewRemark String? @map("review_remark") @db.VarChar(500) + + // 打款信息 + paidBy String? @map("paid_by") @db.VarChar(100) + paidAt DateTime? @map("paid_at") + paymentProof String? @map("payment_proof") @db.VarChar(500) + paymentRemark String? @map("payment_remark") @db.VarChar(500) + + // 详细备注 (操作记录) + detailMemo String? @map("detail_memo") @db.Text + + // 时间戳 + frozenAt DateTime? @map("frozen_at") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + + @@map("fiat_withdrawal_orders") + @@index([accountSequence]) + @@index([userId]) + @@index([status]) + @@index([paymentMethod]) + @@index([reviewedBy]) + @@index([createdAt]) +} + // ============================================ // 提取手续费配置表 // ============================================ diff --git a/backend/services/wallet-service/src/api/api.module.ts b/backend/services/wallet-service/src/api/api.module.ts index 3e5b5809..3508b047 100644 --- a/backend/services/wallet-service/src/api/api.module.ts +++ b/backend/services/wallet-service/src/api/api.module.ts @@ -9,7 +9,8 @@ import { HealthController, } from './controllers'; import { InternalWalletController } from './controllers/internal-wallet.controller'; -import { WalletApplicationService } from '@/application/services'; +import { FiatWithdrawalController } from './controllers/fiat-withdrawal.controller'; +import { WalletApplicationService, FiatWithdrawalApplicationService } from '@/application/services'; import { DepositConfirmedHandler, PlantingCreatedHandler } from '@/application/event-handlers'; import { WithdrawalStatusHandler } from '@/application/event-handlers/withdrawal-status.handler'; import { ExpiredRewardsScheduler } from '@/application/schedulers'; @@ -32,9 +33,11 @@ import { JwtStrategy } from '@/shared/strategies/jwt.strategy'; DepositController, HealthController, InternalWalletController, + FiatWithdrawalController, ], providers: [ WalletApplicationService, + FiatWithdrawalApplicationService, DepositConfirmedHandler, PlantingCreatedHandler, WithdrawalStatusHandler, diff --git a/backend/services/wallet-service/src/api/controllers/fiat-withdrawal.controller.ts b/backend/services/wallet-service/src/api/controllers/fiat-withdrawal.controller.ts new file mode 100644 index 00000000..85e9e4d2 --- /dev/null +++ b/backend/services/wallet-service/src/api/controllers/fiat-withdrawal.controller.ts @@ -0,0 +1,133 @@ +import { Controller, Get, Post, Body, Param, UseGuards, Headers, HttpException, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { FiatWithdrawalApplicationService, RequestFiatWithdrawalCommand } from '@/application/services'; +import { CurrentUser, CurrentUserPayload } from '@/shared/decorators'; +import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; +import { + RequestFiatWithdrawalDTO, + CancelFiatWithdrawalDTO, +} from '@/api/dto/request'; +import { FiatWithdrawalResponseDTO, FiatWithdrawalListItemDTO } from '@/api/dto/response'; +import { IdentityClientService } from '@/infrastructure/external/identity/identity-client.service'; +import { PaymentMethod } from '@/domain/value-objects/fiat-withdrawal-status.enum'; + +@ApiTags('Fiat Withdrawal (法币提现)') +@Controller('wallet/fiat-withdrawal') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class FiatWithdrawalController { + constructor( + private readonly fiatWithdrawalService: FiatWithdrawalApplicationService, + private readonly identityClient: IdentityClientService, + ) {} + + @Post('send-sms') + @ApiOperation({ summary: '发送法币提现验证短信', description: '向用户手机发送法币提现验证码' }) + @ApiResponse({ status: 200, description: '发送成功' }) + async sendFiatWithdrawSms( + @CurrentUser() user: CurrentUserPayload, + @Headers('authorization') authHeader: string, + ): Promise<{ message: string }> { + const token = authHeader?.replace('Bearer ', '') || ''; + + // 调用 identity-service 发送短信验证码 + await this.identityClient.sendWithdrawSmsCode(user.userId, token); + return { message: '验证码已发送' }; + } + + @Post() + @ApiOperation({ summary: '申请法币提现', description: '将绿积分提现到银行卡/支付宝/微信,需要短信验证和密码验证' }) + @ApiResponse({ status: 201, type: FiatWithdrawalResponseDTO }) + async requestFiatWithdrawal( + @CurrentUser() user: CurrentUserPayload, + @Body() dto: RequestFiatWithdrawalDTO, + @Headers('authorization') authHeader: string, + ): Promise { + const token = authHeader?.replace('Bearer ', '') || ''; + + // 验证短信验证码 + if (!dto.smsCode) { + throw new HttpException('请输入短信验证码', HttpStatus.BAD_REQUEST); + } + + const isSmsValid = await this.identityClient.verifyWithdrawSmsCode(user.userId, dto.smsCode, token); + if (!isSmsValid) { + throw new HttpException('短信验证码错误,请重试', HttpStatus.BAD_REQUEST); + } + + // 验证登录密码 + if (!dto.password) { + throw new HttpException('请输入登录密码', HttpStatus.BAD_REQUEST); + } + + const isPasswordValid = await this.identityClient.verifyPassword(user.userId, dto.password, token); + if (!isPasswordValid) { + throw new HttpException('登录密码错误,请重试', HttpStatus.BAD_REQUEST); + } + + // 验证收款账户信息完整性 + this.validatePaymentAccount(dto); + + const command: RequestFiatWithdrawalCommand = { + accountSequence: user.accountSequence, + userId: user.userId, + amount: dto.amount, + paymentMethod: dto.paymentMethod, + bankName: dto.bankName, + bankCardNo: dto.bankCardNo, + cardHolderName: dto.cardHolderName, + alipayAccount: dto.alipayAccount, + alipayRealName: dto.alipayRealName, + wechatAccount: dto.wechatAccount, + wechatRealName: dto.wechatRealName, + }; + + return this.fiatWithdrawalService.requestFiatWithdrawal(command); + } + + @Post(':orderNo/cancel') + @ApiOperation({ summary: '取消法币提现', description: '取消未审核的法币提现订单' }) + @ApiResponse({ status: 200, type: FiatWithdrawalListItemDTO }) + async cancelFiatWithdrawal( + @CurrentUser() user: CurrentUserPayload, + @Param('orderNo') orderNo: string, + @Body() dto: CancelFiatWithdrawalDTO, + ): Promise { + // TODO: 验证订单属于当前用户 + return this.fiatWithdrawalService.cancelFiatWithdrawal(orderNo, dto.reason); + } + + @Get() + @ApiOperation({ summary: '查询法币提现记录', description: '获取用户的法币提现订单列表' }) + @ApiResponse({ status: 200, type: [FiatWithdrawalListItemDTO] }) + async getFiatWithdrawals( + @CurrentUser() user: CurrentUserPayload, + ): Promise { + return this.fiatWithdrawalService.getFiatWithdrawals(user.userId); + } + + /** + * 验证收款账户信息完整性 + */ + private validatePaymentAccount(dto: RequestFiatWithdrawalDTO): 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); + } + } +} 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..57e8a9fa 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 @@ -1,6 +1,6 @@ -import { Controller, Get, Post, Body, Param, Logger } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; -import { WalletApplicationService } from '@/application/services'; +import { Controller, Get, Post, Body, Param, Query, Logger } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { WalletApplicationService, FiatWithdrawalApplicationService } from '@/application/services'; import { GetMyWalletQuery } from '@/application/queries'; import { DeductForPlantingCommand, @@ -11,6 +11,7 @@ import { UnfreezeForPlantingCommand, } from '@/application/commands'; import { Public } from '@/shared/decorators'; +import { FiatWithdrawalStatus } from '@/domain/value-objects/fiat-withdrawal-status.enum'; /** * 内部API控制器 - 供其他微服务调用 @@ -21,7 +22,10 @@ import { Public } from '@/shared/decorators'; export class InternalWalletController { private readonly logger = new Logger(InternalWalletController.name); - constructor(private readonly walletService: WalletApplicationService) {} + constructor( + private readonly walletService: WalletApplicationService, + private readonly fiatWithdrawalService: FiatWithdrawalApplicationService, + ) {} @Get(':userId/balance') @Public() @@ -194,4 +198,79 @@ export class InternalWalletController { this.logger.log(`结算结果: ${JSON.stringify(result)}`); return result; } + + // =============== 法币提现管理 API =============== + + @Get('fiat-withdrawals/pending-review') + @Public() + @ApiOperation({ summary: '获取待审核的法币提现订单(内部API)' }) + @ApiResponse({ status: 200, description: '待审核订单列表' }) + async getFiatWithdrawalsPendingReview() { + return this.fiatWithdrawalService.getPendingReviewOrders(); + } + + @Get('fiat-withdrawals/pending-payment') + @Public() + @ApiOperation({ summary: '获取待打款的法币提现订单(内部API)' }) + @ApiResponse({ status: 200, description: '待打款订单列表' }) + async getFiatWithdrawalsPendingPayment() { + return this.fiatWithdrawalService.getPendingPaymentOrders(); + } + + @Get('fiat-withdrawals/by-status') + @Public() + @ApiOperation({ summary: '根据状态获取法币提现订单(内部API)' }) + @ApiQuery({ name: 'status', enum: FiatWithdrawalStatus }) + @ApiResponse({ status: 200, description: '订单列表' }) + async getFiatWithdrawalsByStatus(@Query('status') status: FiatWithdrawalStatus) { + return this.fiatWithdrawalService.getOrdersByStatus(status); + } + + @Post('fiat-withdrawals/:orderNo/review') + @Public() + @ApiOperation({ summary: '审核法币提现订单(内部API)' }) + @ApiParam({ name: 'orderNo', description: '订单号' }) + @ApiResponse({ status: 200, description: '审核结果' }) + async reviewFiatWithdrawal( + @Param('orderNo') orderNo: string, + @Body() dto: { approved: boolean; reviewedBy: string; remark?: string }, + ) { + return this.fiatWithdrawalService.reviewFiatWithdrawal({ + orderNo, + approved: dto.approved, + reviewedBy: dto.reviewedBy, + remark: dto.remark, + }); + } + + @Post('fiat-withdrawals/:orderNo/start-payment') + @Public() + @ApiOperation({ summary: '开始打款(内部API)' }) + @ApiParam({ name: 'orderNo', description: '订单号' }) + @ApiResponse({ status: 200, description: '操作结果' }) + async startFiatPayment( + @Param('orderNo') orderNo: string, + @Body() dto: { paidBy: string }, + ) { + return this.fiatWithdrawalService.startPayment({ + orderNo, + paidBy: dto.paidBy, + }); + } + + @Post('fiat-withdrawals/:orderNo/complete-payment') + @Public() + @ApiOperation({ summary: '完成打款(内部API)' }) + @ApiParam({ name: 'orderNo', description: '订单号' }) + @ApiResponse({ status: 200, description: '操作结果' }) + async completeFiatPayment( + @Param('orderNo') orderNo: string, + @Body() dto: { paymentProof?: string; remark?: string }, + ) { + return this.fiatWithdrawalService.completePayment({ + orderNo, + paymentProof: dto.paymentProof, + remark: dto.remark, + }); + } } diff --git a/backend/services/wallet-service/src/api/dto/request/fiat-withdrawal.dto.ts b/backend/services/wallet-service/src/api/dto/request/fiat-withdrawal.dto.ts new file mode 100644 index 00000000..b23bd1a2 --- /dev/null +++ b/backend/services/wallet-service/src/api/dto/request/fiat-withdrawal.dto.ts @@ -0,0 +1,109 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsString, IsOptional, IsEnum, Min, ValidateIf } from 'class-validator'; +import { PaymentMethod } from '@/domain/value-objects/fiat-withdrawal-status.enum'; + +/** + * 法币提现请求 DTO + */ +export class RequestFiatWithdrawalDTO { + @ApiProperty({ description: '提现金额 (绿积分)', example: 100 }) + @IsNumber() + @Min(100, { message: '最小提现金额为 100 绿积分' }) + amount: number; + + @ApiProperty({ description: '收款方式', enum: PaymentMethod, example: 'BANK_CARD' }) + @IsEnum(PaymentMethod) + paymentMethod: PaymentMethod; + + // 银行卡信息 + @ApiPropertyOptional({ description: '银行名称', example: '中国工商银行' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD) + @IsString() + bankName?: string; + + @ApiPropertyOptional({ description: '银行卡号', example: '6222021234567890123' }) + @ValidateIf(o => o.paymentMethod === PaymentMethod.BANK_CARD) + @IsString() + 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' }) + @IsString() + smsCode: string; + + @ApiProperty({ description: '登录密码', example: 'password123' }) + @IsString() + password: string; +} + +/** + * 审核法币提现 DTO + */ +export class ReviewFiatWithdrawalDTO { + @ApiProperty({ description: '是否通过', example: true }) + approved: boolean; + + @ApiPropertyOptional({ description: '审核备注', example: '信息核实无误' }) + @IsOptional() + @IsString() + remark?: string; +} + +/** + * 开始打款 DTO + */ +export class StartPaymentDTO { + // 无需额外参数,操作人从 JWT 中获取 +} + +/** + * 完成打款 DTO + */ +export class CompletePaymentDTO { + @ApiPropertyOptional({ description: '打款凭证', example: 'https://example.com/proof.jpg' }) + @IsOptional() + @IsString() + paymentProof?: string; + + @ApiPropertyOptional({ description: '打款备注', example: '已打款至工商银行' }) + @IsOptional() + @IsString() + remark?: string; +} + +/** + * 取消法币提现 DTO + */ +export class CancelFiatWithdrawalDTO { + @ApiPropertyOptional({ description: '取消原因', example: '用户主动取消' }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/backend/services/wallet-service/src/api/dto/request/index.ts b/backend/services/wallet-service/src/api/dto/request/index.ts index 97954713..a5fa86d4 100644 --- a/backend/services/wallet-service/src/api/dto/request/index.ts +++ b/backend/services/wallet-service/src/api/dto/request/index.ts @@ -2,3 +2,4 @@ export * from './deposit.dto'; export * from './ledger-query.dto'; export * from './settlement.dto'; export * from './withdrawal.dto'; +export * from './fiat-withdrawal.dto'; diff --git a/backend/services/wallet-service/src/api/dto/response/fiat-withdrawal.dto.ts b/backend/services/wallet-service/src/api/dto/response/fiat-withdrawal.dto.ts new file mode 100644 index 00000000..7c43e7cc --- /dev/null +++ b/backend/services/wallet-service/src/api/dto/response/fiat-withdrawal.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * 法币提现响应 DTO + */ +export class FiatWithdrawalResponseDTO { + @ApiProperty({ description: '订单号', example: 'FW1704067200ABC123' }) + orderNo: string; + + @ApiProperty({ description: '提现金额', example: 100 }) + amount: number; + + @ApiProperty({ description: '手续费', example: 1 }) + fee: number; + + @ApiProperty({ description: '实际到账金额', example: 99 }) + netAmount: number; + + @ApiProperty({ description: '状态', example: 'REVIEWING' }) + status: string; + + @ApiProperty({ description: '收款方式', example: 'BANK_CARD' }) + paymentMethod: string; +} + +/** + * 法币提现订单列表项 DTO + */ +export class FiatWithdrawalListItemDTO { + @ApiProperty({ description: '订单号', example: 'FW1704067200ABC123' }) + orderNo: string; + + @ApiProperty({ description: '账户序列号', example: 'D2501010001' }) + accountSequence: string; + + @ApiProperty({ description: '用户ID', example: '12345' }) + userId: string; + + @ApiProperty({ description: '提现金额', example: 100 }) + amount: number; + + @ApiProperty({ description: '手续费', example: 1 }) + fee: number; + + @ApiProperty({ description: '实际到账金额', example: 99 }) + netAmount: number; + + @ApiProperty({ description: '收款方式', example: 'BANK_CARD' }) + paymentMethod: string; + + @ApiProperty({ description: '收款方式名称', example: '银行卡' }) + paymentMethodName: string; + + @ApiProperty({ description: '收款账户显示信息', example: '中国工商银行 ****1234 (张三)' }) + paymentAccountDisplay: string; + + @ApiProperty({ description: '状态', example: 'REVIEWING' }) + status: string; + + @ApiPropertyOptional({ description: '审核人', example: 'admin' }) + reviewedBy: string | null; + + @ApiPropertyOptional({ description: '审核时间', example: '2024-01-01T00:00:00.000Z' }) + reviewedAt: string | null; + + @ApiPropertyOptional({ description: '审核备注', example: '信息核实无误' }) + reviewRemark: string | null; + + @ApiPropertyOptional({ description: '打款人', example: 'finance' }) + paidBy: string | null; + + @ApiPropertyOptional({ description: '打款时间', example: '2024-01-01T00:00:00.000Z' }) + paidAt: string | null; + + @ApiPropertyOptional({ description: '冻结时间', example: '2024-01-01T00:00:00.000Z' }) + frozenAt: string | null; + + @ApiPropertyOptional({ description: '完成时间', example: '2024-01-01T00:00:00.000Z' }) + completedAt: string | null; + + @ApiProperty({ description: '创建时间', example: '2024-01-01T00:00:00.000Z' }) + createdAt: string; +} diff --git a/backend/services/wallet-service/src/api/dto/response/index.ts b/backend/services/wallet-service/src/api/dto/response/index.ts index 62ef5789..22fc41b9 100644 --- a/backend/services/wallet-service/src/api/dto/response/index.ts +++ b/backend/services/wallet-service/src/api/dto/response/index.ts @@ -1,4 +1,5 @@ export * from './wallet.dto'; export * from './ledger.dto'; export * from './withdrawal.dto'; +export * from './fiat-withdrawal.dto'; export * from './fee-config.dto'; diff --git a/backend/services/wallet-service/src/application/services/fiat-withdrawal-application.service.ts b/backend/services/wallet-service/src/application/services/fiat-withdrawal-application.service.ts new file mode 100644 index 00000000..f599b1cf --- /dev/null +++ b/backend/services/wallet-service/src/application/services/fiat-withdrawal-application.service.ts @@ -0,0 +1,493 @@ +import { Injectable, Inject, Logger, BadRequestException } from '@nestjs/common'; +import { + IWalletAccountRepository, WALLET_ACCOUNT_REPOSITORY, + ILedgerEntryRepository, LEDGER_ENTRY_REPOSITORY, + IFiatWithdrawalOrderRepository, FIAT_WITHDRAWAL_ORDER_REPOSITORY, +} from '@/domain/repositories'; +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { FiatWithdrawalOrder, LedgerEntry, PaymentAccountInfo } from '@/domain/aggregates'; +import { UserId, Money, LedgerEntryType } from '@/domain/value-objects'; +import { FiatWithdrawalStatus, PaymentMethod } from '@/domain/value-objects/fiat-withdrawal-status.enum'; +import { WalletNotFoundError, OptimisticLockError } from '@/shared/exceptions/domain.exception'; +import { WalletCacheService } from '@/infrastructure/redis'; +import { FeeConfigRepositoryImpl } from '@/infrastructure/persistence/repositories'; +import { FeeType } from '@/api/dto/response'; +import Decimal from 'decimal.js'; + +/** + * 法币提现请求命令 + */ +export interface RequestFiatWithdrawalCommand { + accountSequence: string; + userId: string; + amount: number; + paymentMethod: PaymentMethod; + // 银行卡 + bankName?: string; + bankCardNo?: string; + cardHolderName?: string; + // 支付宝 + alipayAccount?: string; + alipayRealName?: string; + // 微信 + wechatAccount?: string; + wechatRealName?: string; +} + +/** + * 审核命令 + */ +export interface ReviewFiatWithdrawalCommand { + orderNo: string; + approved: boolean; + reviewedBy: string; + remark?: string; +} + +/** + * 打款命令 + */ +export interface StartPaymentCommand { + orderNo: string; + paidBy: string; +} + +export interface CompletePaymentCommand { + orderNo: string; + paymentProof?: string; + remark?: string; +} + +/** + * 法币提现响应 DTO + */ +export interface FiatWithdrawalResponseDTO { + orderNo: string; + amount: number; + fee: number; + netAmount: number; + status: string; + paymentMethod: string; +} + +/** + * 法币提现订单列表项 DTO + */ +export interface FiatWithdrawalListItemDTO { + orderNo: string; + accountSequence: string; + userId: string; + amount: number; + fee: number; + netAmount: number; + paymentMethod: string; + paymentMethodName: string; + paymentAccountDisplay: string; + status: string; + reviewedBy: string | null; + reviewedAt: string | null; + reviewRemark: string | null; + paidBy: string | null; + paidAt: string | null; + frozenAt: string | null; + completedAt: string | null; + createdAt: string; +} + +/** + * 法币提现应用服务 + * + * 处理法币提现相关的业务逻辑: + * 1. 用户申请提现 + * 2. 管理员审核 + * 3. 管理员打款 + */ +@Injectable() +export class FiatWithdrawalApplicationService { + private readonly logger = new Logger(FiatWithdrawalApplicationService.name); + private readonly MIN_WITHDRAWAL_AMOUNT = 100; // 最小提现金额 + + constructor( + @Inject(WALLET_ACCOUNT_REPOSITORY) + private readonly walletRepo: IWalletAccountRepository, + @Inject(LEDGER_ENTRY_REPOSITORY) + private readonly ledgerRepo: ILedgerEntryRepository, + @Inject(FIAT_WITHDRAWAL_ORDER_REPOSITORY) + private readonly fiatWithdrawalRepo: IFiatWithdrawalOrderRepository, + private readonly walletCacheService: WalletCacheService, + private readonly prisma: PrismaService, + private readonly feeConfigRepo: FeeConfigRepositoryImpl, + ) {} + + /** + * 申请法币提现 + */ + async requestFiatWithdrawal(command: RequestFiatWithdrawalCommand): Promise { + const MAX_RETRIES = 3; + let retries = 0; + + while (retries < MAX_RETRIES) { + try { + return await this.executeRequestFiatWithdrawal(command); + } catch (error) { + if (this.isOptimisticLockError(error)) { + retries++; + this.logger.warn(`[requestFiatWithdrawal] Optimistic lock conflict for ${command.accountSequence}, retry ${retries}/${MAX_RETRIES}`); + if (retries >= MAX_RETRIES) { + this.logger.error(`[requestFiatWithdrawal] Max retries exceeded for ${command.accountSequence}`); + throw error; + } + await this.sleep(50 * retries); + } else { + throw error; + } + } + } + + throw new Error('Unexpected: exited retry loop without result'); + } + + /** + * 执行法币提现请求 + */ + private async executeRequestFiatWithdrawal(command: RequestFiatWithdrawalCommand): Promise { + const userId = BigInt(command.userId); + const amount = Money.USDT(command.amount); + + // 从配置获取动态手续费 + const { fee: feeAmount, feeType, feeValue } = await this.feeConfigRepo.calculateFee(command.amount); + const fee = Money.USDT(feeAmount); + const totalRequired = amount; // 法币提现:手续费从提现金额中扣除 + + const feeDescription = feeType === FeeType.FIXED + ? `固定 ${feeValue} 绿积分` + : `${(feeValue * 100).toFixed(2)}%`; + + this.logger.log(`Processing fiat withdrawal request for user ${userId}: ${command.amount} 绿积分, fee: ${feeAmount} (${feeDescription})`); + + // 验证最小提现金额 + if (command.amount < this.MIN_WITHDRAWAL_AMOUNT) { + throw new BadRequestException(`最小提现金额为 ${this.MIN_WITHDRAWAL_AMOUNT} 绿积分`); + } + + // 优先按 accountSequence 查找,如果未找到则按 userId 查找 + let wallet = await this.walletRepo.findByAccountSequence(command.accountSequence); + if (!wallet) { + wallet = await this.walletRepo.findByUserId(userId); + } + if (!wallet) { + throw new WalletNotFoundError(`accountSequence: ${command.accountSequence}`); + } + + // 验证余额是否足够 + if (wallet.balances.usdt.available.lessThan(totalRequired)) { + throw new BadRequestException( + `余额不足: 需要 ${totalRequired.value} 绿积分, 当前可用 ${wallet.balances.usdt.available.value} 绿积分`, + ); + } + + // 构建收款账户信息 + const paymentAccount: PaymentAccountInfo = { + paymentMethod: command.paymentMethod, + bankName: command.bankName, + bankCardNo: command.bankCardNo, + cardHolderName: command.cardHolderName, + alipayAccount: command.alipayAccount, + alipayRealName: command.alipayRealName, + wechatAccount: command.wechatAccount, + wechatRealName: command.wechatRealName, + }; + + // 创建法币提现订单 + const fiatWithdrawalOrder = FiatWithdrawalOrder.create({ + accountSequence: wallet.accountSequence, + userId: UserId.create(userId), + amount, + fee, + paymentAccount, + }); + + // 冻结用户余额 + wallet.freeze(totalRequired); + await this.walletRepo.save(wallet); + + // 标记订单已冻结并提交审核 + fiatWithdrawalOrder.markAsFrozen(); + fiatWithdrawalOrder.submitForReview(); + const savedOrder = await this.fiatWithdrawalRepo.save(fiatWithdrawalOrder); + + // 记录流水 - 冻结 + const freezeEntry = LedgerEntry.create({ + accountSequence: wallet.accountSequence, + userId: UserId.create(userId), + entryType: LedgerEntryType.FREEZE, + amount: Money.signed(-totalRequired.value, 'USDT'), + balanceAfter: wallet.balances.usdt.available, + refOrderId: savedOrder.orderNo, + memo: `法币提现冻结: ${command.amount} 绿积分 (含手续费 ${feeAmount.toFixed(2)} 绿积分)`, + }); + await this.ledgerRepo.save(freezeEntry); + + // 清除钱包缓存 + await this.walletCacheService.invalidateWallet(userId); + + this.logger.log(`Fiat withdrawal order created: ${savedOrder.orderNo}`); + + return { + orderNo: savedOrder.orderNo, + amount: savedOrder.amount.value, + fee: savedOrder.fee.value, + netAmount: savedOrder.netAmount.value, + status: savedOrder.status, + paymentMethod: savedOrder.paymentMethod, + }; + } + + /** + * 审核法币提现 + */ + async reviewFiatWithdrawal(command: ReviewFiatWithdrawalCommand): Promise { + const order = await this.fiatWithdrawalRepo.findByOrderNo(command.orderNo); + if (!order) { + throw new BadRequestException(`提现订单不存在: ${command.orderNo}`); + } + + if (command.approved) { + order.approve(command.reviewedBy, command.remark); + } else { + if (!command.remark) { + throw new BadRequestException('驳回时必须填写原因'); + } + order.reject(command.reviewedBy, command.remark); + + // 驳回时解冻资金 + if (order.needsUnfreeze()) { + await this.unfreezeForOrder(order); + } + } + + const savedOrder = await this.fiatWithdrawalRepo.save(order); + return this.toListItemDTO(savedOrder); + } + + /** + * 开始打款 + */ + async startPayment(command: StartPaymentCommand): Promise { + const order = await this.fiatWithdrawalRepo.findByOrderNo(command.orderNo); + if (!order) { + throw new BadRequestException(`提现订单不存在: ${command.orderNo}`); + } + + order.startPayment(command.paidBy); + const savedOrder = await this.fiatWithdrawalRepo.save(order); + return this.toListItemDTO(savedOrder); + } + + /** + * 完成打款 + */ + async completePayment(command: CompletePaymentCommand): Promise { + const order = await this.fiatWithdrawalRepo.findByOrderNo(command.orderNo); + if (!order) { + throw new BadRequestException(`提现订单不存在: ${command.orderNo}`); + } + + order.completePayment(command.paymentProof, command.remark); + const savedOrder = await this.fiatWithdrawalRepo.save(order); + + // 扣除冻结余额并记录流水 + await this.deductFrozenForOrder(savedOrder); + + return this.toListItemDTO(savedOrder); + } + + /** + * 取消法币提现 + */ + async cancelFiatWithdrawal(orderNo: string, reason?: string): Promise { + const order = await this.fiatWithdrawalRepo.findByOrderNo(orderNo); + if (!order) { + throw new BadRequestException(`提现订单不存在: ${orderNo}`); + } + + order.cancel(reason); + + // 取消时解冻资金 + if (order.needsUnfreeze()) { + await this.unfreezeForOrder(order); + } + + const savedOrder = await this.fiatWithdrawalRepo.save(order); + return this.toListItemDTO(savedOrder); + } + + /** + * 获取用户的法币提现订单列表 + */ + async getFiatWithdrawals(userId: string): Promise { + const orders = await this.fiatWithdrawalRepo.findByUserId(BigInt(userId)); + return orders.map(order => this.toListItemDTO(order)); + } + + /** + * 获取待审核的法币提现订单 + */ + async getPendingReviewOrders(): Promise { + const orders = await this.fiatWithdrawalRepo.findPendingReview(); + return orders.map(order => this.toListItemDTO(order)); + } + + /** + * 获取待打款的法币提现订单 + */ + async getPendingPaymentOrders(): Promise { + const orders = await this.fiatWithdrawalRepo.findPendingPayment(); + return orders.map(order => this.toListItemDTO(order)); + } + + /** + * 根据状态获取订单 + */ + async getOrdersByStatus(status: FiatWithdrawalStatus): Promise { + const orders = await this.fiatWithdrawalRepo.findByStatus(status); + return orders.map(order => this.toListItemDTO(order)); + } + + /** + * 解冻订单资金 + */ + private async unfreezeForOrder(order: FiatWithdrawalOrder): Promise { + const wallet = await this.walletRepo.findByAccountSequence(order.accountSequence); + if (!wallet) { + this.logger.error(`Wallet not found for unfreeze: ${order.accountSequence}`); + return; + } + + // 解冻余额 + wallet.unfreeze(order.amount); + await this.walletRepo.save(wallet); + + // 记录流水 + const unfreezeEntry = LedgerEntry.create({ + accountSequence: wallet.accountSequence, + userId: order.userId, + entryType: LedgerEntryType.UNFREEZE, + amount: Money.signed(order.amount.value, 'USDT'), + balanceAfter: wallet.balances.usdt.available, + refOrderId: order.orderNo, + memo: `法币提现解冻: ${order.amount.value} 绿积分 (${order.status === FiatWithdrawalStatus.REJECTED ? '审核驳回' : '用户取消'})`, + }); + await this.ledgerRepo.save(unfreezeEntry); + + // 清除缓存 + await this.walletCacheService.invalidateWallet(order.userId.value); + } + + /** + * 扣除冻结余额(打款完成后) + */ + private async deductFrozenForOrder(order: FiatWithdrawalOrder): Promise { + const wallet = await this.walletRepo.findByAccountSequence(order.accountSequence); + if (!wallet) { + this.logger.error(`Wallet not found for deduct frozen: ${order.accountSequence}`); + return; + } + + // 使用事务处理 + await this.prisma.$transaction(async (tx) => { + const walletRecord = await tx.walletAccount.findUnique({ + where: { accountSequence: order.accountSequence }, + }); + + if (!walletRecord) { + throw new Error(`Wallet not found: ${order.accountSequence}`); + } + + const totalAmount = new Decimal(order.amount.value); + const currentFrozen = new Decimal(walletRecord.usdtFrozen.toString()); + const currentVersion = walletRecord.version; + + if (currentFrozen.lessThan(totalAmount)) { + throw new Error(`Insufficient frozen balance: ${currentFrozen} < ${totalAmount}`); + } + + const newFrozen = currentFrozen.minus(totalAmount); + + // 乐观锁更新 + const updateResult = await tx.walletAccount.updateMany({ + where: { + id: walletRecord.id, + version: currentVersion, + }, + data: { + usdtFrozen: newFrozen, + version: currentVersion + 1, + updatedAt: new Date(), + }, + }); + + if (updateResult.count === 0) { + throw new OptimisticLockError(`Optimistic lock conflict for wallet ${walletRecord.id}`); + } + + // 记录提现流水 + await tx.ledgerEntry.create({ + data: { + accountSequence: order.accountSequence, + userId: order.userId.value, + entryType: LedgerEntryType.FIAT_WITHDRAWAL, + amount: new Decimal(order.netAmount.value).negated(), + assetType: 'USDT', + balanceAfter: walletRecord.usdtAvailable, + refOrderId: order.orderNo, + memo: `法币提现至${order.getPaymentMethodName()}: ${order.netAmount.value.toFixed(2)} 元人民币`, + payloadJson: { + paymentMethod: order.paymentMethod, + paymentAccountDisplay: order.getPaymentAccountDisplay(), + amount: order.amount.value, + fee: order.fee.value, + netAmount: order.netAmount.value, + }, + }, + }); + }); + + // 清除缓存 + await this.walletCacheService.invalidateWallet(order.userId.value); + } + + /** + * 转换为列表项 DTO + */ + private toListItemDTO(order: FiatWithdrawalOrder): FiatWithdrawalListItemDTO { + return { + 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, + paymentMethodName: order.getPaymentMethodName(), + paymentAccountDisplay: order.getPaymentAccountDisplay(), + status: order.status, + reviewedBy: order.reviewedBy, + reviewedAt: order.reviewedAt?.toISOString() ?? null, + reviewRemark: order.reviewRemark, + paidBy: order.paidBy, + paidAt: order.paidAt?.toISOString() ?? null, + frozenAt: order.frozenAt?.toISOString() ?? null, + completedAt: order.completedAt?.toISOString() ?? null, + createdAt: order.createdAt.toISOString(), + }; + } + + private isOptimisticLockError(error: unknown): boolean { + return error instanceof OptimisticLockError; + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/backend/services/wallet-service/src/application/services/index.ts b/backend/services/wallet-service/src/application/services/index.ts index 349beefe..7d6b3e34 100644 --- a/backend/services/wallet-service/src/application/services/index.ts +++ b/backend/services/wallet-service/src/application/services/index.ts @@ -1 +1,2 @@ export * from './wallet-application.service'; +export * from './fiat-withdrawal-application.service'; diff --git a/backend/services/wallet-service/src/domain/aggregates/fiat-withdrawal-order.aggregate.ts b/backend/services/wallet-service/src/domain/aggregates/fiat-withdrawal-order.aggregate.ts new file mode 100644 index 00000000..a7e7ab6a --- /dev/null +++ b/backend/services/wallet-service/src/domain/aggregates/fiat-withdrawal-order.aggregate.ts @@ -0,0 +1,531 @@ +import Decimal from 'decimal.js'; +import { UserId, Money } from '@/domain/value-objects'; +import { FiatWithdrawalStatus, PaymentMethod } from '@/domain/value-objects/fiat-withdrawal-status.enum'; +import { DomainError } from '@/shared/exceptions/domain.exception'; + +/** + * 收款账户信息 + */ +export interface PaymentAccountInfo { + paymentMethod: PaymentMethod; + // 银行卡 + bankName?: string; + bankCardNo?: string; + cardHolderName?: string; + // 支付宝 + alipayAccount?: string; + alipayRealName?: string; + // 微信 + wechatAccount?: string; + wechatRealName?: string; +} + +/** + * 审核信息 + */ +export interface ReviewInfo { + reviewedBy: string; + reviewedAt: Date; + reviewRemark?: string; +} + +/** + * 打款信息 + */ +export interface PaymentInfo { + paidBy: string; + paidAt: Date; + paymentProof?: string; + paymentRemark?: string; +} + +/** + * 法币提现订单聚合根 + * + * 提现流程: + * 1. 用户发起提现请求 -> PENDING + * 2. 冻结用户余额 -> FROZEN + * 3. 提交审核 -> REVIEWING + * 4. 审核通过 -> APPROVED (等待打款) + * 审核驳回 -> REJECTED (资金解冻) + * 5. 开始打款 -> PAYING + * 6. 打款完成 -> COMPLETED + * + * 失败/取消时解冻资金 + */ +export class FiatWithdrawalOrder { + private readonly _id: bigint; + private readonly _orderNo: string; + private readonly _accountSequence: string; + private readonly _userId: UserId; + private readonly _amount: Money; // 提现金额 (绿积分, 1:1人民币) + private readonly _fee: Money; // 手续费 + + // 收款方式 + 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: FiatWithdrawalStatus; + private _errorMessage: string | null; + + // 审核信息 + private _reviewedBy: string | null; + private _reviewedAt: Date | null; + private _reviewRemark: string | null; + + // 打款信息 + private _paidBy: string | null; + private _paidAt: Date | null; + private _paymentProof: string | null; + private _paymentRemark: string | null; + + // 详细备注 + private _detailMemo: string | null; + + // 时间戳 + private _frozenAt: Date | null; + private _completedAt: Date | null; + private readonly _createdAt: Date; + + private constructor( + id: bigint, + orderNo: string, + accountSequence: string, + userId: UserId, + amount: Money, + fee: Money, + paymentMethod: PaymentMethod, + bankName: string | null, + bankCardNo: string | null, + cardHolderName: string | null, + alipayAccount: string | null, + alipayRealName: string | null, + wechatAccount: string | null, + wechatRealName: string | null, + status: FiatWithdrawalStatus, + 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, + ) { + this._id = id; + this._orderNo = orderNo; + this._accountSequence = accountSequence; + this._userId = userId; + this._amount = amount; + this._fee = fee; + 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._completedAt = completedAt; + this._createdAt = createdAt; + } + + // Getters + get id(): bigint { return this._id; } + get orderNo(): string { return this._orderNo; } + get accountSequence(): string { return this._accountSequence; } + get userId(): UserId { return this._userId; } + get amount(): Money { return this._amount; } + get fee(): Money { return this._fee; } + get netAmount(): Money { return Money.USDT(new Decimal(this._amount.value).minus(this._fee.value)); } + get 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(): FiatWithdrawalStatus { return this._status; } + get errorMessage(): string | null { return this._errorMessage; } + get reviewedBy(): string | null { return this._reviewedBy; } + get reviewedAt(): Date | null { return this._reviewedAt; } + get reviewRemark(): string | null { return this._reviewRemark; } + get paidBy(): string | null { return this._paidBy; } + get paidAt(): Date | null { return this._paidAt; } + get paymentProof(): string | null { return this._paymentProof; } + get paymentRemark(): string | null { return this._paymentRemark; } + get detailMemo(): string | null { return this._detailMemo; } + get frozenAt(): Date | null { return this._frozenAt; } + get completedAt(): Date | null { return this._completedAt; } + get createdAt(): Date { return this._createdAt; } + + // 状态判断 + get isPending(): boolean { return this._status === FiatWithdrawalStatus.PENDING; } + get isFrozen(): boolean { return this._status === FiatWithdrawalStatus.FROZEN; } + get isReviewing(): boolean { return this._status === FiatWithdrawalStatus.REVIEWING; } + get isApproved(): boolean { return this._status === FiatWithdrawalStatus.APPROVED; } + get isPaying(): boolean { return this._status === FiatWithdrawalStatus.PAYING; } + get isCompleted(): boolean { return this._status === FiatWithdrawalStatus.COMPLETED; } + get isRejected(): boolean { return this._status === FiatWithdrawalStatus.REJECTED; } + get isFailed(): boolean { return this._status === FiatWithdrawalStatus.FAILED; } + get isCancelled(): boolean { return this._status === FiatWithdrawalStatus.CANCELLED; } + get isFinished(): boolean { + return this._status === FiatWithdrawalStatus.COMPLETED || + this._status === FiatWithdrawalStatus.REJECTED || + this._status === FiatWithdrawalStatus.FAILED || + this._status === FiatWithdrawalStatus.CANCELLED; + } + + /** + * 生成提现订单号 + */ + private static generateOrderNo(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `FW${timestamp}${random}`; + } + + /** + * 获取收款账户显示信息 + */ + getPaymentAccountDisplay(): string { + switch (this._paymentMethod) { + case PaymentMethod.BANK_CARD: + const maskedCardNo = this._bankCardNo + ? `****${this._bankCardNo.slice(-4)}` + : ''; + return `${this._bankName || ''} ${maskedCardNo} (${this._cardHolderName || ''})`; + case PaymentMethod.ALIPAY: + return `支付宝: ${this._alipayAccount || ''} (${this._alipayRealName || ''})`; + case PaymentMethod.WECHAT: + return `微信: ${this._wechatAccount || ''} (${this._wechatRealName || ''})`; + default: + return '未知收款方式'; + } + } + + /** + * 获取收款方式中文名称 + */ + getPaymentMethodName(): string { + switch (this._paymentMethod) { + case PaymentMethod.BANK_CARD: + return '银行卡'; + case PaymentMethod.ALIPAY: + return '支付宝'; + case PaymentMethod.WECHAT: + return '微信'; + default: + return '未知'; + } + } + + /** + * 创建法币提现订单 + */ + static create(params: { + accountSequence: string; + userId: UserId; + amount: Money; + fee: Money; + paymentAccount: PaymentAccountInfo; + }): FiatWithdrawalOrder { + // 验证金额 + if (params.amount.value <= 0) { + throw new DomainError('提现金额必须大于0'); + } + + // 验证手续费 + if (params.fee.value < 0) { + throw new DomainError('手续费不能为负数'); + } + + // 验证净额大于0 + const netAmount = new Decimal(params.amount.value).minus(params.fee.value); + if (netAmount.lte(0)) { + throw new DomainError('提现金额必须大于手续费'); + } + + // 根据收款方式验证必填信息 + 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 FiatWithdrawalOrder( + BigInt(0), // Will be set by database + orderNo, + params.accountSequence, + params.userId, + params.amount, + params.fee, + paymentAccount.paymentMethod, + paymentAccount.bankName || null, + paymentAccount.bankCardNo || null, + paymentAccount.cardHolderName || null, + paymentAccount.alipayAccount || null, + paymentAccount.alipayRealName || null, + paymentAccount.wechatAccount || null, + paymentAccount.wechatRealName || null, + FiatWithdrawalStatus.PENDING, + null, + null, + null, + null, + null, + null, + null, + null, + detailMemo, + null, + null, + now, + ); + } + + /** + * 从数据库重建 + */ + static reconstruct(params: { + id: bigint; + orderNo: string; + accountSequence: string; + userId: bigint; + amount: Decimal; + fee: Decimal; + 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; + 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; + }): FiatWithdrawalOrder { + return new FiatWithdrawalOrder( + params.id, + params.orderNo, + params.accountSequence, + UserId.create(params.userId), + Money.USDT(params.amount), + Money.USDT(params.fee), + params.paymentMethod as PaymentMethod, + params.bankName || null, + params.bankCardNo || null, + params.cardHolderName || null, + params.alipayAccount || null, + params.alipayRealName || null, + params.wechatAccount || null, + params.wechatRealName || null, + params.status as FiatWithdrawalStatus, + 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 !== FiatWithdrawalStatus.PENDING) { + throw new DomainError('只有待处理的提现订单可以冻结资金'); + } + this._status = FiatWithdrawalStatus.FROZEN; + this._frozenAt = new Date(); + this.appendMemo('资金已冻结,等待审核'); + } + + /** + * 提交审核 + */ + submitForReview(): void { + if (this._status !== FiatWithdrawalStatus.FROZEN) { + throw new DomainError('只有已冻结的提现订单可以提交审核'); + } + this._status = FiatWithdrawalStatus.REVIEWING; + this.appendMemo('已提交审核'); + } + + /** + * 审核通过 + */ + approve(reviewedBy: string, remark?: string): void { + if (this._status !== FiatWithdrawalStatus.REVIEWING && this._status !== FiatWithdrawalStatus.FROZEN) { + throw new DomainError('只有审核中或已冻结的订单可以通过审核'); + } + this._status = FiatWithdrawalStatus.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 !== FiatWithdrawalStatus.REVIEWING && this._status !== FiatWithdrawalStatus.FROZEN) { + throw new DomainError('只有审核中或已冻结的订单可以驳回'); + } + this._status = FiatWithdrawalStatus.REJECTED; + this._reviewedBy = reviewedBy; + this._reviewedAt = new Date(); + this._reviewRemark = remark; + this.appendMemo(`审核驳回 - 审核人: ${reviewedBy}, 原因: ${remark}`); + } + + /** + * 开始打款 + */ + startPayment(paidBy: string): void { + if (this._status !== FiatWithdrawalStatus.APPROVED) { + throw new DomainError('只有审核通过的订单可以开始打款'); + } + this._status = FiatWithdrawalStatus.PAYING; + this._paidBy = paidBy; + this.appendMemo(`开始打款 - 操作人: ${paidBy}`); + } + + /** + * 完成打款 + */ + completePayment(paymentProof?: string, remark?: string): void { + if (this._status !== FiatWithdrawalStatus.PAYING) { + throw new DomainError('只有打款中的订单可以完成打款'); + } + this._status = FiatWithdrawalStatus.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)} 元人民币`); + } + + /** + * 标记为失败 + */ + markAsFailed(errorMessage: string): void { + if (this.isFinished) { + throw new DomainError('已完成的提现订单无法标记为失败'); + } + this._status = FiatWithdrawalStatus.FAILED; + this._errorMessage = errorMessage; + this.appendMemo(`提现失败 - 原因: ${errorMessage}`); + } + + /** + * 取消提现 + */ + cancel(reason?: string): void { + if (this._status !== FiatWithdrawalStatus.PENDING && this._status !== FiatWithdrawalStatus.FROZEN) { + throw new DomainError('只有待处理或已冻结的提现订单可以取消'); + } + this._status = FiatWithdrawalStatus.CANCELLED; + this.appendMemo(`用户取消提现${reason ? ` - 原因: ${reason}` : ''}`); + } + + /** + * 是否需要解冻资金 (驳回/失败/取消且已冻结) + */ + needsUnfreeze(): boolean { + return (this._status === FiatWithdrawalStatus.REJECTED || + this._status === FiatWithdrawalStatus.FAILED || + this._status === FiatWithdrawalStatus.CANCELLED) + && this._frozenAt !== null; + } +} diff --git a/backend/services/wallet-service/src/domain/aggregates/index.ts b/backend/services/wallet-service/src/domain/aggregates/index.ts index 413e464b..e231947d 100644 --- a/backend/services/wallet-service/src/domain/aggregates/index.ts +++ b/backend/services/wallet-service/src/domain/aggregates/index.ts @@ -3,4 +3,5 @@ export * from './ledger-entry.aggregate'; export * from './deposit-order.aggregate'; export * from './settlement-order.aggregate'; export * from './withdrawal-order.aggregate'; +export * from './fiat-withdrawal-order.aggregate'; export * from './pending-reward.aggregate'; diff --git a/backend/services/wallet-service/src/domain/repositories/fiat-withdrawal-order.repository.interface.ts b/backend/services/wallet-service/src/domain/repositories/fiat-withdrawal-order.repository.interface.ts new file mode 100644 index 00000000..92715980 --- /dev/null +++ b/backend/services/wallet-service/src/domain/repositories/fiat-withdrawal-order.repository.interface.ts @@ -0,0 +1,46 @@ +import { FiatWithdrawalOrder } from '@/domain/aggregates'; +import { FiatWithdrawalStatus } from '@/domain/value-objects/fiat-withdrawal-status.enum'; + +export const FIAT_WITHDRAWAL_ORDER_REPOSITORY = Symbol('FIAT_WITHDRAWAL_ORDER_REPOSITORY'); + +export interface IFiatWithdrawalOrderRepository { + /** + * 保存法币提现订单 + */ + save(order: FiatWithdrawalOrder): Promise; + + /** + * 根据订单号查找 + */ + findByOrderNo(orderNo: string): Promise; + + /** + * 根据用户 ID 查找订单列表 + */ + findByUserId(userId: bigint): Promise; + + /** + * 根据账户序列号查找订单列表 + */ + findByAccountSequence(accountSequence: string): Promise; + + /** + * 根据状态查找订单列表 + */ + findByStatus(status: FiatWithdrawalStatus): Promise; + + /** + * 根据多个状态查找订单列表 + */ + findByStatuses(statuses: FiatWithdrawalStatus[]): Promise; + + /** + * 获取待审核订单列表 + */ + findPendingReview(): Promise; + + /** + * 获取待打款订单列表 + */ + findPendingPayment(): Promise; +} diff --git a/backend/services/wallet-service/src/domain/repositories/index.ts b/backend/services/wallet-service/src/domain/repositories/index.ts index 72226d8d..6b7e3b6f 100644 --- a/backend/services/wallet-service/src/domain/repositories/index.ts +++ b/backend/services/wallet-service/src/domain/repositories/index.ts @@ -3,4 +3,5 @@ export * from './ledger-entry.repository.interface'; export * from './deposit-order.repository.interface'; export * from './settlement-order.repository.interface'; export * from './withdrawal-order.repository.interface'; +export * from './fiat-withdrawal-order.repository.interface'; export * from './pending-reward.repository.interface'; diff --git a/backend/services/wallet-service/src/domain/value-objects/fiat-withdrawal-status.enum.ts b/backend/services/wallet-service/src/domain/value-objects/fiat-withdrawal-status.enum.ts new file mode 100644 index 00000000..89d18904 --- /dev/null +++ b/backend/services/wallet-service/src/domain/value-objects/fiat-withdrawal-status.enum.ts @@ -0,0 +1,34 @@ +/** + * 法币提现状态枚举 + * + * 法币提现流程: + * 1. 用户发起提现请求 -> PENDING + * 2. 冻结用户余额 -> FROZEN + * 3. 提交审核 -> REVIEWING + * 4. 审核通过 -> APPROVED (等待打款) + * 审核驳回 -> REJECTED (资金解冻) + * 5. 开始打款 -> PAYING + * 6. 打款完成 -> COMPLETED + * + * 失败/取消时解冻资金 + */ +export enum FiatWithdrawalStatus { + 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', // 微信 +} diff --git a/backend/services/wallet-service/src/domain/value-objects/index.ts b/backend/services/wallet-service/src/domain/value-objects/index.ts index 8e1eb0ba..c498d02f 100644 --- a/backend/services/wallet-service/src/domain/value-objects/index.ts +++ b/backend/services/wallet-service/src/domain/value-objects/index.ts @@ -5,6 +5,7 @@ export * from './ledger-entry-type.enum'; export * from './deposit-status.enum'; export * from './settlement-status.enum'; export * from './withdrawal-status.enum'; +export * from './fiat-withdrawal-status.enum'; export * from './money.vo'; export * from './balance.vo'; export * from './hashpower.vo'; 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..8e9ab3e6 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 @@ -10,7 +10,8 @@ export enum LedgerEntryType { REWARD_SETTLED = 'REWARD_SETTLED', TRANSFER_TO_POOL = 'TRANSFER_TO_POOL', SWAP_EXECUTED = 'SWAP_EXECUTED', - WITHDRAWAL = 'WITHDRAWAL', + WITHDRAWAL = 'WITHDRAWAL', // 区块链划转 + FIAT_WITHDRAWAL = 'FIAT_WITHDRAWAL', // 法币提现 TRANSFER_IN = 'TRANSFER_IN', TRANSFER_OUT = 'TRANSFER_OUT', FREEZE = 'FREEZE', diff --git a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts index 5f455d77..be5ac54f 100644 --- a/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/wallet-service/src/infrastructure/infrastructure.module.ts @@ -6,6 +6,7 @@ import { DepositOrderRepositoryImpl, SettlementOrderRepositoryImpl, WithdrawalOrderRepositoryImpl, + FiatWithdrawalOrderRepositoryImpl, PendingRewardRepositoryImpl, FeeConfigRepositoryImpl, } from './persistence/repositories'; @@ -15,6 +16,7 @@ import { DEPOSIT_ORDER_REPOSITORY, SETTLEMENT_ORDER_REPOSITORY, WITHDRAWAL_ORDER_REPOSITORY, + FIAT_WITHDRAWAL_ORDER_REPOSITORY, PENDING_REWARD_REPOSITORY, } from '@/domain/repositories'; import { RedisModule } from './redis'; @@ -42,6 +44,10 @@ const repositories = [ provide: WITHDRAWAL_ORDER_REPOSITORY, useClass: WithdrawalOrderRepositoryImpl, }, + { + provide: FIAT_WITHDRAWAL_ORDER_REPOSITORY, + useClass: FiatWithdrawalOrderRepositoryImpl, + }, { provide: PENDING_REWARD_REPOSITORY, useClass: PendingRewardRepositoryImpl, diff --git a/backend/services/wallet-service/src/infrastructure/persistence/repositories/fiat-withdrawal-order.repository.impl.ts b/backend/services/wallet-service/src/infrastructure/persistence/repositories/fiat-withdrawal-order.repository.impl.ts new file mode 100644 index 00000000..8611c921 --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/persistence/repositories/fiat-withdrawal-order.repository.impl.ts @@ -0,0 +1,175 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service'; +import { IFiatWithdrawalOrderRepository } from '@/domain/repositories'; +import { FiatWithdrawalOrder } from '@/domain/aggregates'; +import { FiatWithdrawalStatus } from '@/domain/value-objects/fiat-withdrawal-status.enum'; +import Decimal from 'decimal.js'; + +@Injectable() +export class FiatWithdrawalOrderRepositoryImpl implements IFiatWithdrawalOrderRepository { + constructor(private readonly prisma: PrismaService) {} + + async save(order: FiatWithdrawalOrder): Promise { + const data = { + orderNo: order.orderNo, + accountSequence: order.accountSequence, + userId: order.userId.value, + amount: order.amount.toDecimal(), + fee: order.fee.toDecimal(), + 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, + completedAt: order.completedAt, + }; + + if (order.id === BigInt(0)) { + const created = await this.prisma.fiatWithdrawalOrder.create({ data }); + return this.toDomain(created); + } else { + const updated = await this.prisma.fiatWithdrawalOrder.update({ + where: { id: order.id }, + data, + }); + return this.toDomain(updated); + } + } + + async findByOrderNo(orderNo: string): Promise { + const record = await this.prisma.fiatWithdrawalOrder.findUnique({ + where: { orderNo }, + }); + return record ? this.toDomain(record) : null; + } + + async findByUserId(userId: bigint): Promise { + const records = await this.prisma.fiatWithdrawalOrder.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + return records.map(r => this.toDomain(r)); + } + + async findByAccountSequence(accountSequence: string): Promise { + const records = await this.prisma.fiatWithdrawalOrder.findMany({ + where: { accountSequence }, + orderBy: { createdAt: 'desc' }, + }); + return records.map(r => this.toDomain(r)); + } + + async findByStatus(status: FiatWithdrawalStatus): Promise { + const records = await this.prisma.fiatWithdrawalOrder.findMany({ + where: { status }, + orderBy: { createdAt: 'asc' }, + }); + return records.map(r => this.toDomain(r)); + } + + async findByStatuses(statuses: FiatWithdrawalStatus[]): Promise { + const records = await this.prisma.fiatWithdrawalOrder.findMany({ + where: { status: { in: statuses } }, + orderBy: { createdAt: 'asc' }, + }); + return records.map(r => this.toDomain(r)); + } + + async findPendingReview(): Promise { + const records = await this.prisma.fiatWithdrawalOrder.findMany({ + where: { + status: { + in: [FiatWithdrawalStatus.FROZEN, FiatWithdrawalStatus.REVIEWING], + }, + }, + orderBy: { createdAt: 'asc' }, + }); + return records.map(r => this.toDomain(r)); + } + + async findPendingPayment(): Promise { + const records = await this.prisma.fiatWithdrawalOrder.findMany({ + where: { + status: { + in: [FiatWithdrawalStatus.APPROVED, FiatWithdrawalStatus.PAYING], + }, + }, + orderBy: { createdAt: 'asc' }, + }); + return records.map(r => this.toDomain(r)); + } + + private toDomain(record: { + id: bigint; + orderNo: string; + accountSequence: string; + userId: bigint; + amount: Decimal; + fee: Decimal; + 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; + 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; + }): FiatWithdrawalOrder { + return FiatWithdrawalOrder.reconstruct({ + id: record.id, + orderNo: record.orderNo, + accountSequence: record.accountSequence, + userId: record.userId, + amount: new Decimal(record.amount.toString()), + fee: new Decimal(record.fee.toString()), + paymentMethod: record.paymentMethod, + 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, + completedAt: record.completedAt, + createdAt: record.createdAt, + }); + } +} diff --git a/backend/services/wallet-service/src/infrastructure/persistence/repositories/index.ts b/backend/services/wallet-service/src/infrastructure/persistence/repositories/index.ts index 78036509..da22ee57 100644 --- a/backend/services/wallet-service/src/infrastructure/persistence/repositories/index.ts +++ b/backend/services/wallet-service/src/infrastructure/persistence/repositories/index.ts @@ -3,5 +3,6 @@ export * from './ledger-entry.repository.impl'; export * from './deposit-order.repository.impl'; export * from './settlement-order.repository.impl'; export * from './withdrawal-order.repository.impl'; +export * from './fiat-withdrawal-order.repository.impl'; export * from './pending-reward.repository.impl'; export * from './fee-config.repository.impl'; 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..7ffd3b5e --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/withdrawals/page.tsx @@ -0,0 +1,924 @@ +'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 { + usePendingReviewWithdrawals, + usePendingPaymentWithdrawals, + useWithdrawalsByStatus, + useReviewWithdrawal, + useStartPayment, + useCompletePayment, +} from '@/hooks/useWithdrawals'; +import { + FiatWithdrawalOrder, + FiatWithdrawalStatus, + PaymentMethod, + PAYMENT_METHOD_OPTIONS, + getFiatWithdrawalStatusInfo, + getPaymentMethodLabel, +} from '@/types/withdrawal.types'; +import styles from './withdrawals.module.scss'; + +type TabType = 'reviewing' | 'approved' | 'paying' | 'completed' | 'rejected'; + +/** + * 法币提现审核管理页面 + */ +export default function WithdrawalsPage() { + // 当前标签页 + const [activeTab, setActiveTab] = useState('reviewing'); + + // 筛选状态 + const [filters, setFilters] = useState({ + accountSequence: '', + paymentMethod: '' as PaymentMethod | '', + page: 1, + limit: 20, + }); + + // 弹窗状态 + const [viewingOrder, setViewingOrder] = useState(null); + const [reviewingOrder, setReviewingOrder] = useState(null); + const [payingOrder, setPayingOrder] = useState(null); + const [completingOrder, setCompletingOrder] = useState(null); + + // 表单状态 + const [reviewForm, setReviewForm] = useState({ + approved: true, + remark: '', + }); + const [paymentForm, setPaymentForm] = useState({ + paymentProof: '', + remark: '', + }); + + // 数据查询 + const pendingReviewQuery = usePendingReviewWithdrawals({ + accountSequence: filters.accountSequence || undefined, + paymentMethod: filters.paymentMethod || undefined, + page: filters.page, + limit: filters.limit, + }); + + const pendingPaymentQuery = usePendingPaymentWithdrawals({ + accountSequence: filters.accountSequence || undefined, + paymentMethod: filters.paymentMethod || undefined, + page: filters.page, + limit: filters.limit, + }); + + const payingQuery = useWithdrawalsByStatus('PAYING', { + accountSequence: filters.accountSequence || undefined, + paymentMethod: filters.paymentMethod || undefined, + page: filters.page, + limit: filters.limit, + }); + + const completedQuery = useWithdrawalsByStatus('COMPLETED', { + accountSequence: filters.accountSequence || undefined, + paymentMethod: filters.paymentMethod || undefined, + page: filters.page, + limit: filters.limit, + }); + + const rejectedQuery = useWithdrawalsByStatus('REJECTED', { + accountSequence: filters.accountSequence || undefined, + paymentMethod: filters.paymentMethod || undefined, + page: filters.page, + limit: filters.limit, + }); + + // Mutations + const reviewMutation = useReviewWithdrawal(); + const startPaymentMutation = useStartPayment(); + const completePaymentMutation = useCompletePayment(); + + // 获取当前标签页的数据 + const getCurrentData = () => { + switch (activeTab) { + case 'reviewing': + return pendingReviewQuery; + case 'approved': + return pendingPaymentQuery; + case 'paying': + return payingQuery; + case 'completed': + return completedQuery; + case 'rejected': + return rejectedQuery; + default: + return pendingReviewQuery; + } + }; + + const { data, isLoading, error, refetch } = getCurrentData(); + + // 打开审核弹窗 + const handleOpenReview = (order: FiatWithdrawalOrder) => { + setReviewingOrder(order); + setReviewForm({ approved: true, remark: '' }); + }; + + // 提交审核 + const handleSubmitReview = async () => { + if (!reviewingOrder) return; + + try { + await reviewMutation.mutateAsync({ + orderNo: reviewingOrder.orderNo, + data: reviewForm, + }); + toast.success(reviewForm.approved ? '审核通过' : '已拒绝提现'); + setReviewingOrder(null); + } catch (err) { + toast.error((err as Error).message || '审核失败'); + } + }; + + // 开始打款 + const handleStartPayment = async (order: FiatWithdrawalOrder) => { + setPayingOrder(order); + }; + + // 确认开始打款 + const handleConfirmStartPayment = async () => { + if (!payingOrder) return; + + try { + await startPaymentMutation.mutateAsync({ + orderNo: payingOrder.orderNo, + data: {}, + }); + toast.success('已开始打款'); + setPayingOrder(null); + } catch (err) { + toast.error((err as Error).message || '操作失败'); + } + }; + + // 打开完成打款弹窗 + const handleOpenComplete = (order: FiatWithdrawalOrder) => { + setCompletingOrder(order); + setPaymentForm({ paymentProof: '', remark: '' }); + }; + + // 完成打款 + const handleSubmitComplete = async () => { + if (!completingOrder) return; + + try { + await completePaymentMutation.mutateAsync({ + orderNo: completingOrder.orderNo, + data: paymentForm, + }); + toast.success('打款完成'); + setCompletingOrder(null); + } catch (err) { + toast.error((err as Error).message || '操作失败'); + } + }; + + // 搜索 + const handleSearch = useCallback(() => { + setFilters((prev) => ({ ...prev, page: 1 })); + refetch(); + }, [refetch]); + + // 翻页 + const handlePageChange = (page: number) => { + setFilters((prev) => ({ ...prev, page })); + }; + + // 渲染支付方式标签 + const renderMethodTag = (method: PaymentMethod) => { + const className = cn( + styles.withdrawals__methodTag, + method === 'BANK_CARD' && styles['withdrawals__methodTag--bank'], + method === 'ALIPAY' && styles['withdrawals__methodTag--alipay'], + method === 'WECHAT' && styles['withdrawals__methodTag--wechat'] + ); + return {getPaymentMethodLabel(method)}; + }; + + // 渲染支付信息 + const renderPaymentInfo = (order: FiatWithdrawalOrder) => { + switch (order.paymentMethod) { + case 'BANK_CARD': + return ( +
+
+ 银行: + {order.bankName} +
+
+ 卡号: + {order.bankCardNo} +
+
+ 姓名: + {order.cardHolderName} +
+
+ ); + case 'ALIPAY': + return ( +
+
+ 账号: + {order.alipayAccount} +
+
+ 姓名: + {order.alipayRealName} +
+
+ ); + case 'WECHAT': + return ( +
+
+ 账号: + {order.wechatAccount} +
+
+ 姓名: + {order.wechatRealName} +
+
+ ); + default: + return null; + } + }; + + // 渲染操作按钮 + const renderActions = (order: FiatWithdrawalOrder) => { + const actions = []; + + // 查看详情按钮 + actions.push( + + ); + + // 根据状态显示不同操作 + if (order.status === 'REVIEWING') { + actions.push( + + ); + } + + if (order.status === 'APPROVED') { + actions.push( + + ); + } + + if (order.status === 'PAYING') { + actions.push( + + ); + } + + return
{actions}
; + }; + + return ( + +
+ {/* 页面标题 */} +
+

法币提现审核

+

+ 审核和处理用户的法币提现申请,支持银行卡、支付宝、微信打款 +

+
+ + {/* 主内容卡片 */} +
+ {/* 标签页 */} +
+ + + + + +
+ + {/* 筛选区域 */} +
+ setFilters({ ...filters, accountSequence: e.target.value })} + /> + + + +
+ + {/* 列表 */} +
+ {isLoading ? ( +
加载中...
+ ) : error ? ( +
+ {(error as Error).message || '加载失败'} + +
+ ) : !data || data.items.length === 0 ? ( +
暂无数据
+ ) : ( + <> + {/* 表格 */} + + + + + + + + + + + + + + + {data.items.map((order) => { + const statusInfo = getFiatWithdrawalStatusInfo(order.status); + return ( + + + + + + + + + + + ); + })} + +
订单号用户金额支付方式收款信息状态申请时间操作
+
{order.orderNo}
+
+
+ {order.accountSequence} +
+
ID: {order.userId}
+
+
+ ¥{parseFloat(order.amount).toFixed(2)} +
+ {parseFloat(order.fee) > 0 && ( +
+ 手续费: ¥{parseFloat(order.fee).toFixed(2)} +
+ )} +
{renderMethodTag(order.paymentMethod)}{renderPaymentInfo(order)} + + {statusInfo.label} + + {formatDateTime(order.createdAt)}{renderActions(order)}
+ + {/* 分页 */} + {data.total > filters.limit && ( +
+ + 共 {data.total} 条,第 {data.page} / {Math.ceil(data.total / data.limit)} 页 + +
+ + +
+
+ )} + + )} +
+
+ + {/* 查看详情弹窗 */} + setViewingOrder(null)} + footer={ +
+ +
+ } + width={600} + > + {viewingOrder && ( +
+
+
订单信息
+
+ 订单号: + {viewingOrder.orderNo} +
+
+ 用户: + + {viewingOrder.accountSequence} (ID: {viewingOrder.userId}) + +
+
+ 金额: + + ¥{parseFloat(viewingOrder.amount).toFixed(2)} + +
+
+ 手续费: + + ¥{parseFloat(viewingOrder.fee).toFixed(2)} + +
+
+ 状态: + + {getFiatWithdrawalStatusInfo(viewingOrder.status).label} + +
+
+ 申请时间: + + {formatDateTime(viewingOrder.createdAt)} + +
+
+ +
+
+ 收款信息 ({getPaymentMethodLabel(viewingOrder.paymentMethod)}) +
+ {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.reviewedAt || viewingOrder.paidAt) && ( +
+
处理记录
+ {viewingOrder.reviewedAt && ( + <> +
+ 审核人: + + {viewingOrder.reviewedBy} + +
+
+ 审核时间: + + {formatDateTime(viewingOrder.reviewedAt)} + +
+ {viewingOrder.reviewRemark && ( +
+ 审核备注: + + {viewingOrder.reviewRemark} + +
+ )} + + )} + {viewingOrder.paidAt && ( + <> +
+ 打款人: + + {viewingOrder.paidBy} + +
+
+ 打款时间: + + {formatDateTime(viewingOrder.paidAt)} + +
+ {viewingOrder.paymentRemark && ( +
+ 打款备注: + + {viewingOrder.paymentRemark} + +
+ )} + + )} +
+ )} +
+ )} +
+ + {/* 审核弹窗 */} + setReviewingOrder(null)} + footer={ +
+ + +
+ } + width={500} + > + {reviewingOrder && ( +
+
+ 用户: + + {reviewingOrder.accountSequence} + +
+
+ 金额: + + ¥{parseFloat(reviewingOrder.amount).toFixed(2)} + +
+
+ 收款方式: + + {getPaymentMethodLabel(reviewingOrder.paymentMethod)} + +
+ +
+ + +
+ +
+ +