Files
live-hub-py/docs/设计-虎牙外部CK协作台账.md
T

58 KiB
Raw Blame History

设计:虎牙外部 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(普通索引,2026-10-16 由 unique 降级,见 §3.2)、shop、owner_id、status、ck_status、updated_at。

3.2 两个 key 设计上的选择

  • order_no 唯一:Excel 里订单号是天然主键,重复导入要能识别(重复 → 409 order_no_conflict, 导入路径则按"更新"处理)。

  • uid 可空、且【不再唯一】(2026-10-16 用户拍板改的口径):

    口径 时间 行为
    一个虎牙号只能挂一单 2026-10-16 上午 贴 CK / 建单时若 uid 已被占用 → 409 uid_conflict;DB 上是 unique 索引
    一个虎牙号可以挂多单 2026-10-16 晚(当前) 不拦。真机上"同一个买家买两件"常常继续用同一份 CK,拦住就变成第二单建不了

    落地:迁移 20261016_0058 把 ix_huya_external_orders_uid 从 unique 改成普通索引 (降级时会先检查有没有重复 uid,有就报错而不是静默丢数据);服务层 uid_used_orders() / uids_in_use() 只做提示与审计:

    • 新建 / 贴 CK 的审计 detail 里带 uid_reused_in: [其它单号];
    • POST /ck/preview 返回 uid_used_by,新建弹窗的 Alert 里会写「这份 CK 的虎牙号已经挂在: xxx —— 同一个号服务多单是允许的,照常提交」。
    • uid 仍可空,「先建单、后贴 CK」不受影响(多个 NULL 不冲突)。
  • 同一个买家的多单怎么认:见 §5.6(按绑定账号 + 绑定角色软识别)。

3.3 不建的表

  • 不建"操作历史表":直接用现有 audit_logs(§7.3),避免再造一套事件表。
  • 不建"图片表":图片本体在 OSS,元数据在 file_assets(已落地),本表只存 key。

3.4 表 huya_collab_shops(店铺字典,2026-10-16 加)

原来的"店铺"是手打自由文本,同一个店铺会出现多种写法(错别字、多空格),把筛选打散。 改成一个可维护的字典:新建协作单时点一下选中(点选按钮组,不做下拉——就这么几个店铺)。

字段 类型 说明
id int PK
name varchar(64) unique 店铺名(订单里存的就是它)
sort_order int 越小越靠前(下拉顺序)
is_active bool index 停用 = 不再出现在新建下拉;历史订单不受影响
remark varchar(255) 备注
created_by / updated_by int FK users.id 谁加/谁改
created_at / updated_at datetime

三个已定的取舍:

  1. 订单里存的是店铺名字符串(huya_external_orders.shop),不是外键 —— 历史 Excel 就是文本,改成外键要动所有读写与导入。代价:改名默认级联更新已有订单 (update_shop(..., rename_orders=True),前端弹窗里可取消勾选);
  2. 支持自定义输入:下拉里输一个字典里没有的名字,选中即用,后端 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)。

4. 两套生命周期(本设计的核心)

4.1 业务状态机(一行的进度)

pending ─人工确认─▶ bound ─充值─▶ recharged ─上脚本─▶ scripted ─抢购成功─▶ done
   │                 │              │                 │                  │
   └─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ failed(抢购失败)
   │                 │              │                 │                  │
   └─────────────────┴──────────────┴─────────────────┴──────────────────┴──▶ void(作废)
状态 含义 进入条件 谁可触发
pending 待处理 新建 / 上传导入后的默认态(上传完就是这里) —
bound 已确认绑定 人工核对「探针读到的绑定角色」与「主页截图」一致后手动切 人工(单行 / 批量)
recharged 已充值 充值完成后手动切(可跳步);写 recharged_at 人工
scripted 已上脚本 脚本上完后手动切(可跳步);写 scripted_at 人工
done 抢购成功 人工切;或「已上脚本」的单查积分/兑换记录命中礼包后自动切(§5.7) 人工 / 系统判定
failed 抢购失败 人工切;或「已上脚本」的单查兑换记录没有这个礼包后自动切(§5.7) 人工 / 系统判定
void 已作废 任意流程内状态可进;void_reason 必填 人工

规则(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 状态机(凭据本身)

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),并记审计。

5.5 新建协作单的必填项与礼包(2026-10-16 用户拍板)

人工新建表单(POST /api/huya/external-orders,require_verified=true 是默认值):

字段 必填 说明
订单编号 ✅ 台账内唯一
店铺 ✅ 大按钮点选;没有就点「新店」自定义,自动进字典
外部 CK ✅ 贴进来即调 POST /ck/preview 预览
礼包名 ✅ 从礼包列表里点选(= 用户购买的物品);列表平时用浏览器缓存,要更新点「强制刷新礼包」
主页截图 ✅ 至少 1 张,第一张作封面;绑定图不要了(绑了谁由探针读,见 §11.5)
负责人 — 默认 = 当前登录账号,不用选
绑定斗鱼号 / 备注 — 备注可选;绑定斗鱼号已从界面下线(老数据在详情里只读展示)

"CK 有效" = 探针通 + 读到绑定角色:预览与提交都按这条判;读不到角色一律拒绝 (ck_not_verified)。所有校验都在落库之前做完,失败不留半行 —— 前端只是即时反馈, 服务端才是最终关口(AGENTS §2)。

  • 礼包来源:webActUI.getActPrizeList(活动可兑换商品,只读、不落库;拉失败只提示 gifts_message,不影响 CK 的有效性结论)。SID 必须用工作台配置里的那个 (huya_config.sid,走 resolve_gifts_sid(db))—— 见 §11.4 的真机定位: SID 就是活动 ID、会随活动轮换,写死默认值会出现「接口 200 但礼包列表空白」;
  • 记住最近选的礼包:前端 localStorage(huya.collab.lastGift),同一个礼包连开几单不用重复选;
  • 礼包列表缓存:前端 localStorage(huya.collab.gifts,存 {at, gifts})—— 一个活动期间礼包清单基本不变, 贴 CK 时不再每次都去虎牙拉(with_gifts=false),要用最新库存点「强制刷新礼包」; 缓存时间摆在礼包名下方,活动换了(SID 变了)就点一次强制刷新;
  • 探针缓存:同一份 CK 在 PROBE_CACHE_TTL_SECONDS(90 秒)内复用结论 —— "预览一次 + 提交校验一次"只打一次虎牙,别为了校验白白翻倍请求;
  • 粘贴导入不受这些必填项约束(Excel 里本来就可能没有 CK),它走 require_verified=false。

5.6 同一买家的多单(软识别,2026-10-16 用户提的)

一个买家买两件 → 台账里就是两行。要不要把它们认成"同一个买家",判据如下:

口径 说明
买家指纹 = 探针读到的「绑定账号 + 绑定角色」 采用。同一个游戏角色就是同一个买家:buyer_key_of(),两边都有值才参与归组
uid(虎牙号) 不参与归组:一单一份外部 CK,同一个买家两单往往用两份不同的 CK(uid 不同),拿 uid 比永远比不中;反过来说,同一个买家第二单继续用同一份 CK 也是允许的(uid 已放开唯一性,见 §3.2)。uid 只在明细里展示
平台·区服(bind_game_channel) 不做判据(同一角色往往跨端显示不同),只在明细里展示

落地点(只读派生:不落库、不加表字段、不改订单状态;uid 唯一性是单独一条迁移改的,见 §3.2):

  • 服务层 buyer_clusters(db, orders):整批一次 (bind_game_account, bind_game_role) IN (...) 查出同买家的全部行(不按行发 N 条 SQL),只有真 ≥2 单才产出结果;
  • serialize_order(..., buyer_cluster=...) → 出参 buyer_cluster: {key, method, total, others[]} (others 按创建时间升序,带 order_no / shop / gift_name / uid / status); 列表接口整页算一次,单行接口(_serialize_one)单独算一次;
  • 前端:列表多一列「同买家」(显示「还有 N 单」,点开是浮层,列出其它单号 + 状态 + 礼包, 点单号直接切到那一单的详情);详情抽屉里也有一段「同一个买家的其它单」。
  • 这是软识别:不写库、不改任何状态,识别错了也不会污染数据(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. 图片(复用已落地的上传能力)

6.1 槽位

槽位 对应 Excel 列 字段 张数
主页截图 F home_images 多张(第一张作封面);新建必填至少 1 张
绑定图 E bind_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/preview huya:collab:edit 粘贴 CK 后的即时预览(探针 + 拉活动礼包,只读不写库)
POST /api/huya/external-orders/ck/verify huya:collab:edit 批量验证 CK(探针,body {ids: [...]},最多 50 条)
POST /api/huya/external-orders/import huya:collab:edit 粘贴批量导入
GET /api/huya/collab-shops huya:collab:view 店铺字典(含停用的,管理弹窗用)
POST /api/huya/collab-shops huya:collab:edit 新增店铺(重名 409)
PUT /api/huya/collab-shops/{id} huya:collab:edit 改名(可级联)/ 排序 / 停用启用 / 备注
DELETE /api/huya/collab-shops/{id} huya:collab:manage 从字典删除(只给运营;不动订单)
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 / 已作废 N(done / failed 的展示名 2026-10-16 由「已完成」/「兑换失败」改成「抢购成功」/「抢购失败」,枚举值与库里数据不变)。

交互细节(2026-10-16 用户拍板):店铺是点选按钮组(列表里点单元格弹出同一组按钮, 筛选条里也是),不是下拉;新建时不用选负责人——默认就是当前登录账号 (要转单在列表里点一下改)。

新建弹窗(同日拍板):订单编号 / 店铺 / CK / 礼包名必填;贴 CK 后自动验证并显示 「绑定账号 + 绑定角色 + 平台区服」,读不到角色就禁止提交;礼包下拉来自这次 CK 现拉的 活动礼包(点选,不是手打),并记住上次选的那个(见 §5.5)。

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 归探针
— 店铺怎么填(2026-10-16 用户拍板) 点选按钮组(不是下拉);允许"新店"自定义输入,后端自动登记进字典
— 负责人默认值(同日拍板) 默认 = 当前登录账号,新建时不用选;要转单在列表/详情里改
— 新建必填项(同日拍板) 订单编号 / 店铺 / CK / 礼包名必填;CK 必须探针通且读到绑定角色
— 礼包来源(同日拍板) 用这份 CK 现拉活动礼包(getActPrizeList),点选 + 记住上次选择

决定 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
S8 店铺字典(表 huya_collab_shops + 管理弹窗 + 新建/编辑/筛选改成点选) ✅ 2026-10-16
S9 新建弹窗强校验(CK 预览 + 必填 + 礼包点选与记忆) ✅ 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,本地不用手动跑。

11.4 2026-10-16 页面复核后补的点(用户点着看发现的)

  1. 礼包列表空白 = SID 过期,不是解析问题(真机定位,最值钱的一条):

    活动 SID 接口结论 礼包数
    2203(9 月的活动,曾是 HUYA_DEFAULT_SID) status=200 + msg=请求成功 0
    2277(库里 huya_config.sid,今天的工作台配置) status=200 + 请求成功 30(限量返场 / 精英专享)

    同一份真 CK、同一个 UA,只是 SID 不同 → 一个空一个满。UA 不是原因 (web&1.0&huya 与我们的 webh5&... 结果一样),解析器也不是原因 (ActPrizeItem 的 tag 映射与 huya-go 实测一致,to_dict()["goods"] 正常)。

    处理:新增 resolve_gifts_sid(db) —— 优先读工作台配置 huya_config.sid, 读不到(没有配置行 / 不是数字)才回落到 GIFTS_SID;接口层 POST /external-orders/ck/preview 注入 db 后把它传给 preview_ck(sid=...)。 配置行不存在时不建行(预览是只读动作)。空列表的结论也改成人话: 「活动 SID=xxx 没返回商品:接口通了但列表是空的,多半是活动换 ID 了 —— 去工作台 「虎牙配置」把活动 SID 改成当前的再试」。 → 换活动时只改工作台配置,不用改代码。

  2. 店铺必填的地方不再给「不填」按钮:HuyaCollabShopPicker 新增 allowClear (默认 true,筛选条保持「全部」),新建弹窗 / 详情抽屉 / 列表行内改都传 false —— 点出一个后端会拒的空值纯属自找麻烦。

  3. 礼包名改 AutoComplete:列表有货时就是点选,拉不到列表时还能手输 —— 否则「礼包名必填 + 拉取失败」会把新建彻底卡死。选项标签带上 [类目] 名称 · 积分 · 剩余,同名的礼包能分清。

  4. 记住最近礼包改成「选中即记」(原来只在创建成功后记):中途取消 / 提交失败也不丢, 符合「记住最近选择的礼包」的原意。

  5. 预览请求加了序号防串:连贴两份 CK 时,先发的那份迟到返回不再覆盖新结论 (之前可能出现「显示上一份 CK 的有效」这种错觉);清空输入也会让在途请求作废。 真正的把关仍在服务端:提交时 require_verified=true 会拿表单里的 CK 重新探针。

  6. 记忆里的礼包不在本次列表里:清空并提示「上次选的「xx」不在这次拉到的礼包列表里, 请重新点选」(拉取失败时不清,避免把人卡死)。

11.5 2026-10-16 第二轮页面复核(用户点着看又提的)

  1. 弹窗放大了:新建协作单 width 520→920、表单 size 回到 middle、CK 输入框 3→5 行、 正文给 maxHeight: 72vh 滚动区(内容比一屏高时按钮不会被顶出去);粘贴导入弹窗 760→900。
  2. 绑定点选改成"一个店铺一个独立按钮、中间留白":HuyaCollabShopPicker 加 size (small/middle/large),新建弹窗用 large;原来 antd Radio.Group optionType="button" 把按钮连成一整排分段控件,小且不好点 —— 现在每个店铺一个 Button(选中是 primary, 未选是描边),按钮之间留 8–12px 空白;列表单元格与筛选条仍是 small。 「+ 新店」跟在最后一个店铺后面(同一行):店铺这栏在新建弹窗里独占一行, 否则半栏宽度放不下「4 个店铺 + 新店」,Space wrap 会把新店挤到第二行去。
  3. 「绑定斗鱼号」从界面下线(用户原话"绑定斗鱼号不需要了"):绑了谁由探针写 bind_game_*, 人工那一列已无意义。新建弹窗不再有这个字段(提交也不再带);详情抽屉去掉可编辑项, 老数据非空时只读展示;bind_douyu_account 列与粘贴导入的 Excel 列序都不动 (导进来的老 Excel 不会解析错位)。
  4. 礼包列表改为浏览器缓存 + 一个强制刷新:见 §5.5 的「礼包列表缓存」。 贴 CK 时带 with_gifts=false(本地没缓存时才自动拉一次),Alert 里会写明"用本地缓存(N 个 · 时间)"; 按钮从「重新拉取礼包」变成「强制刷新礼包(N)」。 细节:礼包 state 的初值直接读 localStorage(不是等 useEffect)—— 否则"刚打开页面就贴 CK" 那一瞬缓存还没进 state,会白拉一次;CK 输入框的 placeholder / tooltip 也改成 「贴进来即自动验证 + 读绑定角色(礼包有缓存就不再拉)」,别再让人以为每次都会拉礼包。
  5. 主页截图必填、绑定图下线:create_order 新增 home_images 入参,严格模式下 「主页截图」进必填清单(create_field_required),key 走 normalize_images(防路径穿越); 新建弹窗内嵌 HuyaCollabHomeImageUploader(先传 /api/files/upload 拿 object_key、 可预览/设为封面/移除,提交时随表单一起落库)。 ≥1 张的兜底在前端 Form.Item validator + 服务端双重把关。

11.6 2026-10-16 第三轮页面复核(弹窗/列表体验)

  1. 新建弹窗尽量不出滚动条:
    • CK 输入框由固定 5 行改成 autoSize={{ minRows: 3, maxRows: 8 }}(短 CK 就 3 行,不再白占高度);
    • 「主页截图」和「备注」并排一行(14/10),备注 3 行 → 2 行;
    • 表单项间距压到 14px(src/index.css 里 .huya-collab-create .ant-form-item), 正文 maxHeight 72vh → 78vh 只当兜底。
  2. 列表加「序号」列:跨页连续((page-1)*pageSize + index + 1),固定在左侧 —— 群里沟通时可以直接说"第 12 行"。
  3. 搜索扩到 11 个字段:原来只搜订单号 / 绑定斗鱼号 / 绑定账号 / 角色 / uid / 昵称 / hy_username / 礼包 / 备注,现在补上 店铺 与 平台·区服(bind_game_channel), 输入框加宽到 340px 并在 placeholder 里逐项写清能搜什么。 (其实一个输入框就能打穿这些列,之前是列没写全 + 提示太短,容易被当成"没有搜索"。)
  4. 放开 uid 唯一性 + 同买家识别:见 §3.2 与 §5.6;迁移 20261016_0058。
  5. 「CK 状态」列的探针结论必须截断(用户反馈:长文案顶到「最后改动」列上去了): 那一格是「标签 + 探针结论」,而结论里塞了账号 / 角色 / 平台·区服,长度不可控。 改成单行省略(display:block + maxWidth + text-overflow:ellipsis + nowrap), 列宽 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)。
  7. 展示名调整(同日第二刀):done 的展示名由「已完成」改成**「抢购成功」、 failed 由「兑换失败」改成「抢购失败」** —— 只改 ORDER_STATUS_LABELS / STATUS_META / 下拉与卡片文案,枚举值仍是 done / failed(库里数据、接口参数、审计 detail 全不变,无需迁移)。 注意「判定结论」(verdict)那三个标签仍是「兑换成功 / 兑换失败 / 无法判定」: 它说的是"兑换记录里查到没有",与业务状态名是两个概念,徽标放在列表「积分」列与生命周期时间线上。

12. 风险

风险 应对
与 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 可统计
分支 按 AGENTS §1,新功能应从 main 开 feat/*;开工前需确认当前分支与库迁移版本对齐