# FSD M8 · 充值

LLMS 索引： [llms.txt](/llms.txt)

---

> [← 总则](/qmtv/fsd-qmtv/00-%E6%80%BB%E5%88%99/) ｜ [附录 →](/qmtv/fsd-qmtv/99-%E9%99%84%E5%BD%95/) ｜ 上游：[PRD §4 M8](/qmtv/product/prd-qmtv-v1/)
> 实现落点：`backend/internal/domain/recharge/`（model 118/repo 209/service 381）+ `cmd/server/recharge_handlers.go`（161 行 8 handler）+ `cmd/server/routes.go:152-166`（挂载，`up8 := BodyLimit("8mb")` :158 proof/pay-qrcode 共用）。事实基准=Go 代码；冻结版 `qmtv/server/recharge.js`（8 条 :28-:120，admin 4 条 router 内写死 /admin/ 前缀）仅差异对拍参照。
> 域内要点：**M8 是 M7 的返佣写入方**（commission_records 唯一写入方在 approve 链——M7 零域的另一面）；**404 文案三态分叉**（「订单不存在或已处理」vs proof 面「订单不存在」vs 400「已处理不可重复提交凭证」——model.go:21-32 三哨兵）；**approve 零事务补偿序七步**（MarkPaid→到账→两级返佣→资格联动→通知→SSE——禁事务铁律顺合冻结补偿式一致性）；reject **双截断不同源**（落库 200/通知 100）；orderNo='RCH'+毫秒+3 位随机**非 uid 勿用 ids 包**；`recharge_orders.credits` 刻意不 DECIMAL（0004 七列外——到账恒整数）；SSE recharge 仅发本人（M12 无 SSE 的对照面）。bind 形态：**裸传 return err**（与 m12「请求体格式错误」两域不同制）。PRD §4 M8 已登记差异 ①~⑧（prd-qmtv-v1.md:188）引用不重立。

### M8 充值

8 端点全量（用户面 4 条 amw + admin 4 条 amw+adminMw 双挂）。

#### F8.01 充值配置与下单（config/orders GET+POST）

> 对应 PRD：§4 M8 · GET /api/recharge/config、GET、POST /api/recharge/orders ｜ 状态：实现 ｜ 实现落点：recharge_handlers.go:40-99、service.go:65-165、repo.go:92-97 ｜ UI：RechargeView.vue（config staleTime 5min+凭证上传 mutate）｜ 场景挂钩：E2E-RCH-01（待实现，二十七期候选）；RechargeChain 第十三链集成锚（33 元单 0.66 佣金）

##### 1 功能定义

汇率/收款码/推广阈值三键配置面+本人订单列表+创建充值订单（金额两位舍入换算积分）。

##### 2 触发条件与前置状态

config/orders 无前置；下单前置=舍入后金额 ∈ [1,100000]（双 400）。

##### 3 输入与校验

POST body `amount_cny` float64，bind 裸传 return err；校验序（service.go:151-158=冻结 :37-39）：①`amount = Round(amountCny×100)/100`（正数域 half-away 与 Math.round 同形）②`amount==0 || amount<1` → 400「充值金额至少 1 元」③`amount>100000` → 400「单笔金额过大」。

##### 4 处理流程

config：`GetSetting(pay_qrcode)` 空串/缺行→null + creditRate（默认 10）+ affiliateThreshold（默认 980）组装。orders GET：单 SELECT 9 列 `NULLIF(proof_path,'')`，ORDER BY created_at DESC **LIMIT 50**。POST：舍入双 400 → `orderNo = "RCH"+UnixMilli+3 位随机`（**非 uid 勿用 ids 包**，service.go:149 注）→ `credits = int64(Round(amount × creditRate()))` → `ro_` id → INSERT 5 列，status/created_at 走 DDL DEFAULT。

##### 5 输出与结果状态

config 三键序 `credit_per_cny,qrcode,affiliate_threshold`（qrcode 空串→null）；orders 行键序 `id,order_no,credits,amount_cny,status,proof_path,note,created_at,reviewed_at`（**无 user_id**；proof_path NULLIF 还原 null、reviewed_at NULL→null）；POST 四键序 `id,order_no,credits,amount_cny`（amount_cny=舍入后两位小数值）。**LIMIT 分化：用户面 50/admin 面 200**。

##### 6 状态流转

INSERT 建 pending 订单（proof/approve/reject 流转 F8.02/F8.03）。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 金额 <1 元 | 400 | 「充值金额至少 1 元」 |
| 金额 >100000 | 400 | 「单笔金额过大」 |
| settings 值非数字 | — | Go 回退默认 vs 冻结 NaN→JSON null（**PRD ④**） |
| bind 传字符串数值 | 400 | echo 默认（**DIF-31-4 扩域**：冻结 `+amount_cny` 强转放行） |

##### 8 权限与数据规则

orders 严格本人 `user_id=?`；config 全登录用户同值；**threshold 980 与 M7 硬编码 980 两处刻意不同源勿统一**（PRD ⑤=M7 ⑤）；`recharge_orders.credits` BIGINT 恒整数（0004 刻意不动——到账恒整数）。

#### F8.02 凭证上传与收款码（proof/pay-qrcode）

> 对应 PRD：§4 M8 · POST /api/recharge/orders/:id/proof、POST /api/admin/pay-qrcode ｜ 状态：实现 ｜ 实现落点：recharge_handlers.go:18-38/:140-161、service.go:170-230+:364-372、repo.go:100-121 ｜ UI：RechargeView.vue（凭证上传 mutate）/AdminView.vue（收款码设置）｜ 场景挂钩：RechargeChain（真落盘断言 :114-117）

##### 1 功能定义

订单转账凭证图片上传（属主）+admin 收款二维码设置（落 system_settings KV）——**共用同一 upload 函数+8MB 双保险**。

##### 2 触发条件与前置状态

proof 前置=订单存在且属主且 status='pending'（**校验顺序 404→400 非 pending→400 无文件**，service.go:170-183=冻结 :50→:51→:52）；pay-qrcode 无前置。

##### 3 输入与校验

multipart 单文件字段 `file`；**8MB 双保险**：路由级 up8+handler 单文件 `8<<20`，超限 **500「File too large」** 模拟 multer LIMIT_FILE_SIZE（handlers:23-26，M9 ④/M12 同制先例）。

##### 4 处理流程

proof：GetOrderOfUser（`id=? AND user_id=?`）→ 非 pending 400 → 无文件 400 → `upload`（ObjectKey `up_`+extOf 默认 `.png` + store.Put + PathOf `/files/+key`）→ `UPDATE recharge_orders SET proof_path=?` 单列（**不改 status**）。pay-qrcode：无文件 400 → 同一 upload → SetSetting upsert（`key` 保留字反引号）。

##### 5 输出与结果状态

proof `{"proof":"<path>"}`；pay-qrcode `{"qrcode":"<path>"}`；落库/响应形态 `/files/...`。

##### 6 状态流转

recharge_orders.proof_path 单列覆写（status 不动）；system_settings `pay_qrcode` upsert 不触订单。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 订单不存在/非本人 | 404 | 「订单不存在」（**三态分叉之一**——与 approve/reject 的「订单不存在或已处理」不同源） |
| 非 pending 再传凭证 | 400 | 「订单已处理，不可重复提交凭证」（approve 后亦然） |
| 无文件（proof） | 400 | 「缺少凭证图片」 |
| 无文件（pay-qrcode） | 400 | 「缺少二维码图片」 |
| 超 8MB | **500** | 「File too large」 |

##### 8 权限与数据规则

proof=amw+属主；pay-qrcode=amw+adminMw；**proof_path Go DDL NOT NULL DEFAULT '' vs 冻结可空——三处读侧 NULLIF 还原 null 透传**（PRD ⑦）。

#### F8.03 审核与两级返佣（approve/reject/admin list）

> 对应 PRD：§4 M8 · POST /api/admin/recharge-orders/:id/approve、POST .../:id/reject、GET /api/admin/recharge-orders ｜ 状态：实现 ｜ 实现落点：recharge_handlers.go:100-139、service.go:234-361、repo.go:125-146+:151-162+:168-200 ｜ UI：AdminView.vue（充值 tab approveRecharge——**invalidate 缺口保留**不刷 users·summary 照抄）｜ 场景挂钩：RechargeChain（approve 全链/幂等 404/33 元 0.66 佣金/资格联动四锚）

##### 1 功能定义

admin 审核链——approve 到账+两级返佣+资格联动+通知+SSE（**全站返佣唯一写入方**）；reject 驳回+通知；admin 全量订单列表。approveOrder 冻结导出「供重启复用」系死代码零消费方——Go 不建恢复链（PRD ⑥）。

##### 2 触发条件与前置状态

approve/reject 共享 pending 谓词 `WHERE id=? AND status='pending'`；非 pending（含二跑幂等）→ 404「订单不存在或已处理」（**ErrOrderProcessed 两路同源共享哨兵**——与 proof 面「订单不存在」不同源，三态分叉之二/一）。

##### 3 输入与校验

approve 无 body；reject body `note` string（缺省→""），bind 裸传 return err；admin list query `status`。`adminNickname`：JWT 零查库经 auth.Me 取行，空 → 兜底 `'admin'`（=冻结 `admin.nickname||'admin'`）。

##### 4 处理流程（approve 零事务补偿序七步，service.go:236-298=冻结 :59-92 逐条直译）

①`MarkPaid`：`status='paid', reviewed_by=?, reviewed_at=?`（reviewed_at=**newUTCLocalTime UTC**，PRD ②）→ ②到账 `addTolerant`「付费充值 %s（%s 元）」（**仅吞 ErrNoUser 对齐冻结 addCredits 静默，其余 DB 错误照传播**——PRD ③）→ ③GetBuyer（affiliate_enabled 单列）→ ④**两级返佣**：GetReferrer → rel1 → pay(一级) → GetReferrer(rel1) → rel2 → pay(二级)；`pay`：`commission=Round(amount×rate×100)/100`、`<=0 跳过`、INSERT commission_records 7 列 `status='settled'`（0004 后 **order_amount/commission 原值入库**——差异①终结）、`addTolerant` 入账「分销收益（一级|二级）：下级充值 X 元，佣金 Y」+通知「分销收益到账」（**from_user_id 恒=充值人非链上级**，PRD ⑧）→ ⑤资格联动：`!buyer.AffiliateEnabled && AmountCny>=threshold` → EnableAffiliate + 通知「推广资格已开通」（已开通不重复）→ ⑥通知「充值到账」`订单 %s 审核通过，%d 积分已到账。` → ⑦SSE `hub.Emit(o.UserID,"recharge",{order_no,credits})` **仅发本人**。reject（service.go:340-361）：pending 404 同源 → notifyNote 缺省「凭证有误」→ MarkRejected 4 值（**落库 note=truncateRune(note,200) 空串原样**）→ 通知「充值未通过」`订单 %s 未通过审核：%s`（**truncateRune(notifyNote,100)**——双截断不同源）→ ok(true)。admin list：**`status!="" && status!="全部"` 双 SQL 分支照抄勿合并**（带 WHERE vs 不过滤，等价性勿优化）。

##### 5 输出与结果状态

approve `{"order_no","credits"}`；reject `true`；admin list 行键序 13 键 `id,order_no,user_id,credits,amount_cny,status,proof_path,note,reviewed_by,reviewed_at,created_at,email,nickname`（AdminOrderRow 11+2 尾插=冻结 `r.*` SQLite 列序；version 私货列不入投影）。**LIMIT 200**。

##### 6 状态流转

pending →(approve)→ paid / →(reject)→ rejected；proof 不改 status；二级状态无出边（GetPendingOrder 谓词使 approve/reject 二跑恒 404——幂等性缺失）。写序=①~⑦ 顺序（MarkPaid 先行——到账/返佣失败时已 paid 的补偿式语义=冻结同构）。

##### 7 边界与异常

| 条件 | 系统行为 | 提示/码 |
|---|---|---|
| 非 pending（approve/reject，含二跑） | 404 | 「订单不存在或已处理」（三态分叉之二） |
| 非 pending（proof 面） | 400 | 「订单已处理，不可重复提交凭证」 |
| buyer 不存在窗口 | 200 | addTolerant 吞 ErrNoUser（PRD ③——对齐冻结静默） |
| 佣金计算 ≤0 | — | 静默跳过该级返佣（=冻结 :70） |

##### 8 权限与数据规则

三端点均 amw+adminMw；adminNickname 空兜底 'admin'；**reviewed_at UTC 刻意与 auth LocalTime 不同源**（PRD ②，M7 ⑦ 同制）；33 元单集成锚：二级佣金 0.66 原值入库+余额 base+2.66 DECIMAL 往返（integration_recharge_test.go:162-190）。

---

## 册尾端点覆盖对账（8/8）

对账 `backend/cmd/server/routes.go:159-166` 亲数 8 条 ↔ 本册 F8.01~F8.03。**口径脚注**：冻结 recharge.js 8 条（:28/:32/:36/:48 用户 4+:95/:99/:108/:115 admin 4——admin 靠 router 内写死 /admin/ 前缀，挂载点 index.js:27 `app.use('/api', recharge.router)`）；Go 拍平进同一 authed 组、admin 4 条挂 adminMw（routes.go:154-155 注）——冻结-Go 计数自洽。

| # | 端点（routes.go:line） | 节 |
|---|---|---|
| 1 | GET /recharge/config（:159） | F8.01 |
| 2 | GET /recharge/orders（:160） | F8.01 |
| 3 | POST /recharge/orders（:161） | F8.01 |
| 4 | POST /recharge/orders/:id/proof（:162，+up8） | F8.02 |
| 5 | POST /admin/recharge-orders/:id/approve（:163，amw+adminMw） | F8.03 |
| 6 | POST /admin/recharge-orders/:id/reject（:164，amw+adminMw） | F8.03 |
| 7 | GET /admin/recharge-orders（:165，amw+adminMw） | F8.03 |
| 8 | POST /admin/pay-qrcode（:166，amw+adminMw+up8） | F8.02 |

差异纪律：PRD §4 M8 ①~⑧ 全引用不重立（含 DECIMAL 收口①/UTC②/ErrNoUser 容错③/settings NaN④/threshold 双源⑤/死代码⑥/proof_path NULLIF⑦/from_user_id ⑧）；本册零独立新立 DIF——下单 bind 强转（冻结 `+amount_cny` 宽收数值字符串 vs Go bind 400）**并入 DIF-31-4 扩域**（M7 ⑥ credits 面同族、m12 F12.02/04 同型）。注记：资格联动谓词排序冻结「金额判定在前」vs Go「buyer 判定在外层」——纯谓词重排真值表同构无副作用差异，不立目。

---

反链：

- [QMTV 功能规格说明书（FSD）v1.0 · 总则](/qmtv/fsd-qmtv/00-%E6%80%BB%E5%88%99/)
- [FSD 附录 · PRD 对照 / 代码域索引 / 差异台账](/qmtv/fsd-qmtv/99-%E9%99%84%E5%BD%95/)
