feat:自动补货/手动采购逻辑修改:批量获取-》打包-》获取Token列表

This commit is contained in:
2026-10-29 16:05:39 +08:00
parent 8198bd6feb
commit 8494fabc82
23 changed files with 3679 additions and 85 deletions
+413
View File
@@ -0,0 +1,413 @@
# Rock 发货平台(0666.pro)自动发货与库存风控模块分析
> 分析日期:2026-10-27
> 分析范围:`backend/internal/model/rock.go`、`backend/internal/service/rock_*.go`、`provider_rock.go`、`handler/rock.go`、迁移 `012/013`、路由与启动装配,以及 `docs/` 中 Rock 相关文档。
> 来源约定:代码使用 `文件:行号`;文档使用 `docs/文件:行号`。本文所有结论均可按来源复核。
---
## 0. 一句话结论
Rock 模块的本质是:**预采购 Token 库存池 + 一单一 Token + 买家扫码绑定 + UID 强一致门禁 + 自动兑换发货 + 库存水位补货**。
主链路设计完整,防重、防超卖、防历史绑定误判都有专门机制;但**采购的崩溃一致性、取消/重置的回收边界、兑换超时的对账**仍是资金与串单风险最集中的地方。
---
## 1. 模块边界与入口
### 1.1 商品与渠道路由
- Rock 商品使用 `rock_` 前缀 SKU;未显式配置 `RoutingMode` 时自动落到 `RoutingModeRock`,优先级只有 `rock`(`backend/internal/service/provider.go:67-81`)。
- 订单一旦在 `result_data.provider` 写入 `rock`,后续 `ResolveProviders` 只返回 Rock,渠道被锁定(`provider.go:181-188`)。
- Rock Provider 在启动时注册(`backend/cmd/server/main.go:123-132`),库存巡检 goroutine 每 5 分钟运行一次(`main.go:171-178`)。
- 管理端接口全部在平台管理员组下,受 JWT + `RoleAdmin` 保护(`backend/internal/router/router.go:238-240, 284-297`)。
### 1.2 管理端能力
| 能力 | 接口 | Handler |
|------|------|---------|
| 查看/修改配置 | `GET/PUT /platform/rock/config` | `handler/rock.go:22-44` |
| 测试连接 | `POST /platform/rock/test-conn` | `handler/rock.go:51-60` |
| 看板统计 | `GET /platform/rock/stats` | `handler/rock.go:63-70` |
| 手动采购 | `POST /platform/rock/purchase` | `handler/rock.go:77-91` |
| Token 列表 | `GET /platform/rock/tokens` | `handler/rock.go:94-111` |
| 修正买家 UID | `POST /platform/rock/tokens/:id/expected-uid` | `handler/rock.go:118-143` |
| 解冻重置 | `POST /platform/rock/tokens/:id/reset` | `handler/rock.go:146-157` |
| 释放回库存 | `POST /platform/rock/tokens/:id/release` | `handler/rock.go:160-171` |
| 备注/状态联动 | `POST /platform/rock/tokens/:id/remark` | `handler/rock.go:179-199` |
| 标记上报失效 | `POST /platform/rock/tokens/:id/abnormal` | `handler/rock.go:206-223` |
### 1.3 数据表
- `rock_configs`:单行配置(固定 `id=1`),存上游地址、Bearer Token、自动发货/自动采购开关、库存阈值、冷却时间、积分快照等(`backend/internal/database/migrations/012_rock_fulfillment.sql:6-26`、`model/rock.go:35-52`)。
- `rock_tokens`:本地 Token 库存池,存上游 Token、兑换码、状态、订单绑定、UID、绑定快照、履约 ID、备注等(`012_rock_fulfillment.sql:30-55`、`model/rock.go:58-90`)。
- `remark` 字段由 `013_rock_tokens_remark.sql` 追加(`013_rock_tokens_remark.sql:4`)。
- 商品目录:`rock_black_tshirt`(黑色 ROCK 长袖 T 恤)与 `rock_blue_sportswear`(蓝色运动服上衣),默认价 2500 积分,自营商户库存 `-1`(无限),履约配置 `routing_mode=rock`、`delivery_type=LINK_BINDING`(`012_rock_fulfillment.sql:63-101`)。
---
## 2. 端到端主流程
```mermaid
sequenceDiagram
autonumber
participant A as 管理员
participant S as affiliate_dash
participant R as Rock 0666.pro
participant B as 买家
participant O as 订单
A->>S: 配置 Bearer Token / 自动发货 / 库存阈值
A->>S: 手动或自动采购 Token
S->>R: POST /api/user/tokens/claim
R-->>S: token + redeem_code 列表
S->>S: 批量写入 rock_tokens,status=available
O->>S: 买家打开提货页,填写游戏 UID
S->>S: 渠道路由锁定 rock
S->>S: AssignTokenForOrder:一单一 Token,status=using
S->>R: GET /api/redeem/{redeem_code}
R-->>S: bindUrl + qrUrl
S-->>B: 返回二维码/绑定链接
B->>R: 微信/QQ 扫码,在腾讯活动页绑定角色
B->>S: 前端轮询绑定结果
S->>R: POST /api/user/tokens/{id}/refresh
R-->>S: bound_account / bound_account_id / bind_changed_at
S->>S: 反 Ghost 快照比对 + UID 强一致校验
alt UID 一致
S->>S: token.status=bound
B->>S: 点击确认发货
S->>R: POST /api/redeem/{id}(skin/currency/account)
R-->>S: status=success, token.status=redeemed
S->>O: 订单 delivered + 回调 outbox
else UID 不一致
S->>S: token.status=mismatch,阻断发货
S-->>B: 提示修改 UID 或联系客服
end
```
### 2.1 订单侧状态变化
- `Bind` 只写 `result_data`(`provider=rock`、`rock_token_id`、`rock_redeem_code`、`expected_uid`),**不提前把订单改成 `delivering`**(`provider_rock.go:139-178`、`delivery_bind.go:165-174`)。
- `Submit` 先由发货服务抢占提交权(`provider_order_stage=claimed`、`submit_started_at`、`ship_attempts+1`),再调用 Rock 兑换(`delivery_submit.go:70-95, 175-194`)。
- Rock 兑换成功后直接返回 `NextStatus=delivered`,订单从 `paid` 直接到 `delivered`;失败则 `markDeliverySubmissionFailed` 置 `ship_failed`(`provider_rock.go:293-303`、`delivery_submit.go:96-128`)。
- 因此 Rock 订单通常不经过 `delivering` 停留;`delivering` 更多用于乐玩 Webhook / 超时扫描等异步链路。
---
## 3. Token 状态机
```mermaid
stateDiagram-v2
[*] --> available: 采购入库
available --> using: 订单绑定,分配 Token
using --> bound: 扫码绑定 + UID 一致
using --> mismatch: 扫码 UID 不一致
bound --> mismatch: 再次校验发现 UID 不一致
mismatch --> bound: 买家/客服把期望 UID 改成扫码 UID
bound --> redeemed: 上游兑换成功
using --> available: 释放未绑定 / 订单取消
mismatch --> available: 客服解冻重置
abnormal --> available: 客服解冻重置
using --> abnormal: 人工标记失效
bound --> abnormal: 人工标记失效
redeemed --> abnormal: 人工标记失效
redeemed --> available: 备注接口直接改状态(危险)
bound --> available: 备注接口直接改状态(危险)
```
### 3.1 本地状态字典
| 状态 | 含义 | 关键来源 |
|------|------|----------|
| `available` | 可用库存,未被订单占用 | `model/rock.go:21` |
| `using` | 已分配给订单,等待买家扫码 | `model/rock.go:22` |
| `bound` | 扫码绑定成功,UID 已核验 | `model/rock.go:23` |
| `redeemed` | 已向上游兑换发货 | `model/rock.go:24` |
| `mismatch` | 扫码 UID 与订单 UID 不一致,禁止发货 | `model/rock.go:25` |
| `abnormal` | 异常/上报失效/作废 | `model/rock.go:26` |
> 上游文档还提到 `settled`(已结算),但本地模型没有该状态;本地财务结算不落在 Token 状态机里(`docs/rock发货平台(0666.pro)逆向与接口分析.md:107-129`)。
### 3.2 代码允许的流转比业务状态机更宽
- `ReleaseUnboundUsingToken` 只允许 `using -> available`,且必须上游未扫码(`rock_redeem.go:95-144`)。
- `ResetMismatchToken` 允许 `mismatch/abnormal -> available`,但**不检查上游绑定状态**(`rock_recover.go:101-127`)。
- `UpdateTokenRemark` 允许把任意当前状态改成 `available/mismatch/abnormal`,且**不清理订单关联字段**(`rock_recover.go:129-166`)。
- `ProviderRock.Cancel` 只处理 `using -> available`,`bound` 不释放(`provider_rock.go:343-361`)。
---
## 4. 核心机制
### 4.1 渠道选择与 Kill Switch
- `PrepareDelivery` 先读配置;`AutoDeliveryEnabled=false` 时直接返回 `CanShip=false`,订单保留 `paid`,不调用上游、不消耗 Token(`provider_rock.go:28-45`)。
- 若订单已经分配了 `using/bound` Token,即使池子可用库存为 0,也允许继续履约;只有 `abnormal` 会阻断;`mismatch` 留给绑定页提示(`provider_rock.go:47-79`)。
- 若无已分配 Token 且 `AvailableStock <= 0`,异步触发补货检查,并返回“库存补充中”(`provider_rock.go:81-95`)。
### 4.2 一单一 Token:分配算法
入口:`AssignTokenForOrder(orderNo, expectedUID)`(`rock_assign.go:32-158`)。
1. **先查该订单是否已占用 Token**,状态范围 `using/bound/mismatch/redeemed/abnormal`,按 `id DESC` 取最新(`rock_assign.go:43-57`)。
2. 按状态分支:
- `abnormal`:直接拒绝,提示“已绑定失败,不能更换新提货号”(`rock_assign.go:60-62`)。
- `redeemed`:拒绝,“已发货,不能再次领取”(`rock_assign.go:63-64`)。
- `mismatch`:仅当传入的 `expectedUID` 等于扫码实际 UID 时,自动解除阻断并转 `bound`;否则返回“扫码角色是 X,请改成 X 或联系客服”(`rock_assign.go:65-94`)。
- `bound`:若传入 UID 与已记录期望 UID 不同,拒绝换 UID;相同则复用(`rock_assign.go:95-104`)。
- `using`:允许更新 `expected_uid`(`rock_assign.go:95-104`)。
3. 若没有占用记录:在事务中用 `SELECT ... FOR UPDATE SKIP LOCKED` 取 `status=available AND color=black` 的最小 ID,置为 `using`,写 `assigned_order_no` 与 `expected_uid`(`rock_assign.go:107-131`)。
4. 分配成功后异步触发库存检查(`rock_assign.go:129-131`),再调 `GET /api/redeem/{code}` 获取绑定链接并缓存(`rock_assign.go:133-138`)。
5. 首次分配且配置了 Bearer Token 时,调用 `RefreshToken` 记录初始绑定快照(`rock_assign.go:140-155`)。
**关键点**:`SKIP LOCKED` 只解决“两个订单同时抢同一个 available Token”,不解决多实例采购、取消回收、管理员重置带来的状态竞争。
### 4.3 反 Ghost-Binding:初始快照 + 新扫码判定
上游 Token 可能带着上一个买家的历史绑定。若直接把 `refresh` 返回的绑定当成本次扫码,会把货发给历史角色。模块用两层防线:
**第一层:分配时快照**(`rock_assign.go:140-155`)
```go
type BindingSnapshot struct {
Bound bool
RoleName string
RoleID string
Channel string
BindChangedAt string
}
```
快照存在 `rock_tokens.initial_bound_snapshot`,JSON 不对外输出(`model/rock.go:73`)。
**第二层:`rockBuyerNewlyBound` 判定**(`rock_snapshot.go:15-36`)
| 条件 | 结论 | 代码 |
|------|------|------|
| 上游未绑定 / 无角色 ID | 不是新绑定 | `rock_snapshot.go:16-18` |
| 快照已绑定,角色 ID 与 `bind_changed_at` 都与当前一致 | 历史残留,不是新绑定 | `rock_snapshot.go:19-22` |
| 发货记录时间晚于领取基线,或 `bind_changed_at` 晚于基线-1 分钟 | 新绑定 | `rock_snapshot.go:23-25, 90-100, 102-119` |
| 快照角色 ID 与当前不同 | 新绑定(换号) | `rock_snapshot.go:26-28` |
| `bind_changed_at` 为空,且只有历史发货记录 | 历史残留 | `rock_snapshot.go:29-31` |
| 有快照但以上都不满足 | 历史残留 | `rock_snapshot.go:32-34` |
| 无快照、无历史记录 | 按新绑定处理 | `rock_snapshot.go:35` |
- 基线取 `claimed_at`(采购领取时间),没有则取 `created_at`(`rock_snapshot.go:58-66`);**不是分配时间**。真正兜底的是分配时快照。
- 上游时间按固定 UTC+8 解析,避免进程时区漂移(`rock_snapshot.go:68-88`)。
**门禁**:如果 `expected_uid` 非空且与扫码 `actual_role_id` 不一致,立即把 Token 置为 `mismatch`,记录 `mismatch_at/reason`,严禁发货(`rock_bindcheck.go:162-194`)。一致才置 `bound`(`rock_bindcheck.go:196-212`)。
### 4.4 绑定状态轮询与 110001 降级
- `CheckBind` 每轮最多请求一次上游 `refresh`,但有 800ms 进程内缓存,避免前端高频轮询撞上游频控(`rock_bindcheck.go:98-140`)。
- 上游返回 110001“请求过于频繁”时:
- 有缓存则用缓存;
- 本地已是 `bound` 则直接按已绑定返回;
- 否则返回“正在同步,请稍后重试”,不把买家打断(`rock_bindcheck.go:110-129`)。
- 绑定链接缓存 8 分钟(上游签名约 10 分钟过期),过期前复用;上游仍返回旧链接时 15 秒内不重复请求(`rock_bindlink.go:18-23, 51-63, 83-121`)。
- 上游未给 `qrUrl` 时,用 `api.qrserver.com` 生成二维码——这会把带 UID/签名的绑定 URL 发给第三方(`provider.go:234-236`、`provider_rock.go:146-150`)。
### 4.5 最终兑换发货
`RedeemTokenForOrder`(`rock_redeem.go:17-91`):
1. 检查 Kill Switch。
2. 按 `assigned_order_no + status=bound` 取 Token;找不到则拒绝。
3. 最终二次 UID 校验:`actual_role_id` 必须等于 `expected_uid`,否则“终审拦截”(`rock_redeem.go:37-44`)。
4. 按 SKU 决定皮肤:`rock_blue_sportswear` -> 蓝色运动服,否则黑色 ROCK 长袖(`rock_redeem.go:46-50`)。
5. 确保 `actual_role_name` 存在;为空则再 `refresh` 一次(`rock_redeem.go:52-64`)。
6. 调 `POST /api/redeem/{upstreamTokenID}`,请求体 `skin/currency/account`(`rock_client.go:404-449`)。
7. 再 `refresh` 取最新 `deliveryList` 的最后一条 `fulfillId`(`rock_redeem.go:72-78`)。
8. 本地置 `redeemed`,写 `upstream_fulfill_id/target_skin/target_currency/target_currency_quantity/redeemed_at`(`rock_redeem.go:80-88`)。
9. `ProviderRock.Submit` 把这些字段 patch 到订单 `result_data`,返回 `delivered`(`provider_rock.go:281-303`)。
**注意**:`target_currency_quantity` 固定写 `1`(`rock_redeem.go:86`),但上游按皮肤单价动态计算附赠货币数量(文档示例黑色 ROCK 为 10 个幸运币、蓝色运动服为 14 个,`docs/rock发货平台(0666.pro)逆向与接口分析.md:74-103`)。本地订单里的 `currency_quantity` 会与实际发放数量不一致。
### 4.6 取消、释放、重置、备注
| 操作 | 允许的当前状态 | 是否检查上游 | 清理订单关联 | 风险 |
|------|----------------|--------------|--------------|------|
| `ProviderRock.Cancel` | `using` | **否** | **否** | 买家刚扫码但本地未更新时,可能把已绑定 Token 放回池子;`bound` 不释放,订单取消后 Token 卡死 |
| `ReleaseUnboundUsingToken` | `using` | 是(refresh + anti-ghost) | 是(清 token 字段 + detach 订单 result_data) | 相对安全,但依赖上游可用 |
| `ResetMismatchToken` | `mismatch/abnormal` | **否** | **否**(只清 token) | 可能把仍绑定在上游的 Token 重新投放;旧订单 result_data 残留 |
| `UpdateTokenRemark(status=available)` | 任意 | **否** | **否** | 可把 `bound/redeemed` 直接改回可用,旧订单仍指向它,严重串单风险 |
| `CorrectMismatchExpectedUID` | `mismatch` | 否(客服人工判断) | 更新订单 `expected_uid` | 改对扫码 UID 才解除阻断;改错仍 mismatch |
来源:`provider_rock.go:343-361`、`rock_redeem.go:95-168`、`rock_recover.go:18-87, 101-175`。
### 4.7 库存水位与自动采购
**统计口径**:`COUNT(rock_tokens WHERE status='available' AND color='black')`(`rock_stock.go:92-95`)。
**触发点**:
1. 主进程每 5 分钟 ticker(`main.go:171-178`)。
2. 每次新分配 Token 后异步触发(`rock_assign.go:129-131`)。
3. `PrepareDelivery` 发现可用库存为 0 时异步触发(`provider_rock.go:86-88`)。
**判定与采购**(`rock_stock.go:86-113`):
1. 库存 `count <= token_warning_stock` 时打印预警日志。
2. 若 `auto_purchase_enabled=true`,取 `auto_purchase_count`(<=0 时兜底 50)。
3. 调用 `PurchaseTokens(ctx, count, true)`。
4. `PurchaseTokens` 用进程内 `sync.Mutex` 串行化;自动采购额外检查 `last_purchased_at` 冷却(`rock_stock.go:15-35`)。
5. 调上游 `POST /api/user/tokens/claim`,`color=black`,`count` 以字符串提交(`rock_client.go:291-335`)。
6. 上游返回 Token 数组后,事务批量写 `rock_tokens` 并把 `last_purchased_at` 更新为当前时间(`rock_stock.go:47-67`)。
7. 异步 `GET /api/me` 刷新配置里的 `points` 快照(`rock_stock.go:72-80`)。
**手动采购**:`isAuto=false`,跳过冷却,但仍受进程内 mutex 串行化(`handler/rock.go:77-91`、`rock_stock.go:15-35`)。
### 4.8 配置、统计与心跳
- `GetConfig` 找不到 `id=1` 时自动创建默认配置(`rock_service.go:59-83`)。
- 启动时 `ApplyConfigToClient` 把 DB 的 `base_url` 同步到 HTTP 客户端;后台修改 `base_url` 也会立即 `SetBaseURL`(`rock_service.go:89-96, 109-113`、`main.go:128-130`)。
- `TestConnection` 调 `/api/me`,成功时更新 `username/points/last_heartbeat_at`(`rock_service.go:144-171`)。
- `GetStats` 从 DB 聚合各状态数量,`UpstreamPoints/Username/LastHeartbeatAt` 取配置快照(`rock_service.go:174-221`)。
---
## 5. 关键规则速查
| 编号 | 规则 | 来源 |
|------|------|------|
| R1 | `rock_` SKU 自动路由到 Rock;`result_data.provider` 一旦落值即锁定渠道 | `provider.go:67-81, 181-188` |
| R2 | Kill Switch 关闭时只保留 `paid`,不消耗 Token/积分 | `provider_rock.go:38-45`、`rock_assign.go:37-39`、`rock_redeem.go:22-24` |
| R3 | 一个订单只能占用一个 Token;`abnormal/redeemed` 拒绝再分配,`mismatch` 仅允许改成扫码 UID,`bound` 不允许换 UID | `rock_assign.go:43-105` |
| R4 | 新 Token 分配用 `FOR UPDATE SKIP LOCKED`,只取 `available+black` 的最小 ID | `rock_assign.go:107-124` |
| R5 | 首次分配记录上游绑定快照;快照一致视为历史残留,角色变化/绑定时间晚于领取基线视为新扫码 | `rock_assign.go:140-155`、`rock_snapshot.go:15-36` |
| R6 | 扫码 UID 与订单 UID 强一致才允许 `bound`;不一致立即 `mismatch` | `rock_bindcheck.go:162-212` |
| R7 | 最终兑换前再次校验 UID,不一致直接拒绝 | `rock_redeem.go:37-44` |
| R8 | 绑定链接 8 分钟刷新一次,110001 频控时降级使用缓存/本地已绑定状态 | `rock_bindlink.go:18-23, 83-121`、`rock_bindcheck.go:98-140` |
| R9 | 兑换成功后 Token 置 `redeemed`,订单直接 `delivered`,回调走 outbox | `rock_redeem.go:80-88`、`provider_rock.go:293-303` |
| R10 | 取消只释放 `using`,且当前实现不检查上游、不清订单关联 | `provider_rock.go:343-361` |
| R11 | 库存水位 = `available+black` 数量;低于阈值告警,开启自动采购时补货 | `rock_stock.go:86-113` |
| R12 | 自动采购有进程内 mutex + 冷却;手动采购跳过冷却 | `rock_stock.go:15-35` |
| R13 | 采购先调上游、后写本地;没有 pending 采购单/对账 | `rock_stock.go:38-67` |
| R14 | 只有 `TestConnection` 更新心跳和积分;没有周期心跳 | `rock_service.go:144-171` |
| R15 | 管理端可把任意 Token 置 `available/mismatch/abnormal`,没有状态迁移校验 | `rock_recover.go:129-166` |
---
## 6. 问题清单(按风险排序)
### P0:资金/数据不可恢复
**P0-1 采购崩溃窗口:上游已扣积分,本地没入库**
- 代码先 `ClaimTokens`,成功后才在本地事务插入 Token(`rock_stock.go:38-67`)。
- 若进程在两步之间崩溃、DB 事务失败或网络超时,上游已扣积分/已发 Token,本地没有任何记录,也没有 `purchase_attempt` 可对账。
- 建议:先写 `purchase_attempts`(唯一请求号,状态 `pending`)→ 调上游 → 回填 Token;增加按上游流水/Token 列表的对账任务。
**P0-2 多实例自动采购竞态**
- `s.mu` 是进程内锁,冷却检查在 DB 事务之前读取 `last_purchased_at`(`rock_service.go:16-19`、`rock_stock.go:16-35`)。
- 两个实例可同时通过冷却检查,各自采购一批,造成超买/积分浪费(`main.go:171-178`)。
- 建议:PostgreSQL advisory lock、单例 leader worker,或对 `last_purchased_at` 做条件更新抢占。
### P1:串单/状态不一致
**P1-1 `ProviderRock.Cancel` 绕过上游检查,且不清理快照**
- 只把 `using` 改成 `available`,不调用 `RefreshToken`、不检查 `rockBuyerNewlyBound`、不清 `initial_bound_snapshot/actual_*`、不清订单 `result_data`(`provider_rock.go:343-361`)。
- 买家刚扫码但本地轮询还没更新时取消订单,Token 会带着上游绑定回到库存池;下一位买家可能遇到 Ghost-Binding。
- `bound` 状态取消时完全不释放,订单取消后 Token 永久卡在 `bound`。
- 建议:取消时统一走 `ReleaseUnboundUsingToken`;`bound` 需人工复核;清理快照/实际角色/订单关联。
**P1-2 `ResetMismatchToken` / `UpdateTokenRemark` 可把已绑定 Token 重新投放**
- `ResetMismatchToken` 允许 `mismatch/abnormal -> available`,不检查上游是否仍绑定(`rock_recover.go:101-127`)。
- `UpdateTokenRemark` 允许任意状态改成 `available/mismatch/abnormal`,且不清理 `assigned_order_no/expected_uid/actual_*`(`rock_recover.go:129-166`)。
- 把 `bound/redeemed` 改成 `available` 后,旧订单仍指向该 Token;新订单再次分配会造成一 Token 多订单。
- 建议:状态迁移白名单 + 强制清理关联字段 + 可选上游 refresh 校验;`bound/redeemed` 不允许直接改 available。
**P1-3 `CheckBindingAndVerifyUID` 的 mismatch 分支没有校验订单归属**
- 函数在 `status=mismatch` 分支里,只要传入 `expectedUID == actualRoleID` 就直接改成 `bound` 并返回,**没有先校验 `AssignedOrderNo == orderNo`**(`rock_bindcheck.go:35-66`)。
- 攻击者若拿到其他订单的 Token ID(`bind_uuid`)和扫码 UID,可能把别人的 mismatch Token 改成 bound。
- 建议:在任何状态分支之前统一校验 `token.AssignedOrderNo` 与订单号一致。
**P1-4 兑换超时/失败后的对账缺口**
- `RedeemSkin` 成功后本地才置 `redeemed`;若上游已成功但 HTTP 超时/进程崩溃,本地仍是 `bound`,订单可能 `ship_failed`。
- `ProviderRock.SyncStatus` 只看本地 Token 状态,不主动 refresh 上游;`QueryExchangeRecords` 客户端方法存在但从未调用(`rock_client.go:451-487`、`provider_rock.go:306-341`)。
- `deliverySubmittedUpstream` 依赖 `provider_order_no`/`delivery`/`upstream_order`/`provider_order_stage=submitted`;Rock 失败时通常没有这些标记,重试可能再次调用兑换(`delivery.go:29-46`、`delivery_submit.go:96-105`)。
- 建议:增加 `redeeming` 中间态/兑换尝试表;超时后先用 `refresh`/`exchange-records` 对账,再决定重试或人工处理;重试前检查上游是否已 `redeemed`。
**P1-5 `RedeemSkin` 可能把“非 success 且无 error”当成功**
- `RedeemSkin` 只在 `!IsSuccess() && res.Error != ""` 时报错(`rock_client.go:441-448`)。
- 若上游返回 `status=failed` 但 `error` 为空,函数仍返回 response,`RedeemTokenForOrder` 会把本地置 `redeemed`(`rock_redeem.go:66-88`)。
- 建议:以 `IsSuccess()` 为唯一成功条件;非 success 一律返回错误并保留 Token 状态。
**P1-6 附赠货币数量写死为 1**
- `target_currency_quantity` 固定 `1`(`rock_redeem.go:86`),订单 patch 也用它(`provider_rock.go:287`)。
- 上游按皮肤价格动态计算(黑色 ROCK 10 个幸运币、蓝色运动服 14 个),本地数据会误导客服/对账。
- 建议:从上游 `redeem`/`refresh` 响应解析实际数量,或至少不要写死 1。
**P1-7 采购无唯一约束,可能重复入库**
- `rock_tokens.redeem_code` 只有普通索引,`upstream_token_id` 没有唯一约束(`012_rock_fulfillment.sql:30-60`)。
- 上游重试/重复返回同一 Token 时,本地可插入多条相同 `redeem_code`,两个订单可能各自拿到同一上游 Token。
- 建议:至少对 `upstream_token_id` 建唯一索引;`redeem_code` 视业务决定是否唯一;采购入库用 `ON CONFLICT DO NOTHING`。
**P1-8 积分预警/库存告警未落地**
- `min_points_warning` 只存配置,从未参与判断(`model/rock.go:43`、`rock_service.go:117-118`)。
- `CheckStockAndAutoPurchase` 只 `log.Printf`,没有调用现有告警渠道(`rock_stock.go:97-111`)。
- 上游积分不足时采购失败只写日志;没有周期 `GetMe`,`points` 可能长期陈旧。
- 建议:采购前检查积分;低于阈值发钉钉/飞书/企微;增加周期心跳;把告警接入 `alert_notify_channels`。
**P1-9 `Bearer Token` 明文存储并回显**
- `BearerToken` 是明文 `TEXT`(`model/rock.go:40`),`GET /platform/rock/config` 直接返回整个配置(`handler/rock.go:22-29`)。
- 建议:`json:"-"` + 掩码 DTO + `DATA_ENCRYPTION_KEY` 加密;只保留“测试连接”不回显密钥。
### P2:可运维性/边界细节
- **P2-1** `GetStats.AvailableStock` 统计所有颜色,而分配只取黑色;若池中有蓝色 Token,`PrepareDelivery` 可能误判有库存(`rock_service.go:190-218`、`rock_assign.go:110-112`)。
- **P2-2** `refreshCache`/`bindLinkCache` 是进程内 `sync.Map`,没有淘汰;长期运行会累积(`rock_service.go:20-21`)。
- **P2-3** 手动采购没有数量上限;`CheckStockAndAutoPurchase` 成功后日志打印的是请求数量而非实际返回数量(`handler/rock.go:77-90`、`rock_stock.go:105-109`)。
- **P2-4** 二维码兜底依赖 `api.qrserver.com`,绑定 URL 含 UID/签名,存在隐私与可用性风险(`provider.go:234-236`)。
- **P2-5** `ResetMismatchToken` 不清理订单 `result_data` 的 `rock_token_id/redeem_code`;`ProviderRock.Cancel` 也不清理(`rock_recover.go:101-127`、`provider_rock.go:343-361`)。
- **P2-6** `ReportAbnormal`/`QueryExchangeRecords` 客户端方法已实现但未接入业务;当前“上报失效”只是本地 `abnormal`,需要确认人工上报流程是否真的执行(`rock_client.go:451-514`)。
- **P2-7** `GetConfig` 首次并发创建 `id=1` 可能撞唯一键;`UpdateConfig` 先改客户端 baseURL 再写 DB,DB 失败会出现内存/DB 不一致(`rock_service.go:59-83, 109-113`)。
---
## 7. 加固优先级建议
1. **采购链路**:`purchase_attempts` + 上游对账 + 单例锁/ advisory lock + `upstream_token_id` 唯一约束。
2. **回收链路**:取消统一走 `ReleaseUnboundUsingToken`;`bound` 不自动回收;重置/备注加状态迁移白名单和字段清理。
3. **兑换链路**:增加 `redeeming` 中间态/尝试表;超时先对账;`IsSuccess()` 严格判定;解析真实货币数量。
4. **风控观测**:积分预警、库存告警接入通知渠道;周期心跳;采购/兑换/回收指标。
5. **安全**:Bearer Token 加密 + 掩码;`bind_uuid` 校验订单归属;管理端危险操作二次确认。
---
## 8. 来源索引
| 主题 | 文件 |
|------|------|
| 配置/Token 模型 | `backend/internal/model/rock.go` |
| 配置、统计、连接测试 | `backend/internal/service/rock_service.go` |
| Token 分配 | `backend/internal/service/rock_assign.go` |
| 绑定校验/UID 门禁 | `backend/internal/service/rock_bindcheck.go` |
| 反 Ghost 快照 | `backend/internal/service/rock_snapshot.go` |
| 绑定链接缓存 | `backend/internal/service/rock_bindlink.go` |
| 兑换/释放 | `backend/internal/service/rock_redeem.go` |
| 修正/重置/备注 | `backend/internal/service/rock_recover.go` |
| 采购/库存巡检 | `backend/internal/service/rock_stock.go` |
| 上游 HTTP 客户端 | `backend/internal/service/rock_client.go` |
| Rock Provider | `backend/internal/service/provider_rock.go` |
| 渠道路由 | `backend/internal/service/provider.go` |
| 管理端 handler | `backend/internal/handler/rock.go` |
| 路由注册 | `backend/internal/router/router.go:284-297` |
| 启动装配/巡检 | `backend/cmd/server/main.go:123-132, 171-178` |
| 数据表/商品 | `backend/internal/database/migrations/012_rock_fulfillment.sql` |
| 备注字段 | `backend/internal/database/migrations/013_rock_tokens_remark.sql` |
| 上游接口逆向 | `docs/rock发货平台(0666.pro)逆向与接口分析.md` |
| 接入/风控方案 | `docs/Rock发货平台代理接入与库存风控实施方案.md` |
| 失效/备注优化 | `docs/Rock发货记录时间与备注及失效状态优化方案.md` |
+257
View File
@@ -0,0 +1,257 @@
# affiliate_dash 系统脑图与流程图
> 配套文档:`docs/项目梳理-业务流程与数据模型.md`
> 用途:用 5 张图快速建立系统全貌;细节、字段与代码来源以配套文档为准。
> 日期:2026-10-26
## 1. 系统全景脑图
```mermaid
mindmap
root((affiliate_dash))
角色与入口
平台管理员
platform 管理端
商户 商品 渠道 Rock
下游商户
merchant 后台
client v1 开放接口
终端买家
delivery 提货页
上游货源
open v1 回执
乐玩 Webhook
核心业务
下单
开放 API
后台人工
联调测试
资金
钱包账户
不可变流水
充值审核
履约
渠道路由
DLC
乐玩
Rock
提货
HMAC 链接
UID 绑定
回调
事务 outbox
HMAC 签名
状态机
paid
delivering
delivered
ship_failed
cancelled
关键规则
幂等键
金额与手续费
渠道锁定
已提交禁重试
Rock 一单一 Token
北京时间
数据模型
24 张表
订单事实表
钱包流水
回调 outbox
Rock 库存
审计日志
技术栈
Go Gin GORM
PostgreSQL 16
React AntD Vite
Caddy Docker
风险
图片代理 SSRF XSS
数据库暴露
无备份
无优雅停机
无限流
```
## 2. 端到端主流程
```mermaid
flowchart TD
S["商户创建订单"] --> SRC{"订单来源"}
SRC -->|api| API["开放接口:验签 + nonce + scope"]
SRC -->|manual| MAN["后台人工:权限 + 功能开关"]
SRC -->|test| TEST["测试单:不扣款、不占库存"]
API --> TX["事务创建订单"]
MAN --> TX
TX --> IDEM{"client_order_no 已存在?"}
IDEM -->|是,指纹一致| RET["返回原订单"]
IDEM -->|是,指纹不同| REJ["拒绝:幂等冲突"]
IDEM -->|否| PAY["扣钱包 + 写流水 + 扣库存"]
PAY --> ORD["写 fulfillment_orders,状态 paid"]
TEST --> ORD2["写测试订单,状态 paid"]
ORD --> LINK["生成 HMAC 提货链接"]
ORD2 --> LINK
LINK --> BUYER["买家打开提货页"]
BUYER --> BIND["填写或扫码绑定游戏 UID"]
BIND --> ROUTE["渠道路由:priority / lewan / dlc / rock"]
ROUTE --> LOCK["锁定 result_data.provider"]
LOCK --> SUBMIT["提交上游发货"]
SUBMIT --> RES{"提交结果"}
RES -->|成功| DELIV["delivering"]
RES -->|失败| FAIL["ship_failed"]
DELIV --> NOTIFY["上游回执 / 乐玩 Webhook"]
NOTIFY --> SHIP{"发货结果"}
SHIP -->|成功| DONE["delivered"]
SHIP -->|失败| FAIL
DONE --> OUT["事务写 callback outbox"]
FAIL --> OUT
OUT --> WORKER["worker 每 3 秒批量 50 条"]
WORKER --> CB["商户回调:X-Event-ID + X-Sign"]
DELIV -.->|超时 30 分钟| FAIL
FAIL -.->|未提交上游| SUBMIT
PAY -.->|取消| CAN["cancelled + 退款"]
FAIL -.->|未提交上游,取消| CAN
DELIV -.->|已提交上游| NO["禁止自动重试/取消"]
```
## 3. 订单状态机
```mermaid
stateDiagram-v2
[*] --> paid : 创建订单并扣款
paid --> delivering : 提交上游成功
paid --> ship_failed : 上游受理失败
paid --> cancelled : 取消退款
delivering --> delivered : 回执成功
delivering --> ship_failed : 回执失败或超时
ship_failed --> delivering : 未提交上游时重试
ship_failed --> cancelled : 取消退款
delivered --> [*]
cancelled --> [*]
note right of delivering
已提交上游的订单
禁止自动重试/取消
end note
```
**状态速记**
| 状态 | 含义 | 能否取消 | 能否重试 |
|------|------|----------|----------|
| `paid` | 已付款,待发货 | 可以 | 可以提交上游 |
| `delivering` | 已提交上游,发货中 | 不可以 | 不可以,等待回执 |
| `delivered` | 已交付 | 不可以 | 不可以 |
| `ship_failed` | 发货失败 | 可以 | 仅未提交上游时可重试 |
| `cancelled` | 已取消并退款 | 终态 | 不可以 |
## 4. 下单到回调时序
```mermaid
sequenceDiagram
autonumber
participant M as 商户系统
participant A as affiliate_dash
participant DB as 订单/钱包库
participant R as 渠道路由
participant U as 上游货源
participant B as 终端买家
participant C as 商户回调
M->>A: POST /api/client/v1/orders(HMAC 签名)
A->>A: 验签 + nonce + scope + 功能开关
A->>DB: 事务:幂等检查 + 扣款 + 扣库存 + 写订单
DB-->>A: order_no / paid
A-->>M: 订单创建成功 + 提货链接
M-->>B: 发送提货链接
B->>A: 打开提货页,提交游戏 UID
A->>R: 绑定 UID + 选择渠道
R->>U: 提交发货
U-->>R: 受理 / 失败
R-->>A: 更新 delivering / ship_failed
U->>A: ship_notify 或乐玩 Webhook
A->>DB: 幂等处理回执 + 更新 delivered / ship_failed
A->>DB: 同事务写 callback_deliveries
A->>C: 异步投递(X-Event-ID / X-Sign)
C-->>A: 2xx 或按退避重试
```
## 5. 核心数据模型
```mermaid
erDiagram
users ||--o{ merchant_members : "成员"
merchants ||--o{ merchant_members : "成员"
merchants ||--o{ merchant_roles : "角色"
merchants ||--o{ merchant_products : "可售商品"
products ||--o{ merchant_products : "商品目录"
merchants ||--|| wallet_accounts : "钱包"
wallet_accounts ||--o{ wallet_ledger_entries : "流水"
merchants ||--o{ fulfillment_orders : "订单"
merchant_products ||--o{ fulfillment_orders : "SKU"
merchants ||--o{ callback_subscriptions : "回调订阅"
callback_subscriptions ||--o{ callback_deliveries : "投递"
fulfillment_orders ||--o{ callback_deliveries : "事件"
rock_configs ||--o{ rock_tokens : "库存"
fulfillment_orders ||--o{ rock_tokens : "占用"
merchants ||--o{ recharge_applications : "充值"
users ||--o{ user_devices : "设备"
users ||--o{ login_sms_codes : "验证码"
merchants ||--o{ audit_logs : "租户"
users ||--o{ audit_logs : "操作人"
fulfillment_orders {
bigint id PK
string order_no UK
string client_order_no UK
string status
bigint amount
jsonb result_data
}
wallet_ledger_entries {
bigint id PK
bigint merchant_id FK
string idempotency_key UK
bigint amount
bigint balance_after
}
rock_tokens {
bigint id PK
string status
string token
bigint order_id FK
}
```
## 6. 一分钟速记
| 维度 | 记住这些 |
|------|----------|
| 一句话 | 商户下单扣积分,平台路由到上游,买家绑定 UID,上游发货后回调商户 |
| 5 个状态 | `paid` → `delivering` → `delivered`;失败 `ship_failed`;取消 `cancelled` |
| 3 种来源 | `api`、`manual`、`test` |
| 4 条硬规则 | 幂等键防重;渠道锁定;已提交上游禁重试/取消;Rock 一单一 Token |
| 渠道路由 | `priority`、`lewan`、`dlc`、`rock` 四种模式;实际 Provider 为 DLC、乐玩、Rock |
| 3 个核心表 | `fulfillment_orders`、`wallet_ledger_entries`、`callback_deliveries` |
| 1 个异步链路 | 业务事务写 outbox → worker 批量投递 → 商户回调 |
> 需要定位字段、索引、代码行号时,回到 `docs/项目梳理-业务流程与数据模型.md` 的 §5 数据模型与 §7 来源索引。
File diff suppressed because it is too large Load Diff