钱包技术方案
1. 设计原则
- 余额热数据落在持有者主表,记录层全部多态:余额是最高频读取的字段,直接作为
users表列存储可做到查询零 Join;而流水、充值单、提现单三张记录表统一使用owner_type+owner_id多态关联,业务逻辑不与User耦合。 - 可扩展但不过度设计:未来其他主体(如
admin_users平台账户、未来的merchants商户)需要钱包时,只需在其表追加balance/frozen_balance两个字段并use HasWalletBalance,记录层与WalletHelper无需改动。本期不引入独立wallets表和多币种,避免为假想需求付出复杂度。 - 增量更新而非流水汇总:与现有
CoinHelper(每次SUM全表重算)不同,钱包余额采用事务内行锁 + 增量更新(balance + amount),流水表保存前后余额快照用于对账。真实货币场景对一致性与性能要求都更高,SUM全表在数据量增长后不可接受。 - 冻结与可用余额分离:
balance为可用余额,frozen_balance为冻结余额,两者互不重叠。下单冻结即balance -= n; frozen_balance += n,成交扣款即frozen_balance -= n,取消解冻即反向操作。 - 金额精度:统一
decimal(16,2),以元为单位;PHP 侧用字符串/decimalcast 传递,禁止使用float参与金额运算。 - 与虚拟资产完全隔离:钱包是真实货币账本,与
available_points(积分)、available_coins(金币)并行独立,不做迁移合并。
2. 代码结构
app/
├── Enums/
│ ├── WalletTradeType.php # 钱包交易类型枚举(实现 HasLabel)
│ ├── WalletTradeStatus.php # 流水状态枚举(pending/success/failed)
│ ├── WithdrawStatus.php # 提现状态枚举(实现 HasLabel)
│ ├── PayChannel.php # 支付渠道枚举(wechat/alipay/apple)
│ └── WithdrawAccountType.php # 收款账户类型枚举(wechat/alipay/bank)
├── Exceptions/
│ ├── InsufficientBalanceException.php # 余额不足
│ └── InsufficientFrozenBalanceException.php # 冻结余额不足
├── Models/
│ ├── Traits/
│ │ └── HasWalletBalance.php # 钱包能力 trait(多态关系 + 余额判定)
│ └── Wallet/
│ ├── WalletTrade.php # 钱包流水(morphTo owner + morphTo source)
│ ├── Recharge.php # 充值订单(morphTo owner)
│ └── Withdraw.php # 提现申请(morphTo owner)
├── Support/
│ └── WalletHelper.php # 余额引擎(incr/decr/freeze/unfreeze)
├── Services/
│ ├── RechargeService.php # 充值下单与回调入账编排
│ └── WithdrawService.php # 提现申请与审核流转编排
├── Events/Wallet/
│ ├── BalanceChanged.php # 余额变动事件
│ ├── RechargeSucceeded.php # 充值成功事件
│ ├── WithdrawApproved.php # 提现审核通过事件
│ ├── WithdrawRejected.php # 提现驳回事件
│ ├── WithdrawTransferred.php # 提现打款成功事件
│ └── WithdrawFailed.php # 提现打款失败事件
├── Notifications/Wallet/
│ ├── RechargeSucceededNotification.php
│ ├── WithdrawApprovedNotification.php
│ ├── WithdrawRejectedNotification.php
│ ├── WithdrawTransferredNotification.php
│ └── WithdrawFailedNotification.php
├── Jobs/Wallet/
│ └── CloseExpiredRechargesJob.php # 关闭超时未支付订单
├── Policies/
│ ├── RechargePolicy.php
│ └── WithdrawPolicy.php
├── Exports/Admin/
│ ├── WalletTradeExport.php
│ ├── WithdrawExport.php
│ └── RechargeExport.php
├── Http/
│ ├── Controllers/
│ │ ├── Admin/
│ │ │ ├── WalletController.php # 流水列表/详情/统计/调账
│ │ │ ├── WithdrawController.php # 提现审核
│ │ │ └── RechargeController.php # 充值订单查看
│ │ ├── Api/V1/Wallet/
│ │ │ ├── WalletController.php # 前台钱包概览/流水/充值
│ │ │ └── WithdrawController.php # 前台提现提交/列表/详情
│ │ └── PayCallbackController.php # 支付渠道异步回调
│ ├── Requests/
│ │ ├── Admin/Wallet/
│ │ │ ├── WalletTradeIndexRequest.php
│ │ │ ├── WalletAdjustRequest.php
│ │ │ ├── WithdrawIndexRequest.php
│ │ │ ├── WithdrawRejectRequest.php
│ │ │ ├── WithdrawFailRequest.php
│ │ │ └── WithdrawRemarkRequest.php
│ │ └── Api/V1/Wallet/
│ │ ├── CreateRechargeRequest.php
│ │ └── CreateWithdrawRequest.php
│ └── Resources/
│ ├── Admin/
│ │ ├── WalletTradeResource.php
│ │ ├── WalletStatResource.php
│ │ ├── WithdrawResource.php
│ │ └── RechargeResource.php
│ └── Api/V1/
│ ├── WalletResource.php
│ ├── WalletTradeResource.php
│ ├── WithdrawResource.php
│ └── RechargeResource.php
database/
├── migrations/
│ ├── xxxx_add_balance_to_users_table.php # users 表加 balance/frozen_balance
│ ├── xxxx_create_wallet_trades_table.php
│ ├── xxxx_create_recharges_table.php
│ └── xxxx_create_withdraws_table.php
└── factories/Wallet/
├── WalletTradeFactory.php
├── RechargeFactory.php
└── WithdrawFactory.php
tests/Feature/
├── Admin/
│ ├── WalletControllerTest.php
│ ├── WithdrawAdminControllerTest.php
│ └── RechargeAdminControllerTest.php
├── Api/V1/
│ ├── WalletControllerTest.php
│ ├── RechargeControllerTest.php
│ └── WithdrawControllerTest.php
└── Wallet/
├── WalletHelperTest.php
├── RechargeServiceTest.php
└── WithdrawServiceTest.php3. HasWalletBalance Trait
任何主表含 balance / frozen_balance 两列的模型 use HasWalletBalance 即获得完整钱包能力:
| 方法 | 职责 |
|---|---|
walletTrades(): MorphMany | 钱包流水(作为 owner) |
recharges(): MorphMany | 充值订单(作为 owner) |
withdraws(): MorphMany | 提现申请(作为 owner) |
hasBalance(float $amount): bool | 判定可用余额是否充足 |
totalBalance(): float | 总资产(可用 + 冻结) |
本期仅 User 接入。
4. WalletHelper 关键方法
| 方法 | 职责 | 余额变动 |
|---|---|---|
incr($owner, $amount, $source, $type, $desc) | 增加可用余额(充值到账、退款、后台充正) | balance += n |
decr($owner, $amount, $source, $type, $desc) | 扣减可用余额(直接消费、后台扣减) | balance -= n |
freeze($owner, $amount, $source, $desc) | 冻结(下单预授权、提现申请) | balance -= n; frozen_balance += n |
unfreeze($owner, $amount, $source, $desc) | 解冻(订单取消、提现驳回) | frozen_balance -= n; balance += n |
settleFrozen($owner, $amount, $source, $type, $desc) | 结算冻结(订单成交、提现打款成功) | frozen_balance -= n |
getBalance($owner): float | 读取可用余额 | — |
getFrozenBalance($owner): float | 读取冻结余额 | — |
并发安全实现要点
- 所有写方法包裹在
DB::transaction()中,进入事务后先对持有者主表行执行lockForUpdate()取最新余额 - 校验余额充足性使用锁内读到的值,不使用传入模型的过期属性
- 余额更新使用增量表达式(
increment/decrement)而非先读后写的赋值,避免丢失更新 - 流水记录写入
before_balance/after_balance/before_frozen/after_frozen四个快照字段,供财务对账与差异排查 - 余额不足抛
InsufficientBalanceException,控制器层捕获后abort(422, ...),与现有InsufficientCoinsException处理方式一致
5. 充值链路
当前状态:支付渠道对接已挂起(见第 12 章「挂起待办」)。Phase 2 只实现订单生命周期与管理员手动确认到账,下方渠道相关步骤为渠道接入后的目标形态。
Phase 2 实现的链路(管理员手动确认)
POST /api/v1/user/wallet/recharges
→ CreateRechargeRequest 校验开关与金额区间
→ RechargeService::create()
→ 计算赠送金额(wallet.recharge_bonus_rate)
→ 生成 order_no(R + YmdHis + 随机串)
→ 创建 Recharge(pay_status=pending, expired_at=now+30min)
→ 返回订单信息(暂不返回渠道支付参数)
POST /admin/wallet/recharges/{recharge}/confirm
→ RechargeService::confirmManually()
→ 事务内按 order_no 加锁查单
→ 幂等判定:已 success 直接返回
→ 置 pay_status=success、paid_at、confirmed_by
→ WalletHelper::incr() 入账(amount + bonus_amount)
→ 派发 RechargeSucceeded 事件 → 队列发站内信渠道接入后的目标链路(挂起)
POST /api/v1/user/wallet/recharges
→ ... 同上 ...
→ 调用支付渠道预下单,返回客户端支付参数
POST /callback/pay/{channel}
→ PayCallbackController 完成渠道验签
→ RechargeService::handleCallback()
→ 与 confirmManually() 复用同一入账内核(保证两条路径行为一致)超时未支付订单由 CloseExpiredRechargesJob 定时置为 expired,注册到 routes/console.php。
6. 提现链路
POST /api/v1/user/wallet/withdraws
→ CreateWithdrawRequest 校验开关/金额区间/当日次数/待审核单
→ WithdrawService::submit()
→ 事务内按 owner 行锁查最新余额
→ 计算手续费(wallet.withdraw_fee_rate)与实际到账金额
→ WalletHelper::freeze() 冻结申请金额(source = Withdraw 本身)
→ 创建 Withdraw(status=pending)
→ 返回 201(无 data 包裹)
POST /admin/wallet/withdraws/{id}/approve
→ WithdrawService::approve()
→ 校验 status=pending(非终态)
→ 置 status=approved、reviewed_by、reviewed_at
→ 派发 WithdrawApproved 事件 → 队列发站内信
POST /admin/wallet/withdraws/{id}/reject
→ WithdrawService::reject()
→ 校验 status=pending,reason 必填
→ 置 status=rejected、reject_reason
→ WalletHelper::unfreeze() 解冻退回可用余额
→ 派发 WithdrawRejected 事件 → 队列发站内信(含原因)
POST /admin/wallet/withdraws/{id}/transfer
→ WithdrawService::markTransferred()
→ 校验 status=approved
→ 置 status=success、transfer_at
→ WalletHelper::settleFrozen() 扣减冻结余额(资金真正流出,WalletTradeType::WITHDRAW)
→ 派发 WithdrawTransferred 事件 → 队列发站内信
POST /admin/wallet/withdraws/{id}/fail
→ WithdrawService::markFailed()
→ 校验 status=approved,reason 必填
→ 置 status=failed、fail_reason
→ WalletHelper::unfreeze() 解冻退回可用余额
→ 派发 WithdrawFailed 事件 → 队列发站内信(含原因)打款失败置 failed 并执行 unfreeze() 退回余额;所有状态变更均幂等(重复调用已终态记录直接返回)。
7. 权限与菜单
7.1 新增权限
| 权限标识 | 说明 |
|---|---|
| wallet.index | 查看钱包流水与统计 |
| wallet.withdraws | 提现审核管理(列表/详情/审核/打款) |
| wallet.orders | 充值订单查看 |
| wallet.adjust | 后台调整余额(高风险操作) |
| wallet.export | 导出钱包流水/提现记录/充值订单 |
7.2 新增菜单
(挂在「运营管理」目录下,通过编辑初始数据迁移追加)
| 菜单 key | path | component | title | permission |
|---|---|---|---|---|
| operation.wallet | wallet | /operation/wallet | 钱包流水 | wallet.index |
| operation.withdraw | withdraw | /operation/withdraw | 提现审核 | wallet.withdraws |
| operation.recharge | recharge | /operation/recharge | 充值订单 | wallet.orders |
按钮权限:钱包流水页挂「调账」(wallet.adjust)、「导出」(wallet.export);提现审核页挂「审核通过」「审核驳回」「标记打款」「标记失败」(均为 wallet.withdraws)、「导出提现申请」(wallet.export)。
wallet.adjust 属高风险操作,后续接入敏感操作二次验证时需纳入覆盖范围。
8. 配置项
新增 wallet.* 分组系统设置(写入 settings 表种子):
| 配置键 | 默认值 | 类型 | 说明 |
|---|---|---|---|
| wallet.enable_recharge | true | bool | 是否开放充值 |
| wallet.enable_withdraw | true | bool | 是否开放提现 |
| wallet.min_recharge_amount | 1 | int | 单笔最低充值金额(元) |
| wallet.max_recharge_amount | 50000 | int | 单笔最高充值金额(元) |
| wallet.recharge_bonus_rate | 0 | float | 充值赠送比例(%) |
| wallet.recharge_expire_minutes | 30 | int | 充值订单超时时长(分钟) |
| wallet.min_withdraw_amount | 100 | int | 单笔最低提现金额(元) |
| wallet.max_withdraw_amount | 50000 | int | 单笔最高提现金额(元) |
| wallet.withdraw_fee_rate | 0 | float | 提现手续费率(%) |
| wallet.daily_withdraw_limit | 3 | int | 每日提现申请次数上限 |
| wallet.withdraw_throttle | 5,1 | string | 提现接口限流(次数,分钟) |
9. 多态别名
config/morph_maps.php 新增:
php
'wallet_trade' => \App\Models\Wallet\WalletTrade::class,
'recharge' => \App\Models\Wallet\Recharge::class,
'withdraw' => \App\Models\Wallet\Withdraw::class,owner_type 直接复用已注册的 user / admin 别名,无需额外注册。
10. 前端落地清单(Ai Forge Admin)
| 文件 | 说明 |
|---|---|
src/api/wallet-manage.ts | 钱包流水、提现审核、充值订单全部 API 函数 |
src/types/api/api.d.ts | 新增 Api.WalletManage 命名空间类型定义 |
src/views/operation/wallet/index.vue | 钱包流水列表(统计卡片 + 表格) |
src/views/operation/wallet/modules/wallet-search.vue | 流水搜索栏 |
src/views/operation/wallet/modules/wallet-trade-detail-dialog.vue | 流水详情弹窗(含前后余额快照) |
src/views/operation/wallet/modules/wallet-adjust-dialog.vue | 后台调账弹窗 |
src/views/operation/withdraw/index.vue | 提现审核列表 |
src/views/operation/withdraw/modules/withdraw-search.vue | 提现搜索栏 |
src/views/operation/withdraw/modules/withdraw-stats.vue | 提现统计卡片 |
src/views/operation/withdraw/modules/withdraw-detail-drawer.vue | 提现详情抽屉 |
src/views/operation/withdraw/modules/withdraw-reject-dialog.vue | 驳回弹窗 |
src/views/operation/recharge/index.vue | 充值订单列表 |
src/views/operation/recharge/modules/recharge-search.vue | 充值订单搜索栏 |
src/views/operation/recharge/modules/recharge-detail-dialog.vue | 充值订单详情弹窗 |
src/views/user/list/modules/user-detail-dialog.vue | 现有文件追加「钱包」Tab(余额 + 流水 + 导出) |
页面路由由后端菜单动态注册,前端无需手写路由配置。
11. 实施阶段
| 阶段 | 内容 | 交付物 | 状态 |
|---|---|---|---|
| Phase 1 基础账本 | users 表加字段;wallet_trades 表;WalletTradeType / WalletTradeStatus 枚举;HasWalletBalance trait;WalletHelper 四个核心方法 + 结算方法;后台流水列表/详情/统计/调账;前台钱包概览与流水接口;PHPUnit 覆盖并发扣减与余额不足场景 | 可查询、可调账的完整账本 | ✅ 已完成(35 用例 / 137 断言全绿) |
| Phase 2 充值订单 | recharges 表 + 模型 + Policy;PayChannel / RechargeStatus 枚举;RechargeService(下单 + 管理员手动确认到账 + 幂等 + 超时关单);超时关单 Job + 定时任务注册;后台充值订单管理(列表/详情/统计/确认/导出);前台充值下单与订单查询接口;RechargeSucceededNotification | 订单生命周期完整,管理员可手动确认到账 | 🔄 进行中 |
| Phase 3 提现审核 | withdraws 表 + 模型 + Policy;WithdrawStatus / WithdrawAccountType 枚举;WithdrawService 完整状态流转(submit/approve/reject/markTransferred/markFailed);冻结/解冻/结算联动(流水 source_type=withdraw);后台提现审核页面(列表/详情/统计/审核/打款/失败/导出);前台提现提交(限流 wallet.withdraw_throttle)+ 列表 + 详情接口;4 个事件(ShouldBroadcast + ShouldQueue)与 4 个站内信通知;导出类(阈值 2000);收款账号响应脱敏 | 提现申请到打款全流程闭环 | ✅ 已完成 2026-09-09(42 用例全绿) |
| Phase 4 集成与验收 | 订单/优惠券等业务侧接入冻结与结算;钱包指标并入业务数据看板;Apifox 按 Admin/Api 模块规范全量发布;补齐测试至项目 70% 覆盖率要求;README 与文档同步 | 生产可用 | 未开始 |
前置依赖
- Phase 4 的冻结链路需支付与订单模块先落地,否则冻结无实际业务场景
wallet.adjust高风险操作建议与敏感操作二次验证协同推进
12. 挂起待办
以下能力已从当前排期中挂起,待条件满足后启动。挂起不影响 Phase 2/3 交付,因为入账内核与订单状态机均已按可插拔方式设计。
12.1 支付渠道对接(挂起)
| 项目 | 说明 |
|---|---|
| 挂起原因 | 本地无商户号与支付证书,真实支付链路无法验证;强行实现会导致核心的幂等与入账逻辑缺乏可靠测试覆盖 |
| 恢复条件 | 提供微信支付/支付宝商户号、API 密钥与证书,并配置到 config/services.php |
| 待补内容 | ① app/Payments/Contracts/PaymentGateway.php 网关契约(prepay / verifyCallback / queryOrder 三个方法);② PaymentManager 渠道路由;③ WechatPayGateway、AlipayGateway 实现类;④ PayCallbackController 统一回调入口(POST /callback/pay/{channel},豁免认证与 CSRF);⑤ RechargeService::handleCallback() 验签入账;⑥ queryAndSync() 主动查单补偿 |
| 设计约束 | 渠道接入时不得修改 RechargeService 的入账内核与 Recharge 状态机,只允许新增网关实现类并在 PaymentManager 注册;回调入账必须复用 Phase 2 已验证的幂等逻辑 |
| 数据准备 | recharges 表在 Phase 2 已预留 pay_channel、transaction_id(唯一索引防重复入账)、callback_payload 三个字段,渠道接入无需再改表结构 |
12.2 充值套餐配置(挂起)
| 项目 | 说明 |
|---|---|
| 挂起原因 | 属运营增强功能,不阻塞资金链路 |
| 待补内容 | recharge_plans 表(面额、赠送金额、排序、启用状态)+ 后台 CRUD + 前台套餐列表接口 |
| 现状替代 | Phase 2 仅支持自定义金额,按 wallet.min_recharge_amount / wallet.max_recharge_amount 校验区间 |
文档来源:PRD 第 8 章