feat:2、数据显示优化

This commit is contained in:
2026-10-30 16:13:39 +08:00
parent ea46700fe2
commit a7f8f9c639
4 changed files with 607 additions and 21 deletions
+2 -3
View File
@@ -412,9 +412,8 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
}
var tokens []model.RockToken
// 按包 ID 倒序(未打包的手工采购记录排在最后),同包内按本地 ID 倒序,保证分页稳定
err := db.Order("package_id DESC NULLS LAST").
Order("id DESC").
// 按本地 ID 倒序:ID 自增,等价于按入库时间倒序;分页稳定,且与列表首列 ID 的降序一致
err := db.Order("id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&tokens).Error
@@ -15,24 +15,24 @@
依照逆向工程与高强度分析标准,本次所有结论均严格基于抓包实体通信数据与前端源代码交叉验证:
| 证据项编号 | 证据分类 | 来源与定位 | 关键事实 / 发现 | 确信度 |
| :--- | :--- | :--- | :--- | :--- |
| **EV-01** | 通信抓包 (HAR) | `发货黑色.har` [Entry 34] | 提取 Token 请求:`POST /api/user/tokens/claim`,Body: `{"color":"black","count":"2"}`,扣除积分提取可用 Token。 | **100%** |
| **EV-02** | 通信抓包 (HAR) | `发货黑色.har` [Entry 36, 40] | 获取绑定及兑换配置:`GET /api/redeem/{redeem_code}`,返回腾讯官方绑定链接 `bindUrl` 及皮肤/货币配置。 | **100%** |
| **EV-03** | 通信抓包 (HAR) | `发货黑色.har` [Entry 37, 39] | 刷新绑定状态:`POST /api/user/tokens/{id}/refresh`,返回 `binding.bound: true`,角色名、角色ID、大区及历史发放记录。 | **100%** |
| **EV-04** | 通信抓包 (HAR) | `发货黑色.har` [Entry 42] | 提交兑换发货:`POST /api/redeem/{id}`,Body: `{"skin":"黑色ROCK长袖T恤","currency":"幸运币","account":"感性的洁少"}`,官方发货成功。 | **100%** |
| **EV-05** | 前端源码 | `app.js` (L3617~L3720) | 皮肤与货币官方数据字典 `officialSkus`,黑蓝皮肤权限控制与 180 点价值换算公式。 | **100%** |
| **EV-06** | 前端源码 | `app.js` (L217513~L217600) | 兑换发货执行函数 `redeemToken()`,揭示其具备登录态与免登录公开态两种调用形态。 | **100%** |
| **EV-07** | 前端源码 | `app.js` (L305062~L305120) | 绑定链接解析与生成逻辑,底层依托和平精英官方活动链接 `gp.qq.com/cp/a20241128mobile/...`。 | **100%** |
| **EV-08** | 用户截图 | 微信/扫码绑定弹窗截图 | 弹窗展示 Token、状态(已绑定)、大区、角色、当前货币组合与兑换皮肤下拉选择框。 | **100%** |
| **EV-09** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 4, 7] | 兑换历史对账接口:`GET /api/user/exchange-records?page=1&pageSize=20`,支持按账号/UID、Token、区服、时间范围精准分页对账。 | **100%** |
| **EV-10** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 5] | 腾讯官方履约凭证:刷新/兑换成功记录中返回 `fulfillId: 4519960`(官方发放流水号)及 `membership: 202610`(月份期号)。 | **100%** |
| **EV-11** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 3] | 下属发货员列表:`GET /api/user/delivery-agents`,返回该商户绑定的发货员(如丁德鑫、曹强等)。 | **100%** |
| **EV-12** | 用户截图 | 批量获取 Token 弹窗截图 | 提取类型默认下拉选项为 `1 = 黑色`,提取数量 2~500,印证了统一下单通用黑色 Token 的业务形态。 | **100%** |
| **EV-13** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 13] | 蓝色发货兑换请求:`POST /api/redeem/151154`,Body: `{"skin":"蓝色运动服上衣","currency":"幸运币","account":"打破虚妄的浪漫"}`,发放成功。 | **100%** |
| **EV-14** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 1~10] | **旧绑定数据实锤证据**:初次 refresh 返回旧角色“总荒凉”(3751279035);买家真正微信扫码后更新为“打破虚妄的浪漫”(3688825401),`bind_change_type: "rebind"`,旧角色归入 `previous_bound_account`。 | **100%** |
| **EV-15** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 0] | 再次证实用于发货蓝色的 Token 151154 其本身规格仍然是 `color: "black"`(黑色通用 Token)。 | **100%** |
| **EV-16** | 前端源码 | `app.js` (L3700~L3760) | **商户登录与鉴权机制**:平台无固定密码登录,采用“用户名+手机验证码”(`POST /api/auth/send-code` + `POST /api/auth/phone-login`),登录后下发 Bearer Token 并存于 `localStorage.yd_token`。请求鉴权全量走 `Authorization: Bearer <yd_token>`。 | **100%** |
| 证据项编号 | 证据分类 | 来源与定位 | 关键事实 / 发现 | 确信度 |
| :-------- | :--------- | :------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------- |
| **EV-01** | 通信抓包 (HAR) | `发货黑色.har` [Entry 34] | 提取 Token 请求:`POST /api/user/tokens/claim`,Body: `{"color":"black","count":"2"}`,扣除积分提取可用 Token。 | **100%** |
| **EV-02** | 通信抓包 (HAR) | `发货黑色.har` [Entry 36, 40] | 获取绑定及兑换配置:`GET /api/redeem/{redeem_code}`,返回腾讯官方绑定链接 `bindUrl` 及皮肤/货币配置。 | **100%** |
| **EV-03** | 通信抓包 (HAR) | `发货黑色.har` [Entry 37, 39] | 刷新绑定状态:`POST /api/user/tokens/{id}/refresh`,返回 `binding.bound: true`,角色名、角色ID、大区及历史发放记录。 | **100%** |
| **EV-04** | 通信抓包 (HAR) | `发货黑色.har` [Entry 42] | 提交兑换发货:`POST /api/redeem/{id}`,Body: `{"skin":"黑色ROCK长袖T恤","currency":"幸运币","account":"感性的洁少"}`,官方发货成功。 | **100%** |
| **EV-05** | 前端源码 | `app.js` (L3617~L3720) | 皮肤与货币官方数据字典 `officialSkus`,黑蓝皮肤权限控制与 180 点价值换算公式。 | **100%** |
| **EV-06** | 前端源码 | `app.js` (L217513~L217600) | 兑换发货执行函数 `redeemToken()`,揭示其具备登录态与免登录公开态两种调用形态。 | **100%** |
| **EV-07** | 前端源码 | `app.js` (L305062~L305120) | 绑定链接解析与生成逻辑,底层依托和平精英官方活动链接 `gp.qq.com/cp/a20241128mobile/...`。 | **100%** |
| **EV-08** | 用户截图 | 微信/扫码绑定弹窗截图 | 弹窗展示 Token、状态(已绑定)、大区、角色、当前货币组合与兑换皮肤下拉选择框。 | **100%** |
| **EV-09** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 4, 7] | 兑换历史对账接口:`GET /api/user/exchange-records?page=1&pageSize=20`,支持按账号/UID、Token、区服、时间范围精准分页对账。 | **100%** |
| **EV-10** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 5] | 腾讯官方履约凭证:刷新/兑换成功记录中返回 `fulfillId: 4519960`(官方发放流水号)及 `membership: 202610`(月份期号)。 | **100%** |
| **EV-11** | 通信抓包 (HAR) | `兑换记录等杂项.har` [Entry 3] | 下属发货员列表:`GET /api/user/delivery-agents`,返回该商户绑定的发货员(如丁德鑫、曹强等)。 | **100%** |
| **EV-12** | 用户截图 | 批量获取 Token 弹窗截图 | 提取类型默认下拉选项为 `1 = 黑色`,提取数量 2~500,印证了统一下单通用黑色 Token 的业务形态。 | **100%** |
| **EV-13** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 13] | 蓝色发货兑换请求:`POST /api/redeem/151154`,Body: `{"skin":"蓝色运动服上衣","currency":"幸运币","account":"打破虚妄的浪漫"}`,发放成功。 | **100%** |
| **EV-14** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 1~10] | **旧绑定数据实锤证据**:初次 refresh 返回旧角色“总荒凉”(3751279035);买家真正微信扫码后更新为“打破虚妄的浪漫”(3688825401),`bind_change_type: "rebind"`,旧角色归入 `previous_bound_account`。 | **100%** |
| **EV-15** | 通信抓包 (HAR) | `蓝色一个发货.har` [Entry 0] | 再次证实用于发货蓝色的 Token 151154 其本身规格仍然是 `color: "black"`(黑色通用 Token)。 | **100%** |
| **EV-16** | 前端源码 | `app.js` (L3700~L3760) | **商户登录与鉴权机制**:平台无固定密码登录,采用“用户名+手机验证码”(`POST /api/auth/send-code` + `POST /api/auth/phone-login`),登录后下发 Bearer Token 并存于 `localStorage.yd_token`。请求鉴权全量走 `Authorization: Bearer <yd_token>`。 | **100%** |
---
+578
View File
@@ -0,0 +1,578 @@
# affiliate_dash 全项目状态机总览
> 生成日期:2026-10-30
> 分析范围:`backend/internal/model`、`backend/internal/service`、`backend/internal/handler`、`backend/internal/database/migrations`(截至第 18 个迁移)
> 来源约定:代码使用 `文件:行号`;**状态值统一写成 `英文值(中文含义)`**,表格与流转图都可直接对照
> 配套文档:`docs/项目梳理-业务流程与数据模型.md`(业务全貌)、`docs/系统脑图与流程图.md`(系统结构)
---
## 0. 阅读指南
全项目共有 **14 个状态机**,按性质分成 5 组,理解成本从高到低排列:
| 组 | 包含 | 特点 | 改动风险 |
| --- | --- | --- | --- |
| ① 交易主线 | 订单、Rock Token、充值申请 | 真正的多状态业务流程,有审核/重试/回滚 | 高 |
| ② 对外通知 | 回调订阅、回调投递 | 一个单例开关 + 一个带退避的重试机 | 中 |
| ③ 登录与设备 | 设备认证、用户设备、系统账号 | 原子状态跃迁,部分状态不落库 | 中 |
| ④ 基础主数据 | 商户、API 密钥、商品、商户成员 | 本质是启用/停用开关,无终态 | 低 |
| ⑤ 隐藏状态 | Provider 同步结果、发货提交阶段 | 不落库或存在 JSON 子字段里,排查时才可见 | **高(最难发现)** |
阅读顺序建议:先看第 1 节速查表 → 第 2 节全貌图 → 按需跳到具体小节 → 最后看第 8 节终态与第 9 节踩坑。
---
## 1. 30 秒速查表
| # | 实体 | 表.字段 | 状态值(英文 + 中文) | 终态 |
| --- | --- | --- | --- | --- |
| 1 | 订单 | `fulfillment_orders.order_status` | `paid`(已扣款待发货)、`delivering`(发货中)、`delivered`(已交付)、`ship_failed`(发货失败可重试)、`cancelled`(已取消并退款) | `delivered`、`cancelled` |
| 2 | Rock Token | `rock_tokens.status` | `available`(可用库存)、`using`(使用中)、`bound`(已绑定)、`redeemed`(已发货)、`mismatch`(绑定错误)、`abnormal`(异常作废) | `redeemed` |
| 3 | 充值申请 | `recharge_applications.status` | `pending`(待审核)、`approved`(已通过已入账)、`rejected`(已驳回) | `approved`、`rejected` |
| 4 | 回调订阅 | `callback_subscriptions.status` | `active`(推送中)、`disabled`(已停用) | 无(可来回切换) |
| 5 | 回调投递 | `callback_deliveries.status` | `pending`(待投递)、`sending`(投递中)、`delivered`(已送达)、`failed`(已放弃) | `delivered`、`failed` |
| 6 | 设备认证 | `device_challenges.status` | `pending`(待确认)、`authorized`(已授权)、`ticket_issued`(已签发票据)、`consumed`(已消费)、`expired`(已过期,仅读取态)、`rejected`(已拒绝,仅读取态) | `consumed` |
| 7 | 用户设备 | `user_devices.status` | `active`(已绑定)、`revoked`(已解绑) | `revoked` |
| 8 | 系统账号 | `users.status` | `1`(启用)、`0`(禁用) | 无 |
| 9 | 商户 | `merchants.status` | `active`(启用)、`disabled`(停用) | 无 |
| 10 | API 密钥 | `api_clients.status` | `active`(启用)、`disabled`(停用) | 无 |
| 11 | 商品 / 商户商品 | `products.status` / `merchant_products.status` | `active`(上架)、`inactive`(下架) | 无 |
| 12 | 商户成员 / 角色 | `merchant_members.status` / `merchant_roles.status` | `1`(启用)、`0`(停用) | 无 |
| 13 | Provider 同步结果 | 内存结构,**不落库** | `success`(已成功)、`failed`(已失败)、`delivering`(处理中)、`""`(未知) | — |
| 14 | 发货提交阶段 | `result_data.provider_order_stage`(JSON 子字段) | 8 个 stage,见 7.2 | `submitted`(已提交上游) |
---
## 2. 全貌图
```mermaid
flowchart LR
subgraph A["① 交易主线"]
ORDER["订单 fulfillment_orders"]
ROCK["Rock Token rock_tokens"]
RECHARGE["充值申请 recharge_applications"]
end
subgraph B["② 对外通知"]
SUB["回调订阅 callback_subscriptions"]
DELIV["回调投递 callback_deliveries"]
end
subgraph C["③ 登录与设备"]
CHALLENGE["设备认证 device_challenges"]
DEVICE["用户设备 user_devices"]
USER["系统账号 users"]
end
subgraph D["④ 基础主数据"]
MERCHANT["商户 merchants"]
APICLIENT["API 密钥 api_clients"]
PRODUCT["商品 products / merchant_products"]
MEMBER["商户成员 merchant_members"]
end
ORDER -. "占用 / 释放" .-> ROCK
ORDER -- "状态变更事件" --> SUB
SUB -- "生成投递记录" --> DELIV
RECHARGE -. "审核通过后入账" .-> MERCHANT
MERCHANT --> APICLIENT
MERCHANT --> PRODUCT
MERCHANT --> MEMBER
USER --> MEMBER
USER --> DEVICE
DEVICE --> CHALLENGE
```
> 关键理解:**订单是中心**。它驱动 Rock Token 的占用与释放,也驱动回调订阅产生投递记录;其余状态机要么是「开/关」,要么是独立小流程。
---
# ① 交易主线
## 3.1 订单状态机(最核心)
**字段**:`fulfillment_orders.order_status`
**常量定义**:`backend/internal/model/merchant.go:46-50`
| 状态值 | 中文含义 | 何时进入 | 可以去往 |
| --- | --- | --- | --- |
| `paid` | 已扣款,待发货 | 创建订单时写入(`fulfillment_create.go:172`) | `delivering`(发货中)、`delivered`(已交付)、`ship_failed`(发货失败)、`cancelled`(已取消) |
| `delivering` | 发货中 | 开始发货或重试发货(`delivery_submit.go:150`) | `delivered`(已交付)、`ship_failed`(发货失败) |
| `delivered` | 已交付 | 发货接口成功、上游回调 success(`fulfillment_shipnotify.go:48`) | **无(终态)** |
| `ship_failed` | 发货失败,可重试 | 发货失败、`delivering` 超时(`fulfillment_timeout.go:17`) | `delivering`(重试)、`delivered`(补投成功)、`cancelled`(取消)、`ship_failed`(再次失败) |
| `cancelled` | 已取消并退款 | 取消订单并回补库存(`fulfillment_cancel.go:86`) | **无(终态)** |
```mermaid
stateDiagram-v2
state "paid 已扣款待发货" as paid
state "delivering 发货中" as delivering
state "delivered 已交付(终态)" as delivered
state "ship_failed 发货失败可重试" as ship_failed
state "cancelled 已取消并退款(终态)" as cancelled
[*] --> paid : 创建订单并扣款
paid --> delivering : 开始发货
paid --> delivered : 同步接口直接成功
paid --> ship_failed : 发货失败
paid --> cancelled : 取消并退款
delivering --> delivered : 发货成功
delivering --> ship_failed : 发货失败或超时
ship_failed --> delivering : 人工重试发货
ship_failed --> delivered : 补投成功
ship_failed --> ship_failed : 再次失败
ship_failed --> cancelled : 未提交上游可取消
delivered --> [*]
cancelled --> [*]
```
**规则校验**:`backend/internal/service/fulfillment_state.go:76-134`
- `current == next` 直接放行,**幂等**,重复回调不会报错(`fulfillment_state.go:81`)。
- `cancelled`(已取消)与 `delivered`(已交付)是终态,任何变更都被拒绝;错误文案分别是「订单已取消,不能更新状态」「订单已交付,不能回退状态」。
- `timeout` 类型的失败**只能**来自 `delivering`(发货中),防止把 `paid`(待发货)的订单误标超时(`fulfillment_state.go:102`)。
- 取消白名单只有 `paid`(已扣款)与 `ship_failed`(发货失败);`delivering`(发货中)、`delivered`(已交付)一律不可取消(`fulfillment_state.go:118`)。
**五个改状态的入口**
| 入口 | 代码位置 | 允许改成 |
| --- | --- | --- |
| 创建订单 | `fulfillment_create.go:172` | 初始 `paid`(已扣款待发货) |
| 人工更新 | `fulfillment_update.go:22` | `delivering`(发货中)、`delivered`(已交付)、`ship_failed`(发货失败) |
| 发货回调 | `fulfillment_shipnotify.go:48` | success → `delivered`(已交付);failed → `ship_failed`(发货失败) |
| 超时扫描 | `fulfillment_timeout.go:17`、`:28` | `delivering`(发货中)→ `ship_failed`(发货失败) |
| 取消订单 | `fulfillment_cancel.go:26`、`:86` | → `cancelled`(已取消并退款) |
---
## 3.2 Rock Token 状态机
**字段**:`rock_tokens.status`
**常量定义**:`backend/internal/model/rock.go:21-26`
| 状态值 | 中文含义 | 何时进入 | 可以去往 |
| --- | --- | --- | --- |
| `available` | 可用库存 | 打包导入、解冻重置、订单释放回退 | `using`(使用中) |
| `using` | 使用中,已分配给订单正在提货扫码 | 分配订单(`rock_assign.go:120`,行锁 `SKIP LOCKED`) | `bound`(已绑定)、`mismatch`(绑定错误)、`available`(释放回库)、`abnormal`(异常作废) |
| `bound` | 已绑定成功 | 绑定校验通过(`rock_bindcheck.go`、`rock_assign.go:72`、`rock_binding_refresh.go`) | `redeemed`(已发货)、`mismatch`(绑定错误)、`abnormal`(异常作废) |
| `redeemed` | 已发货完成 | 兑换完成(`rock_redeem.go:82`) | **无(终态)** |
| `mismatch` | 绑定错误,UID 与预期不符 | 绑定校验发现 UID 不一致(`rock_bindcheck.go:173`、`rock_binding_refresh.go`) | `bound`(改 UID 对齐)、`available`(解冻重置)、`abnormal`(异常作废) |
| `abnormal` | 异常作废 | 人工上报失效(`rock_recover.go:144`) | `available`(解冻重置) |
```mermaid
stateDiagram-v2
state "available 可用库存" as available
state "using 使用中已分配" as using
state "bound 已绑定成功" as bound
state "redeemed 已发货(终态)" as redeemed
state "mismatch 绑定错误 UID 不符" as mismatch
state "abnormal 异常作废" as abnormal
[*] --> available : 打包导入或人工新增
available --> using : 分配给订单
using --> bound : 扫码绑定成功
using --> mismatch : UID 校验不一致
using --> available : 释放或订单取消
using --> abnormal : 人工上报失效
bound --> redeemed : 发货完成
bound --> mismatch : 刷新后发现 UID 不符
bound --> abnormal : 人工上报失效
mismatch --> bound : 手工改成正确 UID
mismatch --> available : 解冻重置
mismatch --> abnormal : 人工上报失效
abnormal --> available : 解冻重置
redeemed --> [*]
```
**各跃迁的代码位置**
| 跃迁 | 代码位置 |
| --- | --- |
| `available`(可用)→ `using`(使用中) | `rock_assign.go:120`(`FOR UPDATE SKIP LOCKED`) |
| `using`(使用中)→ `bound`(已绑定) | `rock_bindcheck.go`、`rock_assign.go:72`、`rock_binding_refresh.go` |
| `using`(使用中)→ `available`(释放回库) | `rock_redeem.go:125`(释放)、`provider_rock.go:355`(取消) |
| `bound`(已绑定)→ `redeemed`(已发货) | `rock_redeem.go:82` |
| 任意 → `mismatch`(绑定错误) | `rock_bindcheck.go:173`、`rock_binding_refresh.go` |
| 任意 → `abnormal`(异常作废) | `rock_recover.go:144` |
| `mismatch`(绑定错误)→ `bound`(已绑定) | `rock_recover.go:46`(改 UID 对齐) |
| `mismatch` / `abnormal` → `available`(可用) | `rock_recover.go:115`(解冻重置) |
**关联字段 `using_at`(使用中时长预警)**
| 动作 | 位置 |
| --- | --- |
| 写入 `using_at` | 分配时 `rock_assign.go`;打包导入 using 记录时 `rock_package.go` |
| 清空 `using_at` | 释放 `rock_redeem.go`;取消 `provider_rock.go`;解冻重置 `rock_recover.go`;备注改为 available 时 `rock_recover.go` |
> 规则:只对 `using`(使用中)生效;满 24 小时计 1 天,默认 3 天橙色预警、5 天红色预警(阈值存在 `rock_configs.using_warn_days` / `using_danger_days`)。
> **任何把状态改回 `available` 的路径都必须同步清空 `using_at`,否则会留下错误的超时告警。**
**打包导入的特殊规则**:`source=package` 的 Token 在**没有关联订单且非 `available`** 时,允许按上游最新记录刷新字段(`rock_package.go` 的 `canRefreshUpstream`)。
---
## 3.3 充值申请状态机
**字段**:`recharge_applications.status`
**常量定义**:`backend/internal/model/recharge.go:11-13`
| 状态值 | 中文含义 | 何时进入 | 可以去往 |
| --- | --- | --- | --- |
| `pending` | 待审核 | 商户提交充值申请 | `approved`(审核通过)、`rejected`(审核驳回) |
| `approved` | 审核通过,已入账 | 管理员通过,同一事务内调用 `AdjustWallet`(幂等键 = 申请单号) | **无(终态)** |
| `rejected` | 审核驳回 | 管理员驳回并填写原因 | **无(终态)** |
```mermaid
stateDiagram-v2
state "pending 待审核" as pending
state "approved 审核通过已入账(终态)" as approved
state "rejected 审核驳回(终态)" as rejected
[*] --> pending : 商户提交充值申请
pending --> approved : 管理员通过并调用 AdjustWallet
pending --> rejected : 管理员驳回
approved --> [*]
rejected --> [*]
```
> 只有 `pending`(待审核)可被审核(`recharge.go:135`);重复点通过会被拒。入账与状态变更在同一事务,靠申请单号做幂等,不会重复加钱。
---
# ② 对外通知
## 4.1 回调订阅状态机
**字段**:`callback_subscriptions.status`
**常量定义**:`backend/internal/model/merchant.go:61-62`
| 状态值 | 中文含义 | 约束 |
| --- | --- | --- |
| `active` | 推送中 | **每个商户最多只能有一条**。新建或启用时,会自动把该商户其他订阅置为 `disabled`(`callback.go:187-191`) |
| `disabled` | 已停用 | 不产生任何投递记录 |
```mermaid
stateDiagram-v2
state "active 推送中" as active
state "disabled 已停用" as disabled
[*] --> active : 创建订阅
active --> disabled : 停用或被新订阅顶掉
disabled --> active : 重新启用
```
> 这不是终态机,而是一个**单例指针**:`active`(推送中)永远最多一条。排查「回调没收到」时,先确认订阅是不是被别的配置顶成 `disabled`(已停用)了。
---
## 4.2 回调投递状态机(Outbox 重试)
**字段**:`callback_deliveries.status`
**常量定义**:`backend/internal/model/merchant.go:64-67`
| 状态值 | 中文含义 | 何时进入 | 可以去往 |
| --- | --- | --- | --- |
| `pending` | 待投递 | 业务事件入队;或失败后等待退避 | `sending`(投递中) |
| `sending` | 投递中 | Worker 用原子 UPDATE 认领(`callback.go:292`) | `delivered`(已送达)、`pending`(重试)、`failed`(已放弃) |
| `delivered` | 已送达 | 收到 HTTP 2xx | **无(终态)** |
| `failed` | 已放弃 | 达到最大次数 `16`,或遇到终止性错误(如 404)(`callback.go:365`) | **无(终态)** |
```mermaid
stateDiagram-v2
state "pending 待投递" as pending
state "sending 投递中" as sending
state "delivered 已送达(终态)" as delivered
state "failed 已放弃(终态)" as failed
[*] --> pending : 业务事件入队
pending --> sending : Worker 原子认领
sending --> delivered : 收到 HTTP 2xx
sending --> pending : 失败且仍可重试
sending --> failed : 满 16 次或终止性错误
sending --> pending : 卡住超 5 分钟被回收
delivered --> [*]
failed --> [*]
```
**重试节奏**(`callback.go:30-46`,可用环境变量 `CALLBACK_RETRY_SCHEDULE` / `CALLBACK_MAX_ATTEMPTS` 覆盖):
```text
第 2 次 15 秒
第 3 次 15 秒
第 4 次 30 秒
第 5 次 3 分钟
第 6 次 10 分钟
第 7 次 20 分钟
第 8 次 30 分钟
第 9 次 30 分钟
第 10 次 30 分钟
第 11 次 60 分钟
第 12 次 3 小时
第 13 次 3 小时
第 14 次 3 小时
第 15 次 6 小时
第 16 次 6 小时
```
> 共 16 次尝试,全程约 26 小时。超过 5 分钟仍停留在 `sending`(投递中)的记录会被重新认领(`callback.go:261`),因此**接收方必须按事件 ID 做幂等**。
---
# ③ 登录与设备
## 5.1 设备认证 Challenge 状态机(PC ↔ 手机扫码)
**字段**:`device_challenges.status`
**常量定义**:`backend/internal/model/login_auth.go:8-13`
**有效期**:120 秒
| 状态值 | 中文含义 | 何时进入 | 可以去往 |
| --- | --- | --- | --- |
| `pending` | 待手机确认 | 客户端发起挑战 | `authorized`(已授权) |
| `authorized` | 手机已确认授权 | 手机端确认,原子 UPDATE 条件为 `status=pending` 且未过期(`login_dcauth_service.go:302`) | `ticket_issued`(已签发票据) |
| `ticket_issued` | 已签发一次性票据 | 首次轮询时签发(`login_dcauth_service.go:354`) | `consumed`(已消费) |
| `consumed` | 已消费(登录完成) | PC 端原子消费票据(`login_dcauth_service.go:393`) | **无(终态)** |
| `expired` | 已过期 | **读取时计算,不落库**(`login_dcauth_service.go:344`) | — |
| `rejected` | 已拒绝 | **读取时计算,不落库** | — |
```mermaid
stateDiagram-v2
state "pending 待手机确认" as pending
state "authorized 手机已确认" as authorized
state "ticket_issued 已签发一次性票据" as ticket_issued
state "consumed 已消费(终态)" as consumed
state "expired 已过期(不落库)" as expired
state "rejected 已拒绝(不落库)" as rejected
[*] --> pending : 发起挑战 有效期 120 秒
pending --> authorized : 手机端确认授权
authorized --> ticket_issued : 首次轮询签发 ticket
ticket_issued --> consumed : PC 端消费 ticket
consumed --> [*]
pending -.-> expired : 超时后读取判定
authorized -.-> expired : 超时后读取判定
pending -.-> rejected : 手机端拒绝或记录不存在
note right of expired
expired 与 rejected 不写库
仅在读取时按 expires_at 与 status 计算
end note
```
> 最易误判的一点:数据库里**永远查不到** `expired`(已过期)或 `rejected`(已拒绝)的行,它们只出现在接口返回值里。想查「失败的挑战」只能看 `created_at` 与 `expires_at`。
---
## 5.2 用户设备状态机
**字段**:`user_devices.status`
**常量定义**:`backend/internal/model/login_auth.go:15-16`
| 状态值 | 中文含义 | 约束 |
| --- | --- | --- |
| `active` | 已绑定为信任设备 | **每个用户最多 1 台**,由部分唯一索引 `ON CONFLICT (user_id) WHERE status='active'` 保证;登录时自动绑定(`login_dcauth_service.go:427`) |
| `revoked` | 已解绑 | 管理员重置密码、删除账号时置为此态(`user.go:344`、`user.go:455`) |
```mermaid
stateDiagram-v2
state "active 已绑定信任设备" as active
state "revoked 已解绑" as revoked
[*] --> active : 登录时自动绑定
active --> revoked : 管理员重置密码或改密
revoked --> active : 重新登录绑定
```
> `revoked`(已解绑)不是永久终态:用户重新登录会建一条新的 `active` 记录,旧记录保持 `revoked`。
---
## 5.3 系统账号状态
**字段**:`users.status`
**定义**:`backend/internal/model/models.go:28`,注意是 **int 型而不是字符串**
| 状态值 | 中文含义 | 说明 |
| --- | --- | --- |
| `1` | 启用 | 字段默认值 |
| `0` | 禁用 | 禁用后不能登录;系统保护「至少保留一个启用的管理员」 |
```mermaid
stateDiagram-v2
state "1 启用" as enabled
state "0 禁用" as disabled
[*] --> enabled : 创建账号
enabled --> disabled : 管理员禁用
disabled --> enabled : 管理员启用
```
---
# ④ 基础主数据(开关型)
这一组本质上是同一个模式,可以统一理解为一张图。
| 实体 | 字段 | 启用值 | 停用值 | 停用后的效果与特殊约束 |
| --- | --- | --- | --- | --- |
| 商户 | `merchants.status`(`merchant.go:10-11`) | `active`(启用) | `disabled`(停用) | 不能下单、加成员、建密钥、配回调(`fulfillment_create.go:110`) |
| API 密钥 | `api_clients.status`(`merchant.go:40-41`) | `active`(启用) | `disabled`(停用) | 不能调用开放接口;**必须先停用才能删除**(`merchant_api_client.go:123`) |
| 商品 / 商户商品 | `products.status` / `merchant_products.status`(`merchant.go:43-44`) | `active`(上架) | `inactive`(下架) | 下单只认 `active`(`fulfillment_create.go:118`);下架后才能删除(`merchant_product.go:140`) |
| 商户成员 / 角色 | `merchant_members.status`(`merchant.go:109`)/ `merchant_roles.status`(`merchant.go:125`) | `1`(启用) | `0`(停用) | 成员停用后失去权限;角色停用时判断能否复用(`merchant_member.go:292`) |
```mermaid
stateDiagram-v2
state "active 或 1 启用" as ON
state "disabled 或 0 停用" as OFF
[*] --> ON : 创建
ON --> OFF : 停用或下架
OFF --> ON : 重新启用
```
> 设计取舍:这些表刻意要求「先停用再删除」,避免「删了又被历史订单引用」造成业务断链。它们都**没有终态**,可以反复切换。
---
# ⑤ 隐藏状态机
## 6.1 Provider 同步结果(瞬时,不落库)
`ProviderSyncResult.Status` 只在内存中流转,随后被翻译成订单状态,**不写数据库**。
| 状态值 | 中文含义 | 后续动作 |
| --- | --- | --- |
| `success` | 上游已成功 | 调 `HandleShipNotify` → 订单 `delivered`(已交付) |
| `failed` | 上游已失败 | 调 `HandleShipNotify` → 订单 `ship_failed`(发货失败) |
| `delivering` | 上游处理中 | 补充写入游戏 UID / 角色名 / 区服,订单保持 `delivering`(发货中) |
| `""` | 未知 | 不做任何处理 |
**映射位置**:`backend/internal/service/delivery_provider.go:58`
> 调试要点:这个状态无法用 SQL 查到,只能看日志或订单的 `result_data`。
---
## 6.2 发货提交阶段(订单内的子状态机)
**位置**:`result_data` JSON 字段 `provider_order_stage`(**不是独立列,无法建索引**)
**常量定义**:`backend/internal/service/delivery.go:14-22`
**用途**:断点续传 + 防止重复发货
| 阶段值 | 中文含义 | 是否算「已提交上游」 |
| --- | --- | --- |
| `claimed` | 已锁定订单,尚未创建 queue | 否 |
| `queue_created` | 已在上游创建 queue,尚未生成正式订单 | 否 |
| `submitted` | 已在上游创建正式订单 | **是,禁止自动重发** |
| `bind_verify` | 绑定账号校验失败 | 否 |
| `bind_mismatch` | 玩家编号与绑定账号不一致 | 否 |
| `create_queue` | 创建上游 queue 失败 | 否 |
| `patch_queue` | 补充 queue 账号信息失败 | 否 |
| `create_upstream` | 创建上游正式订单失败 | 否 |
```mermaid
stateDiagram-v2
state "claimed 已锁单未建 queue" as claimed
state "queue_created 已建上游 queue" as queue_created
state "submitted 已创建上游正式订单" as submitted
state "create_queue 建 queue 失败" as create_queue
state "patch_queue 补账号信息失败" as patch_queue
state "bind_verify 绑定校验失败" as bind_verify
state "bind_mismatch 编号不一致" as bind_mismatch
state "create_upstream 建正式单失败" as create_upstream
[*] --> claimed : 锁定订单并写 submit_started_at
claimed --> queue_created : 上游建 queue 成功
claimed --> create_queue : 建 queue 失败
queue_created --> submitted : 上游正式下单成功
queue_created --> patch_queue : 补账号信息失败
queue_created --> bind_verify : 绑定校验失败
queue_created --> bind_mismatch : 玩家编号与绑定账号不一致
queue_created --> create_upstream : 创建正式订单失败
submitted --> [*]
```
**并发保护**(`delivery_submit.go:157-174`)
| 规则 | 数值 | 说明 |
| --- | --- | --- |
| 活跃提交窗口 | 35 秒 | 处于 `claimed`(已锁单)/ `queue_created`(已建 queue)且 `submit_started_at` 在 35 秒内,即使被 Webhook 临时推回 `ship_failed`,也**禁止并发重入** |
| stale 回收阈值 | 10 分钟 | `deliverySubmissionStaleTimeout`(`delivery.go:24`),超过可被重新认领 |
**「已提交上游」的判定**(`delivery.go:31`)——以下任一成立即为真,都会禁止自动重发与取消:
1. `provider_order_no` 非空;
2. `result_data.delivery` 存在;
3. `result_data.provider_order_stage == submitted`(已创建上游正式订单)。
---
# 7. 终态汇总(不可再变)
| 实体 | 终态值 | 强行变更的后果 |
| --- | --- | --- |
| 订单 | `delivered`(已交付) | 报错「订单已交付,不能回退状态」 |
| 订单 | `cancelled`(已取消) | 报错「订单已取消,不能更新状态」 |
| Rock Token | `redeemed`(已发货) | 无代码路径可回退,只能人工改库 |
| 充值申请 | `approved`(已通过) / `rejected`(已驳回) | 非 `pending` 一律拒绝审核 |
| 回调投递 | `delivered`(已送达) / `failed`(已放弃) | 不再被 Worker 认领 |
| 设备认证 | `consumed`(已消费) | 票据一次性,重放失败 |
| 用户设备 | `revoked`(已解绑) | 只能重新登录生成新记录 |
| 发货提交阶段 | `submitted`(已提交上游) | 禁止自动重发、禁止取消 |
---
# 8. 容易踩的坑(按严重度排序)
| # | 坑 | 说明 | 相关代码 |
| --- | --- | --- | --- |
| 1 | `delivered`(已交付)不可回退 | 上游回调 success 后即使发现发错货,也只能人工退款,不能改状态 | `fulfillment_state.go:87` |
| 2 | `ship_failed`(发货失败)分两种含义 | 已提交上游(有 `provider_order_no` 或 stage=`submitted`)的失败单**禁止自动重发、禁止取消**;只有提交中断的才允许重试 | `fulfillment_cancel.go:76`、`delivery_submit.go:60` |
| 3 | Token `mismatch`(绑定错误)不能换号 | 只能改 `expected_uid` 对齐成 `bound`(已绑定),或解冻回 `available`(可用);没有「换一个 Token」的路径 | `rock_recover.go:46` |
| 4 | 回调 `sending`(投递中)会重入 | 超过 5 分钟未更新即可被重新认领,接收方必须按事件 ID 幂等 | `callback.go:261`、`callback.go:292` |
| 5 | `expired`(已过期)/ `rejected`(已拒绝)查不到 | 设备认证的这两个状态不落库,只能靠 `expires_at` 计算 | `login_dcauth_service.go:334`、`:344` |
| 6 | 订单 `current == next` 放行 | 重复回调安全,但也意味着「状态没变」不等于「没执行」,副作用要看别处 | `fulfillment_state.go:81` |
| 7 | 回调订阅是单例 | 新建一条 `active` 会自动禁用旧的,不会报错;排查「回调没收到」先查是否被顶掉 | `callback.go:187` |
| 8 | 基础主数据删除前必须停用 | 商户 / 密钥 / 商品 / 成员都有前置校验,直接删会被拒 | `merchant_api_client.go:123`、`merchant_product.go:140` |
| 9 | `provider_order_stage` 是 JSON 子字段 | 无法直接建索引,只能 `result_data->>'provider_order_stage'`,数据量大时查询慢 | `delivery.go:13` |
| 10 | Token `using_at` 与 `status` 强耦合 | 任何把状态改回 `available` 的路径都要同步清空 `using_at`,漏了会误报超时告警 | `rock_recover.go`、`rock_redeem.go` |
| 11 | 商户/账号状态是 int | `users.status`、`merchant_members.status`、`merchant_roles.status` 用 `1/0`,不是 `active/disabled`,写 SQL 时容易混 | `models.go:28`、`merchant.go:109` |
| 12 | Provider 同步状态不落库 | 只能看日志,排查上游返回什么必须翻应用日志 | `delivery_provider.go:58` |
---
# 9. 非状态机的枚举(避免混淆)
以下值看起来像状态,但**不参与流转**,只是分类标签:
| 枚举 | 取值(英文 + 中文) | 位置 |
| --- | --- | --- |
| 订单来源 | `api`(开放接口创建)、`manual`(后台人工创建)、`test`(联调测试) | `merchant.go:52-54` |
| 钱包流水类型 | `credit`(入账)、`debit`(扣款)、`refund`(退款)、`adjust`(人工调整) | `merchant.go:56-59` |
| 手续费类型 | `rate`(按百分比)、`fixed`(按固定金额) | `merchant.go:37-38` |
| 商户成员角色 | `owner`(负责人)、`operator`(运营)、`finance`(财务)、`viewer`(只读) | `merchant.go:23-26` |
| Rock 颜色 | `black`(黑色)、`blue`(蓝色) | `rock.go:18-19` |
---
# 10. 附录:状态常量位置索引
| 文件 | 常量 | 覆盖范围 |
| --- | --- | --- |
| `backend/internal/model/merchant.go:46-50` | `OrderStatus*` | 订单 5 状态 |
| `backend/internal/model/merchant.go:40-44` | `APIClientStatus*`、`ProductStatus*` | 密钥、商品 |
| `backend/internal/model/merchant.go:61-67` | `CallbackStatus*`、`CallbackDelivery*` | 回调订阅、回调投递 |
| `backend/internal/model/merchant.go:10-11` | `MerchantStatus*` | 商户 |
| `backend/internal/model/rock.go:21-26` | `RockStatus*` | Rock Token 6 状态 |
| `backend/internal/model/recharge.go:11-13` | `RechargeStatus*` | 充值申请 3 状态 |
| `backend/internal/model/login_auth.go:8-13` | `DeviceChallengeStatus*` | 设备认证 6 状态 |
| `backend/internal/model/login_auth.go:15-16` | `UserDeviceStatus*` | 用户设备 2 状态 |
| `backend/internal/model/models.go:28` | `User.Status`(int) | 系统账号 1/0 |
| `backend/internal/service/fulfillment_state.go:76-134` | 状态跃迁校验函数 | 订单合法流转 |
| `backend/internal/service/delivery.go:14-22` | `deliveryStage*` | 发货提交阶段 8 值 |
| `backend/internal/service/callback.go:30-46` | `defaultCallbackRetrySchedule` | 回调重试节奏 |
---
> 维护提示:状态相关常量集中在 `backend/internal/model/`,跃迁校验集中在 `backend/internal/service/`。**新增状态值时必须同步修改:常量定义 → 跃迁校验函数 → 前端标签映射 → 本文件。**
@@ -55,6 +55,15 @@ export function buildTokenColumns({
onMarkAbnormal,
}: TokenColumnHandlers): ColumnsType<RockTokenItem> {
return [
{
title: 'ID',
dataIndex: 'id',
width: 80,
align: 'right',
render: (id: number) => (
<Text style={{ fontSize: 12, fontFamily: 'monospace', color: '#64748b' }}>{id}</Text>
),
},
{
title: '0666 编号 / 兑换码',
dataIndex: 'upstream_token_id',