From dfdd8ed65a47e27ce16921fe7c7f3901e3fddf45 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 3 Jan 2026 07:04:46 -0800 Subject: [PATCH] feat(pending-actions): add special deduction feature for admin-created user actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现特殊扣减功能,允许管理员为用户创建扣减待办操作,由用户在移动端确认执行。 ## 后端 (wallet-service) ### 领域层 - 新增 `SPECIAL_DEDUCTION` 到 LedgerEntryType 枚举 用于记录特殊扣减的账本流水类型 ### 应用层 - 新增 `executeSpecialDeduction` 方法 - 验证用户钱包存在性 - 检查余额是否充足 - 乐观锁控制并发 - 扣减余额并记录账本流水 - 返回操作结果和新余额 ### API层 - 新增内部API: POST /api/v1/wallets/special-deduction/execute 供移动端调用执行特殊扣减操作 ## 前端 (admin-web) ### 类型定义 - 新增 `SPECIAL_DEDUCTION` 到 ACTION_CODES - 新增 `SpecialDeductionParams` 接口定义扣减参数 - amount: 扣减金额 - reason: 扣减原因 ### 页面 - 更新待办操作管理页面 - 当选择 SPECIAL_DEDUCTION 时显示扣减金额和原因输入框 - 验证扣减金额必须大于0 - 验证扣减原因不能为空 ### 样式 - 新增特殊扣减表单区域样式 ## 前端 (mobile-app) ### 服务层 - 新增 `executeSpecialDeduction` 方法到 WalletService - 新增 `SpecialDeductionResult` 结果类 - 新增 `specialDeduction` 到 PendingActionCode 枚举 ### 页面 - 新增 `SpecialDeductionPage` 特殊扣减确认页面 - 显示扣减金额和管理员备注 - 显示当前余额和扣减后余额 - 余额不足时禁用确认按钮 - 温馨提示说明操作性质 - 更新 `PendingActionsPage` - 处理 SPECIAL_DEDUCTION 类型的待办操作 - 从 actionParams 解析 amount 和 reason - 导航到特殊扣减确认页面 ## 工作流程 1. 管理员在 admin-web 创建 SPECIAL_DEDUCTION 待办操作 - 选择目标用户 - 输入扣减金额 - 输入扣减原因 2. 用户在 mobile-app 待办操作列表看到该操作 3. 用户点击后进入特殊扣减确认页面 - 查看扣减详情 - 确认余额充足 - 点击确认执行扣减 4. 后端执行扣减并记录账本流水 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../controllers/internal-wallet.controller.ts | 28 + .../services/wallet-application.service.ts | 149 +++++ .../value-objects/ledger-entry-type.enum.ts | 1 + .../app/(dashboard)/pending-actions/page.tsx | 61 ++ .../pending-actions.module.scss | 23 + .../src/types/pending-action.types.ts | 11 + .../core/services/pending_action_service.dart | 3 +- .../lib/core/services/wallet_service.dart | 69 +++ .../pages/pending_actions_page.dart | 26 + .../pages/special_deduction_page.dart | 537 ++++++++++++++++++ 10 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 frontend/mobile-app/lib/features/pending_actions/presentation/pages/special_deduction_page.dart 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 57e8a9fa..e4cea227 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 @@ -273,4 +273,32 @@ export class InternalWalletController { remark: dto.remark, }); } + + // =============== 特殊扣减 API =============== + + @Post('special-deduction/execute') + @Public() + @ApiOperation({ summary: '执行特殊扣减(内部API) - 用户确认扣减操作' }) + @ApiResponse({ status: 200, description: '扣减结果' }) + async executeSpecialDeduction( + @Body() dto: { + accountSequence: string; + pendingActionId: string; + amount: number; + reason: string; + }, + ) { + this.logger.log(`========== special-deduction/execute 请求 ==========`); + this.logger.log(`请求参数: ${JSON.stringify(dto)}`); + + const result = await this.walletService.executeSpecialDeduction({ + accountSequence: dto.accountSequence, + amount: dto.amount, + memo: dto.reason, + operatorId: `pending-action:${dto.pendingActionId}`, + }); + + this.logger.log(`特殊扣减结果: ${JSON.stringify(result)}`); + return result; + } } 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..9bb8a771 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 @@ -2331,6 +2331,8 @@ export class WalletApplicationService { TRANSFER_TO_POOL: '转入矿池', SWAP_EXECUTED: '兑换执行', WITHDRAWAL: '提现', + FIAT_WITHDRAWAL: '法币提现', + SPECIAL_DEDUCTION: '特殊扣减', TRANSFER_IN: '转入', TRANSFER_OUT: '转出', FREEZE: '冻结', @@ -2339,4 +2341,151 @@ export class WalletApplicationService { }; return nameMap[entryType] || entryType; } + + // =============== 特殊扣减 (管理员操作) =============== + + /** + * 特殊扣减命令 + */ + async executeSpecialDeduction(command: { + accountSequence: string; + amount: number; + memo: string; + operatorId: string; + }): Promise<{ success: boolean; orderNo: string; newBalance: number }> { + const MAX_RETRIES = 3; + let retries = 0; + + while (retries < MAX_RETRIES) { + try { + return await this.doExecuteSpecialDeduction(command); + } catch (error) { + if (this.isOptimisticLockError(error)) { + retries++; + this.logger.warn(`[executeSpecialDeduction] Optimistic lock conflict for ${command.accountSequence}, retry ${retries}/${MAX_RETRIES}`); + if (retries >= MAX_RETRIES) { + this.logger.error(`[executeSpecialDeduction] 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 doExecuteSpecialDeduction(command: { + accountSequence: string; + amount: number; + memo: string; + operatorId: string; + }): Promise<{ success: boolean; orderNo: string; newBalance: number }> { + this.logger.log(`[executeSpecialDeduction] 执行特殊扣减: accountSequence=${command.accountSequence}, amount=${command.amount}`); + + // 验证金额 + if (command.amount <= 0) { + throw new BadRequestException('扣减金额必须大于 0'); + } + + // 查找钱包 + const wallet = await this.walletRepo.findByAccountSequence(command.accountSequence); + if (!wallet) { + throw new WalletNotFoundError(`accountSequence: ${command.accountSequence}`); + } + + // 验证余额是否足够 + const deductAmount = new (require('decimal.js').default)(command.amount); + if (wallet.balances.usdt.available.value < command.amount) { + throw new BadRequestException( + `余额不足: 需要 ${command.amount} 绿积分, 当前可用 ${wallet.balances.usdt.available.value} 绿积分`, + ); + } + + // 生成订单号 + const orderNo = this.generateSpecialDeductionOrderNo(); + + // 使用事务处理 + let newBalance = 0; + await this.prisma.$transaction(async (tx) => { + const walletRecord = await tx.walletAccount.findUnique({ + where: { accountSequence: command.accountSequence }, + }); + + if (!walletRecord) { + throw new Error(`Wallet not found: ${command.accountSequence}`); + } + + const currentAvailable = new (require('decimal.js').default)(walletRecord.usdtAvailable.toString()); + const currentVersion = walletRecord.version; + + if (currentAvailable.lessThan(deductAmount)) { + throw new BadRequestException(`余额不足: 需要 ${command.amount} 绿积分, 当前可用 ${currentAvailable} 绿积分`); + } + + const newAvailable = currentAvailable.minus(deductAmount); + newBalance = newAvailable.toNumber(); + + // 乐观锁更新 + const updateResult = await tx.walletAccount.updateMany({ + where: { + id: walletRecord.id, + version: currentVersion, + }, + data: { + usdtAvailable: newAvailable, + 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: command.accountSequence, + userId: walletRecord.userId, + entryType: LedgerEntryType.SPECIAL_DEDUCTION, + amount: deductAmount.negated(), + assetType: 'USDT', + balanceAfter: newAvailable, + refOrderId: orderNo, + memo: `特殊扣减: ${command.memo}`, + payloadJson: { + operatorId: command.operatorId, + originalAmount: command.amount, + reason: command.memo, + executedAt: new Date().toISOString(), + }, + }, + }); + + this.logger.log(`[executeSpecialDeduction] 成功扣减 ${command.amount} 绿积分, 新余额: ${newBalance}`); + }); + + // 清除缓存 + await this.walletCacheService.invalidateWallet(wallet.userId.value); + + return { + success: true, + orderNo, + newBalance, + }; + } + + /** + * 生成特殊扣减订单号 + */ + private generateSpecialDeductionOrderNo(): string { + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10).replace(/-/g, ''); + const timeStr = now.toISOString().slice(11, 19).replace(/:/g, ''); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `SD${dateStr}${timeStr}${random}`; + } } 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 8e9ab3e6..fdaf9093 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 @@ -12,6 +12,7 @@ export enum LedgerEntryType { SWAP_EXECUTED = 'SWAP_EXECUTED', WITHDRAWAL = 'WITHDRAWAL', // 区块链划转 FIAT_WITHDRAWAL = 'FIAT_WITHDRAWAL', // 法币提现 + SPECIAL_DEDUCTION = 'SPECIAL_DEDUCTION', // 特殊扣减(管理员操作) TRANSFER_IN = 'TRANSFER_IN', TRANSFER_OUT = 'TRANSFER_OUT', FREEZE = 'FREEZE', diff --git a/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx b/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx index a1ad9eec..03136f03 100644 --- a/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/pending-actions/page.tsx @@ -34,6 +34,7 @@ import { PendingAction, PendingActionStatus, ACTION_CODE_OPTIONS, + ACTION_CODES, STATUS_OPTIONS, getStatusInfo, getActionCodeLabel, @@ -118,6 +119,12 @@ export default function PendingActionsPage() { expiresAt: '', }); + // 特殊扣减表单状态 + const [specialDeductionForm, setSpecialDeductionForm] = useState({ + amount: '', + reason: '', + }); + // 单个操作编辑时使用的表单 const [editFormData, setEditFormData] = useState({ actionParams: '', @@ -167,6 +174,7 @@ export default function PendingActionsPage() { actionParamsMap: {}, expiresAt: '', }); + setSpecialDeductionForm({ amount: '', reason: '' }); setShowCreateModal(true); }; @@ -283,6 +291,25 @@ export default function PendingActionsPage() { // 解析每个操作的参数 const actionParamsMap: Record | undefined> = {}; for (const actionCode of formData.selectedActions) { + // 特殊扣减有专用表单 + if (actionCode === ACTION_CODES.SPECIAL_DEDUCTION) { + const amount = parseFloat(specialDeductionForm.amount); + if (isNaN(amount) || amount <= 0) { + toast.error('特殊扣减金额必须大于 0'); + return; + } + if (!specialDeductionForm.reason.trim()) { + toast.error('特殊扣减必须填写原因/备注'); + return; + } + actionParamsMap[actionCode] = { + amount, + reason: specialDeductionForm.reason.trim(), + createdBy: 'admin', // TODO: 从登录状态获取 + }; + continue; + } + const paramsStr = formData.actionParamsMap[actionCode]; if (paramsStr?.trim()) { try { @@ -718,6 +745,40 @@ export default function PendingActionsPage() { )} + {/* 特殊扣减专用表单 */} + {formData.selectedActions.includes(ACTION_CODES.SPECIAL_DEDUCTION) && ( +
+
+ 特殊扣减设置 +
+
+
+ + setSpecialDeductionForm({ ...specialDeductionForm, amount: e.target.value })} + placeholder="请输入扣减金额" + min={0} + step={0.01} + /> +
+
+
+ +