# 10 现金钱包与提现（for AI Agents）

> 关联代码：internal/service/wallet/（service.go/ledger.go/sweeper.go）、internal/wechat/pay/（商家转账）、internal/model/wallet.go
> 管理端页面：web/src/views/wallet/ + web/src/views/config/wallet/；小程序页面：pages/wallet/*
> 深度设计文档：docs/redpacket-wallet/design.md

====================================================================
1. 功能概述
====================================================================

资金体系：现金红包奖品中奖 → 站内钱包余额 → 用户申请提现 → 后台审核 → 微信商家转账到零钱。金额单位一律为分。账本设计对标传统资金系统：每笔变动幂等、余额/冻结非负、全额流水可审计。

资金来源：普通抽奖红包（Prize.Amount）、签到抽奖红包（Winner.Extra）、每日666 红包（累积制入账）、命运盒子红包（auto 入账）。幸运抽奖无现金红包。

====================================================================
2. 底层逻辑
====================================================================

2.1 模型
- UserWallet：Balance 可用 / Frozen 冻结（提现申请即冻结）/ TotalIncome / TotalWithdraw；DB 层 CHECK 约束非负。
- MoneyLog：全量资金流水（类型 prize_income/withdraw_freeze/withdraw_paid/withdraw_refund/admin_adjust；业务 prize/withdrawal/admin）；idempotency_key 唯一防重复记账；每条带 BalanceAfter/FrozenAfter 快照。
- Withdrawal：提现单，status = applying（审核中）→ approved（打款中）→ paid（已到账）；或 rejected（已驳回）/ failed（打款失败）/ offline_paid（线下已付，管理员确认线下打款，冻结已扣减）。ClientToken 客户端幂等键。
- RedpacketPayout：转账出账单（商家转账到零钱）；OutBatchNo=WD<提现ID> 唯一幂等锚点；status pending/processing/success/failed；Scene=withdrawal/prize（prize 为红包直发场景）。

2.2 Ledger（internal/service/wallet/ledger.go）
- Credit/Freeze/Unfreeze/SettleFrozen/Adjust 全部：原子单条 UPDATE 带 balance>=x / frozen>=x 守卫（RowsAffected==0 即余额不足）；同时插 MoneyLog（OnConflict DoNothing，logRow.ID==0 判定幂等命中）。

2.3 提现全流程
- 申请（用户，POST /mp/wallet/withdraw，带 SignMW 签名 + client_token）：
  运行时开关校验（KV wallet.withdraw_enabled，关闭即拒新申请）→ 最低金额（KV wallet.min_withdraw_fen，错误文案动态带门槛值）→ 风控前置 → 余额预检 → 每日限额（静态配置 daily_withdraw_count=3 次、daily_withdraw_amount_fen=500 元/日）→ 事务：建 Withdrawal(applying) + Freeze 冻结余额。ClientToken 与在途单双重幂等去重。
- 审核（后台，POST /admin/withdrawals/:id/approve|reject）：
  approve = 事务内建 RedpacketPayout（OutBatchNo=WD<id>）+ CAS applying→approved，事务外调微信商家转账接口；reject = 解冻退回余额（withdraw_refund 流水）。
- 转账结果：发起转账返回错误 ≠ 转账失败——保持冻结 + payout=processing，交由对账裁决（资金安全不变量，勿改）。
- 对账自愈：wallet Sweeper 每 60s 对 processing 单主动 QueryTransfer；微信回调 POST /api/wechat/transfer/notify 与之互为兜底。仅当明细状态确认为 FAIL 才退款（Withdrawal→failed + 解冻）；成功置 paid（SettleFrozen 扣冻结）。
- 红包入账模式（静态配置 redpacket.credit_mode）：
  auto = 开奖后自动入账（AfterDrawCreditRedpackets，幂等键 prize_credit:<winner_id>，Winner 置 delivered）
  claim = 用户在中奖详情页点领取触发（POST /mp/redpacket/:winnerId/claim，幂等）
- 封禁即时性：ApplyWithdrawal/ClaimPrize 前 assertUserActive（MpUser.Status==0 拒绝）；JWT 长效，服务层实时校验保证封禁即时生效。
- 管理端人工调整：POST /admin/wallets/:id/adjust（正加负减 + 必填原因，写 admin_adjust 流水）。

====================================================================
3. 后台操作（资金管理菜单）
====================================================================

3.1 提现审核 /wallet/withdrawals（views/wallet/Withdrawals.vue）
- 表格列：提现单号/用户/金额(元)/状态/申请时间/审核备注/操作。
- 状态筛选：applying 审核中 / approved 打款中 / paid 已到账 / rejected 已驳回 / failed 打款失败 / offline_paid 线下已付。
- applying 行操作：「通过」（approve，触发商家转账）、「驳回」（reject，备注固定"后台驳回"，解冻退回）、「线下打款」（offline-pay，确认线下已打款，冻结扣减、状态置 offline_paid，不创建微信转账单）。
- approved 行操作：「对账」（reconcile，手动触发一次 QueryTransfer 对账，处理卡在打款中的单）。

3.2 红包发放记录 /wallet/payouts（Payouts.vue）
- 筛选：搜索用户、场景（withdrawal 提现 / prize 中奖直发）、状态。
- 表格列：场景/商户批次号/微信批次号/金额(元)/状态/失败原因/创建时间；只读。

3.3 用户余额 /wallet/users（UserWallets.vue）
- 筛选：搜索用户（支持 URL query.user_id 预筛选）。
- 表格列：用户/余额(元)/冻结(元)/累计提现(元)/操作。
- 「查看/调整」弹窗：余额详情描述列表 + 人工调整表单（变动元，正加负减 step 1，原因必填）→ POST /admin/wallets/:id/adjust。

3.4 余额流水 /wallet/transactions（MoneyTransactions.vue）
- 筛选：搜索用户、类型（prize_income/withdraw_freeze/withdraw_paid/withdraw_refund/admin_adjust）、业务（prize/withdrawal/admin）。
- 表格列：用户/变动(元)/余额(元)/类型/业务/备注/创建时间；只读分页。

3.5 提现设置 /config/wallet（views/config/wallet/）
- 提现开关（NSwitch；关闭后：小程序隐藏提现按钮/入口/表单，后端拒新申请，存量单审核打款不受影响）+ 单次最低提现金额（元，≥1）。保存即时生效（KV）。
- 提现规则配置 /config/withdraw-disclaimer：纯文本 textarea（≤2000 字），小程序提现页展示。

3.6 后台操作逻辑备注（隐藏约束，UI 不直接体现）
- 人工调整余额：POST /wallets/:id/adjust 要求 reason 必填；adjust 流水类型为 admin_adjust，不影响 TotalIncome/TotalWithdraw 统计（这两个字段仅记录业务累计）。
- 提现审核约束：仅 applying 状态可审核（通过/驳回/线下打款）；approved 状态表示已发起转账，不可再驳回——此时只能通过「对账」（reconcile）查询转账结果。
- 线下打款（offline-pay）：仅 applying 状态可操作；调用 Ledger.SettleFrozen 扣减冻结余额（与 paid 相同），但不创建 RedpacketPayout（不走微信转账）；适用于管理员确认已线下现金打款的场景。
- 对账唯一裁决：failed 状态只能由 Sweeper 或手动 reconcile 确认微信转账结果后设置；切勿直接驳回 processing 状态的提现（可能造成双付）。
- 提现开关副作用：关闭提现开关（wallet.withdraw_enabled=false）只拦截新申请；审核中的存量单仍可正常审核打款，不受影响。
- 幂等键约定：prize_credit:<winnerID>（红包入账）、wd_freeze/wd_settle/wd_reject/wd_failrefund:<wdID>（提现各步骤）——排查重复记账时按此查 MoneyLog.idempotency_key。

====================================================================
4. 小程序端操作
====================================================================

- pages/wallet/index/index：余额/冻结/累计展示（提现关闭时隐藏「申请提现」按钮与「累计提现」字段）；资金流水列表；提现入口。
- pages/wallet/withdraw/index：输入提现金额 → 提交（客户端生成幂等键 wd_*，失败重试同 key）；展示我的提现记录、提现规则文案（后台可配）、FAQ 入口。所有门槛校验在后端，前端仅金额格式校验，错误经 toast 展示后端文案。提现关闭时表单区替换为公告文案。
- 中奖红包领取（claim 模式）：中奖详情页（pages/lottery/prize-detail）或 POST /mp/redpacket/:winnerId/claim。
- 首页余额栏：展示红包余额；「满1元可提现」为静态文案（门槛调整后可能不准确，已知边缘）。

====================================================================
5. API 速查
====================================================================

小程序端（/api/mp）：
GET  /api/mp/wallet/me                     我的钱包（含 withdraw_enabled 标志）
GET  /api/mp/wallet/logs                   资金流水
POST /api/mp/wallet/withdraw               申请提现（SignMW + client_token 幂等）
GET  /api/mp/wallet/withdrawals            我的提现单
POST /api/mp/redpacket/:winnerId/claim     领取中奖红包（claim 模式）

管理端（/api/admin，权限 withdrawal:read/review/adjust）：
GET  /api/admin/withdrawals                提现单列表
POST /api/admin/withdrawals/:id/approve|reject|reconcile   通过/驳回/手动对账
POST /api/admin/withdrawals/:id/offline-pay                线下打款确认（withdrawal:review）
GET  /api/admin/redpacket/payouts          转账出账单
GET  /api/admin/wallets(/:id)              钱包查询
POST /api/admin/wallets/:id/adjust         人工调整余额
GET  /api/admin/money-logs                 全站资金流水
GET|PUT /api/admin/config/wallet           提现设置（开关+门槛）

====================================================================
6. 注意事项（资金安全不变量，改动前必读）
====================================================================

- 微信转账是网络调用，绝不放进 DB 事务内。
- 「发起转账报错」≠「转账失败」：网络超时可能微信已受理打款。正确动作：保持冻结与 processing 状态，等 Sweeper/手动对账查实；切勿直接驳回退款（可能造成双付）。
- failed 状态只能由对账确认设置；Reconcile 是唯一真实结果裁决者。pending（从未发起/后补配置）用同一 OutBatchNo 幂等再发起，微信侧按批次号去重不会重复打款。
- 幂等键约定：prize_credit:<winnerID>、wd_freeze/wd_settle/wd_reject/wd_failrefund:<wdID>。
- 提现开关关闭只拦新申请；审核中的存量单仍可正常审核打款。
- 已知未做：提现手续费、收款人实名加密（单笔≥2000 元需实名信息，微信侧要求）、转账错误码网络/业务分类。
- 后台「提现设置」页在 DB 键未配置时展示硬编码默认（100 分），与静态 config.yaml 值可能短暂不一致（既有行为）。
