feat:自动补货/手动采购逻辑修改:批量获取-》打包-》获取Token列表
This commit is contained in:
@@ -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` |
|
||||
@@ -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
Reference in New Issue
Block a user