feat: 完善 Rock 发货异常闭环与绑定判定
This commit is contained in:
@@ -0,0 +1,872 @@
|
||||
# Rock 绑定锚点与超时语义优化方案
|
||||
|
||||
> 背景:Rock(0666.pro)的 Token 会被循环使用。上游包详情里的
|
||||
> `bound_account` / `bound_account_id` / `status` 可能保留上一任买家的绑定信息,
|
||||
> 不能单独作为本轮绑定依据。真正能证明本轮发生绑定的是 `bind_changed_at`。
|
||||
>
|
||||
> 本文定义优化方案,并记录本次代码、数据库与页面落地的实际状态。
|
||||
|
||||
---
|
||||
|
||||
## 一、目标
|
||||
|
||||
1. 统一 Rock Token 的绑定判定口径,消除多套互相矛盾的 `IsBound` 判断。
|
||||
2. 明确“待绑定超时”和“绑定后未发货”的业务语义。
|
||||
3. 阻止历史绑定残留把 `using` 错误推进到 `bound`。
|
||||
4. 为“绑定超时”记录提供安全、可追溯的人工释放回库存入口。
|
||||
5. 保持现有状态值、筛选参数和 API 路径兼容,降低上线风险。
|
||||
6. 持久化自动发货错误,支持“无权益”独立筛选和统一人工处理。
|
||||
7. 阻止确定性上游业务错误触发无效自动重试。
|
||||
|
||||
---
|
||||
|
||||
## 二、业务语义
|
||||
|
||||
### 2.1 主链路
|
||||
|
||||
```text
|
||||
available
|
||||
-> using 提货链接已分配给订单,等待买家扫码
|
||||
-> bound 本轮真实扫码绑定成功,等待完成兑换发货
|
||||
-> redeemed 上游确认发货完成
|
||||
```
|
||||
|
||||
异常终态:
|
||||
|
||||
```text
|
||||
using / bound
|
||||
-> mismatch 本轮真实扫码,但 UID 与预期不一致
|
||||
-> abnormal 已确认失效或上游异常
|
||||
```
|
||||
|
||||
`bound` 不是最终状态。买家已扫码绑定但兑换仍在异步确认时,主状态继续保持
|
||||
`bound`,同时通过 `redeem_requested_at` / `redeem_checked_at` 等字段表示
|
||||
“发货确认中”。
|
||||
|
||||
### 2.2 两种超时
|
||||
|
||||
| 当前代码值 | 当前展示名 | 建议展示名 | 业务含义 |
|
||||
|---|---|---|---|
|
||||
| `using_overdue` | 使用超时 | 待绑定超时 | 链接已经发出,但本轮一直没有真实扫码绑定 |
|
||||
| `bound_overdue` | 绑定超时 | 绑定后未发货 | 本轮已经真实扫码绑定,但长时间没有完成兑换发货 |
|
||||
|
||||
判定公式:
|
||||
|
||||
```text
|
||||
待绑定超时:
|
||||
status = using
|
||||
AND using_at IS NOT NULL
|
||||
AND now - using_at >= using_warn_days
|
||||
|
||||
绑定后未发货:
|
||||
status = bound
|
||||
AND bound_at IS NOT NULL
|
||||
AND now - bound_at >= bound_warn_days
|
||||
AND 发货确认中标记不存在
|
||||
```
|
||||
|
||||
默认阈值:
|
||||
|
||||
- `using_warn_days = 3`
|
||||
- `using_danger_days = 5`
|
||||
- `bound_warn_days = 2`
|
||||
|
||||
“使用超时”不是指买家已经开始使用 Token,而是指提货链接发出后一直没有完成
|
||||
扫码绑定。建议保留 `using_overdue` 作为 API 和数据库兼容值,只修改前端展示名。
|
||||
|
||||
“绑定超时”不是指绑定动作超时,而是指绑定已经成功,但后续发货没有走完。
|
||||
建议保留 `bound_overdue` 作为兼容值,前端展示为“绑定后未发货”。
|
||||
|
||||
### 2.3 发货确认中
|
||||
|
||||
以下条件表示 Token 已提交兑换、等待上游异步确认:
|
||||
|
||||
```text
|
||||
status = bound
|
||||
AND redeem_requested_at IS NOT NULL
|
||||
AND redeemed_at IS NULL
|
||||
```
|
||||
|
||||
这类记录不应继续计入普通“绑定后未发货”:
|
||||
|
||||
- 不应因为等待上游确认而触发人工释放。
|
||||
- 如未来需要告警,应新增独立的“发货确认超时”筛选。
|
||||
- 当前阶段先在 `bound_overdue` 判定中排除,避免和“绑定后未发货”重叠。
|
||||
|
||||
---
|
||||
|
||||
## 三、上游字段锚点
|
||||
|
||||
### 3.1 字段优先级
|
||||
|
||||
| 字段 | 含义 | 是否可作为本轮绑定依据 | 使用方式 |
|
||||
|---|---|---|---|
|
||||
| `bind_changed_at` | 绑定/换绑时间,上游页面红色“绑定”时间 | 是,主锚点 | 与本轮 `using_at` 比较 |
|
||||
| `bind_change_type` | `bind` / `rebind` 事件类型 | 辅助 | 只用于说明事件类型 |
|
||||
| `redeemed_at` | 上游兑换时间 | 是,发货锚点 | 与本轮 `using_at` 比较 |
|
||||
| `bound_account` | 最后绑定角色名 | 否 | 只在存在真实绑定事件时作为身份展示 |
|
||||
| `bound_account_id` | 最后绑定角色 UID | 否 | 只在存在真实绑定事件时作为身份展示 |
|
||||
| `previous_bound_account` | 上一次绑定角色 | 否 | 仅用于换绑追溯 |
|
||||
| `previous_bound_account_id` | 上一次绑定 UID | 否 | 仅用于换绑追溯 |
|
||||
| `status` | 上游汇总状态 | 否,不能单独判断 | `redeemed` 可用于交叉确认 |
|
||||
| `region` / `phone` | 区服、领取信息 | 否 | 不作为绑定布尔值 |
|
||||
| `updated_at` | 上游数据更新时间 | 否 | 不参与绑定判定 |
|
||||
|
||||
### 3.2 绑定事件
|
||||
|
||||
以下字段非空才代表存在绑定事件:
|
||||
|
||||
```text
|
||||
bind_changed_at
|
||||
```
|
||||
|
||||
`bind_change_type` 非空但 `bind_changed_at` 为空时属于上游数据不一致:
|
||||
|
||||
- 不自动判定为本轮绑定。
|
||||
- 不自动释放。
|
||||
- 释放前要求人工确认,并在操作日志中记录 `ambiguous_bind_event`。
|
||||
|
||||
如果当前刷新接口额外返回 `binding.bindTime`,只有在以下条件同时满足时才可作为
|
||||
`bind_changed_at` 的兼容回退:
|
||||
|
||||
```text
|
||||
binding.bindTime 非空
|
||||
AND binding.bindTime 可解析
|
||||
AND binding.bindTime 晚于本轮 using_at
|
||||
```
|
||||
|
||||
单纯 `binding.bound = true` 或只有 `binding.roleId` 不足以判定本轮绑定。
|
||||
|
||||
### 3.3 兑换事件
|
||||
|
||||
以下任一条件可以判定当前周期已发货:
|
||||
|
||||
```text
|
||||
redeemed_at 晚于本轮 using_at
|
||||
OR deliveryList 中存在晚于本轮 using_at 的履约记录
|
||||
```
|
||||
|
||||
如果 `status = redeemed` 但 `redeemed_at` 为空,按“已发货、数据待核对”处理,
|
||||
禁止释放回库存,并在备注或日志中标明 `redeemed_at_missing`。
|
||||
|
||||
---
|
||||
|
||||
## 四、本地字段语义
|
||||
|
||||
| 字段 | 统一语义 |
|
||||
|---|---|
|
||||
| `claimed_at` | 上游 Token 领取/创建时间,保留为业务来源信息 |
|
||||
| `using_at` | 本轮本地分配时间,作为绑定新鲜度的唯一时间基线 |
|
||||
| `bound_at` | 本轮真实绑定时间,只能来自本轮 `bind_changed_at` |
|
||||
| `redeemed_at` | 本轮真实发货完成时间 |
|
||||
| `initial_bound_snapshot` | 分配瞬间保存的上游残留绑定快照 |
|
||||
| `actual_role_name` / `actual_role_id` | 本轮真实绑定角色;无本轮绑定事件时不应视为当前角色 |
|
||||
| `redeem_requested_at` | 本地提交兑换时间,表示发货确认中 |
|
||||
|
||||
### 4.1 时间基线
|
||||
|
||||
本轮判定的唯一基线是 `using_at`:
|
||||
|
||||
```text
|
||||
cycleBaseline = using_at
|
||||
```
|
||||
|
||||
不要再使用 `created_at` 作为绑定新鲜度基线。`created_at` 是本地记录创建时间,
|
||||
与订单分配轮次无关。
|
||||
|
||||
`claimed_at` 可以作为旧数据兼容回退,但只允许在 `using_at` 为空时使用:
|
||||
|
||||
```text
|
||||
cycleBaseline = using_at
|
||||
fallback = claimed_at
|
||||
禁止 fallback = created_at
|
||||
```
|
||||
|
||||
如果两者都为空,本轮绑定状态为“不可判定”,必须人工确认,不能自动写 `bound_at`。
|
||||
|
||||
### 4.2 初始绑定快照
|
||||
|
||||
每次 `available -> using` 分配时,重新抓取并保存上游原始绑定快照:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "bound",
|
||||
"bound_account": "那若水",
|
||||
"bound_account_id": "4613930835",
|
||||
"bind_changed_at": "",
|
||||
"bind_change_type": "",
|
||||
"redeemed_at": "",
|
||||
"captured_at": "2026-11-14T21:10:22+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
快照中的 `Bound` 不再使用“状态为 bound 或存在账号 ID”作为布尔值,而应改为:
|
||||
|
||||
```text
|
||||
bound = bind_changed_at 非空
|
||||
```
|
||||
|
||||
`bound_account` / `bound_account_id` 只作为残留身份保存,用于后续比对和审计。
|
||||
|
||||
---
|
||||
|
||||
## 五、唯一判定函数
|
||||
|
||||
新增统一分类入口,所有业务路径共用:
|
||||
|
||||
```go
|
||||
type RockBindingEvidence struct {
|
||||
Kind string // delivered / current_bound / residual / pending / ambiguous
|
||||
BindChangedAt time.Time
|
||||
RedeemedAt time.Time
|
||||
RoleName string
|
||||
RoleID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func classifyRockBinding(
|
||||
token *model.RockToken,
|
||||
ref *RockRefreshResponse,
|
||||
) RockBindingEvidence
|
||||
```
|
||||
|
||||
### 5.1 分类顺序
|
||||
|
||||
```text
|
||||
1. 本地发货确认中
|
||||
-> pending
|
||||
|
||||
2. 本轮 redeemed_at 或本轮 deliveryList
|
||||
-> delivered
|
||||
|
||||
3. bind_changed_at 晚于 cycleBaseline
|
||||
-> current_bound
|
||||
|
||||
4. bind_changed_at 早于 cycleBaseline
|
||||
-> residual
|
||||
|
||||
5. bind_changed_at 与 initial_bound_snapshot 相同
|
||||
-> residual
|
||||
|
||||
6. bind_changed_at 为空,但存在 bound_account / bound_account_id
|
||||
-> residual
|
||||
|
||||
7. bind_change_type 非空但时间缺失
|
||||
-> ambiguous
|
||||
|
||||
8. 其他
|
||||
-> unbound
|
||||
```
|
||||
|
||||
时间比较允许 1 分钟误差,用于吸收 Token 领取和分配之间的时钟差。
|
||||
|
||||
### 5.2 分类后的状态动作
|
||||
|
||||
| 分类 | 本地状态 | 时间字段 | 是否允许释放 |
|
||||
|---|---|---|---|
|
||||
| `unbound` | `using` | 清空 `bound_at` | 允许,仍需确认订单不会履约 |
|
||||
| `residual` | `using` | 清空 `bound_at` | 允许,走普通释放校验 |
|
||||
| `current_bound` | `bound` | 写入上游 `bind_changed_at` | 允许人工强制释放 |
|
||||
| `pending` | `bound` + redeem 标记 | 保持原值 | 禁止 |
|
||||
| `delivered` | `redeemed` | 写入本轮 `redeemed_at` | 禁止 |
|
||||
| `ambiguous` | 保持原状态并告警 | 不自动写入 | 仅人工强制释放 |
|
||||
|
||||
---
|
||||
|
||||
## 六、当前代码问题
|
||||
|
||||
### 6.1 绑定布尔值口径分裂
|
||||
|
||||
- `RockTokenData.IsBound()` 将 `status=bound`、`status=redeemed` 或
|
||||
`bound_account_id` 非空都视为绑定。
|
||||
- `rockUpstreamBound()` 将 `status=bound` 视为当前绑定。
|
||||
- `isUnboundRockToken()` 已使用 `bind_changed_at` / `bind_change_type`,口径与其他路径不一致。
|
||||
- `rockBuyerNewlyBound()` 有快照判断,但入口仍依赖 `ref.IsBound()`。
|
||||
|
||||
### 6.2 刷新会制造错误的绑定时间
|
||||
|
||||
`RefreshTokenBinding` 遇到 `status=bound` 后直接写本地 `bound`:
|
||||
|
||||
- 清除已有 `mismatch`。
|
||||
- 当 `bound_at` 为空时,使用 `bind_changed_at`;缺失时回退到当前时间。
|
||||
|
||||
这会把没有本轮绑定事件的残留账号写成“本轮已绑定”,随后进入错误的
|
||||
“绑定后未发货”筛选。
|
||||
|
||||
### 6.3 时间基线可能错误
|
||||
|
||||
`rockClaimBaseline()` 会使用:
|
||||
|
||||
```text
|
||||
claimed_at -> created_at
|
||||
```
|
||||
|
||||
其中 `created_at` 与订单分配轮次无关。旧 Token 重新分配后,历史绑定时间可能被
|
||||
错误判断为晚于基线。
|
||||
|
||||
### 6.4 释放状态门禁不完整
|
||||
|
||||
- 普通释放只允许 `using`,`bound / 绑定后未发货` 没有入口。
|
||||
- force 模式当前可能允许上游已发货、异常或校验失败的 Token 回库存。
|
||||
- 如果只放开 `bound` 状态而不先增加 pending / delivered 硬门禁,会产生重复发货风险。
|
||||
|
||||
---
|
||||
|
||||
## 七、目标行为
|
||||
|
||||
### 7.1 刷新绑定
|
||||
|
||||
刷新时只同步真实绑定事件:
|
||||
|
||||
```text
|
||||
本轮 bind_changed_at 存在
|
||||
-> status=bound
|
||||
-> bound_at=上游 bind_changed_at
|
||||
-> 按预期 UID 判断 bound 或 mismatch
|
||||
|
||||
本轮 bind_changed_at 不存在
|
||||
-> 保持 using
|
||||
-> 不写 bound_at
|
||||
-> 不把 bound_account 当成本轮角色
|
||||
```
|
||||
|
||||
### 7.2 待绑定超时
|
||||
|
||||
```text
|
||||
status = using
|
||||
AND using_at <= now - using_warn_days
|
||||
```
|
||||
|
||||
前端展示“待绑定超时”。操作列继续显示释放入口。
|
||||
|
||||
### 7.3 绑定后未发货
|
||||
|
||||
```text
|
||||
status = bound
|
||||
AND bound_at <= now - bound_warn_days
|
||||
AND 非发货确认中
|
||||
```
|
||||
|
||||
前端展示“绑定后未发货”。操作列增加“释放回库存”按钮。
|
||||
|
||||
释放前必须调用 `release-check`:
|
||||
|
||||
| 上游校验结果 | 行为 |
|
||||
|---|---|
|
||||
| 本轮无绑定事件 | 普通释放 |
|
||||
| 本轮已绑定 | 强制确认释放 |
|
||||
| 发货确认中 | 硬阻断 |
|
||||
| 已发货 | 硬阻断 |
|
||||
| 上游异常 / Token 不存在 | 硬阻断,引导“上报失效” |
|
||||
| 校验失败 | 硬阻断,不允许盲放 |
|
||||
|
||||
### 7.4 释放事务
|
||||
|
||||
允许来源状态:
|
||||
|
||||
```text
|
||||
using
|
||||
bound
|
||||
```
|
||||
|
||||
释放时统一执行:
|
||||
|
||||
```text
|
||||
status -> available
|
||||
清空 assigned_order_no / expected_uid
|
||||
清空 actual_role_name / actual_role_id / actual_channel
|
||||
清空 mismatch / bound_at / using_at / redeemed_at
|
||||
清空 upstream_fulfill_id
|
||||
清空 redeem_* 标记
|
||||
清空 initial_bound_snapshot
|
||||
写入 restocked_at
|
||||
解除订单 result_data 中的 rock_token 关联
|
||||
写状态历史和审计日志
|
||||
```
|
||||
|
||||
状态历史中的 `from_status` 必须保存真实的 `using` 或 `bound`。
|
||||
|
||||
### 7.5 下一次分配
|
||||
|
||||
Token 释放后再次进入 `available`,下一次分配时:
|
||||
|
||||
1. 清除上一轮本地绑定字段。
|
||||
2. 设置新的 `using_at`。
|
||||
3. 重新抓取上游绑定快照。
|
||||
4. 以新 `using_at` 作为本轮唯一时间基线。
|
||||
|
||||
即使上游仍保留上一任账号,也只能作为快照残留,不能直接判定为新绑定。
|
||||
|
||||
---
|
||||
|
||||
## 八、兼容性与数据修复
|
||||
|
||||
### 8.1 保持兼容
|
||||
|
||||
- 数据库状态值保持不变:继续使用 `using`、`bound`、`redeemed`。
|
||||
- API 筛选值保持不变:继续使用 `using_overdue`、`bound_overdue`。
|
||||
- 前端只调整中文展示名和按钮条件。
|
||||
- 第一阶段不新增数据库字段,优先使用现有字段完成口径统一。
|
||||
|
||||
### 8.2 历史异常数据
|
||||
|
||||
重点排查:
|
||||
|
||||
```text
|
||||
status = bound
|
||||
AND bound_at IS NOT NULL
|
||||
AND redeem_requested_at IS NULL
|
||||
AND raw_data.bind_changed_at 为空
|
||||
```
|
||||
|
||||
处理流程:
|
||||
|
||||
1. 先补充只读审计脚本,统计数量和抽样明细。
|
||||
2. 人工确认这些记录没有正在进行的发货。
|
||||
3. 刷新上游状态后,把无本轮绑定事件的记录修复为 `using`。
|
||||
4. 清理错误的 `bound_at` 和本轮角色字段。
|
||||
5. 写状态日志,来源标记为 `binding_anchor_repair`。
|
||||
|
||||
禁止直接批量把所有 `bound` 改成 `using`。发货确认中、已发货和人工锁定记录必须排除。
|
||||
|
||||
---
|
||||
|
||||
## 九、前端调整
|
||||
|
||||
### 9.1 文案
|
||||
|
||||
| 当前文案 | 新文案 |
|
||||
|---|---|
|
||||
| 使用超时 | 待绑定超时 |
|
||||
| 绑定超时 2.1 天 | 绑定后未发货 2.1 天 |
|
||||
| 已授权绑定 | 已绑定待发货 |
|
||||
|
||||
### 9.2 操作列
|
||||
|
||||
- `using`:显示“释放回库存”。
|
||||
- `bound` 且非发货确认中:显示“释放回库存”。
|
||||
- `bound` 且发货确认中:显示“发货确认中”,不显示释放按钮。
|
||||
- `redeemed` / `abnormal`:不显示释放按钮。
|
||||
|
||||
### 9.3 释放弹窗
|
||||
|
||||
弹窗需要明确展示:
|
||||
|
||||
- 本地状态。
|
||||
- 本轮绑定时间。
|
||||
- 上游绑定角色和 UID。
|
||||
- 是否为本轮真实绑定。
|
||||
- 上游是否已发货。
|
||||
- 是否处于发货确认中。
|
||||
|
||||
强制释放必须输入固定确认文案,并记录操作人、上游状态和判定原因。
|
||||
|
||||
### 9.4 错误分类筛选
|
||||
|
||||
筛选栏最终顺序:
|
||||
|
||||
```text
|
||||
全部 | 可用 | 使用中 | 已绑定 | 已发货 |
|
||||
绑定错误 | 已上报失效 | 待绑定超时 | 绑定后未发货 | 无权益 | 异常
|
||||
```
|
||||
|
||||
- “无权益”独立放在“异常”之前。
|
||||
- 除“无权益”外,其他未处理的发货错误全部归入最后的“异常”。
|
||||
- “无权益”和“异常”都是错误事件筛选,不替代 Token 主状态。
|
||||
- 同一个 Token 可以同时出现在“已绑定”和“无权益/异常”中,这是预期行为。
|
||||
- 只有完成人工处理动作后,对应错误事件才从筛选计数中消失。
|
||||
|
||||
---
|
||||
|
||||
## 十、测试计划
|
||||
|
||||
### 10.1 分类函数
|
||||
|
||||
- `bind_changed_at` 晚于 `using_at`:`current_bound`。
|
||||
- `bind_changed_at` 早于 `using_at`:`residual`。
|
||||
- `bind_changed_at` 为空但 `bound_account_id` 非空:`residual`。
|
||||
- `bind_change_type` 非空但时间缺失:`ambiguous`。
|
||||
- 本轮 `redeemed_at` 非空:`delivered`。
|
||||
- 历史 `redeemed_at` 早于 `using_at`:不能错误判定本轮已发货。
|
||||
- `redeem_requested_at` 非空且 `redeemed_at` 为空:`pending`。
|
||||
|
||||
### 10.2 刷新
|
||||
|
||||
- 残留绑定刷新后保持 `using`,不写 `bound_at`。
|
||||
- 真实绑定刷新后写 `bound`,`bound_at` 使用上游时间。
|
||||
- 真实绑定且 UID 不一致时进入 `mismatch`。
|
||||
- 历史已兑换状态不能被当成当前轮次发货。
|
||||
|
||||
### 10.3 超时
|
||||
|
||||
- `using` 超过阈值进入“待绑定超时”。
|
||||
- `bound` 超过阈值且非 pending 进入“绑定后未发货”。
|
||||
- pending 不计入“绑定后未发货”。
|
||||
- `redeemed` 不进入任何绑定超时。
|
||||
|
||||
### 10.4 释放
|
||||
|
||||
- `using + residual` 可普通释放。
|
||||
- `bound + current_bound` 必须强制确认。
|
||||
- pending 禁止释放。
|
||||
- delivered 禁止释放。
|
||||
- abnormal / not_found 禁止释放。
|
||||
- 释放后所有绑定和履约字段清空,订单关联解除。
|
||||
- 状态历史记录真实来源状态和判定原因。
|
||||
|
||||
### 10.5 回归用例
|
||||
|
||||
- HAR 中的 Token `176369` 不再进入“绑定后未发货”。
|
||||
- `176369` 刷新后按本轮无绑定事件处理,进入待绑定/历史残留分支。
|
||||
- 其余具有本轮 `bind_changed_at` 和 `redeemed_at` 的 Token 仍正常显示已发货。
|
||||
|
||||
---
|
||||
|
||||
## 十一、实施顺序
|
||||
|
||||
1. 新增统一 `classifyRockBinding` 和单元测试。
|
||||
2. 用统一分类替换 `RockTokenData.IsBound()`、`rockUpstreamBound()`、
|
||||
`rockBuyerNewlyBound()` 的业务调用。
|
||||
3. 修正 `RefreshTokenBinding`,禁止残留状态写 `bound_at`。
|
||||
4. 修正时间基线,使用 `using_at`,禁止回退 `created_at`。
|
||||
5. 调整超时筛选,pending 排除出 `bound_overdue`。
|
||||
6. 扩展 `release-check` 和释放事务,先补硬门禁再加前端按钮。
|
||||
7. 更新前端文案和操作列。
|
||||
8. 运行历史异常数据审计,不直接批量修复。
|
||||
9. 小范围验证后再执行历史数据修复。
|
||||
|
||||
---
|
||||
|
||||
## 十二、验收标准
|
||||
|
||||
- `status=bound` 但 `bind_changed_at` 为空的旧账号不再被识别为本轮绑定。
|
||||
- “待绑定超时”表示链接发出后未发生本轮真实绑定。
|
||||
- “绑定后未发货”表示本轮真实绑定成功,但未完成兑换发货。
|
||||
- 发货确认中的 Token 不进入“绑定后未发货”。
|
||||
- 绑定后未发货支持人工释放,但必须经过上游复核和强制确认。
|
||||
- 已发货、pending、异常和校验失败的 Token 无法被错误释放。
|
||||
- `176369` 这类 Token 不再因历史残留账号被误判为绑定后未发货。
|
||||
|
||||
---
|
||||
|
||||
## 十三、自动发货错误记录与人工处理计划
|
||||
|
||||
### 13.1 生产排查结论
|
||||
|
||||
2026-11-14 对生产日志和数据库进行了只读排查,确认上游实际错误为:
|
||||
|
||||
```text
|
||||
用户不存在可以兑换的可用权益
|
||||
```
|
||||
|
||||
排查结果:
|
||||
|
||||
- 累计发现 16 笔该错误订单,全部仍为 `ship_failed`。
|
||||
- 其中 9 个 Token 仍为 `bound`。
|
||||
- 7 个 Token 后续因上游卡密不存在或状态异常转为 `abnormal`。
|
||||
- 当前全库有 27 个 `bound` Token 关联在 `ship_failed` 订单上。
|
||||
- 错误报文已写入 `rock_delivery_logs`,但 Token 的
|
||||
`redeem_last_error` 和 `remark` 为空。
|
||||
- 系统未对确定性 400 业务错误做分类,同一订单可能被重复提交上游。
|
||||
- 管理端可从“历史 -> 发货报文”查看原始记录,但没有可处理的错误事件。
|
||||
|
||||
### 13.2 错误分类
|
||||
|
||||
内部错误码与前端归类:
|
||||
|
||||
| 内部错误码 | 上游特征 | 前端归类 | 是否允许自动重试 |
|
||||
|---|---|---|---|
|
||||
| `no_entitlement` | 用户不存在可以兑换的可用权益 | 无权益 | 否 |
|
||||
| `duplicate_item` | 同一角色无法重复兑换已兑换过的道具 | 异常 | 否 |
|
||||
| `gift_exhausted` | 礼包已发放完毕 | 异常 | 否 |
|
||||
| `token_already_redeemed` | 该 Token 已兑换 | 异常 | 否 |
|
||||
| `token_not_redeemable` | 该 Token 暂不可兑换 | 异常 | 否 |
|
||||
| `token_not_found` | Token 不存在 | 异常 | 否 |
|
||||
| `upstream_timeout` | HTTP 超时、连接失败 | 异常 | 否,人工确认后重试 |
|
||||
| `upstream_rate_limited` | 请求过于频繁或频控错误 | 异常 | 否,人工确认后重试 |
|
||||
| `ship_timeout` | 已提交但长时间未回传 | 异常 | 否,先对上游客服端 |
|
||||
| `bind_mismatch` | 绑定 UID 与预期 UID 不一致 | 异常 | 否 |
|
||||
| `order_cancelled` | 订单取消时 Token 已绑定或已发货 | 异常 | 否 |
|
||||
| `other` | 未识别错误 | 异常 | 否 |
|
||||
|
||||
“无权益”表示当前绑定角色在上游没有可兑换权益。它不能证明 Token 已失效,
|
||||
因此不能直接自动标记为 `abnormal`。
|
||||
|
||||
### 13.3 错误持久化模型
|
||||
|
||||
新增表 `rock_delivery_incidents`,每条记录代表一次需要人工处理的发货异常:
|
||||
|
||||
```sql
|
||||
CREATE TABLE rock_delivery_incidents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
token_id BIGINT NOT NULL,
|
||||
order_no VARCHAR(128) NOT NULL DEFAULT '',
|
||||
upstream_token_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
category VARCHAR(40) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open',
|
||||
blocking_retry BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
upstream_http_status INT NOT NULL DEFAULT 0,
|
||||
upstream_response TEXT NOT NULL DEFAULT '',
|
||||
occurrences INT NOT NULL DEFAULT 1,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by BIGINT,
|
||||
resolution_action VARCHAR(32) NOT NULL DEFAULT '',
|
||||
resolution_note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
建议索引:
|
||||
|
||||
```text
|
||||
(status, category, last_seen_at DESC)
|
||||
(token_id, created_at DESC)
|
||||
(order_no, created_at DESC)
|
||||
唯一开放事件:(token_id, order_no, category) WHERE status = 'open'
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 原始请求和响应继续保存在 `rock_delivery_logs`。
|
||||
- `rock_delivery_incidents` 只保存分类、处理状态和人工处置结果。
|
||||
- 禁止把 Bearer Token、JWT、请求签名等敏感头写入异常表。
|
||||
- 同一 Token、订单、错误类型重复发生时应累加 `occurrences`,不能无限新增重复事件。
|
||||
|
||||
同时在 `rock_tokens` 上保留便于列表快速展示的摘要字段:
|
||||
|
||||
- `redeem_last_error`
|
||||
- `remark`
|
||||
- `updated_at`
|
||||
|
||||
错误写入后,Token 历史和备注中必须能看到最近一次失败原因。
|
||||
|
||||
### 13.4 发货失败写入流程
|
||||
|
||||
Rock 兑换失败时按顺序执行:
|
||||
|
||||
1. 解析 HTTP 状态和上游错误文本。
|
||||
2. 归类为内部错误码。
|
||||
3. Upsert 对应 `rock_delivery_incidents`。
|
||||
4. 写入 Token 的 `redeem_last_error` 和系统备注。
|
||||
5. 记录 `redeem_result failed` 和 `incident_opened` 日志。
|
||||
6. 将订单更新为 `ship_failed`,保留 `fail_stage`、`fail_reason`、`fail_at`。
|
||||
7. 设置 `blocking_retry=true`,阻止相同订单再次直接请求上游。
|
||||
|
||||
不能在只写订单错误后直接返回。否则订单失败、Token 状态和错误列表会彼此脱节。
|
||||
|
||||
### 13.5 自动重试策略
|
||||
|
||||
以下情况不属于异常,系统继续正常轮询:
|
||||
|
||||
- 上游返回 `pending`。
|
||||
- 本地处于“发货确认中”。
|
||||
- 上游确认仍在异步处理。
|
||||
|
||||
以下情况属于开放异常,禁止自动重复提交兑换:
|
||||
|
||||
- 无权益。
|
||||
- 重复道具。
|
||||
- 礼包耗尽。
|
||||
- Token 已兑换或不可兑换。
|
||||
- 绑定 UID 不一致。
|
||||
- 未识别的确定性 400 业务错误。
|
||||
|
||||
对于超时和频控错误:
|
||||
|
||||
- 不立即重复兑换。
|
||||
- 先记录异常并人工确认上游状态。
|
||||
- 人工确认后只允许一次受控重试。
|
||||
|
||||
公共发货接口重复调用时:
|
||||
|
||||
- 如果存在 `blocking_retry=true` 的开放异常,直接返回已缓存的失败结果。
|
||||
- 不再重复请求 0666.pro。
|
||||
- 不通过隐式重试清理异常,必须由管理员执行人工动作。
|
||||
|
||||
### 13.6 人工处理闭环
|
||||
|
||||
管理端的“处理异常”弹窗展示:
|
||||
|
||||
- Token、订单号、上游 Token ID。
|
||||
- 当前 Token 状态和订单状态。
|
||||
- 错误分类、错误文本、HTTP 状态。
|
||||
- 首次发生时间、最近发生时间、累计次数。
|
||||
- 预期 UID、实际角色、绑定时间。
|
||||
- 最近一次上游请求和响应的关键内容。
|
||||
|
||||
允许的人工动作:
|
||||
|
||||
| 动作 | 结果 |
|
||||
|---|---|
|
||||
| 人工重试发货 | 仅对可重试错误开放,只允许一次受控重试 |
|
||||
| 释放回库存 | 先走 `release-check`,成功后关闭异常并释放 Token |
|
||||
| 上报失效 | Token 转为 `abnormal`,关闭异常并记录备注 |
|
||||
| 标记已处理 | 仅关闭异常事件,不改变 Token 和订单状态 |
|
||||
|
||||
所有动作必须记录:
|
||||
|
||||
- 操作人。
|
||||
- 操作时间。
|
||||
- 动作类型。
|
||||
- 原错误分类。
|
||||
- 处理后 Token 状态。
|
||||
- 处理备注。
|
||||
|
||||
“标记已处理”不能用于绕过 Token 状态限制。例如 `bound` Token 仍需通过释放校验
|
||||
才能回到库存池。
|
||||
|
||||
### 13.7 无权益订单的当前处理建议
|
||||
|
||||
对已存在的 16 笔“无权益”订单:
|
||||
|
||||
1. 不自动重新提交兑换。
|
||||
2. 在 Token 列表的“无权益”筛选中统一展示。
|
||||
3. 人工确认订单是否已退款、是否允许更换绑定的游戏角色。
|
||||
4. 如果确认 Token 未被消耗,可人工释放回库存。
|
||||
5. 如果上游已作废 Token,则执行“上报失效”。
|
||||
6. 订单保持 `ship_failed`,直到业务侧完成退款、补发或人工重试。
|
||||
|
||||
不能把所有“无权益”Token 直接重置为 `available`。当前有 9 个 Token 仍是 `bound`,
|
||||
必须先确认它们是否仍绑定在下一任用户不可使用的角色上。
|
||||
|
||||
### 13.8 API 与统计
|
||||
|
||||
Token 列表新增筛选值:
|
||||
|
||||
```text
|
||||
no_entitlement
|
||||
anomaly
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `no_entitlement`:存在开放的 `no_entitlement` 异常事件。
|
||||
- `anomaly`:存在除 `no_entitlement` 以外的任意开放异常事件。
|
||||
|
||||
统计接口新增:
|
||||
|
||||
```text
|
||||
no_entitlement_stock
|
||||
unresolved_anomaly_stock
|
||||
```
|
||||
|
||||
已实现接口:
|
||||
|
||||
```text
|
||||
GET /api/platform/rock/tokens/:id/incidents
|
||||
GET /api/platform/rock/incidents
|
||||
PATCH /api/platform/rock/incidents/:id
|
||||
POST /api/platform/rock/incidents/:id/retry
|
||||
```
|
||||
|
||||
接口动作:
|
||||
|
||||
```text
|
||||
PATCH :id 标记异常已处理,仅关闭事件
|
||||
POST :id/retry 超时/频控异常的一次受控人工重试
|
||||
Token release API 释放前复核,成功后关闭同 Token 的开放异常
|
||||
Token mark-abnormal API 上报失效,成功后关闭同 Token 的开放异常
|
||||
```
|
||||
|
||||
### 13.9 前端调整
|
||||
|
||||
筛选栏在“绑定后未发货”和“异常”之间增加“无权益”:
|
||||
|
||||
```text
|
||||
待绑定超时 | 绑定后未发货 | 无权益 | 异常
|
||||
```
|
||||
|
||||
- “无权益”使用独立红色计数。
|
||||
- 其他所有开放错误统一计入最后的“异常”。
|
||||
- Token 状态列增加独立错误标签:
|
||||
- `无权益`
|
||||
- `异常`
|
||||
- 有开放异常时,操作列增加“处理异常”。
|
||||
- “历史”抽屉增加“异常记录”标签页,和“状态历史”“发货报文”并列。
|
||||
|
||||
### 13.10 历史数据策略
|
||||
|
||||
- `029` 迁移只创建异常事件表与索引,不回填历史订单。
|
||||
- 历史 `ship_failed`、取消订单和人工已处理记录不自动生成开放异常。
|
||||
- 部署后新发生的发货错误、取消冲突正常写入异常事件。
|
||||
- 历史 Token 主状态不由迁移自动修改。
|
||||
|
||||
### 13.11 测试补充
|
||||
|
||||
- “用户不存在可以兑换的可用权益”必须分类为 `no_entitlement`。
|
||||
- 其他上游错误必须进入“异常”,不能进入“无权益”。
|
||||
- 同一 Token 连续 3 次相同错误只保留 1 个开放事件,`occurrences=3`。
|
||||
- 存在开放异常时,重复发货请求不得再次调用上游兑换接口。
|
||||
- 人工释放成功后异常关闭、Token 回 `available`。
|
||||
- 上报失效后异常关闭、Token 转 `abnormal`。
|
||||
- 人工重试只允许一次,并写完整审计记录。
|
||||
- 筛选计数与列表结果一致。
|
||||
- 订单取消时 `using` Token 自动回库存。
|
||||
- 订单取消时 `bound/redeemed` Token 生成 `order_cancelled` 异常。
|
||||
|
||||
### 13.12 实施顺序
|
||||
|
||||
1. 新增错误分类器及单元测试。
|
||||
2. 新增 `rock_delivery_incidents` 迁移和索引。
|
||||
3. 在 `RedeemTokenForOrder` 错误分支写入异常事件。
|
||||
4. 在重复发货入口增加开放异常短路,禁止无效上游请求。
|
||||
5. 新增异常列表、详情和人工动作 API。
|
||||
6. 增加 `no_entitlement` / `anomaly` 筛选与统计。
|
||||
7. 更新前端筛选栏、状态标签和“处理异常”弹窗。
|
||||
8. 不自动回填历史订单,只处理部署后的新异常。
|
||||
|
||||
### 13.13 验收标准
|
||||
|
||||
- “无权益”作为独立筛选项出现在“异常”之前。
|
||||
- 其他所有未处理发货错误统一进入“异常”。
|
||||
- `no_entitlement` 订单不再自动重复请求上游。
|
||||
- 错误在订单、Token、发货报文和异常记录中口径一致。
|
||||
- Token 历史可看到失败事件和人工处理结果。
|
||||
- 所有开放异常都有明确处理状态和操作人。
|
||||
- 现有 16 笔无权益订单全部进入待处理列表。
|
||||
- 9 个 `bound` Token 可通过安全的人工释放或上报失效流程完成处理。
|
||||
|
||||
---
|
||||
|
||||
## 十四、本次实现状态
|
||||
|
||||
已完成:
|
||||
|
||||
- 绑定判定统一以 `bind_changed_at` / `binding.bindTime` 和本轮 `using_at` 为锚点。
|
||||
- 绑定快照与上游时间格式不同但指向同一事件时,仍按历史残留处理。
|
||||
- `status=bound` 和 `bound_account_id` 不再单独推进本轮绑定状态。
|
||||
- `redeemed_at` / `deliveryList` 同样必须晚于本轮 `using_at`,历史发货记录不再误判本轮已发货。
|
||||
- “待绑定超时”和“绑定后未发货”文案与筛选语义已更新。
|
||||
- 发货确认中的 Token 不再计入“绑定后未发货”,操作列显示“发货确认中”。
|
||||
- pending 订单不会再被普通订单超时任务标记为 `ship_failed`。
|
||||
- 新增 `rock_delivery_incidents` 异常事件表和 `029` 迁移。
|
||||
- 新增 `no_entitlement` 独立错误分类和筛选项。
|
||||
- 其他发货错误统一进入“异常”筛选,内部保留细分错误码。
|
||||
- 重复发货时存在开放异常会阻止再次请求上游。
|
||||
- 新增人工标记已处理、释放回库存和上报失效闭环。
|
||||
- 新增超时/频控异常的一次受控人工重试;其他确定性强错误不允许直接重试。
|
||||
- 新增订单取消冲突处理:`using` 自动回库,`bound/redeemed` 生成人工异常。
|
||||
- 新增 Token 异常历史、处理状态、操作人和处理备注。
|
||||
- 新增 Rock 订单与 Token 状态后台对账。
|
||||
- 关键履约状态更新不再忽略数据库错误。
|
||||
- `bound` Token 在确认未发货后支持人工释放。
|
||||
- 上游已发货、发货确认中、异常或校验失败的 Token 禁止释放。
|
||||
- 备注接口不能再绕过释放流程直接改成 `available`。
|
||||
- 可用库存统计改为与实际分配一致的黑色 Token 口径。
|
||||
- Token 列表分页大小增加 100 条上限。
|
||||
|
||||
按当前要求暂不处理:
|
||||
|
||||
- Rock Bearer Token 明文存储。
|
||||
- `rock_delivery_logs` 请求头中的 Authorization 脱敏。
|
||||
- 配置接口返回原始 Bearer Token。
|
||||
|
||||
部署说明:
|
||||
|
||||
- `029_rock_delivery_incidents.sql` 只创建异常事件表与索引,不回填历史订单。
|
||||
- 部署后新发生的订单取消冲突和发货异常会进入人工处理列表。
|
||||
- 历史记录保持现状,不自动生成异常,也不自动修改 Token 主状态。
|
||||
Reference in New Issue
Block a user