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

81 KiB
Raw Permalink 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. 业务全生命周期:incomplete → to_recharge → recharged → scripted → done,核账异常 points_error,旁路 failed / 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) 普通索引, nullable 虎牙 uid(从 CK 解析;空 = 还没贴 CK)。2026-10-16 起不再唯一:同一买家多件常继续用同一份 CK(迁移 0058)
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 列,原表头写的"备注")
required_points int nullable 该单礼包所需积分快照(选中礼包时从 queryAllocation 带出,可手工修正)
remark varchar(512) 自由备注
status varchar(16) index 业务状态(§4.1)
bound_at datetime 资料齐全、进入「待充值」的时间(列名沿用历史)
recharged_at datetime 充值核账通过时间
scripted_at datetime 上脚本时间
done_at datetime 抢购成功时间
points_checked_at datetime 最近一次充值核账时间
points_balance int nullable 核账时该组账号积分合计
points_required_total int nullable 核账时该组礼包所需积分合计
points_surplus int nullable 结余 = 积分合计 - 礼包合计
points_threshold int nullable 本次核账使用的允许结余阈值
points_check_reason varchar(16) ok / shortage / excess / error
points_check_message varchar(512) 给人看的核账结论
points_check_detail JSON nullable 同组订单、各 uid 余额、缺口/多余等明细
void_reason varchar(256) 作废原因(status=void 时必填)
archived_at datetime index 归档时间;非空 = 已归档(可见性状态,不是业务状态,§4.4)
archived_by int FK users.id 归档人
archive_reason varchar(256) 归档备注;取消归档时清空,审计里保留原文
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、archived_at、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 业务状态机(一行的进度)

incomplete ─资料齐全且 CK 有效─▶ to_recharge ─充值核账─▶ recharged ─上脚本─▶ scripted ─抢购成功─▶ done
     ▲                              │                    │
     └────────关键资料被清空──────────┘                    ├─▶ points_error(积分不足 / 多出超过阈值)
                                                          └─▶ failed(抢购失败)
任意流程内状态 ───────────────────────────────────────────────▶ void(作废)
状态 含义 进入条件 谁可触发
incomplete 待补资料 新建 / 粘贴导入的默认态;资料被清空时也会自动退回 人工 / 系统
to_recharge 待充值 外部 CK、绑定账号、绑定角色、礼包名、至少 1 张主页截图齐全,且 CK 有效;由服务层自动推进 系统自动
recharged 已充值 只能通过充值核账(POST /external-orders/recharge):整组积分结余在 [0, 阈值];写 recharged_at 系统核账
points_error 积分异常 同上,但整组结余 < 0(不足)或 > 阈值(多出);写 points_check_* 快照 系统核账
scripted 已上脚本 人工切换(可跳步);写 scripted_at 人工
done 抢购成功 人工切;或「已上脚本」的单查积分/兑换记录命中礼包后自动切(§5.7) 人工 / 系统判定
failed 抢购失败 人工切;或「已上脚本」的单查兑换记录没有这个礼包后自动切(§5.7) 人工 / 系统判定
void 已作废 任意流程内状态可进;void_reason 必填 人工

规则(2026-10-19 用户拍板的新口径):

  1. 不再有人工 pending / bound:客户资料齐全 + CK 有效后自动进 to_recharge。 旧数据里这两个值由迁移 20261019_0061 转成 to_recharge(资料齐全)或 incomplete。
  2. 自动推进(_maybe_advance_to_recharge()):创建、编辑、补图、验证 CK 后都会检查 资料完整性;满足条件时从 incomplete 自动切到 to_recharge,审计写 huya:collab:status_auto(reason=data_complete)。
  3. 自动退回(_maybe_demote_missing_data()):to_recharge / points_error 的关键资料 (CK、绑定账号、绑定角色、礼包名、主页截图)被清空时,自动退回 incomplete, 审计 reason=data_missing。CK 失效本身不触发退回:业务上可能只是凭据过期, 充值核账会强制重验并阻止误判。
  4. 换 CK 不回退业务状态:set_ck() 只把 ck_status 打回 unverified;等用户点 「充值核账」时强制重验。若探针读到的 bind_game_account / bind_game_role 变了, 整组核账报错,不会把订单误落到旧买家组。
  5. 人工切换允许往前跳步:目标阶段序号 ≥ 当前即放行(is_forward_status())。 跳步会把沿途的 *_at 时间戳补齐(_sync_stage_timestamps())。
  6. 回退必须显式声明:目标阶段序号 < 当前时必须 rollback=true(界面勾「回退」), 会清掉后面阶段的时间戳,审计里 rollback=true 留痕。
  7. failed / void 是旁路:流程内任意状态都能进(不用勾回退);从这两态回到流程内 必须 rollback=true。void 不可逆(只能编辑或新建),failed 可回流程内重试。
  8. recharged / points_error 不能人工直写:set_status() 只有核账流程传 allow_recharge=True 才接受这两个目标;普通状态接口和批量改状态都会返回 recharge_check_required。points_error 补足/调整积分后重新核账即可回到 recharged 或 to_recharge。
  9. 不用一个布尔列表达"充值/上脚本":recharged_at / scripted_at 是 "什么时候做过"的证据,status 是"现在卡在哪"。两者都留。
  10. 离开「待补资料」要求资料齐全(_assert_can_leave_incomplete()): 外部 CK / 绑定账号 / 绑定角色 / 礼包名 / 主页截图缺一不可;作废 / 抢购失败是旁路, 不受这条限制。

充值核账(整组,check_recharge_groups()):

  • 分组键 = bind_game_account + bind_game_role(和「同一买家」软识别一致)。 探针读到的账号经常只是「微信用户 / QQ用户」这种通用昵称,只按账号会把不同买家并成一组 (2026-10-19 生产上 8 单被合成 48000 分);组内只统计 to_recharge / recharged / points_error,incomplete / scripted / done / failed / void 不参与;
  • 同一 uid 只验一次 CK、只查一次积分;组内多个 uid 的积分合计;
  • 必充积分 = Σ required_points;结余 = 账号积分合计 - 必充积分;
  • 结余 < 0 → 整组 points_error(reason=shortage); 结余 > 阈值 → 整组 points_error(reason=excess);其余 → 整组 recharged;
  • 阈值默认 100,前端可改并记忆;范围 0..1_000_000;
  • CK 失效 / 积分查询失败 / 缺少 required_points 时整组保持原状态并逐组报错;
  • 每个组内订单都写同一份快照:points_checked_at / points_balance / points_required_total / points_surplus / points_threshold / points_check_reason / points_check_message / points_check_detail,审计动作 huya:collab:recharge_check。

批量改状态(set_status_batch(),接口 POST /external-orders/status/batch):

  • 勾选多行一次切到同一个目标状态;逐行 SAVEPOINT 隔离,某一行前置校验没过 (例如资料不全进不了「待充值」)只记在该行的 message 里,不影响其它行; 一次上限 PROBE_BATCH_MAX=50;
  • 批量接口不接受 recharged / points_error;要落这两个状态请用充值核账接口;
  • 单行与批量走同一个 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, 失败时整组保持原状态并报错,由人决定换 CK 还是停用。

4.3 防漂移(学 docs/设计-账号全生命周期.md §3.4.1 的教训)

  • 状态只有一处写入口:services/huya_collab_service.set_status(), 接口层不允许直接 UPDATE status;
  • 每次状态变化必须同时写 *_at 时间戳 + audit_logs;
  • 加一条测试:任何状态写入都必须带审计行(漏了就是 bug)。

4.4 归档(可见性,不是第三种状态,2026-10-19)

抢购成功的单会越积越多,但它们不该一直挤在活跃台账里;「归档」把行从默认视图里 藏起来,但不改变业务状态(status 仍是 done / 抢购成功),也不删 CK、 截图、积分快照或审计记录。

项 口径
可归档 只有 status=done 的活跃行;failed 不归档(先处理或作废)
判定 archived_at IS NULL = 活跃;非空 = 已归档
列表/统计 archive_scope=active(默认)/ archived / all;默认列表与统计都排除归档行
只读 归档行不能改状态、CK、字段、图片、删除,也不能查积分/兑换记录;接口统一 409 order_archived,必须先取消归档
备注 归档时可写 archive_reason(≤256 字);取消归档清空该字段,但审计保留原始备注
批量 单行 / 勾选 / 「归档当前筛选结果」三种入口;按筛选归档强制 active + done,前端传 expected_count,数量变化返回 409 archive_count_changed;单次上限 ARCHIVE_BATCH_MAX=5000
审计 huya:collab:archive / huya:collab:unarchive,写单号、状态与备注;归档/取消归档都会 _touch() 递增 version
权限 复用 huya:collab:edit,不新增权限点
实时 归档变化发布 scope=orders 的 order.archive / order.unarchive 失效通知

归档视图仍支持原有筛选与关键词搜索(订单号、店铺、绑定账号、角色、虎牙号、礼包、备注), 只是所有写操作隐藏,仅保留「详情」与「取消归档」。


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,列表默认过滤掉(可切换显示)。
  • 归档(2026-10-19):只针对抢购成功的活跃行,保留全部数据、默认隐藏、取消归档前只读(§4.4)。
  • 物理删除:huya:collab:manage 才能做,且要级联清理图片(§6.4),并记审计;归档行必须先取消归档。

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

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

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

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

  • 礼包来源:PlayMallUI.queryAllocation(积分中心商品,只读、不落库;拉失败只提示 gifts_message,不影响 CK 的有效性结论)。商品积分取 tag75.tag0.tag2,与积分中心 页面显示一致;不再依赖活动 SID。旧 webActUI.getActPrizeList 保留给精英宝典工作台的 兑换链路,本模块不再调用;
  • 礼包名默认空白(2026-10-18 用户要求):不再自动带上次选的礼包,避免没注意时建错单; 想少点两下用「常用礼包」快捷按钮;
  • 礼包列表缓存:前端 localStorage(huya.collab.gifts.v2,存 {at, gifts})—— 一个活动期间礼包清单基本不变, 贴 CK 时不再每次都去虎牙拉(with_gifts=false),要用最新库存点「强制刷新礼包」; 缓存时间摆在礼包名下方,商品清单变了就点一次强制刷新;
  • 探针缓存:同一份 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 共享视图 + 负责人

  • 一张表,所有登录用户看到同一份数据(默认不按人过滤);
  • 默认排序:按创建序号 seq 升序(老单在上、新单追加在后);表头可按订单号/店铺/负责人/状态/礼包/更新时间等白名单字段排序;
  • "我的单"是一个筛选器(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/include_void/archive_scope,分页,默认排除 void 与归档行
GET /api/huya/external-orders/summary huya:collab:view 各状态计数(页头统计条),口径跟随 include_void/archive_scope;另返回 archived_total
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/status/batch huya:collab:edit 勾选多行切成同一个状态(逐行 SAVEPOINT 隔离,最多 50 条;不接受 recharged / points_error;§4.1)
POST /api/huya/external-orders/recharge huya:collab:edit 充值核账:{ids, threshold},按「绑定账号 + 绑定角色」整组验证 CK + 积分,整组落 recharged / points_error(§4.1)
POST /api/huya/external-orders/archive huya:collab:edit 归档抢购成功订单:ids 单条/勾选,或 archive_filtered=true + 筛选条件按当前筛选批量;可带 reason,expected_count 校验数量(§4.4)
POST /api/huya/external-orders/unarchive huya:collab:edit 取消归档({ids: [...]}),恢复回活跃台账
POST /api/huya/external-orders/activity/query huya:collab:edit 勾选多行查积分 + 兑换记录(串行带间隔,每行写一条生命周期快照;§5.7)
POST /api/huya/external-orders/{id}/activity/query huya:collab:edit 查这一行的积分 + 兑换记录(单行「查询」按钮)
GET /api/huya/external-orders/{id}/metrics huya:collab:view 账号全生命周期时间线(每次查询的快照,新→旧;§3.5)
POST /api/huya/external-orders/ck/export huya:collab:ck_export 导出勾选行的 CK 明文(前端写剪贴板;逐行写审计;最多 50 条;§11.8)
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;行内不再放「→ 下一步」快捷按钮(2026-10-19),推进统一走详情抽屉或批量改状态;
  • CK 状态:Tag + 一键"验证";
  • 工具栏批量动作(都要先勾选):批量验证 CK / 批量改状态 / 充值核账 / 批量查积分·兑换 / 批量归档(仅全部选中行都是活跃 done)/ 导出 CK(勾选几行导几行,写剪贴板;§11.8);
  • 归档入口(2026-10-19,§4.4):筛选行右侧「查看归档」开关 + 已归档总数角标;活跃视图提供「批量归档」和(状态筛选为 done 时)「归档当前筛选结果(N)」;归档视图提供「批量取消归档」,写操作全部隐藏;
  • 顶部工具区固定两行(2026-10-16 用户反馈「不要选中后两行」): 第一行永远筛选 = 店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、店铺、绑定账号、角色、虎牙号、礼包、备注)/ 显示已作废 / 查看归档; 第二行永远动作 = 刷新 + 上面那些批量动作 + 粘贴导入 / 店铺管理 / 新建。 不要用「筛选 + 动作放在同一个 wrap 里靠 space-between 排」那种写法 —— 不勾选时两组挤成一行、勾选后按钮多了「(N)」就被顶到第二行,列表跟着往下跳;
  • 页头统计条:全部 N / 待补资料 N / 待充值 N / 已充值 N / 已上脚本 N / 抢购成功 N / 抢购失败 N / 积分异常 N / CK 失效 N。 卡片可点:点状态卡筛到该状态、点「CK 失效」只筛 CK 状态、点「全部」清空两种筛选; 当前筛选对应的卡片高亮,并自动滚到列表。右上角是「实时同步」开关(可手动暂停;暂停后不建 WebSocket、 不轮询,只能手动刷新);已归档总数显示在筛选行的「查看归档」开关旁。

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

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

9.2 详情抽屉

左侧:图片(两个槽位,可上传/删除/换序); 右侧:字段编辑 + 操作按钮(验证 CK / 重建 CK / 推进状态 / 作废 / 删除); 底部:审计时间线(谁在什么时候改了什么)。

归档行(§4.4):标题与描述区显示「已归档」+ 归档时间/归档人/备注;字段编辑、图片编辑、 状态推进、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 2026-10-16 起不再唯一(同一买家多件可复用同一份 CK,迁移 0058)
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 必须探针通且读到绑定角色
— 礼包来源(2026-10-19 调整) PlayMallUI.queryAllocation 积分中心商品,点选 + 记住上次选择;旧 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"] 正常)。

    2026-10-19 起协作台账已切换到 PlayMallUI.queryAllocation,不再依赖 SID; 下面这段是旧 getActPrizeList 的排查记录,保留给精英宝典工作台参考。

    处理:新增 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 第四轮:状态机新口径 + 账号全生命周期

历史口径:本节的 bound(已确认绑定)已在 2026-10-19 第九轮删除,见 §11.12;保留原文用于追溯当时决策。

用户原话(这一轮的全部需求): "业务状态刷新一下,目前上传后都属于待处理"、 "手动确认绑定角色信息和主页截图相同后,可以手动切换到已确认绑定"、 "充值完成后可以切换到已充值"、"完成上脚本后可以切换到已上脚本"、 "抢购完成后可以查询兑换记录、积分等等,每次查询积分变化都要写入这个账号的全生命周期, 这样就可以通过兑换记录和积分变化确认是否兑换成功"、 "可以根据查询的兑换是否成功或失败,自动改变状态",并且"手动切换一个或者勾选多个都可以"。

落地清单:

面 落地
状态机(服务层) 新增终态 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)那三个标签仍是「兑换成功 / 兑换失败 / 无法判定」: 它说的是"兑换记录里查到没有",与业务状态名是两个概念,徽标放在列表「积分」列与生命周期时间线上。

11.8 2026-10-16 第五轮:勾选导出 CK 到剪贴板

需求原文:"选择、多选后增加一个导出 CK 功能,可以导出选中的 CK 到剪贴板。"

这是本模块第一个把凭据往外送的读接口(列表/详情都刻意不返回 cookie,见 §2.3), 所以按"凭据出口"来设计,而不是当成一个普通导出:

面 落地
权限 新增权限点 huya:collab:ck_export("导出外部 CK 协作台账的 CK");默认只给运营,客服默认没有(贴 CK 是"录入",导出是"把凭据带走",两件事不共用一个权限;口径同 huya:cookie:export)
服务层 export_cks(db, current, orders):只读 + 逐行写审计;返回 {total, exported, skipped[], items[]},items[].ck_text 是明文(只有这个接口会返回);上限 PROBE_BATCH_MAX=50
接口 POST /api/huya/external-orders/ck/export(字面量路径,排在 /{order_id} 之前),body {ids: [...]}
审计 逐行写 huya:collab:ck_export,target=huya_external_order:<id>,detail 只记 order_no / uid / ck_status / batch_size —— 绝不写 CK 本体(测试里用 assert "C"*152 not in detail 钉死)
前端 工具栏「导出 CK(N)」→ 拉明文 → 弹窗预览 + 「复制到剪贴板」(走 utils/clipboard.ts,兼容 http 远程访问的 execCommand 回退)

六条约定(改这个功能前先读):

  1. 明文只活在弹窗的 state 里:不写 localStorage、不进 URL、不缓存(关弹窗即清);
  2. 复制失败不吞:回退提示"在下面的框里手动选中复制",明文本来就在框里;
  3. 格式两种:默认「每行一份纯 CK」(直接粘),可切「订单号 + Tab + CK」(多选时对号);
  4. 没 CK 的行显式跳过并回原因(skipped),界面逐条列出来,不静默丢;
  5. 作废 / 停用的单只要还有 CK 就照导,但把 status_label / ck_status_label 一起给, 界面上点名"其中 N 条已失效/已停用" —— 不替用户做隐含决策(服务层只管"有没有凭据");
  6. 上限 50 行(和批量验证/查询一致),一次别点太多,人看不过来也难追溯。

待跟进:导出动作目前只写审计;如果以后要做"谁在什么时候导出了什么"的专门报表, audit_logs.action = 'huya:collab:ck_export' 已经可以直接筛(不用加表)。

11.9 2026-10-17 第六轮:批量查询拆成「查积分」+「查兑换记录」两个按钮

需求原文:"批量查积分/兑换 改成两个按钮,一个查询积分,一个查询兑换记录。"

原先是一个按钮查两件事(POST /external-orders/activity/query 一次打两个接口)。 拆开之后,一半的查询必然缺另一半的数据,所以这一轮的重点不是按钮,而是三件事:

面 落地
服务层 query_order_activity(scope=...) / query_activity_batch(scope=...):both(默认,原行为)/ score / records;没查的那一半不写值
判定 只查积分时 verdict 写空串(VERDICT_NOT_QUERIED),显示名「未判定」——与 unknown「无法判定」(查了但判不了)必须分开;ok 改看"这次要查的东西查到没有"
列表回填 order_metric_views():latest(时间/结论)+ score(最近一条查过积分的)+ verdict(最近一条有判定结论的),三条线索各发一次分组查询
接口 请求加 scope;响应条目加 scope/scope_label,汇总加 not_judged;serialize_order 加 last_verdict_at/last_verdict_message;serialize_metric 加 scope
前端 工具栏拆成「批量查积分(N)」「批量查兑换记录(N)」;结果弹窗按 scope 换标题与正文;列表「积分」列 Tooltip 分别写清"最新查询"与"判定"各自的时间;详情时间线按 scope 决定显示哪几段(不再把"没查"显示成"积分 -""0 条")
测试 新增 TestCollabActivityScopeSplit(11 例);全量 pytest 978 passed / 6 skipped

这一轮定下的四条口径(改这块前先读):

  1. 只查积分永远不自动改状态:判定都没有,凭什么改?target 为空,连"因为状态不是已上脚本所以没改" 那套提示都不会出现。反向验证:去掉这个分支后,一个"兑换记录里明明有它礼包"的已上脚本单 会被判成 failed 并自动改成「抢购失败」—— 6 个用例同时变红。
  2. 「没查」不是「没有」:只查积分时不许拿"兑换记录是空的"去判失败;records_total 保持 0 但 verdict 留空,汇总里那行进 not_judged(不计入 unknown)。
  3. 列表上的判定徽标取「最近一条真的查过兑换记录的快照」:否则点一次「查积分」就会把整页 「兑换成功 / 兑换失败」冲成「未判定」,等于把结论抹掉。但新的 unknown 照样盖掉旧的 success —— 不能为了"保住徽标"把抖动的结果藏起来(两条都有专门用例钉着,各注入一次反向验证)。 同理,列表上的积分取「最近一条查到过积分的快照」,只查兑换记录时不清空。
  4. 时间线按 raw.scope 取舍,不靠"字段是不是空"猜:快照行是只追加的,raw.scope 记着当时查了哪一半。

没有动的地方:单行「查积分 / 兑换记录」与详情抽屉里的按钮仍是 both(一个动作查两件事), scope 是可选参数、默认值就是原来的行为 —— 老的调用方一个字段不加也照旧。

11.10 2026-10-18 第七轮:礼包名默认空白 + 常用礼包备选

需求原文:"礼包名 需要优化, 每次都会选择上次选中的, 修复这个, 可以在上方留几个经常选择的"; 补充:"不能带上次的, 需要默认空白, 只留下 几个备选"。

原来的行为:新建时自动把「上次选的礼包」填进 gift_name(localStorage['huya.collab.lastGift'])。 连开几单同一个礼包确实省事,但每开一单都顶着上一次的选择,稍不留神就按错礼包建单; 而且这个自动值会被草稿自动保存吃进去,下次打开又"恢复"出来。

面 落地
默认值 礼包名一律空白:删掉 LAST_GIFT_KEY(不再写、不再读),新建弹窗不预填
草稿 恢复草稿时只恢复订单号 / 店铺 / CK / 截图 / 备注,礼包名留空;hasDraftContent() 不再把「只有礼包名」当有内容(否则一份被旧逻辑自动填过的草稿会一直弹"已恢复草稿")
常用礼包 新增 GET /external-orders/gifts/frequent?limit=8 → service.frequent_gift_names():按使用次数降序、次数相同按最近一次使用;新建与补资料弹窗在字段上方渲染最多 6 个按钮(短标签 + Tooltip 显示全名与用过次数)
过滤 按钮只显示当前礼包缓存里还有的那几个(活动换 SID 后老礼包会整批下架,见 §11.4);缓存为空时整行不显示

四条口径:

  1. 礼包名是"点一下"的事,不值得替用户记住:CK / 截图那些重活才需要草稿,礼包有备选按钮后一次点击即可, 所以宁可默认空白。
  2. 服务层只回答"历史上哪些用得多":frequent_gift_names 不判断当前活动还有没有这个礼包 —— 那是前端拿礼包缓存比对的事;活动换 SID 后礼包整批变,服务层不该替前端猜(§11.4 的教训)。
  3. 空礼包名不进榜单:那是"还没填",不是一种礼包(SQL 里滤掉,有用例钉着)。
  4. 次数相同看最近:并列时让最近用过的排前面,符合"常用"的直觉(有用例 + 反向验证)。

没有动的地方:详情抽屉里编辑既有订单的礼包名仍是"显示这一单自己的值"(本来就该如此); 补资料弹窗的初值仍是这一单自己的礼包(不是"上次选的"),没有改成空白。

11.11 2026-10-19 第八轮:统计拆分、暂停实时同步、状态列清理与归档

需求原文:"拆分已充值和已上脚本统计;实时同步放右上角并可手动暂停;状态列只显示当前状态; 抢购成功的单太多,需要归档(可备注),归档后默认不显示但能搜索查看,取消归档前只读"。

面 落地
统计条 「已充值」「已上脚本」拆成两张卡;右上角新增「实时同步」卡,开关状态存 localStorage['huya.collab.realtime-paused']。暂停后不建 WebSocket、不排轮询、丢弃待刷新,恢复时立即重拉一次
状态列 删除行内「→ 下一步」Popconfirm,只保留当前状态 Tag(归档行额外显示「已归档」)。推进仍在详情抽屉与批量改状态里
数据模型 迁移 20261019_0060:archived_at / archived_by(FK users.id)/ archive_reason(varchar 256)+ archived_at 索引。status=done 保持不变,归档只是可见性(§4.4)
后端 list_orders / summary 增加 archive_scope(active 默认 / archived / all);新增 POST /external-orders/archive 与 /unarchive;归档行所有写入口(状态、CK、字段、图片、删除、积分查询)统一 409 order_archived
批量归档 单行 / 勾选 / 「归档当前筛选结果」;按筛选强制 active + done 并校验 expected_count(409 archive_count_changed),单次上限 5000;返回逐行 results(成功/跳过原因)
前端 筛选行「查看归档」开关 + 已归档总数;归档弹窗可填 ≤256 字备注;归档视图只读,提供详情与单行/批量「取消归档」;归档行在列表与详情里的内联编辑、图片编辑、状态与 CK 操作全部隐藏
审计 huya:collab:archive / huya:collab:unarchive,记录单号、状态与备注;取消归档清空行上的 archive_reason,审计保留原文;两个动作都递增 version 并清掉实时缓存

验收口径:默认列表/统计不含归档行;归档视图能按原有关键词与筛选搜到;归档行改任何东西都提示先取消归档; 取消归档后回到原位置(业务状态未变),归档备注仍能在审计时间线里查到。

11.12 2026-10-19 第九轮:状态机重做 + 按绑定账号充值核账

需求原文:「去掉待处理和已确认绑定;客户资料齐了自动待充值;点已充值要服务端校验 CK 和积分; 按 bind_game_account 整组核账,整组落已充值 / 积分异常;积分不足或多出阈值都算异常;阈值可改默认 100」。

面 落地
状态 删除 pending / bound;新增 to_recharge(待充值)、points_error(积分异常);incomplete 成为唯一入口
自动推进 _maybe_advance_to_recharge():CK 有效 + 绑定账号 + 绑定角色 + 礼包名 + ≥1 张主页截图 → 自动 incomplete → to_recharge;创建/编辑/补图/验证 CK 都触发
自动退回 _maybe_demote_missing_data():to_recharge / points_error 关键资料被清空 → 自动退回 incomplete;CK 失效不触发
核账 check_recharge_groups() + POST /external-orders/recharge:按「bind_game_account + bind_game_role」分组;只统计 to_recharge/recharged/points_error;同 uid 只验一次 CK、只查一次积分;多 uid 积分合计
判定 结余 = 积分合计 - Σ required_points;<0 → points_error/shortage;>阈值 → points_error/excess;其余 → recharged;CK 失效/积分失败/积分缺失整组保持原状态
字段 新增 required_points + points_checked_at/points_balance/points_required_total/points_surplus/points_threshold/points_check_reason/points_check_message/points_check_detail
迁移 20261019_0061:加列、status 默认改 incomplete、旧 pending/bound 按资料是否齐全转 to_recharge 或 incomplete;降级把 to_recharge → bound、points_error → recharged
礼包来源 改用 PlayMallUI.queryAllocation(tag75.tag0.tag2 取积分,tag13 库存),不再依赖活动 SID;旧 webActUI.getActPrizeList 仅保留给精英宝典工作台
前端 统计卡改为 全部/待补资料/待充值/已充值/已上脚本/抢购成功/抢购失败/积分异常/CK 失效,可点击筛选并高亮;工具栏新增「充值核账」;新建/补资料/详情增加「礼包所需积分」,选礼包自动带出;核账弹窗阈值默认 100
审计 核账逐单写 huya:collab:recharge_check;自动推进/退回写 huya:collab:status_auto;主操作(create/update/image)先落审计,再追加自动状态,时间线因果顺序不再倒置
生产修正 首版只按 bind_game_account 分组,生产发现「微信用户 / QQ用户」是通用昵称,8 个不同角色被合成一组(required_total=48000)。现改为「账号 + 角色」;points_check_detail 同时保留 bind_game_account 与 bind_game_role

三条边界:

  1. 不要只拿 bind_game_account 当分组键:它经常是「微信用户 / QQ用户」这种通用昵称; 同一买家必须靠「账号 + 角色」识别。账号或角色为空的行保持/退回 incomplete, 绝不回落到 uid。
  2. 不要让人工直写 recharged / points_error:这两个状态必须由核账产生,否则积分快照与状态会各说各话。
  3. 换 CK 不等于资料缺失:set_ck() 只重置 ck_status;核账会强制重验,若绑定账号/角色变了整组报错,不会误落旧组。

12. 风险

风险 应对
与 huya_accounts 出现"两份 CK 谁是准的" §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入
探针接口被风控 只做人工触发、串行、带间隔;不做定时轮询
CK 被导出后外传(导出到剪贴板 = 凭据出门) 独立权限点 huya:collab:ck_export(默认只给运营)+ 逐行审计(不含 CK 本体)+ 界面常驻警告;明文只在弹窗 state 里,关掉即清(§11.8)
积分 / 兑换记录查询被风控 同上:只手动触发(单行 + 勾选批量,串行带间隔),不做定时轮询(§5.7)
自动改状态误判(接口抖动 → 判成"抢购失败") 三态判定:取不到数据一律 unknown 且不动状态;只有 scripted 的单才自动改;每次查询留快照,人工可回退(rollback)
积分中心 queryAllocation 拉不到 / 返回空 → 礼包列表空白 只影响「拉礼包」,不影响 CK 有效性;提示用户稍后点「强制刷新礼包」。旧 getActPrizeList 的 SID 过期问题只保留给精英宝典工作台(§11.4)
自由文本的"绑定斗鱼号"没法校验 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 bind_douyu_uid
图片越攒越多 §6.4 孤儿清理脚本 + file_assets 台账已有 size_bytes 可统计
分支 按 AGENTS §1,新功能应从 main 开 feat/*;开工前需确认当前分支与库迁移版本对齐