feat(huya): 协作台账状态机新口径(跳步/已确认绑定/兑换失败)+ 账号全生命周期快照(迁移 0059)

This commit is contained in:
yml2213
2026-10-16 22:00:34 +08:00
parent aac264b639
commit e8abd60734
11 changed files with 1897 additions and 84 deletions
+141 -15
View File
@@ -163,6 +163,35 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
`ensure_shops_registered()` 会把它登记进字典(新建/编辑/粘贴导入共用同一条路);
3. **字典从空开始**:不回填历史订单里出现过的店铺名(用户明确"不用看之前的")。
### 3.5 表 `huya_external_order_metrics`(账号全生命周期快照,2026-10-16 加)
一次「查积分 / 兑换记录」= 一行。**只追加、不更新**(要改就是重新查询,历史留在那里),
但**不是事件表**:它是"这一刻账号长什么样"的证据,跟 §7.3 的 `audit_logs`(谁改了什么)分工不同。
设计动机见 §5.7。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | int PK | |
| `order_id` | int FK `huya_external_orders.id` **ON DELETE CASCADE** index | 随订单删除一起清掉 |
| `order_no` / `uid` | varchar | 冗余快照(订单删了也看得懂导出过的报表) |
| `sid` | int | 查询用的活动 ID(工作台 `huya_config.sid`,见 §11.4) |
| `score` / `gained_score` / `used_score` | int null | 可用 / 累计获得 / 已消耗积分 |
| `records_total` / `records_matched` | int | 兑换记录条数 / 命中这一单礼包的条数 |
| `verdict` | varchar(16) index | `success` / `failed` / `unknown`(三态,见 §5.7) |
| `message` | varchar(512) | 人话结论(含"为什么不判/为什么没自动改状态") |
| `source` | varchar(16) | `manual`(单行点)/ `batch`(勾选批量) |
| `raw` | JSON | 接口原始体裁剪(**不含任何凭据**,见下) |
| `created_by` | int FK `users.id` | 谁查的 |
| `created_at` | datetime index | 查询时刻 |
**四条取舍**:
1. **不存 CK**:`raw` 只放探针/活动接口返回的字段(积分、记录、命中预览),
凭据不落这张表 —— 表会随查询次数线性增长,多一处凭据就多一处泄漏面;
2. **verdict 三态**:`unknown` 是"数据不够,判不了",不是"失败"。否则接口抖一下就把单子判死;
3. **只追加**:业务上要的是"积分变化",能被覆盖的快照看不出变化;
4. **随订单删除**:订单是主体,快照是附属(`ON DELETE CASCADE`)。
---
@@ -171,29 +200,54 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
### 4.1 业务状态机(一行的进度)
```
pending ──绑定成功──▶ bound ──充值──▶ recharged ──上脚本──▶ scripted ──归档──▶ done
│ │ │ │
└────────────────────┴────────────────┴───────────────────┴──────▶ void(作废)
pending ─人工确认─▶ bound ─充值─▶ recharged ─上脚本─▶ scripted ─兑换成功─▶ done
│ │ │ │ │
└─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ failed(兑换失败)
│ │ │ │ │
└─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ void(作废)
```
| 状态 | 含义 | 进入条件 | 谁可触发 |
| --- | --- | --- | --- |
| `pending` | 待绑定 | 新建行(默认) | — |
| `bound` | 已绑定 | 填了 `bind_douyu_account` **且** 至少 1 张绑定图 | 所有人 |
| `recharged` | 已充值 | 从 `bound` 前进;写 `recharged_at` | 所有人 |
| `scripted` | 已上脚本 | 从 `recharged` 前进;写 `scripted_at` | 所有人 |
| `done` | 已完成 | 从 `scripted` 前进;写 `done_at` | 所有人 |
| `void` | 作废 | 任意状态可进;`void_reason` 必填 | 所有人 |
| `pending` | 待处理 | 新建 / 上传导入后的默认态(**上传完就是这里**) | — |
| `bound` | **已确认绑定** | 人工核对「探针读到的绑定角色」与「主页截图」一致后手动切 | 人工(单行 / 批量) |
| `recharged` | 已充值 | 充值完成后手动切(可跳步);写 `recharged_at` | 人工 |
| `scripted` | 已上脚本 | 脚本上完后手动切(可跳步);写 `scripted_at` | 人工 |
| `done` | 已完成 | 人工切;**或**「已上脚本」的单查积分/兑换记录命中礼包后自动切(§5.7) | 人工 / 系统判定 |
| `failed` | **兑换失败** | 人工切;**或**「已上脚本」的单查兑换记录**没有**这个礼包后自动切(§5.7) | 人工 / 系统判定 |
| `void` | 已作废 | 任意流程内状态可进;`void_reason` 必填 | 人工 |
**规则**:
1. 状态**只能向前**。要回退必须显式勾选"回退"并从下拉里选目标态,
审计里记为 `status_rollback`(保留痕迹,不静默改)。
2. 进入 `bound` 的前置校验在**接口层**做(缺绑定图/缺绑定斗鱼号 → 400,
错误信息要说清缺什么),不在前端做——前端只是体验,不能当校验。
3. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是
**规则**(2026-10-16 用户拍板的口径,旧版"只能一步一步往前"作废):
1. **人工切换允许往前跳步**:目标阶段序号 ≥ 当前即放行(`is_forward_status()`)。
实际场景就是会跳(没记充值就直接上脚本),逼人一步一步点只会逼出假数据。
跳步会把沿途的 `*_at` 时间戳**补齐**(`_sync_stage_timestamps()`),
否则会出现"已上脚本但没有充值时间"的自相矛盾。
2. **回退必须显式声明**:目标阶段序号 < 当前时必须 `rollback=true`(界面勾「回退」),
会**清掉后面阶段的时间戳**(`scripted → pending` 之后 `bound_at/recharged_at/scripted_at` 全为 null),
审计里 `rollback=true` 留痕。
3. **`failed` / `void` 是旁路**:流程内任意状态都能进(不用勾回退);
从这两态回到流程内**必须** `rollback=true`。`void` 不可逆(只能编辑或新建),
`failed` 可回流程内(人工确认后重试)。
4. **作废 / 失败不动流程时间戳**:`void` / `failed` 保留原来的 `scripted_at` 等 ——
那是"什么时候上的脚本"的证据,与"结果成没成"无关。
5. **进入 `bound` 的前置校验放在服务层**(`_assert_can_enter_bound()`):
**CK + 绑定角色(探针)+ 主页截图**三样缺一不可,缺什么在错误里写清(400)。
绑定图(`bind_images`)与自由文本「绑定斗鱼号」**不再**参与这门校验
—— 绑定关系现在由探针读,绑定图已停用(§11.3)。
6. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是
"什么时候做过"的证据,`status` 是"现在卡在哪"。两者都留,
才能既显示"已充值/已上脚本"这种组合,也能显示"只充了没上脚本"的中间态。
**批量改状态**(`set_status_batch()`,接口 `POST /external-orders/status/batch`):
- 勾选多行一次切到**同一个**目标状态;**逐行 SAVEPOINT 隔离**,
某一行前置校验没过(例如没主页截图进不了「已确认绑定」)只记在该行的 `message` 里,
不影响其它行(同 §5.1 导入的做法);一次上限 `PROBE_BATCH_MAX=50`;
- 单行与批量走**同一个** `set_status()`,规则不会漂移;错误信息统一带上
"要往前的话下一步是 X(也可以直接跳到更后面的状态)"。
### 4.2 CK 状态机(凭据本身)
```
@@ -324,6 +378,39 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
**点单号直接切到那一单的详情**);详情抽屉里也有一段「同一个买家的其它单」。
- 这是**软识别**:不写库、不改任何状态,识别错了也不会污染数据(`method` 字段在界面上写明口径)。
### 5.7 账号全生命周期:积分 / 兑换记录(2026-10-16 用户提的)
抢购跑完之后要回答的问题只有一个:**到底兑换成功没有**。能拿来判断的线索有两条 ——
**兑换记录里有没有这一单的礼包** + **积分有没有变化**。所以每查一次就往
`huya_external_order_metrics`(§3.5)追加一条快照,**只追加、不更新**,
详情抽屉里按时间倒序排成一条时间线;列表上只回填最近一次(`last_score` / `last_verdict`,
整页一次查询,不按行发 SQL)。
判定规则(`query_order_activity()`):
| 情况 | verdict | 业务状态 |
| --- | --- | --- |
| 兑换记录里有这一单的礼包(名字互相包含即算命中,`gift_matches_records()`) | `success` | 「已上脚本」→ 自动「已完成」`done` |
| 接口通了,但记录里没有这个礼包 | `failed` | 「已上脚本」→ 自动「兑换失败」`failed` |
| 接口没通 / 这单没填礼包名 | `unknown` | **不动**(数据不够,不许瞎判) |
**四条边界**:
1. **只手动触发**:单行「查积分 / 兑换记录」+ 勾选批量查询(串行 + `PROBE_BATCH_INTERVAL_SECONDS`
间隔,上限 `PROBE_BATCH_MAX=50`);**不做定时轮询** —— 虎牙风控(§12),
而且"什么时候去查"本身是运营的判断(抢购是人工触发的);
2. **只有 `scripted` 的单会自动改状态**:抢购发生在上脚本之后,
别的阶段查到什么都说明不了问题;这种情况 metric 照写,结论里写明
"只有「已上脚本」的单才会按查询结果自动改状态",由人决定要不要手动切;
3. **接口失败也写快照**(verdict=`unknown`):失败本身也是"这一刻的状态",
下次查到成功就能在时间线上看出"积分从 X 涨到 Y",这正是要的证据;
4. **`void` 的单不查**、没 CK 的单不查(`void_order` / `no_cookie`,400),
两种情况都会写审计(`huya:collab:activity_query`,含 `score/records_total/
records_matched/verdict/auto_status`)。
礼包匹配用**互相包含**(`prize_name` 与 `order.gift_name` 双向包含即命中),
但名字短于 2 个字(`_MIN_GIFT_MATCH_LENGTH`)不参与 —— 免得"礼包"两个字匹配一堆记录。
---
## 6. 图片(复用已落地的上传能力)
@@ -701,6 +788,43 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
列宽 150 → 170,全文放 Tooltip(附验证时间)。
顺带把礼包 / 备注那格的可编辑文本加上 `wordBreak: break-word`,防止一长串无空格字符串顶穿列。
### 11.7 2026-10-16 第四轮:状态机新口径 + 账号全生命周期
用户原话(这一轮的全部需求):
"业务状态刷新一下,目前上传后都属于待处理"、
"手动确认绑定角色信息和主页截图相同后,可以手动切换到已确认绑定"、
"充值完成后可以切换到已充值"、"完成上脚本后可以切换到已上脚本"、
"抢购完成后可以查询兑换记录、积分等等,每次查询积分变化都要写入这个账号的全生命周期,
这样就可以通过兑换记录和积分变化确认是否兑换成功"、
"可以根据查询的兑换是否成功或失败,自动改变状态",并且"手动切换一个或者勾选多个都可以"。
**落地清单**:
| 面 | 落地 |
| --- | --- |
| 状态机(服务层) | 新增终态 `failed`(兑换失败);`bound` 标签改「已确认绑定」;`is_forward_status()` 允许往前跳步;`_assert_can_enter_bound()` 改成 CK + 绑定角色 + 主页截图;`void`/`failed` 不动时间戳 |
| 批量改状态 | `set_status_batch()` + `POST /external-orders/status/batch`(逐行 SAVEPOINT 隔离,上限 50) |
| 全生命周期 | 新表 `huya_external_order_metrics` + 迁移 `20261016_0059`;`query_order_activity()` / `query_activity_batch()`;`POST /{id}/activity/query`、`POST /activity/query`、`GET /{id}/metrics`;列表回填 `last_score/last_metric_at/last_verdict/last_metric_message` |
| 前端 | 状态筛选与汇总卡片加「兑换失败」;工具栏「批量改状态(下拉选目标)」+「批量查积分/兑换」;行操作加「查积分 / 兑换记录」「改状态…」;列表加「积分」列(积分 + 判定徽标 + 时间,全文进 Tooltip);详情抽屉加「账号全生命周期」时间线 + 逐条命中的兑换记录 |
| 测试 | 新增 `TestCollabStatusBatch`(4 例)、`TestCollabActivityLifecycle`(9 例);`tests/test_migrations.py` 的 `HEAD_REVISION` → `20261016_0059`;全量 `pytest` 944 passed / 6 skipped |
| 接口冒烟 | `/tmp/smoke_collab_api.py` 补到 **67 项全绿**:批量跳步 + 时间戳补齐、单行查询命中→自动「已完成」、非 `scripted` 只记快照、时间线倒序、列表回填、批量查询判定→自动「兑换失败」、`failed` 状态筛选、批量逐行隔离、回退仍要 `rollback`、删单级联删快照 |
**这一轮踩到/定下的点**:
1. **「已确认绑定」的门槛是"能核对"**,不是"已经绑定":要求探针读到了**角色**
(没角色就没法跟截图对)**且**至少有 1 张主页截图。CK 本身也在必查项里。
2. **跳步必须补齐时间戳**,否则列表上会出现"已上脚本但没有充值时间"的自相矛盾;
反过来,回退必须清掉后面阶段的时间戳(`_sync_stage_timestamps()` 两个方向都管)。
3. **自动改状态只在 `scripted` 生效**,其它状态只写快照 + 在结论里说清原因 ——
自动改状态是最容易"越权"的地方,口径必须窄。
4. **`unknown`(判不了)不写状态**:接口抖一下就把单子判成"兑换失败",比不判更糟。
5. **`statusAction` 弹窗合一**:单行的「改状态 / 回退 / 作废」共用一个 Modal,
靠 `mode: 'set' | 'rollback' | 'void'` 分支(`set` 允许选全部状态含 `failed`/`void`,
`rollback` 只在流程链里选),避免三个弹窗三套校验。
6. **尚未真机验证**:`getUserScore` / `getUserPrizeRecords` 两个接口只做了假客户端单测,
真机字段名(`available_score` / `records[].prize_name`)还没拿真 CK 跑过一次;
真机跑通前不要把"兑换失败"当成定论(先用单行查询看一眼 `raw`)。
---
## 12. 风险
@@ -709,6 +833,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| --- | --- |
| 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 |
| 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 |
| 积分 / 兑换记录查询被风控 | 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7) |
| 自动改状态误判(接口抖动 → 判成"兑换失败") | 三态判定:取不到数据一律 `unknown` 且不动状态;只有 `scripted` 的单才自动改;每次查询留快照,人工可回退(`rollback`) |
| 活动换 ID(SID 过期)→ 礼包列表空白 | 礼包 SID 一律读 `huya_config.sid`(工作台「虎牙配置」可改,见 §11.4);空列表时结论直接提示去改 SID |
| 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` |
| 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 |