feat(huya): 外部 CK 协作台账(S1–S6)+ 图片上传能力
协作台账:新表 huya_external_orders 替代 Excel 多人协同表(设计见
docs/设计-虎牙外部CK协作台账.md),一条 CK 一行,所有人共享、改动留痕。
- 前端 pages/HuyaCollabPage.tsx + 菜单「虎牙 → 外部单协作」/huya/collab:
内联编辑(带乐观锁 version)、图片缩略图、详情抽屉(字段表单 + 两个图片槽位 +
操作时间线)、粘贴导入弹窗(带 TSV 预览)、批量/单条验证 CK、页头统计条、筛选
- 两套状态机:业务 pending→bound→recharged→scripted→done(旁路 void)、
凭据 unverified/valid/invalid/disabled;唯一写入口在 services/huya_collab_service.py,
字段级审计 + 整数 version 乐观锁(不用 updated_at:DATETIME 只到秒)
- CK 只读探针 check_user_bind_game_account(真机判据:响应非 None 且 status == 200),
成功即覆盖写绑定快照 bind_game_account/bind_game_role/bind_game_channel(迁移 0056),
失败只改凭据状态、不动业务状态;人工只能停用/恢复(POST /{id}/ck/status)
- 贴/换/重建 CK、TSV 批量导入(SAVEPOINT 单行隔离,部分失败不整体失败)、
权限 huya:collab:view/edit/manage(删除只给运营)
- 孤儿图清理脚本 scripts/cleanup_orphan_file_assets.py(默认 --dry-run)
图片上传(协作台账截图用):OSS/本地双后端 + 缩略图变体 + /api/files 代理回读,
权限 file:upload / file:view。
验证:pytest 880 passed / 6 skipped;ruff format 全绿、ruff check 仅剩既有告警;
前端 tsc -b / eslint / vite build 通过;接口端到端冒烟 29 项全绿(TestClient,探针用假客户端);
探针真机冒烟 3 例(有效 CK=True、别人 uid=False、裸 cookie=False)。
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
# 设计:虎牙外部 CK 协作台账(替代 Excel 多人协同表)
|
||||
|
||||
状态:**已评审**(2026-10-16 决策见 §10;S1 开工)
|
||||
日期:2026-10-16
|
||||
前置:图片上传能力已落地(`web/backend/services/file_service.py` + `routers/files.py`,对象落 OSS 桶 `live-hub-assets`)
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
把现在用 Excel 维护的「外部 CK 跟进表」搬进后台:**一行 = 一单 = 一个外部虎牙 CK**,多人共同维护,
|
||||
CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重建 → 作废),
|
||||
两张截图(绑定图 / 主页账号图)走已经接好的 OSS 上传。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景:Excel 现在承担什么
|
||||
|
||||
现有 Excel 的表头与系统现状对照:
|
||||
|
||||
| Excel 列 | 现在的样子 | 本设计怎么落 |
|
||||
| --- | --- | --- |
|
||||
| 店铺 | ❌ 系统里没有 | `shop`,人工录入/批量导入 |
|
||||
| 订单编号 | ❌ 没有(虎牙侧没有订单概念;`external_order_cache` 是斗鱼 P0 的) | `order_no`,唯一键 |
|
||||
| 负责人 | ⚠️ 只有 `huya_accounts.assigned_to`(语义是"谁能操作这账号") | `owner_id` → `users.id` |
|
||||
| 绑定斗鱼号 | ⚠️ 只在 `huya_tasks.result`(`bind_status`/`bind_phase`)里,账号上无字段 | `bind_douyu_account` + `bind_douyu_uid` |
|
||||
| 绑定图 | ❌ 完全没有 | `bind_images`(OSS object_key 数组) |
|
||||
| 主页账号图 | ❌ 完全没有 | `home_images`(同上) |
|
||||
| 备注(实为礼包名) | ⚠️ 模型有 `remark`,但这列写的是商品 | `gift_name`(礼包名)+ `remark`(自由备注) |
|
||||
| 状态(已充值/已上脚本) | ❌ 无。现有的是 `points`/`elite_opened` 这类**查询结果**,不是人工推进的**业务状态** | `status` + `recharged_at`/`scripted_at` |
|
||||
| 账密 / 积分 / 宝典状态 | ✅ `huya_accounts` 有 `username`/`account_password`/`points`/`elite_opened` | 本表自己一份(见 §2 边界) |
|
||||
|
||||
**Excel 撑不住的三个点**(本设计的动机):
|
||||
1. **并发覆盖**:两个人同时改一行,后保存的把前一个覆盖掉,且没有任何痕迹;
|
||||
2. **截图外挂**:图存在本地/群聊里,找不到、对不上号、换人接手就断;
|
||||
3. **状态靠肉眼**:CK 什么时候失效、这单卡在哪一步,没有可查的历史。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与非目标(边界,先划清)
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
1. 外部 CK 的 CRUD:新增(粘贴 CK)、编辑、删除,解析出 uid/昵称;
|
||||
2. CK 全生命周期:`unverified → valid → invalid`,支持重贴、从 `raw_cookie` 重建、人工停用;
|
||||
3. 业务全生命周期:`pending → bound → recharged → scripted → done`,旁路 `void`;
|
||||
4. 多人在同一张表上协作:共享视图 + 负责人 + 字段级审计 + 并发保护;
|
||||
5. 两个图片槽位(绑定图 / 主页账号图)走 OSS,行内可预览。
|
||||
|
||||
### 2.2 非目标(本次**不做**,避免范围失控)
|
||||
|
||||
- ❌ **不碰 `huya_accounts`**:本表自存 CK,**不双写、不互查、不做外键**。
|
||||
这是明确选择("完全独立一套表")。代价见 §2.3。
|
||||
- ❌ 不把 CK 灌进现有任务执行器。`huya_tasks` 的 runner 全部挂在 `HuyaAccount` 上,
|
||||
走它就必须建账号行,等于破坏上面的边界。
|
||||
- ❌ 不做订单自动拉取(预留:以后可接 `order_site` 只读适配层,按 `order_no` 回显商品)。
|
||||
- ❌ 不做执行类动作(生成支付码 / 充值 / 浏览器兑换)。**绑定与充值仍在现有工具里做,
|
||||
本台账只负责"记录结果"**(见 §10 待确认 A)。
|
||||
|
||||
### 2.3 独立存 CK 的代价(必须写下来,别以后当 bug)
|
||||
|
||||
- 同一个虎牙号可能**同时**存在于 `huya_accounts` 和本表 → 两边 CK 会各自过期,
|
||||
没有机制保证一致。**约定:本表的 CK 只在"协作单"语境里有效**,需要拿去跑任务时,
|
||||
人工到工作台重新导入(不自动同步)。
|
||||
- 本表拿不到工作台的设备态补齐(`guid`/`_qimei_uuid42`)等能力,
|
||||
CK 有效性验证只做**只读探针**(§5.2),不做重登。
|
||||
|
||||
---
|
||||
|
||||
## 3. 领域模型
|
||||
|
||||
### 3.1 表 `huya_external_orders`
|
||||
|
||||
一行 = 一单 = 一个外部 CK。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | int PK | |
|
||||
| `order_no` | varchar(64) **unique** | 订单编号(Excel B 列),唯一 |
|
||||
| `shop` | varchar(64) index | 店铺(A 列) |
|
||||
| `owner_id` | int FK `users.id` index | 负责人(C 列),可空 |
|
||||
| `uid` | varchar(32) **unique, nullable** | 虎牙 uid(从 CK 解析;空 = 还没贴 CK) |
|
||||
| `yyuid` | varchar(32) | 虎牙 yyuid |
|
||||
| `hy_username` | varchar(128) | CK 里的 `udb_passport`(登录名) |
|
||||
| `nickname` | varchar(128) | 昵称(探针查到后补全) |
|
||||
| `cookie` | EncryptedText | 网页运行 CK |
|
||||
| `raw_cookie` | EncryptedText | App 原始凭据(可用来重建网页 CK) |
|
||||
| `ck_status` | varchar(16) index | `unverified` / `valid` / `invalid` / `disabled` |
|
||||
| `ck_checked_at` | datetime | 最近一次探针时间 |
|
||||
| `ck_check_message` | varchar(256) | 探针的可读结论(失败原因) |
|
||||
| `bind_douyu_account` | varchar(128) | 绑定斗鱼号(D 列,**人工自由文本**;Excel 粘贴导入按它映射) |
|
||||
| `bind_douyu_uid` | varchar(32) | 绑定对象的结构化 uid(能拿到就落) |
|
||||
| `bind_game_account` | varchar(128) | **探针读到的绑定游戏账号**(昵称),每次验证覆盖写入 |
|
||||
| `bind_game_role` | varchar(128) | **探针读到的绑定角色名**(2026-10-16 用户明确:这条重要) |
|
||||
| `bind_game_channel` | varchar(128) | 平台 / 区服(如「手Q · 苹果(iOS)」) |
|
||||
| `bind_snapshot_at` | datetime | 上面三个绑定快照的读取时间 |
|
||||
| `bind_images` | JSON | 绑定图 object_key 数组(E 列) |
|
||||
| `home_images` | JSON | 主页账号图 object_key 数组(F 列) |
|
||||
| `gift_name` | varchar(128) | 礼包名(G 列,原表头写的"备注") |
|
||||
| `remark` | varchar(512) | 自由备注 |
|
||||
| `status` | varchar(16) index | 业务状态(§4.1) |
|
||||
| `bound_at` | datetime | 绑定完成时间 |
|
||||
| `recharged_at` | datetime | 充值完成时间 |
|
||||
| `scripted_at` | datetime | 上脚本时间 |
|
||||
| `done_at` | datetime | 归档完成时间 |
|
||||
| `void_reason` | varchar(256) | 作废原因(`status=void` 时必填) |
|
||||
| `points` | int nullable | 最近一次只读查询的积分快照 |
|
||||
| `elite_opened` | bool nullable | 最近一次只读查询的宝典开通状态 |
|
||||
| `snapshot_at` | datetime | 上面两个快照的查询时间 |
|
||||
| `created_by` | int FK `users.id` | 创建人 |
|
||||
| `updated_by` | int FK `users.id` | **最后修改人**(协作可见) |
|
||||
| `version` | int | **乐观锁版本号**,每次写入 +1(见 §7.2 为什么不用 `updated_at`) |
|
||||
| `created_at` / `updated_at` | datetime | `updated_at` 只用于显示与排序 |
|
||||
|
||||
索引:`order_no`(unique)、`uid`(unique)、`shop`、`owner_id`、`status`、`ck_status`、`updated_at`。
|
||||
|
||||
### 3.2 两个 key 设计上的选择
|
||||
|
||||
- **`uid` 唯一但可空**:MySQL 的唯一索引允许多个 NULL,
|
||||
所以"先建单、后贴 CK"和"一个 CK 只能挂一单"同时成立(1 单 = 1 CK 已确认)。
|
||||
贴 CK 时如果 `uid` 已被别的行占用 → 报冲突,提示"这个虎牙号已在第 N 行(订单 xxx)"。
|
||||
- **`order_no` 唯一**:Excel 里订单号是天然主键,重复导入要能识别。
|
||||
|
||||
### 3.3 不建的表
|
||||
|
||||
- 不建"操作历史表":直接用现有 `audit_logs`(§7.3),避免再造一套事件表。
|
||||
- 不建"图片表":图片本体在 OSS,元数据在 `file_assets`(已落地),本表只存 key。
|
||||
|
||||
---
|
||||
|
||||
## 4. 两套生命周期(本设计的核心)
|
||||
|
||||
### 4.1 业务状态机(一行的进度)
|
||||
|
||||
```
|
||||
pending ──绑定成功──▶ bound ──充值──▶ recharged ──上脚本──▶ scripted ──归档──▶ done
|
||||
│ │ │ │
|
||||
└────────────────────┴────────────────┴───────────────────┴──────▶ void(作废)
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 进入条件 | 谁可触发 |
|
||||
| --- | --- | --- | --- |
|
||||
| `pending` | 待绑定 | 新建行(默认) | — |
|
||||
| `bound` | 已绑定 | 填了 `bind_douyu_account` **且** 至少 1 张绑定图 | 所有人 |
|
||||
| `recharged` | 已充值 | 从 `bound` 前进;写 `recharged_at` | 所有人 |
|
||||
| `scripted` | 已上脚本 | 从 `recharged` 前进;写 `scripted_at` | 所有人 |
|
||||
| `done` | 已完成 | 从 `scripted` 前进;写 `done_at` | 所有人 |
|
||||
| `void` | 作废 | 任意状态可进;`void_reason` 必填 | 所有人 |
|
||||
|
||||
**规则**:
|
||||
1. 状态**只能向前**。要回退必须显式勾选"回退"并从下拉里选目标态,
|
||||
审计里记为 `status_rollback`(保留痕迹,不静默改)。
|
||||
2. 进入 `bound` 的前置校验在**接口层**做(缺绑定图/缺绑定斗鱼号 → 400,
|
||||
错误信息要说清缺什么),不在前端做——前端只是体验,不能当校验。
|
||||
3. **不用一个布尔列表达"充值/上脚本"**:`recharged_at` / `scripted_at` 是
|
||||
"什么时候做过"的证据,`status` 是"现在卡在哪"。两者都留,
|
||||
才能既显示"已充值/已上脚本"这种组合,也能显示"只充了没上脚本"的中间态。
|
||||
|
||||
### 4.2 CK 状态机(凭据本身)
|
||||
|
||||
```
|
||||
unverified ──探针成功──▶ valid ──探针失败──▶ invalid ──重贴/重建──▶ valid
|
||||
│ │ │
|
||||
└──────────────────────┴────────────────────┴────▶ disabled(人工停用)
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 进入方式 |
|
||||
| --- | --- | --- |
|
||||
| `unverified` | 刚贴进来,没验过 | 新建/换 CK 时的默认值 |
|
||||
| `valid` | 探针通了 | "验证 CK"动作成功 |
|
||||
| `invalid` | 探针失败(CK 过期/被踢) | "验证 CK"动作失败,`ck_check_message` 记原因 |
|
||||
| `disabled` | 人工停用(不想再用这个号) | `POST /api/huya/external-orders/{id}/ck/status`(**只能**改成 `disabled`/`unverified`,`valid`/`invalid` 归探针) |
|
||||
|
||||
**与业务状态解耦**:CK 失效**不自动**把业务行改成作废——已经绑定/充值的单子,
|
||||
CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红色标签),由人决定怎么处理。
|
||||
|
||||
### 4.3 防漂移(学 `docs/设计-账号全生命周期.md` §3.4.1 的教训)
|
||||
|
||||
- 状态**只有一处写入口**:`services/huya_collab_service.set_status()`,
|
||||
接口层不允许直接 UPDATE `status`;
|
||||
- 每次状态变化必须同时写 `*_at` 时间戳 + `audit_logs`;
|
||||
- 加一条测试:**任何**状态写入都必须带审计行(漏了就是 bug)。
|
||||
|
||||
---
|
||||
|
||||
## 5. CK 的 CRUD 与全生命周期
|
||||
|
||||
### 5.1 新增 / 导入
|
||||
|
||||
- 单条新增:表单里贴 CK(支持 `纯CK`、`账号----密码----CK` 两种形态,
|
||||
复用 `huya_service.parse_huya_cookie_line`,它是纯函数、不依赖账号表)
|
||||
→ 解析出 `uid/yyuid/hy_username` → 落表,`ck_status=unverified`。
|
||||
- 批量导入:Excel/文本粘贴,见 §9.3。逐行解析,**部分失败不整体失败**,
|
||||
返回 `{created, updated, skipped, errors[]}`(每条错误的行号 + 原因)。
|
||||
- 重复 uid:如果 uid 已在本表 → 视为"换 CK"(更新 cookie 并记审计),
|
||||
而不是报错;如果被别的 `order_no` 占用 → 报冲突。
|
||||
|
||||
### 5.2 有效性探针(怎么判 valid / invalid)
|
||||
|
||||
**2026-10-16 真机定论:用 `check_user_bind_game_account`(只读),
|
||||
判据 = 响应非 None 且 `status == 200`。**
|
||||
|
||||
| 候选接口 | 有效 CK | 篡改/失效 | 能否当探针 |
|
||||
| --- | --- | --- | --- |
|
||||
| `get_redeem_need_goods`(积分中心) | 200 | 单改 `udb_cred`、甚至删光 `udb_` 字段仍 200 | ❌ 不校验凭据(tag7 是公共数据) |
|
||||
| `get_grow_level_user_info`(能拿虎牙昵称) | 成功 | 单改 `udb_cred` 仍成功 | ❌ 不看凭据 |
|
||||
| `query_user_score` / `get_act_user_task_detail` / `get_user_prize_records` | — | — | ❌ 接口已下线(603「活动已结束」/ 601「活动已下线」) |
|
||||
| **`check_user_bind_game_account`** | `status=200` + 真实绑定数据 | **裸 cookie(无凭据)+ 真 uid → `None`;真 CK + 别人 uid → `None`** | ✅ **用它** |
|
||||
|
||||
- 参数走 `web/backend/huya_defaults.py` 的默认活动:
|
||||
`b_act_id=HUYA_DEFAULT_BIND_ACT_ID(9271)`、`is_use_outer_act_id=1`、
|
||||
`outer_act_id=HUYA_DEFAULT_OUTER_ACT_ID(17096)`。
|
||||
- 探针是**人工触发**(列表勾选 → "验证 CK"):**串行 + 间隔**
|
||||
(`PROBE_BATCH_INTERVAL_SECONDS`,一次最多 `PROBE_BATCH_MAX` = 50 条),
|
||||
不做后台定时——避免账号被风控盯上。
|
||||
- 结果写 `ck_status` / `ck_checked_at` / `ck_check_message`,并记审计(`huya:collab:ck_status`)。
|
||||
`disabled` 是人工停用,不刷 `ck_checked_at`(不算"刚跑过探针")。
|
||||
- 成功时**覆盖写入绑定快照**:`bind_game_account` / `bind_game_role` / `bind_game_channel`
|
||||
+ `bind_snapshot_at`(这几列探针独占,就是"现在到底绑了哪个号、哪个角色")。
|
||||
覆盖前把旧值记进 `huya:collab:probe_snapshot` 审计;人工填的列(含 `bind_douyu_account`)**一律不碰**。
|
||||
- **不写 `nickname`**:按 §3.1 那是"虎牙号昵称",这个探针给不出(能给出昵称的接口都不认凭据)
|
||||
——硬写就是假数据。
|
||||
- 一次批量里单条失败不影响其他条(AGENTS §2:单项失败不要整体失败,但要如实暴露)。
|
||||
没有 CK 的行**保持 `unverified`**,不谎报成 `invalid`。
|
||||
|
||||
### 5.3 重建 / 重贴
|
||||
|
||||
- **重建**:复用 `huya_service.rebuild_web_cookie_from_raw` 的逻辑
|
||||
(从 `raw_cookie` 补齐网页设备字段),把结果写回 `cookie`,`ck_status → unverified`;
|
||||
没有 `raw_cookie` 时明确报错"该行没有 App 原始凭据"。
|
||||
- **重贴**:覆盖 `cookie` + `raw_cookie`,记审计(旧值脱敏后入审计,见 §7.3)。
|
||||
|
||||
### 5.4 删除 / 作废
|
||||
|
||||
- `void` 作废:保留行、留 `void_reason`,列表默认过滤掉(可切换显示)。
|
||||
- 物理删除:`huya:collab:manage` 才能做,且要**级联清理图片**(§6.4),并记审计。
|
||||
|
||||
---
|
||||
|
||||
## 6. 图片(复用已落地的上传能力)
|
||||
|
||||
### 6.1 槽位
|
||||
|
||||
| 槽位 | 对应 Excel 列 | 字段 | 张数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 绑定图 | E | `bind_images` | 多张(第一张作封面) |
|
||||
| 主页账号图 | F | `home_images` | 多张(第一张作封面) |
|
||||
|
||||
存的是 OSS `object_key` 数组;前端用 `/api/files/object?key=...` 直接 `<img>` 预览。
|
||||
**显示时不在列表里下原图**——用 `thumbnail_url`(缩略图变体),点开才看原图。
|
||||
|
||||
### 6.2 链路
|
||||
|
||||
```
|
||||
前端 antd Upload(上传前 canvas 压到长边 1600)
|
||||
→ POST /api/files/upload(scene=collab,需 file:upload)
|
||||
→ 返回 {object_key, url, thumbnail_url, medium_url}
|
||||
→ 前端把 object_key 提交到 PUT /api/huya/external-orders/{id}
|
||||
→ 本表存 key(不存 URL,URL 由后端拼)
|
||||
```
|
||||
|
||||
**为什么存 key 不存 URL**:URL 是可推导的(`/api/files/object?key=...`),
|
||||
存 URL 会在以后换存储/改路径时全部失效(order_site 踩过"存签名链接过期后到处裂图")。
|
||||
|
||||
### 6.3 读取权限
|
||||
|
||||
图片走 `/api/files/object`(需 `file:view`)。本模块的用户默认都授 `file:view` + `file:upload`
|
||||
(见 §10 待确认 C)。
|
||||
|
||||
### 6.4 孤儿图清理
|
||||
|
||||
- 换图时旧 key 从行里移除,但**不立即删对象**(可能有别人在引用/误删难恢复);
|
||||
- 留一个 `scripts/cleanup_orphan_file_assets.py`:扫 `file_assets` 里
|
||||
`scene=collab` 且 **不被任何行引用** 的对象,超过 N 天(默认 30)才删;
|
||||
- 先用 `--dry-run` 出清单,确认后再删(对齐仓库既有的回填脚本风格)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 多人协同机制
|
||||
|
||||
### 7.1 共享视图 + 负责人
|
||||
|
||||
- 一张表,**所有登录用户看到同一份数据**(默认不按人过滤);
|
||||
- 默认排序:`待处理优先,再看 updated_at 倒序`;
|
||||
- "我的单"是一个筛选器(`owner_id = 我`),不是权限边界。
|
||||
|
||||
### 7.2 并发保护(解决 Excel 覆盖痛点)
|
||||
|
||||
- `PUT /api/huya/external-orders/{id}` 必须带 `expected_version`;
|
||||
- 不匹配 → **409**,响应里带"谁在什么时候改过 + 改了哪些字段",
|
||||
前端弹"这行刚被 XXX 改过,要看最新还是覆盖保存";
|
||||
- 说明:这是乐观锁,不锁行(协作场景冲突少,锁行会让列表变卡)。
|
||||
- **为什么用整数 `version` 而不是 `updated_at`**(实现时发现的问题):
|
||||
本项目的 `UTCDateTime` 落库是 MySQL `DATETIME`,**只到秒**。同一秒内的两次改动会
|
||||
得到完全相同的 `updated_at`,锁会失效——Excel 那种"后保存的覆盖前一个"会原样复活。
|
||||
整数版本号没有精度问题,且 `version` 与 `updated_at` 各司其职(一个是并发控制,
|
||||
一个是显示排序)。
|
||||
|
||||
### 7.3 审计留痕(字段级 diff)
|
||||
|
||||
复用 `services/audit_service.record_audit`:
|
||||
|
||||
| action | 触发 | detail |
|
||||
| --- | --- | --- |
|
||||
| `huya:collab:create` | 新建行 | `{order_no, shop, uid, ck_status}` |
|
||||
| `huya:collab:update` | 编辑 | **`{字段: {old, new}}`** 只记变化项 |
|
||||
| `huya:collab:status` | 状态推进 | `{from, to, reason?}` |
|
||||
| `huya:collab:ck` | 贴/换/重建 CK | `{uid, kind: paste/rebuild/refresh, ck_status}` |
|
||||
| `huya:collab:image` | 增删图 | `{slot, added, removed}` |
|
||||
| `huya:collab:delete` | 删除 | `{order_no, uid}` |
|
||||
|
||||
> `record_audit` 已内置敏感字段脱敏(key 里含 `cookie`/`token`/`password` 等会变 `[已脱敏]`),
|
||||
> 所以 diff 里带 cookie 字段是安全的——**但要断言这一点**(见 §11 S2 验收)。
|
||||
|
||||
### 7.4 列表上能看到的"协作信号"
|
||||
|
||||
- `updated_by` 的头像/名字 + `updated_at`("最后改动"列);
|
||||
- 详情抽屉里有时间线(从 `audit_logs` 按 target 查,不另建事件表)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 接口设计
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/huya/external-orders` | `huya:collab:view` | 列表;筛选 `shop/owner_id/status/ck_status/keyword`,分页,默认排除 `void` |
|
||||
| GET | `/api/huya/external-orders/summary` | `huya:collab:view` | 各状态计数(页头统计条) |
|
||||
| POST | `/api/huya/external-orders` | `huya:collab:edit` | 新建(可带 CK 文本) |
|
||||
| GET | `/api/huya/external-orders/{id}` | `huya:collab:view` | 详情(含图片 key、审计时间线) |
|
||||
| PUT | `/api/huya/external-orders/{id}` | `huya:collab:edit` | 编辑(带 `expected_version`) |
|
||||
| POST | `/api/huya/external-orders/{id}/status` | `huya:collab:edit` | 推进/回退状态 |
|
||||
| POST | `/api/huya/external-orders/{id}/ck` | `huya:collab:edit` | 贴/换/重建 CK |
|
||||
| POST | `/api/huya/external-orders/{id}/ck/status` | `huya:collab:edit` | 人工停用/恢复(只能 `disabled`/`unverified`) |
|
||||
| POST | `/api/huya/external-orders/{id}/verify` | `huya:collab:edit` | 验证这一行的 CK(只读探针,§5.2) |
|
||||
| POST | `/api/huya/external-orders/ck/verify` | `huya:collab:edit` | 批量验证 CK(探针,body `{ids: [...]}`,最多 50 条) |
|
||||
| POST | `/api/huya/external-orders/import` | `huya:collab:edit` | 粘贴批量导入 |
|
||||
| DELETE | `/api/huya/external-orders/{id}` | `huya:collab:manage` | 删除(级联清图) |
|
||||
| GET | `/api/huya/external-orders/owners` | `huya:collab:view` | 可选负责人列表(给筛选/下拉用) |
|
||||
|
||||
**注意**:图片上传不新增接口,直接用已有的 `POST /api/files/upload`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 页面设计
|
||||
|
||||
新菜单:**虎牙 → 外部单协作**(`/huya/collab`),放在"精英宝典"之后。
|
||||
|
||||
### 9.1 列表
|
||||
|
||||
列(对齐 Excel,去掉它没有的信息):
|
||||
|
||||
```
|
||||
店铺 | 订单编号 | 负责人 | 绑定账号/角色 | 绑定图·主页图 | 礼包/备注 | 状态 | 虎牙号(uid) | CK状态 | 最后改动 | 操作
|
||||
```
|
||||
|
||||
> 落地(2026-10-16):**「绑定斗鱼号」不再作为列表主列** —— 用户明确"绑定在该虎牙号上的
|
||||
> 游戏账号需要展示,绑定角色重要"。列表主展示探针读到的 `bind_game_account` + `bind_game_role`
|
||||
> (附平台/区服);人工的 `bind_douyu_account` 只在详情抽屉里作为可编辑字段保留(Excel 导入列)。
|
||||
|
||||
- **内联编辑**:店铺 / 负责人 / 礼包名 / 备注(点开即改,失焦提交,走 §7.2 乐观锁);
|
||||
- **图片列**:缩略图 + 张数,点击开大图(`Image.PreviewGroup`);
|
||||
- **状态**:Tag 展示,行内"下一步"按钮推进(带二次确认);
|
||||
- **CK 状态**:Tag + 一键"验证";
|
||||
- 筛选:店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、斗鱼号、uid);
|
||||
- 页头统计条:`待处理 N / 已绑定 N / 已充值 N / 已上脚本 N / 已完成 N / CK失效 N`。
|
||||
|
||||
### 9.2 详情抽屉
|
||||
|
||||
左侧:图片(两个槽位,可上传/删除/换序);
|
||||
右侧:字段编辑 + 操作按钮(验证 CK / 重建 CK / 推进状态 / 作废 / 删除);
|
||||
底部:审计时间线(谁在什么时候改了什么)。
|
||||
|
||||
### 9.3 批量导入弹窗
|
||||
|
||||
- 大文本框贴 Excel 区域(TSV,`\t` 分隔,直接 Ctrl+V 即可);
|
||||
- 自动识别表头(支持中英列名,也支持"无表头按固定列序");
|
||||
- 预览解析结果(识别出 N 行,其中 M 行缺订单号/重复)→ 确认后提交;
|
||||
- 返回 `{created, updated, skipped, errors[]}`,错误按行号展示。
|
||||
|
||||
---
|
||||
|
||||
## 10. 已定决策(2026-10-16 评审通过)
|
||||
|
||||
### 10.1 结构决策(用户拍板)
|
||||
|
||||
| # | 问题 | 决定 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 一行(一单)跟虎牙 CK 的基数关系 | **1 单 = 1 个 CK**(`uid` 在台账内唯一) |
|
||||
| 2 | 台账与 `huya_accounts` 的关系 | **完全独立一套表,自己存 CK,不碰现有账号体系** |
|
||||
| 3 | 店铺 / 订单编号来源 | **全部人工录入 + 支持 Excel 粘贴批量导入** |
|
||||
| 4 | 协作范围与权限 | **所有登录用户共享同一张表,都能编辑**(靠审计留痕) |
|
||||
| 5 | 权限点分配 | `view` + `edit` 给 operation 和 support;`manage`(删除)**只给 operation** |
|
||||
| 6 | 图片槽位张数 | **允许多张,第一张作封面** |
|
||||
| 7 | 绑定/充值/上脚本在哪执行 | **本台账只记录**;以后再加「一键送进工作台」(届时才需要建 `HuyaAccount`) |
|
||||
|
||||
### 10.2 我按推荐值定的(未被反对即生效,随时可改)
|
||||
|
||||
| # | 项 | 取的值 |
|
||||
| --- | --- | --- |
|
||||
| B | 列表默认是否隐藏 `void` | 隐藏,提供"显示已作废"开关 |
|
||||
| E | `order_no` 唯一性 | 唯一;重复导入按"更新"处理 |
|
||||
| F | 积分/宝典状态只读查询 | 需要,人工触发,写本表 `points`/`elite_opened` |
|
||||
| G | 负责人来源 | 全部活跃用户(`users.is_active = 1`) |
|
||||
| — | `uid` 可空 | 可空(唯一索引允许多 NULL)→ 支持"先建单、后贴 CK" |
|
||||
| — | **绑定对象的展示口径**(2026-10-16 用户拍板) | 展示**探针读到的游戏账号 + 绑定角色**(角色最重要);人工的"绑定斗鱼号"不再作列表主列,只留详情抽屉(Excel 导入列) |
|
||||
| — | 人工能否改 CK 状态 | 只能"停用"/"恢复(未验证)";`valid`/`invalid` 归探针 |
|
||||
|
||||
> 决定 7 的后果:**本模块不 import 任何 `huya_tasks` / runner 代码**。
|
||||
> 以后要做「一键送进工作台」,那是一个**显式的**新动作(会创建 `HuyaAccount` 并写
|
||||
> 一条 `huya_workbench_accounts`),不会在本阶段悄悄发生。
|
||||
|
||||
### 10.3 待定:网页 CK 能不能「重建」
|
||||
|
||||
> **状态(2026-10-16)**:用户当天的关注点是"绑定账号/角色要展示"(已按 §10.2 落地),
|
||||
> 本项**暂按 A 执行**:网页形态 CK 的行「重建」按钮置灰并提示"这行没有 App 原始凭据"。
|
||||
> 以后要放宽成 B,只需改 `services/huya_collab_service._apply_ck()` 一处(把两种形态都写进
|
||||
> `raw_cookie`)—— 但要注意:对一份正在正常使用的网页 CK 按「重建」会换掉设备指纹。
|
||||
|
||||
用一份真实外部 CK 验证后发现:**真机拿到的是网页形态 CK**(含 `guid` / `huya_ua` /
|
||||
`_qimei_uuid42` 等网页设备字段,`udb_cred` 有 152 字符)。而当前实现沿用了
|
||||
`huya_accounts` 的语义 —— `raw_cookie` 只存 **App 原始凭据**(没有网页设备字段的那些),
|
||||
所以:
|
||||
|
||||
| 贴进来的形态 | `cookie` | `raw_cookie` | 「重建」可用 |
|
||||
| --- | --- | --- | --- |
|
||||
| App 原始凭据(无 guid) | ✅ | ✅ 同值 | ✅ |
|
||||
| 网页 CK(有 guid,**真机实测就是这种**) | ✅ | ❌ 空 | ❌ 按钮不可用 |
|
||||
|
||||
两种改法,选一个:
|
||||
|
||||
- **A(当前实现,保守)**:维持现状。网页 CK 的行「重建」不可用,按钮置灰并提示
|
||||
"这行没有 App 原始凭据,请重新贴一份"。好处:语义与账号侧一致,不会拿一份好 CK
|
||||
去换设备态。坏处:对真机数据来说这个功能基本用不上。
|
||||
- **B(放宽语义)**:`raw_cookie` 改成"**导入时收到的 CK 原文**",`cookie` 是"当前运行态"。
|
||||
贴任何形态都两者同写,「重建」= 把原文再过一遍 `fill_web_cookie_fields` 刷新设备态
|
||||
(这正是账号侧被风控后换设备指纹的做法)。好处:所有行都能重建。坏处:字段语义与
|
||||
`huya_accounts.raw_cookie` 不再一致;对一份**正在正常使用**的网页 CK 按一下重建,
|
||||
会把 guid 换成新的(虽然会置为"未验证",但仍算改动了可用凭据)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 施工单(建议按阶段,每阶段可独立验收)
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| **S1** | 模型 `HuyaExternalOrder` + 迁移 `20261016_0055` + `services/huya_collab_service.py`(CK 解析/去重/CRUD) | ✅ 2026-10-16 |
|
||||
| **S2** | 两套状态机 + 字段级审计 + 乐观锁(`version`) | ✅ 2026-10-16 |
|
||||
| **S3** | 图片槽位读写(存 `object_key`,URL 由后端拼) | ✅ 2026-10-16 |
|
||||
| **S4** | 批量导入(TSV 表头识别 + SAVEPOINT 单行隔离 + 部分失败) | ✅ 2026-10-16 |
|
||||
| **S5** | CK 探针验证(只读 `check_user_bind_game_account`,真机判据已定) | ✅ 2026-10-16 |
|
||||
| **S6** | 前端页面(列表 + 内联编辑 + 图片 + 详情抽屉 + 导入弹窗) | ✅ 2026-10-16 |
|
||||
| **S7** | 权限点接入(`huya:collab:view/edit/manage`) | ✅ 2026-10-16 |
|
||||
|
||||
测试:`tests/test_huya_collab_orders.py`(68 例,其中探针 21 例,用假客户端离线跑)+
|
||||
`tests/test_cleanup_orphan_file_assets.py`(6 例,孤儿图清理脚本)。
|
||||
用例里的 CK 是**合成**的,形态对齐真机(分号后无空格、重复 `guid`、超长 `udb_biztoken`)——
|
||||
真实凭据绝不进仓库,一旦提交就永久留在 git 历史里。
|
||||
|
||||
孤儿图清理:`scripts/cleanup_orphan_file_assets.py`(默认 `--dry-run`,见 §6.4)。
|
||||
|
||||
### 11.1 S1–S4 落地时发现的、设计文档原稿没写到的点
|
||||
|
||||
1. **乐观锁从 `updated_at` 改成整数 `version`**(原因见 §7.2);
|
||||
2. **向导批次导入必须用 SAVEPOINT**:`SessionLocal` 是 `autoflush=False`,且一次约束
|
||||
冲突会把会话打成失败态(`PendingRollbackError`),后面每一行都会跟着报错——
|
||||
不加 SAVEPOINT,"单行失败不整体失败"这条就是空话。反向验证已证实(去掉
|
||||
SAVEPOINT 后第二行直接挂掉);
|
||||
3. **审计里不传 cookie 字段**:`record_audit` 虽有脱敏,但凭据不该多经一手
|
||||
(脱敏规则一改就是泄漏)。只记 `uid` 与 `credential_changed` 布尔;
|
||||
4. **真机 CK 是「网页形态」**(含 `guid`/`huya_ua`/`_qimei_uuid42`),按当前实现
|
||||
`raw_cookie` 会留空 → 这些行的「重建」按钮不可用。是否要让网页 CK 也能重建,
|
||||
见 §10.3。
|
||||
|
||||
**先做 S1–S4(纯后端,可测),S5 需要真机,S6 前端单独一轮。**
|
||||
|
||||
### 11.2 S5(探针)落地时发现的点
|
||||
|
||||
1. **探针判据必须是「非 None 且 `status == 200`」**:真机上「裸 cookie + 真 uid」与
|
||||
「真 CK + 别人 uid」都返回 `None`,只有两者都对才拿到 200——这是唯一能区分有效/失效的
|
||||
信号组合。反向验证(把判据改成「有数据就算成功」)会让 3 个用例变红。
|
||||
2. **探针能补的字段只有 `bind_douyu_account` / `bind_douyu_uid`**:返回的是**绑定在该虎牙号
|
||||
上的游戏账号**(账号昵称 / 角色名 / 角色 id)。`nickname` 按 §3.1 是"虎牙号昵称",
|
||||
本探针给不出(能给出昵称的 `get_grow_level_user_info` 不校验凭据,当不了探针),
|
||||
**不能拿游戏账号昵称冒充**。原稿 §5.2 写的"顺带补 nickname"据此修正。
|
||||
3. **绑定快照由探针独占(2026-10-16 当天修订)**:最早实现是"只补空值写
|
||||
`bind_douyu_account`",用户明确要求"展示绑定在该虎牙号上的游戏账号、**绑定角色重要**、
|
||||
之前那套不要了"之后,改成探针**覆盖写入** `bind_game_account`/`bind_game_role`/
|
||||
`bind_game_channel`(+ `bind_snapshot_at`,见 §3.1 与迁移 `20261016_0056`),
|
||||
覆盖前把旧值记进 `huya:collab:probe_snapshot` 审计;人工列一个都不碰。
|
||||
反向验证(去掉"只补空值"、判据改成"有数据就算成功")会让对应用例变红。
|
||||
4. **没有 CK 的行不能被标成 `invalid`**:那不是"凭据失效",是"还没有凭据",保持
|
||||
`unverified` 并在 `results[].message` 里说明;批量里这类行不发请求。
|
||||
5. **`/ck/verify` 的字面量路径要声明在 `/{order_id}` 之前**(同 §8 的注意点),
|
||||
否则可能被 `/{order_id}/timeline` 之类的模式先匹配。
|
||||
|
||||
### 11.3 S6(前端)落地的点
|
||||
|
||||
1. **页面**:`web/frontend/src/pages/HuyaCollabPage.tsx`(菜单「虎牙 → 外部单协作」,
|
||||
路由 `/huya/collab`,`MainLayout.tsx` 的 `huyaItems`);表格内联编辑、图片缩略图、
|
||||
详情抽屉(字段表单 + 两个图片槽位 + 操作时间线 + 状态操作 + 贴/换/重建/停用 CK)、
|
||||
粘贴导入弹窗(带本地预览)、页头统计条、筛选条、批量验证 CK。
|
||||
2. **菜单不受 `huya:task` 分支影响**:原布局里虎牙分组对客服只显示"浏览器兑换",
|
||||
而协作台账 `huya:collab:view` 是给客服的 —— 用**独立判断**追加菜单项,否则客服看不到这张表。
|
||||
3. **列表主列 = 探针读到的绑定账号 + 绑定角色**(§9.1 的落地说明);人工的
|
||||
`bind_douyu_account` 只留在详情抽屉,避免两处「绑定」互相矛盾的观感。
|
||||
4. **新增接口 `POST /{id}/ck/status`**:前端要能"停用这份 CK"(不想再用这个号)。
|
||||
服务层 `set_manual_ck_status()` 只允许 `disabled` / `unverified` ——
|
||||
人工把 CK 标成"有效"等于绕过探针,必须挡住。
|
||||
5. **乐观锁 409 的处理**:前端把 `expected_version` 原样带回,冲突时提示并自动刷新列表,
|
||||
不做"覆盖保存"(那正是 Excel 的毛病)。
|
||||
6. **图片上传**:`web/frontend/src/api/files.ts` 的 `fileApi.upload(file, 'collab')` →
|
||||
拿 `object_key` 提交给 `/{id}/images`;图片一律走 `/api/files/object` 代理回读。
|
||||
7. 迁移 `20261016_0056` 只加列(`bind_game_*` + `bind_snapshot_at`),老列一动不动;
|
||||
应用启动时会自动 `upgrade head`,本地不用手动跑。
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险
|
||||
|
||||
| 风险 | 应对 |
|
||||
| --- | --- |
|
||||
| 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 |
|
||||
| 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 |
|
||||
| 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` |
|
||||
| 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 |
|
||||
| 分支 | 按 AGENTS §1,新功能应从 `main` 开 `feat/*`;开工前需确认当前分支与库迁移版本对齐 |
|
||||
Reference in New Issue
Block a user