feat: 完善 Rock 发货异常闭环与绑定判定

This commit is contained in:
yml
2026-11-14 23:58:57 +08:00
parent 9353b439a7
commit 6a87fe5228
45 changed files with 3497 additions and 389 deletions
@@ -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 主状态。