feat(huya): 协作台账店铺字典·新建强校验·同买家多单 + uid 放开(迁移 0057/0058)

- 店铺字典(迁移 0057):大按钮点选 + 「新店」自定义自动登记;重命名可级联、
  停用/备注;列表行内改、详情、筛选条共用同一个 picker
- 新建强校验:订单编号/店铺/CK/礼包名/主页截图必填;贴 CK 即探针预览,
  读不到绑定角色禁止提交;礼包用工作台配置的活动 SID 现拉(浏览器缓存 + 强制刷新)
- 图片:绑定图停用、「绑定斗鱼号」下线(绑定关系改由探针读 bind_game_*),
  主页截图改为新建必填(弹窗内先传 OSS 再随表单落库)
- 同买家多单软识别:按「绑定账号 + 绑定角色」整批一次查询,列表「同买家」列 +
  详情分组,点单号直接切单
- uid 放开唯一性(迁移 0058):同一份 CK 可服务多单,原 409 冲突改为提示 + 审计
- 列表体验:序号列、搜索扩到 11 个字段、CK 状态列截断、新建弹窗放大并去滚动条
- 验证:pytest 932 passed / 6 skipped;接口冒烟 56 项全绿;tsc+eslint+build 通过
This commit is contained in:
yml2213
2026-10-16 21:29:08 +08:00
parent df8c63b829
commit aac264b639
17 changed files with 3237 additions and 155 deletions
+198 -9
View File
@@ -78,7 +78,7 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
| `id` | int PK | |
| `order_no` | varchar(64) **unique** | 订单编号(Excel B 列),唯一 |
| `shop` | varchar(64) index | 店铺(A 列) |
| `owner_id` | int FK `users.id` index | 负责人(C 列),可空 |
| `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`(登录名) |
@@ -112,20 +112,58 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
| `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`。
索引:`order_no`(unique)、`uid`(**普通索引**,2026-10-16 由 unique 降级,见 §3.2)、`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 里订单号是天然主键,重复导入要能识别。
- **`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. **字典从空开始**:不回填历史订单里出现过的店铺名(用户明确"不用看之前的")。
---
## 4. 两套生命周期(本设计的核心)
@@ -235,6 +273,57 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
- `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` 字段在界面上写明口径)。
---
## 6. 图片(复用已落地的上传能力)
@@ -243,8 +332,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| 槽位 | 对应 Excel 列 | 字段 | 张数 |
| --- | --- | --- | --- |
| 绑定图 | E | `bind_images` | 多张(第一张作封面) |
| 主页账号图 | F | `home_images` | 多张(第一张作封面) |
| 主页截图 | F | `home_images` | 多张(第一张作封面);**新建必填至少 1 张** |
| ~~绑定图~~ | ~~E~~ | `bind_images` | **已停用**:绑定关系由探针自动读,不需要人工截图。列还在(老数据不丢),详情里只读展示 |
存的是 OSS `object_key` 数组;前端用 `/api/files/object?key=...` 直接 `<img>` 预览。
**显示时不在列表里下原图**——用 `thumbnail_url`(缩略图变体),点开才看原图。
@@ -332,8 +421,13 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| 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` | 可选负责人列表(给筛选/下拉用) |
@@ -361,9 +455,17 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
- **图片列**:缩略图 + 张数,点击开大图(`Image.PreviewGroup`);
- **状态**:Tag 展示,行内"下一步"按钮推进(带二次确认);
- **CK 状态**:Tag + 一键"验证";
- 筛选:店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、斗鱼号、uid);
- 筛选:店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、绑定账号、角色、uid);
- 页头统计条:`待处理 N / 已绑定 N / 已充值 N / 已上脚本 N / 已完成 N / CK失效 N`。
> 交互细节(2026-10-16 用户拍板):**店铺是点选按钮组**(列表里点单元格弹出同一组按钮,
> 筛选条里也是),不是下拉;**新建时不用选负责人**——默认就是当前登录账号
> (要转单在列表里点一下改)。
> 新建弹窗(同日拍板):**订单编号 / 店铺 / CK / 礼包名必填**;贴 CK 后自动验证并显示
> 「绑定账号 + 绑定角色 + 平台区服」,**读不到角色就禁止提交**;礼包下拉来自这次 CK 现拉的
> 活动礼包(点选,不是手打),并记住上次选的那个(见 §5.5)。
### 9.2 详情抽屉
左侧:图片(两个槽位,可上传/删除/换序);
@@ -404,6 +506,10 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| — | `uid` 可空 | 可空(唯一索引允许多 NULL)→ 支持"先建单、后贴 CK" |
| — | **绑定对象的展示口径**(2026-10-16 用户拍板) | 展示**探针读到的游戏账号 + 绑定角色**(角色最重要);人工的"绑定斗鱼号"不再作列表主列,只留详情抽屉(Excel 导入列) |
| — | 人工能否改 CK 状态 | 只能"停用"/"恢复(未验证)";`valid`/`invalid` 归探针 |
| — | 店铺怎么填(2026-10-16 用户拍板) | **点选按钮组**(不是下拉);允许"新店"自定义输入,后端自动登记进字典 |
| — | 负责人默认值(同日拍板) | **默认 = 当前登录账号**,新建时不用选;要转单在列表/详情里改 |
| — | 新建必填项(同日拍板) | **订单编号 / 店铺 / CK / 礼包名**必填;CK 必须探针通且读到绑定角色 |
| — | 礼包来源(同日拍板) | 用这份 CK 现拉活动礼包(`getActPrizeList`),点选 + 记住上次选择 |
> 决定 7 的后果:**本模块不 import 任何 `huya_tasks` / runner 代码**。
> 以后要做「一键送进工作台」,那是一个**显式的**新动作(会创建 `HuyaAccount` 并写
@@ -450,6 +556,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| **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 例,孤儿图清理脚本)。
@@ -513,6 +621,86 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
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`,防止一长串无空格字符串顶穿列。
---
## 12. 风险
@@ -521,6 +709,7 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
| --- | --- |
| 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 |
| 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 |
| 活动换 ID(SID 过期)→ 礼包列表空白 | 礼包 SID 一律读 `huya_config.sid`(工作台「虎牙配置」可改,见 §11.4);空列表时结论直接提示去改 SID |
| 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` |
| 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 |
| 分支 | 按 AGENTS §1,新功能应从 `main` 开 `feat/*`;开工前需确认当前分支与库迁移版本对齐 |
+553 -9
View File
@@ -22,6 +22,7 @@ from web.backend.huya_defaults import (
from web.backend.models import (
AuditLog,
HuyaAccount,
HuyaConfig,
HuyaExternalOrder,
HuyaWorkbenchAccount,
User,
@@ -114,6 +115,15 @@ class TestCollabCrud(TestCollabBase):
order = self._create(ck_text="")
assert order.uid is None
def test_create_without_owner_defaults_to_the_creator(self):
"""负责人不填 = 当前登录用户(新建时不用选)。"""
order = self._create(order_no="OWNER1")
assert order.owner_id == self.user.id
def test_create_can_still_assign_someone_else(self):
order = self._create(order_no="OWNER2", owner_id=self.other.id)
assert order.owner_id == self.other.id
def test_multiple_rows_without_ck_are_allowed(self):
"""uid 唯一但可空:多个 NULL 不冲突,所以「先建单、后贴 CK」走得通。"""
self._create(order_no="A1")
@@ -127,14 +137,33 @@ class TestCollabCrud(TestCollabBase):
assert excinfo.value.status_code == 409
assert excinfo.value.code == "order_no_conflict"
def test_create_rejects_uid_already_used_by_another_order(self):
self._create(order_no="A1", ck_text=fake_ck("1001"))
with pytest.raises(collab.CollabError) as excinfo:
self._create(order_no="A2", ck_text=fake_ck("1001"))
assert excinfo.value.status_code == 409
assert excinfo.value.code == "uid_conflict"
# 消息里要说清被谁占了,不然客服不知道该找哪一行
assert "A1" in excinfo.value.detail
def test_same_uid_can_serve_multiple_orders(self):
"""2026-10-16 用户拍板**放开** uid 唯一性:同一个虎牙号可以挂多单。
原来这里是 409(``uid_conflict``)—— 但真机上"同一个买家买两件"常常继续用同一份 CK,
挡了就变成第二单建不了。现在只把「这个号还挂在哪些单上」记进创建审计。
"""
first = self._create(order_no="A1", ck_text=fake_ck("1001"))
second = self._create(order_no="A2", ck_text=fake_ck("1001"))
assert first.uid == second.uid == "1001"
assert self.db.query(HuyaExternalOrder).count() == 2
assert "A1" in str(self._audits(second)[-1].detail) # 复用痕迹留在审计里
def test_uid_used_orders_lists_other_rows(self):
self._create(order_no="A3", ck_text=fake_ck("2001"))
second = self._create(order_no="A4", ck_text=fake_ck("2001"))
assert [row.order_no for row in collab.uid_used_orders(self.db, "2001")] == [
"A3",
"A4",
]
assert [
row.order_no
for row in collab.uid_used_orders(self.db, "2001", exclude_id=second.id)
] == ["A3"]
assert collab.uid_used_orders(self.db, "") == []
assert collab.uids_in_use(self.db, "2001")[0]["order_no"] == "A3"
def test_web_ck_does_not_fill_raw_cookie(self):
"""网页 CK(有 guid)不塞进 raw_cookie —— 它是 App 原始凭据的专用位。"""
@@ -579,6 +608,12 @@ class TestCollabImport(TestCollabBase):
assert result["skipped"] == 1
assert result["created"] == 0
def test_import_without_owner_defaults_to_the_importer(self):
"""导入时负责人留空 = 导入人自己(与手建同一套默认)。"""
result = collab.import_orders(self.db, self.user, "佳佳\tO1\t\t用户1\t礼包\t")
assert result["created"] == 1
assert self.db.query(HuyaExternalOrder).one().owner_id == self.user.id
class FakeBindResp:
"""合成一份 ``check_user_bind_game_account`` 响应(字段名对齐真机响应结构)。"""
@@ -607,12 +642,40 @@ class FakeBindResp:
)
class FakeGiftResp:
"""合成一份 ``getActPrizeList`` 响应(礼包列表)。"""
def __init__(self, *, status: int = 200, msg: str = "", goods: list | None = None):
self.status = status
self.msg = msg
self._goods = (
goods
if goods is not None
else [
{
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"price": 200,
"remain_text": "39%",
"category_name": "礼包",
},
{"product_id": "", "name": "缺 product_id 的脏数据"},
{"product_id": "hy-1002", "name": ""},
]
)
def to_dict(self) -> dict:
return {"status": self.status, "msg": self.msg, "goods": self._goods}
class FakeProbeClient:
"""离线探针客户端:按脚本依次给出 ``None`` / 异常 / 响应,并记录调用参数。"""
def __init__(self, *outcomes):
def __init__(self, *outcomes, gifts: list | None = None):
self.outcomes = list(outcomes)
self.gift_outcomes = list(gifts or [])
self.calls: list[dict] = []
self.gift_calls: list[dict] = []
def check_user_bind_game_account(self, **kwargs):
self.calls.append(kwargs)
@@ -623,6 +686,13 @@ class FakeProbeClient:
raise outcome
return outcome
def get_act_prize_list(self, **kwargs):
self.gift_calls.append(kwargs)
outcome = self.gift_outcomes.pop(0) if self.gift_outcomes else FakeGiftResp()
if isinstance(outcome, Exception):
raise outcome
return outcome
class TestCollabCkProbe(TestCollabBase):
"""S5:只读探针。判据(真机对照 2026-10-16)= 响应非 None 且 status == 200。"""
@@ -989,6 +1059,480 @@ class TestCollabCkProbe(TestCollabBase):
assert excinfo.value.code == "verify_empty"
class TestCollabCkPreview(TestCollabBase):
"""粘贴 CK 后的即时预览:探针 + 顺带拉礼包,一行都不写库。"""
def test_preview_returns_binding_and_gifts(self):
client = FakeProbeClient(
FakeBindResp(
nick="随遇而安", role="吃鸡199706", plat="手Q", area="苹果(iOS)"
)
)
preview = collab.preview_ck(fake_ck("1199647239697"), client=client)
assert preview["ok"] is True
assert preview["has_role"] is True
assert preview["uid"] == "1199647239697"
assert preview["hy_username"] == "hy_1199647239697"
assert preview["bind_game_role"] == "吃鸡199706"
assert preview["gifts"] == [
{
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"price": 200,
"remain_text": "39%",
"stock": None,
"score": None,
"category_name": "礼包",
"icon": "",
}
]
assert "已拉取 1 个礼包" in preview["gifts_message"]
assert client.gift_calls[0]["sid"] == collab.GIFTS_SID
def test_preview_of_invalid_ck_does_not_fetch_gifts(self):
client = FakeProbeClient(None)
preview = collab.preview_ck(fake_ck("1199647239697"), client=client)
assert preview["ok"] is False
assert preview["has_role"] is False
assert preview["gifts"] == []
assert client.gift_calls == [] # CK 都没通,不去拉礼包
def test_preview_of_probe_without_role_is_not_submittable(self):
"""探针通了但读不到绑定角色 → 不允许提交(has_role=False)。"""
preview = collab.preview_ck(
fake_ck("1199647239697"),
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="")),
)
assert preview["ok"] is True
assert preview["has_role"] is False
def test_preview_writes_nothing(self):
order = self._create(order_no="PREVIEW-1", shop="佳佳")
before = order.version
collab.preview_ck(
fake_ck("1199647239697"),
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")),
)
self.db.refresh(order)
assert order.version == before
assert self.db.query(HuyaExternalOrder).count() == 1
def test_preview_rejects_unparsable_ck(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.preview_ck("这不是一份 Cookie", client=FakeProbeClient(None))
assert excinfo.value.code == "ck_invalid"
class TestCollabGifts(TestCollabBase):
def test_gift_fetch_skips_dirty_rows(self):
gifts, message = collab.fetch_activity_gifts(
1199647239697, fake_ck("1199647239697"), client=FakeProbeClient()
)
assert [gift["name"] for gift in gifts] == ["不朽伊人大礼包"]
assert message == "已拉取 1 个礼包"
def test_gift_fetch_reports_failure_without_raising(self):
gifts, message = collab.fetch_activity_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(gifts=[FakeGiftResp(status=500, msg="活动已结束")]),
)
assert gifts == []
assert "拉取礼包失败" in message and "500" in message
def test_gift_fetch_survives_exception(self):
gifts, message = collab.fetch_activity_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(gifts=[RuntimeError("boom")]),
)
assert gifts == []
assert "RuntimeError" in message
def test_gift_fetch_empty_list_hints_stale_sid(self):
"""200 但 0 个商品 = SID 不是当前活动(真机 2026-10-16:2203 → 0,2277 → 30)。"""
gifts, message = collab.fetch_activity_gifts(
1199647239697,
fake_ck("1199647239697"),
sid=2203,
client=FakeProbeClient(gifts=[FakeGiftResp(goods=[])]),
)
assert gifts == []
assert "SID=2203" in message and "工作台" in message
def test_resolve_gifts_sid_prefers_workbench_config(self):
self.db.add(HuyaConfig(sid="2277"))
self.db.commit()
assert collab.resolve_gifts_sid(self.db) == 2277
def test_resolve_gifts_sid_falls_back_when_unset_or_garbage(self):
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID # 没有配置行
row = HuyaConfig(sid="")
self.db.add(row)
self.db.commit()
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID
row.sid = "不是数字"
self.db.commit()
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID
def test_preview_uses_configured_sid(self):
self.db.add(HuyaConfig(sid="2277"))
self.db.commit()
client = FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706"))
preview = collab.preview_ck(fake_ck("1199647239697"), sid=2277, client=client)
assert client.gift_calls[0]["sid"] == 2277
assert preview["has_role"] is True
def test_preview_endpoint_passes_configured_sid(self, monkeypatch):
"""接口层必须把工作台配置的 SID 传下去,别再退回写死默认值。"""
from web.backend.routers.huya import collab as collab_router
from web.backend.schemas import HuyaCollabCkPreviewRequest
self.db.add(HuyaConfig(sid="2277"))
self.db.commit()
client = FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706"))
monkeypatch.setattr(collab, "_build_probe_client", lambda: client)
out = collab_router.preview_external_order_ck(
HuyaCollabCkPreviewRequest(ck_text=fake_ck("1199647239697")),
db=self.db,
_current=self.user,
)
# 直接调路由函数时没走 pydantic 出参,拿到的就是 dict
assert client.gift_calls[0]["sid"] == 2277
assert out["has_role"] is True
class TestCollabCreateStrict(TestCollabBase):
"""人工新建表单:订单编号 / 店铺 / CK / 礼包名 / 主页截图必填,且 CK 必须读到绑定角色。"""
HOME_IMAGE = "collab/2026/10/16/home01.png"
def _row_count(self) -> int:
return self.db.query(HuyaExternalOrder).count()
def test_missing_required_fields_blocks_creation(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.create_order(
self.db,
self.user,
order_no="STRICT-1",
require_verified=True,
client=FakeProbeClient(
FakeBindResp(nick="随遇而安", role="吃鸡199706")
),
)
assert excinfo.value.code == "create_field_required"
assert "店铺" in excinfo.value.detail and "礼包名" in excinfo.value.detail
assert "主页截图" in excinfo.value.detail
assert self._row_count() == 0 # 失败不留半行
def test_missing_home_screenshot_blocks_creation(self):
"""绑定图不再要求,但主页截图是必填(2026-10-16 用户拍板)。"""
with pytest.raises(collab.CollabError) as excinfo:
collab.create_order(
self.db,
self.user,
order_no="STRICT-NOIMG",
shop="佳佳",
gift_name="不朽伊人大礼包",
ck_text=fake_ck("1199647239697"),
require_verified=True,
client=FakeProbeClient(
FakeBindResp(nick="随遇而安", role="吃鸡199706")
),
)
assert excinfo.value.code == "create_field_required"
assert "主页截图" in excinfo.value.detail
assert self._row_count() == 0
def test_bad_home_image_key_is_rejected_before_insert(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.create_order(
self.db,
self.user,
order_no="STRICT-BADIMG",
shop="佳佳",
gift_name="不朽伊人大礼包",
ck_text=fake_ck("1199647239697"),
home_images=["../../etc/passwd"],
require_verified=True,
client=FakeProbeClient(
FakeBindResp(nick="随遇而安", role="吃鸡199706")
),
)
assert excinfo.value.code == "image_key_invalid"
assert self._row_count() == 0
def test_probe_without_role_blocks_creation(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.create_order(
self.db,
self.user,
order_no="STRICT-2",
shop="佳佳",
gift_name="不朽伊人大礼包",
ck_text=fake_ck("1199647239697"),
home_images=[self.HOME_IMAGE],
require_verified=True,
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="")),
)
assert excinfo.value.code == "ck_not_verified"
assert "读不到绑定角色" in excinfo.value.detail
assert self._row_count() == 0
def test_invalid_ck_blocks_creation(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.create_order(
self.db,
self.user,
order_no="STRICT-3",
shop="佳佳",
gift_name="不朽伊人大礼包",
ck_text=fake_ck("1199647239697"),
home_images=[self.HOME_IMAGE],
require_verified=True,
client=FakeProbeClient(None),
)
assert excinfo.value.code == "ck_not_verified"
assert self._row_count() == 0
def test_valid_ck_creates_row_with_snapshot_and_audit(self):
order = collab.create_order(
self.db,
self.user,
order_no="STRICT-4",
shop="佳佳",
gift_name="不朽伊人大礼包",
ck_text=fake_ck("1199647239697"),
home_images=[self.HOME_IMAGE, "collab/2026/10/16/home02.png"],
require_verified=True,
client=FakeProbeClient(
FakeBindResp(
nick="随遇而安", role="吃鸡199706", plat="手Q", area="苹果(iOS)"
)
),
)
assert order.ck_status == "valid"
assert order.ck_checked_at is not None
assert order.bind_game_account == "随遇而安"
assert order.bind_game_role == "吃鸡199706"
assert order.bind_game_channel == "手Q · 苹果(iOS)"
assert order.bind_snapshot_at is not None
assert order.gift_name == "不朽伊人大礼包"
assert order.home_images == [self.HOME_IMAGE, "collab/2026/10/16/home02.png"]
assert order.bind_images == [] # 绑定图不再人工上传
assert order.owner_id == self.user.id
actions = [row.action for row in self._audits(order)]
assert "huya:collab:create" in actions
assert "huya:collab:ck_status" in actions
def test_import_path_is_not_forced_to_verify(self):
"""粘贴导入没有 CK 列,不能被严格模式挡住(走的是默认 require_verified=False)。"""
result = collab.import_orders(
self.db, self.user, "佳佳\tIMP-1\t\t用户1\t礼包A\t"
)
assert result["created"] == 1
assert self.db.query(HuyaExternalOrder).one().ck_status == "unverified"
class TestCollabKeywordSearch(TestCollabBase):
"""列表搜索:一个输入框打穿订单号 / 店铺 / 绑定账号·角色 / 平台区服 / 虎牙号 / 礼包 / 备注。"""
def _search(self, text: str) -> list[HuyaExternalOrder]:
rows, total = collab.list_orders(self.db, keyword=text)
assert total == len(rows)
return rows
def test_keyword_hits_each_searchable_column(self):
order = self._create(
order_no="K-1",
shop="向日葵",
gift_name="天网终结者大礼包",
remark="熟人介绍",
ck_text=fake_ck("1199647123456"),
)
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(
FakeBindResp(
nick="微信用户", role="不打PG打老公", plat="苹果(iOS)", area="微信"
)
),
)
for text in (
"K-1", # 订单编号
"向日葵", # 店铺
"微信用户", # 绑定账号(探针)
"不打PG打老公", # 绑定角色(探针)
"苹果(iOS)", # 平台 · 区服
"1199647123456", # 虎牙号
"天网终结者", # 礼包名
"熟人", # 备注
):
assert [row.id for row in self._search(text)] == [order.id], text
def test_keyword_miss_returns_empty_page(self):
self._create(order_no="K-2", shop="佳佳")
assert self._search("查不到的东西") == []
class TestCollabBuyerCluster(TestCollabBase):
"""同一买家的多单:按「绑定账号 + 绑定角色」软识别(只读派生,不动 uid 唯一性)。
真机业务口径:一单一份外部 CK,同一个买家买两件往往是**两份不同的 CK**(uid 不同),
所以买家指纹只能用探针读到的绑定对象,不能用 uid。
"""
def _order_with_binding(
self,
order_no: str,
uid: str,
*,
account: str,
role: str,
gift_name: str = "礼包A",
) -> HuyaExternalOrder:
order = self._create(
order_no=order_no, shop="佳佳", gift_name=gift_name, ck_text=fake_ck(uid)
)
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(
FakeBindResp(nick=account, role=role, plat="手Q", area="苹果(iOS)")
),
)
return order
def _cluster_of(self, order: HuyaExternalOrder) -> dict | None:
return collab.buyer_clusters(self.db, [order]).get(int(order.id))
def test_two_orders_of_same_buyer_are_grouped(self):
first = self._order_with_binding(
"BUY-1", "1199647000001", account="微信用户", role="不打PG打老公"
)
second = self._order_with_binding(
"BUY-2", "1199647000002", account="微信用户", role="不打PG打老公"
)
first_cluster = self._cluster_of(first)
second_cluster = self._cluster_of(second)
assert first_cluster is not None
assert first_cluster["total"] == 2
assert first_cluster["key"] == "微信用户 · 不打PG打老公"
assert [item["order_no"] for item in first_cluster["others"]] == ["BUY-2"]
assert second_cluster is not None
assert [item["order_no"] for item in second_cluster["others"]] == ["BUY-1"]
def test_same_account_different_role_is_not_grouped(self):
first = self._order_with_binding(
"BUY-3", "1199647000003", account="微信用户", role="角色甲"
)
self._order_with_binding(
"BUY-4", "1199647000004", account="微信用户", role="角色乙"
)
assert self._cluster_of(first) is None
def test_single_order_of_a_buyer_has_no_cluster(self):
only = self._order_with_binding(
"BUY-5", "1199647000005", account="微信用户", role="独苗角色"
)
assert self._cluster_of(only) is None
def test_rows_without_probe_snapshot_are_not_grouped(self):
"""没验证过 CK(绑定账号/角色为空)的行不参与归组,也不会被别人的快照误伤。"""
verified = self._order_with_binding(
"BUY-6", "1199647000006", account="微信用户", role="角色丙"
)
blank = self._create(
order_no="BUY-7", shop="佳佳", ck_text=fake_ck("1199647000007")
)
clusters = collab.buyer_clusters(self.db, [blank, verified])
assert clusters == {} or int(blank.id) not in clusters
assert self._cluster_of(verified) is None
assert collab.buyer_key_of(blank) is None
def test_serialize_order_carries_cluster_and_defaults_to_null(self):
first = self._order_with_binding(
"BUY-8", "1199647000008", account="微信用户", role="角色丁"
)
second = self._order_with_binding(
"BUY-9", "1199647000009", account="微信用户", role="角色丁"
)
clusters = collab.buyer_clusters(self.db, [first, second])
payload = collab.serialize_order(
first, buyer_cluster=clusters.get(int(first.id))
)
assert payload["buyer_cluster"]["total"] == 2
assert payload["buyer_cluster"]["method"] == collab.BUYER_KEY_METHOD
assert payload["buyer_cluster"]["others"][0]["gift_name"] == "礼包A"
alone = collab.serialize_order(first)
assert alone["buyer_cluster"] is None
class TestProbeCache(TestCollabBase):
def test_same_ck_probed_twice_only_hits_the_api_once(self, monkeypatch):
calls: list[dict] = []
class CountingClient:
def check_user_bind_game_account(self, **kwargs):
calls.append(kwargs)
return FakeBindResp(nick="随遇而安", role="吃鸡199706")
monkeypatch.setattr(collab, "_build_probe_client", lambda: CountingClient())
collab._probe_cache.clear()
order = self._create(order_no="CACHE-1", ck_text=fake_ck("1199647239697"))
collab.probe_ck(order)
collab.probe_ck(order)
assert len(calls) == 1
collab._probe_cache.clear()
def test_different_ck_is_probed_separately(self, monkeypatch):
calls: list[dict] = []
class CountingClient:
def check_user_bind_game_account(self, **kwargs):
calls.append(kwargs)
return FakeBindResp(nick="随遇而安", role="吃鸡199706")
monkeypatch.setattr(collab, "_build_probe_client", lambda: CountingClient())
collab._probe_cache.clear()
first = self._create(order_no="CACHE-2", ck_text=fake_ck("1001"))
second = self._create(order_no="CACHE-3", ck_text=fake_ck("1002"))
collab.probe_ck(first)
collab.probe_ck(second)
assert len(calls) == 2
collab._probe_cache.clear()
class TestCollabPermissions:
def test_role_defaults_match_the_agreed_matrix(self):
"""运营能看能改能删;客服能看能改但不能删。"""
+199
View File
@@ -0,0 +1,199 @@
"""协作台账的店铺字典:增删改查 / 停用 / 改名级联 / 自定义输入自动登记。
背景:新建协作单时"店铺"原来是手打自由文本,同一个店铺会出现多种写法。
现在改成字典(`huya_collab_shops`):新建时点一下选,也能自定义输入新名字。
"""
from __future__ import annotations
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import AuditLog, HuyaCollabShop, HuyaExternalOrder, User
from web.backend.services import huya_collab_service as collab
class TestShopBase:
def setup_method(self):
self.engine = get_test_engine()
self.db = make_test_session(self.engine)
self.user = User(username="shop_op", password_hash="h", role="operation")
self.db.add(self.user)
self.db.commit()
def teardown_method(self):
self.db.close()
def _shop(self, name: str, **kwargs) -> HuyaCollabShop:
return collab.create_shop(self.db, self.user, name=name, **kwargs)
def _order(self, order_no: str, shop: str = "") -> HuyaExternalOrder:
return collab.create_order(self.db, self.user, order_no=order_no, shop=shop)
def _shop_audits(self, shop: HuyaCollabShop) -> list[AuditLog]:
return (
self.db.query(AuditLog)
.filter(AuditLog.target == f"huya_collab_shop:{shop.id}")
.order_by(AuditLog.id.asc())
.all()
)
class TestShopCrud(TestShopBase):
def test_create_and_list_sorted(self):
self._shop("小鱼儿", sort_order=2)
self._shop("佳佳", sort_order=1)
shops = collab.list_shop_records(self.db)
assert [shop.name for shop in shops] == ["佳佳", "小鱼儿"] # 按 sort_order
assert all(shop.is_active for shop in shops)
def test_create_rejects_duplicate_name(self):
self._shop("佳佳")
with pytest.raises(collab.CollabError) as excinfo:
self._shop(" 佳佳 ") # 去空白后同名
assert excinfo.value.status_code == 409
assert excinfo.value.code == "shop_name_conflict"
def test_create_rejects_blank_and_too_long_name(self):
with pytest.raises(collab.CollabError) as excinfo:
self._shop(" ")
assert excinfo.value.code == "shop_name_required"
with pytest.raises(collab.CollabError) as excinfo:
collab.create_shop(self.db, self.user, name="店" * 65)
assert excinfo.value.code == "field_too_long"
def test_create_writes_audit(self):
shop = self._shop("佳佳")
assert [row.action for row in self._shop_audits(shop)] == [
"huya:collab:shop_create"
]
def test_update_returns_field_diff_and_audits(self):
shop = self._shop("佳佳")
changes = collab.update_shop(
self.db,
self.user,
shop,
{"sort_order": 5, "remark": "熟人开的", "is_active": False},
)
assert set(changes) == {"sort_order", "remark", "is_active"}
assert changes["is_active"] == {"old": True, "new": False}
assert shop.remark == "熟人开的"
assert "huya:collab:shop_update" in [
row.action for row in self._shop_audits(shop)
]
def test_update_without_changes_is_a_noop(self):
shop = self._shop("佳佳")
assert collab.update_shop(self.db, self.user, shop, {"name": "佳佳"}) == {}
assert [row.action for row in self._shop_audits(shop)] == [
"huya:collab:shop_create"
]
def test_rename_cascades_to_existing_orders(self):
"""改名默认级联:否则筛选里会堆一堆"改名前的老名字"。"""
shop = self._shop("佳佳")
order = self._order("S1", shop="佳佳")
changes = collab.update_shop(self.db, self.user, shop, {"name": "佳佳优选"})
assert changes["name"] == {"old": "佳佳", "new": "佳佳优选"}
self.db.refresh(order)
assert order.shop == "佳佳优选"
detail = self._shop_audits(shop)[-1].detail
assert "renamed_orders" in detail and "1" in detail
def test_rename_can_skip_the_cascade(self):
shop = self._shop("佳佳")
order = self._order("S1", shop="佳佳")
collab.update_shop(
self.db, self.user, shop, {"name": "佳佳优选"}, rename_orders=False
)
self.db.refresh(order)
assert order.shop == "佳佳" # 历史单子保持原名
def test_rename_rejects_name_taken_by_another_shop(self):
self._shop("佳佳")
other = self._shop("小鱼儿")
with pytest.raises(collab.CollabError) as excinfo:
collab.update_shop(self.db, self.user, other, {"name": "佳佳"})
assert excinfo.value.code == "shop_name_conflict"
def test_delete_only_removes_the_dictionary_row(self):
shop = self._shop("佳佳")
order = self._order("S1", shop="佳佳")
collab.delete_shop(self.db, self.user, shop)
assert self.db.query(HuyaCollabShop).count() == 0
self.db.refresh(order)
assert order.shop == "佳佳" # 订单不动
def test_get_missing_shop_is_404(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.get_shop(self.db, 999999)
assert excinfo.value.status_code == 404
assert excinfo.value.code == "shop_not_found"
class TestShopAutoRegister(TestShopBase):
def test_creating_an_order_registers_its_shop(self):
"""自定义输入一个新店铺名 → 自动进字典,下次下拉里就能点。"""
self._order("S1", shop="新店")
assert [shop.name for shop in collab.list_shop_records(self.db)] == ["新店"]
assert collab.list_shops(self.db) == ["新店"]
def test_order_without_shop_does_not_touch_the_dictionary(self):
self._order("S1", shop="")
assert collab.list_shop_records(self.db) == []
def test_editing_an_order_to_a_new_shop_registers_it(self):
order = self._order("S1", shop="")
collab.update_order(self.db, self.user, order, {"shop": "随手填的店"})
assert [shop.name for shop in collab.list_shop_records(self.db)] == [
"随手填的店"
]
def test_import_registers_all_shops_and_keeps_partial_failure_isolated(self):
result = collab.import_orders(
self.db,
self.user,
"店铺A\tS1\tshop_op\t用户1\t礼包\t\n店铺B\tS2\tshop_op\t用户2\t礼包\t",
owner_by_name={"shop_op": self.user.id},
)
assert (result["created"], result["errors"]) == (2, [])
assert collab.list_shops(self.db) == ["店铺A", "店铺B"]
def test_registering_twice_does_not_duplicate(self):
self._order("S1", shop="佳佳")
self._order("S2", shop="佳佳")
assert collab.list_shops(self.db) == ["佳佳"]
def test_auto_registered_shop_is_recorded_in_audit(self):
self._order("S1", shop="新店")
rows = (
self.db.query(AuditLog).filter(AuditLog.target == "huya_collab_shops").all()
)
assert [row.action for row in rows] == ["huya:collab:shop_create"]
assert "新店" in rows[0].detail
def test_ensure_shops_registered_ignores_blank_and_oversized_names(self):
created = collab.ensure_shops_registered(
self.db, self.user, ["", " ", "店" * 65, "正常店"]
)
assert created == ["正常店"]
assert collab.list_shops(self.db) == ["正常店"]
def test_registered_shop_can_be_picked_by_the_filter(self):
self._order("S1", shop="佳佳")
rows, total = collab.list_orders(self.db, shop="佳佳")
assert total == 1 and rows[0].order_no == "S1"
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261016_0056"
HEAD_REVISION = "20261016_0058"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
@@ -0,0 +1,53 @@
"""协作台账的店铺字典表 `huya_collab_shops`。
为什么要有它:新建协作单时"店铺"原来是手打的自由文本,同一个店铺各种写法(错别字、
多空格)会把筛选打散。改成一张可维护的字典:新建时**点一下选中**,
下拉里也能直接输一个新名字(自定义,会自动登记进字典)。
**不回填历史订单里的店铺名**(2026-10-16 用户明确"不用看之前的"):字典从空开始,
需要哪些店铺就在「店铺管理」里加、或在新建时自定义输入。
注意:订单里存的仍是店铺**名字**(`huya_external_orders.shop`),不是 id —— 历史 Excel
就是文本,本轮不改语义;改名走 `update_shop(..., rename_orders=True)` 级联。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261016_0057"
down_revision: str | None = "20261016_0056"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "huya_collab_shops"
def upgrade() -> None:
bind = op.get_bind()
if TABLE in set(sa.inspect(bind).get_table_names()):
return
op.create_table(
TABLE,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("remark", sa.String(length=255), nullable=False, server_default=""),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(f"ix_{TABLE}_name", TABLE, ["name"], unique=True)
op.create_index(f"ix_{TABLE}_is_active", TABLE, ["is_active"])
def downgrade() -> None:
bind = op.get_bind()
if TABLE in set(sa.inspect(bind).get_table_names()):
# MySQL 删表会连索引一起删。
op.drop_table(TABLE)
@@ -0,0 +1,78 @@
"""放开 `huya_external_orders.uid` 的唯一性:一个虎牙号可以挂多单。
背景(2026-10-16 用户拍板):真机上"同一个买家买两件"往往**继续用同一份外部 CK**,
而 0055 建的 `ix_huya_external_orders_uid` 是**唯一索引** —— 第二单会被数据库顶掉
(`Duplicate entry ... for key ...uid`)。原来"一个虎牙号只能挂一单"的规则随之作废:
服务层不再抛 `uid_conflict`,「这个号还挂在哪些单上」只作为提示/审计信息(见 §5.6)。
做法:把该索引改成**普通索引**(保留查询性能,不再约束唯一)。
uid 仍然可空 —— 「先建单、后贴 CK」不受影响(MySQL 索引对多个 NULL 宽松,普通索引更是)。
可重复执行:按当前索引的实际唯一性判断,已经是非唯一就直接返回。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261016_0058"
down_revision: str | None = "20261016_0057"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "huya_external_orders"
INDEX = "ix_huya_external_orders_uid"
COLUMNS = ["uid"]
def _has_table(bind) -> bool:
return TABLE in set(sa.inspect(bind).get_table_names())
def _index(bind) -> dict | None:
for index in sa.inspect(bind).get_indexes(TABLE):
if index.get("name") == INDEX:
return index
return None
def upgrade() -> None:
bind = op.get_bind()
if not _has_table(bind):
return
index = _index(bind)
if index is None:
# 索引丢了(手工库):补一个普通索引就行
op.create_index(INDEX, TABLE, COLUMNS, unique=False)
return
if not index.get("unique"):
return
op.drop_index(INDEX, table_name=TABLE)
op.create_index(INDEX, TABLE, COLUMNS, unique=False)
def downgrade() -> None:
bind = op.get_bind()
if not _has_table(bind):
return
index = _index(bind)
if index is None:
op.create_index(INDEX, TABLE, COLUMNS, unique=True)
return
if index.get("unique"):
return
# 回退前先确认没有重复 uid,否则唯一索引建不起来(宁可报错也不要静默丢数据)
duplicates = bind.execute(
sa.text(
f"SELECT uid FROM {TABLE} WHERE uid IS NOT NULL AND uid <> '' "
"GROUP BY uid HAVING COUNT(*) > 1 LIMIT 5"
)
).fetchall()
if duplicates:
names = ", ".join(str(row[0]) for row in duplicates)
raise RuntimeError(f"存在重复 uid({names}),先合并/清理这些订单再降级")
op.drop_index(INDEX, table_name=TABLE)
op.create_index(INDEX, TABLE, COLUMNS, unique=True)
+43 -5
View File
@@ -1033,6 +1033,45 @@ class FileAsset(Base):
)
class HuyaCollabShop(Base):
"""协作台账的**店铺字典**(新建协作单时点一下选,不用手打)。
几个取舍,改之前先看:
1. **订单里存的是店铺名字符串,不是 id**(`huya_external_orders.shop`)——
历史 Excel 数据就是文本,改成外键要动所有读写与导入。代价:改名要么级联
(`update_shop(..., rename_orders=True)`,默认开),要么只影响以后的单子;
2. **订单里出现过的店铺名会自动登记**(新建/编辑/粘贴导入都走
`ensure_shops_registered`),所以字典永远覆盖已有数据,筛选下拉不会漏;
3. **停用(`is_active=0`)≠ 删除**:只是不再出现在新建的选择里,历史订单与筛选照旧;
4. **不加乐观锁 version**:字典是十几行的低频维度,改名冲突靠审计看,不值得为它
再加一套版本号(台账订单行才需要,见 `HuyaExternalOrder`)。
"""
__tablename__ = "huya_collab_shops"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
# 越小越靠前(新建下拉、筛选下拉都按它排)。
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="1", index=True
)
remark: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
updated_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class HuyaExternalOrder(Base):
"""虎牙外部 CK 协作台账(替代 Excel 多人协同表)。
@@ -1067,11 +1106,10 @@ class HuyaExternalOrder(Base):
)
# ---- 外部 CK 本体(独立于 huya_accounts)----
# uid 唯一但可空:MySQL 唯一索引允许多个 NULL,于是「先建单、后贴 CK」与
# 「一个虎牙号只能挂一单」同时成立。
uid: Mapped[str | None] = mapped_column(
String(32), unique=True, nullable=True, index=True
)
# uid 可空、**不再唯一**(2026-10-16 用户拍板):同一个买家买两件常常继续用同一份 CK,
# 唯一索引会让第二单直接插不进去(迁移 0058 把唯一索引降成普通索引)。
# 「这个虎牙号还挂在哪些单上」只作为提示与审计信息,不做约束(见设计文档 §5.6)。
uid: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
yyuid: Mapped[str] = mapped_column(String(32), default="", server_default="")
hy_username: Mapped[str] = mapped_column(String(128), default="", server_default="")
nickname: Mapped[str] = mapped_column(String(128), default="", server_default="")
+126 -4
View File
@@ -24,6 +24,11 @@ from ...database import get_db
from ...deps import require_permission
from ...models import AuditLog, HuyaExternalOrder, User
from ...schemas import (
HuyaCollabCkPreviewOut,
HuyaCollabCkPreviewRequest,
HuyaCollabShopCreate,
HuyaCollabShopOut,
HuyaCollabShopUpdate,
HuyaExternalOrderAuditOut,
HuyaExternalOrderCkRequest,
HuyaExternalOrderCkStatusRequest,
@@ -52,7 +57,12 @@ def _http_error(exc: collab.CollabError) -> HTTPException:
def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict:
return collab.serialize_order(order, users=collab.users_by_id(db, [order]))
clusters = collab.buyer_clusters(db, [order])
return collab.serialize_order(
order,
users=collab.users_by_id(db, [order]),
buyer_cluster=clusters.get(int(order.id)),
)
# --------------------------------------------------------------------------
@@ -89,10 +99,81 @@ def external_orders_shops(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""已用过的店铺列表(筛选下拉)。"""
"""店铺名列表(现在就是**店铺字典**里的名字;筛选下拉用)。"""
return collab.list_shops(db)
# --------------------------------------------------------------------------
# 店铺字典(新建协作单时点选;也支持自定义输入后自动登记)
# --------------------------------------------------------------------------
@router.get("/collab-shops", response_model=list[HuyaCollabShopOut])
def list_collab_shops(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""全部店铺(**含停用的**:管理弹窗要看到;新建下拉由前端按 `is_active` 过滤)。"""
shops = collab.list_shop_records(db)
names = collab.user_names(
db,
[shop.created_by for shop in shops] + [shop.updated_by for shop in shops],
)
return [collab.serialize_shop(shop, users=names) for shop in shops]
@router.post("/collab-shops", response_model=HuyaCollabShopOut)
def create_collab_shop(
req: HuyaCollabShopCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""新增店铺(重名 409)。"""
try:
shop = collab.create_shop(
db, current, name=req.name, sort_order=req.sort_order, remark=req.remark
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return collab.serialize_shop(shop)
@router.put("/collab-shops/{shop_id}", response_model=HuyaCollabShopOut)
def update_collab_shop(
shop_id: int,
req: HuyaCollabShopUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""改店铺:名字 / 排序 / 停用启用 / 备注。
``rename_orders``(默认 true)为真时,改名会级联改掉已有订单里的旧名字。
"""
payload = req.model_dump(exclude_unset=True)
rename_orders = bool(payload.pop("rename_orders", True))
try:
shop = collab.get_shop(db, shop_id)
collab.update_shop(db, current, shop, payload, rename_orders=rename_orders)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return collab.serialize_shop(shop)
@router.delete("/collab-shops/{shop_id}")
def delete_collab_shop(
shop_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:manage")),
):
"""从字典里删掉(**只给运营**)。不动订单:订单存的是店铺名,历史单子照旧。"""
try:
shop = collab.get_shop(db, shop_id)
snapshot = collab.delete_shop(db, current, shop)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return {"success": True, "name": snapshot["name"]}
@router.get("/external-orders", response_model=HuyaExternalOrderListOut)
def list_external_orders(
shop: str = Query("", max_length=64),
@@ -119,8 +200,15 @@ def list_external_orders(
page_size=page_size,
)
names = collab.users_by_id(db, rows)
# 「同一买家的其它单」整页一次算完(按绑定账号 + 绑定角色软识别,只读派生)
clusters = collab.buyer_clusters(db, rows)
return {
"items": [collab.serialize_order(row, users=names) for row in rows],
"items": [
collab.serialize_order(
row, users=names, buyer_cluster=clusters.get(int(row.id))
)
for row in rows
],
"total": total,
"page": page,
"page_size": page_size,
@@ -133,7 +221,12 @@ def create_external_order(
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。"""
"""新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。
``require_verified``(默认 true):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK /
礼包名 / 主页截图必填,且 CK 必须探针通并读到绑定角色,否则 400
(粘贴导入走另一个接口,不受影响)。
"""
try:
order = collab.create_order(
db,
@@ -144,7 +237,9 @@ def create_external_order(
ck_text=req.ck_text,
bind_douyu_account=req.bind_douyu_account,
gift_name=req.gift_name,
home_images=req.home_images,
remark=req.remark,
require_verified=req.require_verified,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@@ -167,6 +262,33 @@ def import_external_orders(
return collab.import_orders(db, current, req.text, owner_by_name=owners)
@router.post("/external-orders/ck/preview", response_model=HuyaCollabCkPreviewOut)
def preview_external_order_ck(
req: HuyaCollabCkPreviewRequest,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:edit")),
):
"""粘贴 CK 后的**即时预览**(只读):探针 + 顺带拉活动礼包,一行都不写库。
新建弹窗据此决定能不能提交:``ok`` 且 ``has_role`` 才允许
(服务端在创建时还会再校验一次,见 ``POST /external-orders``)。
礼包用**工作台配置里的活动 SID**(``huya_config.sid``)拉:SID 就是活动 ID,
换活动就得换它,写死会出现「接口 200 但礼包列表是空的」(真机 2026-10-16)。
"""
try:
preview = collab.preview_ck(
req.ck_text,
with_gifts=req.with_gifts,
sid=collab.resolve_gifts_sid(db),
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
# 「这个虎牙号已经在哪些单上」:允许同一份 CK 服务多单,这里只提示、不挡
preview["uid_used_by"] = collab.uids_in_use(db, preview.get("uid"))
return preview
@router.post("/external-orders/ck/verify", response_model=HuyaExternalOrderVerifyOut)
def verify_external_orders_ck(
req: HuyaExternalOrderVerifyRequest,
+110 -1
View File
@@ -1079,16 +1079,109 @@ class FileAssetOut(BaseModel):
size: int
class HuyaCollabShopCreate(BaseModel):
"""新增店铺(字典),新建协作单时就能点选。"""
name: str = Field(..., min_length=1, max_length=64)
sort_order: int = 0
remark: str = Field("", max_length=255)
class HuyaCollabShopUpdate(BaseModel):
"""改店铺:名字 / 排序 / 停用启用 / 备注(只提交要改的字段)。"""
name: str | None = Field(None, min_length=1, max_length=64)
sort_order: int | None = None
is_active: bool | None = None
remark: str | None = Field(None, max_length=255)
# 改名时是否把已有订单里的旧名字一起改掉(默认改,避免筛选里堆一堆改名前的老值)
rename_orders: bool = True
class HuyaCollabShopOut(BaseModel):
"""店铺字典项。"""
id: int
name: str
sort_order: int = 0
is_active: bool = True
remark: str = ""
created_by_name: str = ""
updated_by_name: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
# ---- 虎牙外部 CK 协作台账(见 docs/设计-虎牙外部CK协作台账.md) ----
class HuyaCollabGiftOut(BaseModel):
"""活动礼包(用一份有效 CK 现拉,不落库)。"""
product_id: str = ""
name: str = ""
price: int | None = None
remain_text: str = ""
stock: int | None = None
score: int | None = None
category_name: str = ""
icon: str = ""
class HuyaCollabCkPreviewRequest(BaseModel):
"""粘贴 CK 后的即时预览(只读:探针 + 顺带拉礼包)。"""
ck_text: str = Field(..., min_length=1)
with_gifts: bool = True
class HuyaCollabBuyerOrderOut(BaseModel):
"""「同一买家的其它单」里的一条(够列表/详情里一眼看清是哪单)。"""
id: int
order_no: str
shop: str = ""
gift_name: str = ""
uid: str = ""
status: str = "pending"
status_label: str = ""
created_at: datetime | None = None
class HuyaCollabCkPreviewOut(BaseModel):
"""预览结论:``ok`` 且 ``has_role`` 才允许提交新建。"""
ok: bool = False
message: str = ""
uid: str = ""
yyuid: str = ""
hy_username: str = ""
ck_status: str = "unverified"
bind_game_account: str = ""
bind_game_role: str = ""
bind_game_channel: str = ""
has_role: bool = False
gifts: list[HuyaCollabGiftOut] = Field(default_factory=list)
gifts_message: str = ""
# 这个虎牙号已经挂在哪些单上(放开 uid 唯一性后只是提示,不拦提交)
uid_used_by: list[HuyaCollabBuyerOrderOut] = Field(default_factory=list)
class HuyaExternalOrderCreate(BaseModel):
"""新建一行(可同时贴一份外部 CK)。"""
"""新建一行(可同时贴一份外部 CK)。
``require_verified``(默认开):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK /
礼包名 / **主页截图**必填,且 CK 必须探针通并读到绑定角色;粘贴导入不走这条路。
"""
order_no: str = Field(..., min_length=1, max_length=64)
shop: str = Field("", max_length=64)
require_verified: bool = True
owner_id: int | None = None
ck_text: str = ""
bind_douyu_account: str = Field("", max_length=128)
gift_name: str = Field("", max_length=128)
# 主页截图(object_key 数组,第一张作封面)。绑定图不再收:绑定关系由探针自动读,
# 人工截图没有意义(2026-10-16 用户拍板)。
home_images: list[str] = Field(default_factory=list)
remark: str = Field("", max_length=512)
@@ -1175,6 +1268,19 @@ class HuyaExternalOrderVerifyOut(BaseModel):
results: list[HuyaExternalOrderVerifyItemOut] = Field(default_factory=list)
class HuyaCollabBuyerClusterOut(BaseModel):
"""同一买家的多单(软识别,只读派生)。
``key`` 是买家指纹的可读文本(``绑定账号 · 绑定角色``);``method`` 说明识别口径,
免得以后有人以为这是人工"软绑定"关系。
"""
key: str = ""
method: str = ""
total: int = 0
others: list[HuyaCollabBuyerOrderOut] = Field(default_factory=list)
class HuyaExternalOrderOut(BaseModel):
"""一行的完整视图。
@@ -1237,6 +1343,9 @@ class HuyaExternalOrderOut(BaseModel):
created_at: datetime | None = None
updated_at: datetime | None = None
# 同一买家的其它单(软识别:绑定账号 + 绑定角色相同;只读派生,没识别到就是 null)
buyer_cluster: HuyaCollabBuyerClusterOut | None = None
class HuyaExternalOrderListOut(BaseModel):
"""列表响应:共享视图,带上总数供分页使用。"""
+662 -45
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
import dataclasses
import hashlib
import re
import time
from collections.abc import Mapping, Sequence
@@ -26,11 +27,15 @@ from datetime import UTC, datetime
from typing import Any
from loguru import logger
from sqlalchemy import func, or_
from sqlalchemy import func, or_, tuple_
from sqlalchemy.orm import Session
from ..huya_defaults import HUYA_DEFAULT_BIND_ACT_ID, HUYA_DEFAULT_OUTER_ACT_ID
from ..models import HuyaExternalOrder, User
from ..huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_SID,
)
from ..models import HuyaCollabShop, HuyaConfig, HuyaExternalOrder, User
from .audit_service import record_audit
from .file_service import build_file_url
from .file_storage import normalize_object_key
@@ -127,6 +132,7 @@ EDITABLE_FIELDS: tuple[str, ...] = (
FIELD_LIMITS = {
"order_no": 64,
"shop": 64,
"shop_remark": 255,
"bind_douyu_account": 128,
"bind_douyu_uid": 32,
"gift_name": 128,
@@ -137,6 +143,7 @@ FIELD_LIMITS = {
FIELD_LABELS = {
"order_no": "订单编号",
"shop": "店铺",
"shop_remark": "店铺备注",
"owner_id": "负责人",
"bind_douyu_account": "绑定斗鱼号",
"bind_douyu_uid": "绑定斗鱼号 uid",
@@ -149,7 +156,7 @@ IMAGE_SLOTS: tuple[str, ...] = ("bind_images", "home_images")
IMAGE_SLOT_LABELS = {
"bind_images": "绑定图",
"home_images": "主页账号图",
"home_images": "主页截图",
}
MAX_IMAGES_PER_SLOT = 20
@@ -303,23 +310,53 @@ def _assert_order_no_available(
)
def _assert_uid_available(
db: Session, uid: str | None, *, exclude_id: int | None = None
) -> None:
"""一个虎牙号只能挂一单(已确认 1 单 = 1 CK)。"""
def uid_used_orders(
db: Session,
uid: str | None,
*,
exclude_id: int | None = None,
limit: int = 10,
) -> list[HuyaExternalOrder]:
"""这个虎牙号名下**其它**订单(按创建时间升序)。
2026-10-16 用户拍板**放开**「一个虎牙号只能挂一单」:同一个买家第二单常常继续用同一份
CK。所以这里**不再抛 409**(旧行为见设计文档 §1 的变更记录),只用来在界面上提示
「这份 CK 已经挂在哪些单上」、以及在审计里留一笔复用痕迹。
"""
text = str(uid or "").strip()
if not text:
return
query = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.uid == text)
return []
query = (
db.query(HuyaExternalOrder)
.filter(HuyaExternalOrder.uid == text)
.order_by(HuyaExternalOrder.created_at.asc(), HuyaExternalOrder.id.asc())
)
if exclude_id is not None:
query = query.filter(HuyaExternalOrder.id != exclude_id)
other = query.first()
if other is not None:
raise CollabError(
f"虎牙号 {text} 已经挂在订单 {other.order_no}(第 {other.id} 行)",
status_code=409,
code="uid_conflict",
)
return query.limit(limit).all()
def brief_order(order: HuyaExternalOrder) -> dict[str, Any]:
"""一行的最小视图(列表/详情里"另一个单"那种展示用)。"""
return {
"id": int(order.id),
"order_no": order.order_no or "",
"shop": order.shop or "",
"gift_name": order.gift_name or "",
"uid": order.uid or "",
"status": order.status,
"status_label": ORDER_STATUS_LABELS.get(order.status, order.status),
"created_at": order.created_at,
}
def uids_in_use(
db: Session, uid: str | None, *, exclude_id: int | None = None
) -> list[dict]:
"""给预览用的「这个虎牙号已经在哪些单上」(没有就空列表)。"""
return [
brief_order(order) for order in uid_used_orders(db, uid, exclude_id=exclude_id)
]
def _apply_ck(order: HuyaExternalOrder, cookie_text: str) -> dict:
@@ -481,10 +518,16 @@ def set_ck(
*,
commit: bool = True,
) -> dict:
"""贴入 / 换一份 CK(uid 冲突会挡住),状态回到「未验证」。"""
"""贴入 / 换一份 CK,状态回到「未验证」。
同一个虎牙号**允许**挂在多张单上(2026-10-16 用户拍板放开 uid 唯一性):
复用到的那些单号只记一笔审计,不挡操作 —— 挡了就变成"同一个买家的第二单建不了"。
"""
before = str(order.cookie or "")
detail = _apply_ck(order, cookie_text)
_assert_uid_available(db, order.uid, exclude_id=order.id)
reused = [
item.order_no for item in uid_used_orders(db, order.uid, exclude_id=order.id)
]
_touch(order, current)
record_audit(
db,
@@ -499,6 +542,8 @@ def set_ck(
# 凭据也不该多经一手(脱敏规则一改就是凭据泄漏)。
# 只留一个「内容是否变了」的布尔,够用来追溯换没换过。
"credential_changed": before != str(order.cookie or ""),
# 这个虎牙号还挂在哪些单上(同一份 CK 服务多单是允许的,留个痕)
"uid_reused_in": reused,
},
)
if commit:
@@ -667,6 +712,18 @@ PROBE_SNAPSHOT_FIELDS: tuple[str, ...] = (
"bind_game_channel",
)
# 活动礼包(get_act_prize_list)用的 SID **兜底值**:真正该用的是工作台配置里的那个
# (``huya_config.sid``,见 :func:`resolve_gifts_sid`)。
# 2026-10-16 真机定论:**SID 就是活动 ID,会随活动轮换** —— 库里 9 月的活动是 2203,
# 今天是 2277。写死默认值时接口照样 ``status=200`` 但 **0 个商品**,界面上就是
# 「礼包列表空白」(换 SID 立刻回到 30 个礼包,跟 CK、UA、解析都无关)。
GIFTS_SID = int(HUYA_DEFAULT_SID)
# 一次最多回多少个礼包(避免前端下拉塞几百项)。
GIFTS_LIMIT = 200
# 同一份 CK 的探针结论缓存时长("预览一次 + 提交校验一次" 只打一次接口)。
PROBE_CACHE_TTL_SECONDS = 90.0
_probe_cache: dict[str, tuple[float, CkProbeResult]] = {}
# 批量验证**串行 + 间隔**:不做定时轮询,一次也别勾太多,避免把账号送进风控名单。
PROBE_BATCH_INTERVAL_SECONDS = 1.0
PROBE_BATCH_MAX = 50
@@ -681,6 +738,12 @@ def _clip(value: Any, limit: int = _PROBE_PIECE_LIMIT) -> str:
return text if len(text) <= limit else text[: limit - 3] + "..."
def _probe_cache_key(uid: int, cookie: str) -> str:
"""探针缓存键:uid + CK 的指纹(只存哈希,不留凭据原文)。"""
digest = hashlib.sha256(f"{uid}|{cookie}".encode()).hexdigest()
return digest
@dataclasses.dataclass(frozen=True)
class CkProbeResult:
"""一次只读探针的结论(不落库;落库由 :func:`verify_ck` 负责)。"""
@@ -775,6 +838,9 @@ def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeR
实测支持这个判据:裸 cookie(无任何凭据)+ 真 uid → 返回 ``None``;
真 CK + 别人的 uid → 也返回 ``None``;只有「真 CK + 真 uid」才拿到 200 + 真实数据。
同一个 CK 在 ``PROBE_CACHE_TTL_SECONDS`` 秒内复用上次结论:新建弹窗先"预览"、
提交时再校验,不该真的打两次虎牙接口。**注入 client 时不走缓存**(测试要真结论)。
"""
uid_text = str(order.uid or "").strip()
cookie = str(order.cookie or "").strip()
@@ -785,6 +851,12 @@ def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeR
except ValueError:
return CkProbeResult(False, f"CK 里的 uid 不是数字:{_clip(uid_text)}")
cache_key = _probe_cache_key(uid, cookie) if client is None else ""
if cache_key:
cached = _probe_cache.get(cache_key)
if cached and time.time() - cached[0] < PROBE_CACHE_TTL_SECONDS:
return cached[1]
try:
probe_client = client or _build_probe_client()
resp = probe_client.check_user_bind_game_account(
@@ -806,7 +878,141 @@ def probe_ck(order: HuyaExternalOrder, *, client: Any | None = None) -> CkProbeR
msg = _clip(getattr(resp, "msg", ""))
suffix = f":{msg}" if msg else ""
return CkProbeResult(False, f"探针返回 status={status}{suffix}")
return CkProbeResult(True, _probe_success_message(resp), _probe_snapshot(resp))
result = CkProbeResult(True, _probe_success_message(resp), _probe_snapshot(resp))
if cache_key:
_probe_cache[cache_key] = (time.time(), result)
return result
def resolve_gifts_sid(db: Session) -> int:
"""当前活动 SID:**优先用工作台配置**(``huya_config.sid``),读不到再回落到默认值。
2026-10-16 真机定位:礼包接口的 SID 是「活动 ID」,会随活动轮换(9 月 2203 → 今天 2277),
对旧 SID 打过去是 ``status=200`` + 0 个商品 —— 界面上就表现为「礼包列表空白」。
工作台「虎牙配置」里改过 SID 之后,这里跟着变,不用改代码。
只读:配置行不存在时**不会**顺手建一行(预览是只读动作,设计文档 §2.3)。
"""
try:
row = db.query(HuyaConfig).first()
except Exception as exc: # noqa: BLE001 配置读失败不该让预览 500
logger.debug(
f"[huya-collab] 读取虎牙活动 SID 失败:{type(exc).__name__}: {exc}"
)
return GIFTS_SID
try:
value = int(str(getattr(row, "sid", "") or "").strip())
except (TypeError, ValueError):
return GIFTS_SID
return value or GIFTS_SID
def fetch_activity_gifts(
uid: int,
cookie: str,
*,
sid: int | None = None,
client: Any | None = None,
) -> tuple[list[dict], str]:
"""用这份 CK 拉**活动可兑换礼包**列表(只读,不落库)。
``sid`` 不传就用 :data:`GIFTS_SID` 兜底;接口层会传工作台配置里的活动 ID
(:func:`resolve_gifts_sid`)。
Returns:
``(礼包列表, 可读结论)`` —— 每项是 ``{product_id, name, price, ...}``;
拉不到时返回 ``([], "…")``,不影响 CK 本身的有效性结论。
"""
sid_value = int(sid or GIFTS_SID)
try:
gift_client = client or _build_probe_client()
resp = gift_client.get_act_prize_list(
uid=int(uid), cookie=cookie, sid=sid_value
)
except Exception as exc: # noqa: BLE001 拉礼包失败不该让整个预览 500
logger.debug(f"[huya-collab] 拉取活动礼包异常:{type(exc).__name__}: {exc}")
return [], f"拉取礼包失败:{type(exc).__name__}: {_clip(exc)}"
if resp is None:
return [], "拉取礼包失败:接口没有返回数据"
status = int(getattr(resp, "status", 0) or 0)
if status != 200:
msg = _clip(getattr(resp, "msg", ""))
return [], f"拉取礼包失败:status={status}{(':' + msg) if msg else ''}"
raw_goods = (resp.to_dict() or {}).get("goods") or []
gifts: list[dict] = []
for item in raw_goods:
product_id = str(item.get("product_id") or "").strip()
name = str(item.get("name") or "").strip()
if not product_id or not name:
continue
gifts.append(
{
"product_id": product_id,
"name": name[:256],
"price": item.get("price"),
"remain_text": str(item.get("remain_text") or "")[:64],
"stock": item.get("stock"),
"score": item.get("score"),
"category_name": str(item.get("category_name") or "")[:64],
"icon": str(item.get("icon") or "")[:512],
}
)
gifts = gifts[:GIFTS_LIMIT]
if not gifts:
# 200 + 空列表 = 这个 SID 不是当前活动(见 resolve_gifts_sid 的真机定位)。
return [], (
f"活动 SID={sid_value} 没返回商品:接口通了但列表是空的,"
"多半是活动换 ID 了 —— 去工作台「虎牙配置」把活动 SID 改成当前的再试"
)
return gifts, f"已拉取 {len(gifts)} 个礼包"
def preview_ck(
ck_text: Any,
*,
with_gifts: bool = True,
sid: int | None = None,
client: Any | None = None,
) -> dict:
"""**只读预览**一份待提交的 CK:解析 + 探针(+ 顺带拉活动礼包),一行都不写库。
新建协作单时用它给出「CK 有效 + 绑定角色」的即时反馈;提交时 :func:`create_order`
还会再校验一次(同一份 CK 命中探针缓存,不会重复打接口)。
``sid`` = 活动 ID(接口层传工作台配置里的那个,见 :func:`resolve_gifts_sid`):
它决定礼包列表能不能拉到,**不影响 CK 有效性结论**。
"""
parsed = parse_ck(str(ck_text or ""))
uid_text = str(parsed.get("uid") or "")
cookie = str(parsed.get("cookie") or "")
probe_target = HuyaExternalOrder(order_no="preview")
probe_target.uid = uid_text
probe_target.cookie = cookie
result = probe_ck(probe_target, client=client)
gifts: list[dict] = []
gifts_message = ""
if with_gifts and result.ok and uid_text:
gifts, gifts_message = fetch_activity_gifts(
int(uid_text), cookie, sid=sid, client=client
)
return {
"ok": result.ok,
"message": result.message,
"uid": uid_text,
"yyuid": str(parsed.get("yyuid") or ""),
"hy_username": str(parsed.get("username") or ""),
"ck_status": CK_VALID if result.ok else CK_INVALID,
"bind_game_account": result.snapshot.get("bind_game_account", ""),
"bind_game_role": result.snapshot.get("bind_game_role", ""),
"bind_game_channel": result.snapshot.get("bind_game_channel", ""),
"has_role": bool(result.snapshot.get("bind_game_role")),
"gifts": gifts,
"gifts_message": gifts_message,
}
def verify_ck(
@@ -960,6 +1166,263 @@ def list_orders_by_ids(db: Session, ids: Sequence[int]) -> list[HuyaExternalOrde
return [by_id[value] for value in wanted if value in by_id]
# --------------------------------------------------------------------------
# 店铺字典(新建协作单时点一下选;也支持自定义输入)
# --------------------------------------------------------------------------
SHOP_NAME_LIMIT = 64
def normalize_shop_name(value: Any) -> str:
"""店铺名统一收口:去空白 + 限长(空串 = 不填店铺)。"""
return clean_text(value, field="shop", max_length=SHOP_NAME_LIMIT)
def _shop_audit_target(shop: HuyaCollabShop) -> str:
return f"huya_collab_shop:{shop.id}"
def _touch_shop(shop: HuyaCollabShop, current: User | None) -> None:
shop.updated_by = current.id if current else None
shop.updated_at = _now()
def serialize_shop(
shop: HuyaCollabShop, *, users: Mapping[int, str] | None = None
) -> dict:
names = users or {}
def name_of(value: int | None) -> str:
return names.get(int(value), "") if value else ""
return {
"id": shop.id,
"name": shop.name or "",
"sort_order": int(shop.sort_order or 0),
"is_active": bool(shop.is_active),
"remark": shop.remark or "",
"created_by_name": name_of(shop.created_by),
"updated_by_name": name_of(shop.updated_by),
"created_at": shop.created_at,
"updated_at": shop.updated_at,
}
def list_shop_records(db: Session) -> list[HuyaCollabShop]:
"""全部店铺(**含停用的**:管理弹窗要能看到;新建下拉由前端按 `is_active` 过滤)。"""
return (
db.query(HuyaCollabShop)
.order_by(HuyaCollabShop.sort_order.asc(), HuyaCollabShop.name.asc())
.all()
)
def get_shop(db: Session, shop_id: int) -> HuyaCollabShop:
shop = db.query(HuyaCollabShop).filter(HuyaCollabShop.id == shop_id).first()
if shop is None:
raise CollabError(
"这个店铺不存在(可能已被别人删除)", status_code=404, code="shop_not_found"
)
return shop
def _assert_shop_name_available(
db: Session, name: str, *, exclude_id: int | None = None
) -> None:
query = db.query(HuyaCollabShop).filter(HuyaCollabShop.name == name)
if exclude_id is not None:
query = query.filter(HuyaCollabShop.id != exclude_id)
other = query.first()
if other is not None:
raise CollabError(
f"店铺「{name}」已经在字典里了(第 {other.id} 行)",
status_code=409,
code="shop_name_conflict",
)
def create_shop(
db: Session,
current: User | None,
*,
name: Any,
sort_order: int = 0,
remark: Any = "",
commit: bool = True,
) -> HuyaCollabShop:
"""新增一个店铺(重名 409)。"""
cleaned = normalize_shop_name(name)
if not cleaned:
raise CollabError("店铺名不能为空", code="shop_name_required")
_assert_shop_name_available(db, cleaned)
shop = HuyaCollabShop(
name=cleaned,
sort_order=int(sort_order or 0),
is_active=True,
remark=clean_text(remark, field="shop_remark", max_length=255),
created_by=current.id if current else None,
updated_by=current.id if current else None,
)
db.add(shop)
db.flush()
record_audit(
db,
current,
action="huya:collab:shop_create",
target=_shop_audit_target(shop),
detail={"name": shop.name, "sort_order": shop.sort_order},
)
if commit:
db.commit()
db.refresh(shop)
return shop
def update_shop(
db: Session,
current: User | None,
shop: HuyaCollabShop,
payload: Mapping[str, Any],
*,
rename_orders: bool = True,
commit: bool = True,
) -> dict:
"""改店铺(名字 / 排序 / 停用 / 备注),返回字段级 diff。
``rename_orders=True``(默认)时,改名会**级联**把已有订单里的旧名字一起改掉,
否则筛选会出现"改名前的老名字"这一堆孤儿值;不想要级联就显式传 False。
"""
changes: dict[str, dict[str, Any]] = {}
old_name = shop.name or ""
renamed_orders = 0
if "name" in payload:
new_name = normalize_shop_name(payload["name"])
if not new_name:
raise CollabError("店铺名不能为空", code="shop_name_required")
if new_name != old_name:
_assert_shop_name_available(db, new_name, exclude_id=shop.id)
changes["name"] = {"old": old_name, "new": new_name}
shop.name = new_name
if "sort_order" in payload:
value = int(payload["sort_order"] or 0)
if value != int(shop.sort_order or 0):
changes["sort_order"] = {"old": int(shop.sort_order or 0), "new": value}
shop.sort_order = value
if "is_active" in payload:
value = bool(payload["is_active"])
if value != bool(shop.is_active):
changes["is_active"] = {"old": bool(shop.is_active), "new": value}
shop.is_active = value
if "remark" in payload:
value = clean_text(payload["remark"], field="shop_remark", max_length=255)
if value != (shop.remark or ""):
changes["remark"] = {"old": shop.remark or "", "new": value}
shop.remark = value
if not changes:
return {}
if "name" in changes and rename_orders:
# 批量改名:订单里存的是名字字符串(见 models.HuyaCollabShop 的取舍说明)。
renamed_orders = (
db.query(HuyaExternalOrder)
.filter(HuyaExternalOrder.shop == old_name)
.update(
{HuyaExternalOrder.shop: changes["name"]["new"]},
synchronize_session=False,
)
)
_touch_shop(shop, current)
record_audit(
db,
current,
action="huya:collab:shop_update",
target=_shop_audit_target(shop),
detail={"changes": changes, "renamed_orders": renamed_orders},
)
if commit:
db.commit()
db.refresh(shop)
return changes
def delete_shop(
db: Session, current: User | None, shop: HuyaCollabShop, *, commit: bool = True
) -> dict:
"""从字典里删掉(需 ``huya:collab:manage``)。
**不动订单**:订单里存的是名字字符串,历史单子照旧;想让以后不再用它,
用「停用」更稳。
"""
snapshot = {"id": shop.id, "name": shop.name or ""}
record_audit(
db,
current,
action="huya:collab:shop_delete",
target=_shop_audit_target(shop),
detail=snapshot,
)
db.delete(shop)
if commit:
db.commit()
return snapshot
def ensure_shops_registered(
db: Session, current: User | None, names: Sequence[Any]
) -> list[str]:
"""把订单里用到的店铺名补进字典(新建 / 编辑 / 粘贴导入共用),返回新登记的名字。
这样"自定义输入一个新店铺"就能直接出现在下次的下拉里,不用先去管理弹窗加一遍。
"""
wanted = [str(name or "").strip() for name in names]
wanted = [name for name in dict.fromkeys(wanted) if name]
if not wanted:
return []
known = {
row[0]
for row in db.query(HuyaCollabShop.name)
.filter(HuyaCollabShop.name.in_(wanted))
.all()
}
created: list[str] = []
for name in wanted:
if name in known or len(name) > SHOP_NAME_LIMIT:
continue
db.add(
HuyaCollabShop(
name=name,
sort_order=0,
is_active=True,
remark="",
created_by=current.id if current else None,
updated_by=current.id if current else None,
)
)
created.append(name)
if created:
# 先 flush 让同一批里的重名在唯一索引上炸出来(而不是拖到 commit)。
db.flush()
record_audit(
db,
current,
action="huya:collab:shop_create",
target="huya_collab_shops",
detail={"auto_registered": created},
)
return created
def list_shops(db: Session) -> list[str]:
"""字典里的店铺名(筛选/下拉用;停用的也在,前端自己决定展示哪些)。"""
return sorted(shop.name for shop in list_shop_records(db))
# --------------------------------------------------------------------------
# CRUD
# --------------------------------------------------------------------------
@@ -975,34 +1438,91 @@ def create_order(
ck_text: str = "",
bind_douyu_account: str = "",
gift_name: str = "",
home_images: Any = None,
remark: str = "",
require_verified: bool = False,
client: Any | None = None,
commit: bool = True,
) -> HuyaExternalOrder:
"""新建一行(可同时贴 CK)。订单号唯一,重复直接报冲突。"""
"""新建一行(可同时贴 CK)。订单号唯一,重复直接报冲突。
**负责人不填就是当前登录用户**(创建人)—— 新建时不用选,谁建的单归谁;
要转给别人在列表里点一下改负责人即可(负责人不是权限边界,见设计文档 §7.1)。
``require_verified=True``(**人工新建表单用**,粘贴导入不传)时按"人工必填项"严格校验:
**订单编号 / 店铺 / CK / 礼包名 / 主页截图**都不许空,且 CK 必须探针通、
**读到绑定角色**才让建;读不到角色直接 400 —— 这条在服务层做,前端只是体验(AGENTS §2)。
校验全部在落库**之前**完成,失败不会留下半行。
图片只收**主页截图**(``home_images``):绑定图不再人工上传 —— 绑了谁由探针自动读
(``bind_game_*``),人工截图没有意义(2026-10-16 用户拍板)。
"""
order_no = clean_text(order_no, field="order_no", required=True)
_assert_order_no_available(db, order_no)
home_images_keys = normalize_images(home_images, field="home_images")
order = HuyaExternalOrder(
order_no=order_no,
shop=clean_text(shop, field="shop"),
owner_id=_clean_owner_id(owner_id),
owner_id=_clean_owner_id(owner_id) if owner_id is not None else current.id,
bind_douyu_account=clean_text(bind_douyu_account, field="bind_douyu_account"),
gift_name=clean_text(gift_name, field="gift_name"),
remark=clean_text(remark, field="remark"),
bind_images=[],
home_images=[],
home_images=home_images_keys,
status=STATUS_PENDING,
ck_status=CK_UNVERIFIED,
created_by=current.id,
updated_by=current.id,
version=0,
)
if str(ck_text or "").strip():
uid_reused: list[str] = []
ck_provided = bool(str(ck_text or "").strip())
if ck_provided:
_apply_ck(order, ck_text)
_assert_uid_available(db, order.uid)
# 同一个虎牙号允许挂多单(2026-10-16 放开 uid 唯一性):只记复用痕迹,不拦
uid_reused = [item.order_no for item in uid_used_orders(db, order.uid)]
probe_result: CkProbeResult | None = None
if require_verified:
missing: list[str] = []
if not order.shop:
missing.append("店铺")
if not ck_provided:
missing.append("外部 CK")
if not order.gift_name:
missing.append("礼包名")
if not order.home_images:
missing.append("主页截图")
if missing:
raise CollabError(
f"下面这几项必须填:{'、'.join(missing)}", code="create_field_required"
)
# 先探针再落库:读不到绑定角色就不许建(临时对象,不会留下半行数据)。
probe_result = probe_ck(order, client=client)
role = probe_result.snapshot.get("bind_game_role", "")
if not probe_result.ok or not role:
raise CollabError(
f"这份 CK 不能用:{probe_result.message}"
+ ("(探针通了但读不到绑定角色)" if probe_result.ok else ""),
code="ck_not_verified",
)
for field_name in PROBE_SNAPSHOT_FIELDS:
setattr(order, field_name, probe_result.snapshot.get(field_name, ""))
order.bind_snapshot_at = _now()
if order.shop:
# 自定义输入的店铺名顺手登记进字典 → 下次下拉里就能直接点选。
ensure_shops_registered(db, current, [order.shop])
db.add(order)
db.flush() # 先拿到 id,审计里要带 target
if probe_result is not None and probe_result.ok:
# 结论仍然走 set_ck_status 这个唯一写入口(审计与 ck_checked_at 不会漂)。
set_ck_status(
db, current, order, CK_VALID, message=probe_result.message, commit=False
)
record_audit(
db,
current,
@@ -1013,6 +1533,11 @@ def create_order(
"shop": order.shop,
"uid": order.uid or "",
"ck_status": order.ck_status,
"gift_name": order.gift_name,
"bind_game_role": order.bind_game_role or "",
"home_images": len(order.home_images or []),
# 这个虎牙号还挂在哪些单上(允许同一份 CK 服务多单,留个痕)
"uid_reused_in": uid_reused,
},
)
if commit:
@@ -1060,6 +1585,10 @@ def update_order(
if not changes:
return {}
if "shop" in changes:
# 改成字典里没有的名字也允许(自定义),顺手登记,筛选下拉不会漏。
ensure_shops_registered(db, current, [changes["shop"]["new"]])
_touch(order, current)
record_audit(
db,
@@ -1160,9 +1689,11 @@ def list_orders(
query = query.filter(
or_(
HuyaExternalOrder.order_no.like(like),
HuyaExternalOrder.shop.like(like),
HuyaExternalOrder.bind_douyu_account.like(like),
HuyaExternalOrder.bind_game_account.like(like),
HuyaExternalOrder.bind_game_role.like(like),
HuyaExternalOrder.bind_game_channel.like(like),
HuyaExternalOrder.uid.like(like),
HuyaExternalOrder.nickname.like(like),
HuyaExternalOrder.hy_username.like(like),
@@ -1205,38 +1736,40 @@ def summary(db: Session) -> dict:
}
def list_shops(db: Session) -> list[str]:
rows = (
db.query(HuyaExternalOrder.shop)
.filter(HuyaExternalOrder.shop != "")
.distinct()
.order_by(HuyaExternalOrder.shop)
.all()
)
return [str(row[0]) for row in rows]
def user_names(db: Session, ids: Sequence[Any]) -> dict[int, str]:
"""一批用户 id → 用户名(避免每行懒加载,N+1)。"""
clean = {int(value) for value in ids if value}
if not clean:
return {}
rows = db.query(User.id, User.username).filter(User.id.in_(clean)).all()
return {int(row[0]): str(row[1]) for row in rows}
def users_by_id(db: Session, orders: Sequence[HuyaExternalOrder]) -> dict[int, str]:
"""一次性把涉及的用户名查出来,避免每行都懒加载(N+1)。"""
ids = {
int(value)
for order in orders
for value in (order.owner_id, order.created_by, order.updated_by)
if value
}
if not ids:
return {}
rows = db.query(User.id, User.username).filter(User.id.in_(ids)).all()
return {int(row[0]): str(row[1]) for row in rows}
return user_names(
db,
[
value
for order in orders
for value in (order.owner_id, order.created_by, order.updated_by)
],
)
def serialize_order(
order: HuyaExternalOrder, *, users: Mapping[int, str] | None = None
order: HuyaExternalOrder,
*,
users: Mapping[int, str] | None = None,
buyer_cluster: Mapping[str, Any] | None = None,
) -> dict:
"""把行对象转成接口输出。
图片存的是 object_key,URL 在这里现拼 —— 库里存 URL 会在换存储/改路径时
全部失效(order_site 踩过「存下来的签名链接过期后到处裂图」)。
``buyer_cluster`` = 同一买家的其它单(见 :func:`buyer_clusters`),由调用方
传进来(列表是一次查询算出整页的,别在这里按行查)。
"""
names = users or {}
bind_images = images_of(order, "bind_images")
@@ -1298,9 +1831,93 @@ def serialize_order(
"version": int(order.version or 0),
"created_at": order.created_at,
"updated_at": order.updated_at,
# 同一买家的其它单(软识别,只读派生;没有就是 null)
"buyer_cluster": dict(buyer_cluster) if buyer_cluster else None,
}
# --------------------------------------------------------------------------
# 同一买家的多单(软识别:只读派生,不落库、不改 uid 唯一性规则)
# --------------------------------------------------------------------------
# 买家指纹 = 探针读到的「绑定账号 + 绑定角色」(同一个游戏角色 = 同一个买家)。
# **不拿 uid 当主键**:一单一份外部 CK,同一个买家买两件往往是两份不同的 CK(uid 不同),
# 拿 uid 去比反而永远比不中;uid 只作为明细里的辅助信息展示。
BUYER_KEY_METHOD = "bind_game_account+bind_game_role"
def buyer_key_of(order: HuyaExternalOrder) -> tuple[str, str] | None:
"""一行的买家指纹;探针还没读到「绑定账号 + 绑定角色」时返回 None(不参与归组)。"""
account = str(order.bind_game_account or "").strip()
role = str(order.bind_game_role or "").strip()
if not account or not role:
return None
return (account, role)
def buyer_clusters(
db: Session, orders: Sequence[HuyaExternalOrder]
) -> dict[int, dict[str, Any]]:
"""算出这批行各自「同一买家的其它单」(不是按行发 SQL:整批一次查完)。
只有真的 ≥2 单才给结果 —— 单独一单不必在界面上标「同买家 1 单」。
返回 ``{order_id: {key, method, total, others: [...]}}``,``others`` 按创建时间升序。
"""
keys: dict[tuple[str, str], None] = {}
for order in orders:
key = buyer_key_of(order)
if key is not None:
keys[key] = None
if not keys:
return {}
pairs = list(keys)
rows = (
db.query(HuyaExternalOrder)
.filter(
tuple_(
HuyaExternalOrder.bind_game_account, HuyaExternalOrder.bind_game_role
).in_(pairs)
)
.order_by(HuyaExternalOrder.created_at.asc(), HuyaExternalOrder.id.asc())
.all()
)
grouped: dict[tuple[str, str], list[HuyaExternalOrder]] = {}
for row in rows:
key = buyer_key_of(row)
if key is not None and key in keys:
grouped.setdefault(key, []).append(row)
clusters: dict[int, dict[str, Any]] = {}
for key, siblings in grouped.items():
if len(siblings) < 2:
continue
for row in siblings:
others = [
{
"id": int(item.id),
"order_no": item.order_no or "",
"shop": item.shop or "",
"gift_name": item.gift_name or "",
"uid": item.uid or "",
"status": item.status,
"status_label": ORDER_STATUS_LABELS.get(item.status, item.status),
"created_at": item.created_at,
}
for item in siblings
if int(item.id) != int(row.id)
]
clusters[int(row.id)] = {
"key": f"{key[0]} · {key[1]}",
"method": BUYER_KEY_METHOD,
"total": len(siblings),
"others": others,
}
return clusters
# --------------------------------------------------------------------------
# 批量导入(Excel 粘贴)
# --------------------------------------------------------------------------
+22
View File
@@ -32,6 +32,7 @@ import type {
HuyaTaskItem,
HuyaBrowserExchangeKit,
HuyaCollabAuditItem,
HuyaCollabCkPreview,
HuyaCollabCkRequest,
HuyaCollabCkStatusRequest,
HuyaCollabImagesRequest,
@@ -42,6 +43,9 @@ import type {
HuyaCollabOrderQuery,
HuyaCollabOrderUpdate,
HuyaCollabOwner,
HuyaCollabShop,
HuyaCollabShopCreate,
HuyaCollabShopUpdate,
HuyaCollabStatusRequest,
HuyaCollabSummary,
HuyaCollabVerifyResult,
@@ -199,6 +203,18 @@ export const huyaApi = {
api.get<HuyaCollabOwner[], HuyaCollabOwner[]>('/huya/external-orders/owners'),
/** 已用过的店铺(筛选下拉) */
collabOrderShops: () => api.get<string[], string[]>('/huya/external-orders/shops'),
// ---- 店铺字典(新建协作单时点选;见业务规则 docs/设计-虎牙外部CK协作台账.md)----
/** 全部店铺(含停用的:管理弹窗要看到;新建下拉由页面按 is_active 过滤) */
listCollabShops: () => api.get<HuyaCollabShop[], HuyaCollabShop[]>('/huya/collab-shops'),
createCollabShop: (data: HuyaCollabShopCreate) =>
api.post<HuyaCollabShop, HuyaCollabShop>('/huya/collab-shops', data),
updateCollabShop: (id: number, data: HuyaCollabShopUpdate) =>
api.put<HuyaCollabShop, HuyaCollabShop>(`/huya/collab-shops/${id}`, data),
deleteCollabShop: (id: number) =>
api.delete<{ success: boolean; name: string }, { success: boolean; name: string }>(
`/huya/collab-shops/${id}`,
),
getCollabOrder: (id: number) =>
api.get<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}`),
createCollabOrder: (data: HuyaCollabOrderCreate) =>
@@ -218,6 +234,12 @@ export const huyaApi = {
/** 整槽替换图片(提交 /api/files/upload 返回的 object_key 数组) */
updateCollabOrderImages: (id: number, data: HuyaCollabImagesRequest) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/images`, data),
/** 粘贴 CK 后的即时预览(只读):探针 + 拉活动礼包,一行都不写库 */
previewCollabCk: (ck_text: string, with_gifts = true) =>
api.post<HuyaCollabCkPreview, HuyaCollabCkPreview>('/huya/external-orders/ck/preview', {
ck_text,
with_gifts,
}),
/** 验证 CK(只读探针):写 ck_status,并刷新绑定账号/角色快照 */
verifyCollabOrder: (id: number) =>
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/verify`),
+90
View File
@@ -1259,6 +1259,33 @@ export interface HuyaCollabOrder {
version: number;
created_at: string | null;
updated_at: string | null;
/**
* 同一买家的其它单(软识别:绑定账号 + 绑定角色相同,只读派生)。
* 只有真的 ≥2 单才有值,否则是 null。
*/
buyer_cluster: HuyaCollabBuyerCluster | null;
}
/** 「同一买家的其它单」里的一条 */
export interface HuyaCollabBuyerOrder {
id: number;
order_no: string;
shop: string;
gift_name: string;
uid: string;
status: HuyaCollabStatus;
status_label: string;
created_at: string | null;
}
/** 同一买家的多单(软识别,不是人工"软绑定"关系) */
export interface HuyaCollabBuyerCluster {
/** 买家指纹的可读文本:绑定账号 · 绑定角色 */
key: string;
/** 识别口径(后端说明字段,界面上标注用) */
method: string;
total: number;
others: HuyaCollabBuyerOrder[];
}
export interface HuyaCollabOrderList {
@@ -1306,7 +1333,11 @@ export interface HuyaCollabOrderCreate {
ck_text?: string;
bind_douyu_account?: string;
gift_name?: string;
/** 主页截图(object_key 数组,第一张作封面);严格模式下必填至少 1 张 */
home_images?: string[];
remark?: string;
/** 严格模式(默认 true):店铺/CK/礼包名/主页截图必填,且 CK 必须读到绑定角色 */
require_verified?: boolean;
}
export interface HuyaCollabOrderUpdate {
@@ -1386,3 +1417,62 @@ export interface FileUploadResult {
content_type: string;
size: number;
}
/** 活动礼包(用一份有效 CK 现拉) */
export interface HuyaCollabGift {
product_id: string;
name: string;
price: number | null;
remain_text: string;
stock: number | null;
score: number | null;
category_name: string;
icon: string;
}
/** 粘贴 CK 后的即时预览:ok 且 has_role 才允许提交新建 */
export interface HuyaCollabCkPreview {
ok: boolean;
message: string;
uid: string;
yyuid: string;
hy_username: string;
ck_status: HuyaCollabCkStatus;
bind_game_account: string;
bind_game_role: string;
bind_game_channel: string;
has_role: boolean;
gifts: HuyaCollabGift[];
gifts_message: string;
/** 这个虎牙号已经挂在哪些单上(允许同一份 CK 服务多单,只是提示) */
uid_used_by: HuyaCollabBuyerOrder[];
}
/** 协作台账的店铺字典项 */
export interface HuyaCollabShop {
id: number;
name: string;
sort_order: number;
/** false = 停用(新建时不再出现在下拉里;历史订单照旧) */
is_active: boolean;
remark: string;
created_by_name: string;
updated_by_name: string;
created_at: string | null;
updated_at: string | null;
}
export interface HuyaCollabShopCreate {
name: string;
sort_order?: number;
remark?: string;
}
export interface HuyaCollabShopUpdate {
name?: string;
sort_order?: number;
is_active?: boolean;
remark?: string;
/** 改名时是否级联更新已有订单里的店铺名(默认 true) */
rename_orders?: boolean;
}
@@ -0,0 +1,143 @@
/**
* 新建协作单时的「主页截图」上传(先传 OSS 拿 object_key,随表单一起提交)。
*
* - 第一张就是封面(列表和详情都按第一张当封面);
* - 新建时还没落库,所以预览用的地址来自上传接口返回的 thumbnail_url / url;
* - 放进 `Form.Item name="home_images"`,值就是 object_key 数组。
*/
import { useEffect, useRef, useState } from 'react';
import { Button, Image, Popconfirm, Space, Tag, Tooltip, Typography, Upload } from 'antd';
import { CloudUploadOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons';
import { fileApi } from '../api/files';
import type { FileUploadResult } from '../api/types';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
type Preview = Pick<FileUploadResult, 'url' | 'thumbnail_url'>;
export default function HuyaCollabHomeImageUploader({
value = [],
max = 20,
disabled,
onChange,
}: {
value?: string[];
max?: number;
disabled?: boolean;
onChange?: (value: string[]) => void;
}) {
const [busy, setBusy] = useState(false);
const [previews, setPreviews] = useState<Record<string, Preview>>({});
// 连传多张时每次都用"上一次的结果 + 新 key",不吃闭包里的旧 value(否则会互相覆盖)
const keysRef = useRef<string[]>(value ?? []);
useEffect(() => {
keysRef.current = value ?? [];
}, [value]);
const keys = value ?? [];
const full = keys.length >= max;
const uploadOne = async (file: File) => {
setBusy(true);
try {
const uploaded = await fileApi.upload(file, 'collab');
setPreviews((prev) => ({
...prev,
[uploaded.object_key]: { url: uploaded.url, thumbnail_url: uploaded.thumbnail_url },
}));
const next = [...keysRef.current, uploaded.object_key];
keysRef.current = next;
onChange?.(next);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const remove = (key: string) => {
const next = keysRef.current.filter((item) => item !== key);
keysRef.current = next;
onChange?.(next);
};
const setCover = (key: string) => {
const next = [key, ...keysRef.current.filter((item) => item !== key)];
keysRef.current = next;
onChange?.(next);
};
return (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{keys.length === 0 ? (
<Text type="secondary">还没有图(必填:至少 1 张,第一张作封面)</Text>
) : (
<Image.PreviewGroup
items={keys.map((key) => previews[key]?.url || `/api/files/object?key=${encodeURIComponent(key)}`)}
>
<Space wrap size={12}>
{keys.map((key, index) => (
<Space key={key} direction="vertical" size={2} align="center">
<div style={{ position: 'relative' }}>
<Image
src={
previews[key]?.thumbnail_url
|| previews[key]?.url
|| `/api/files/object?key=${encodeURIComponent(key)}&variant=thumb`
}
width={88}
height={88}
style={{ objectFit: 'cover', borderRadius: 4 }}
alt={`主页截图${index + 1}`}
/>
{index === 0 && (
<Tag color="blue" style={{ position: 'absolute', left: 0, top: 0, margin: 0 }}>
封面
</Tag>
)}
</div>
{!disabled && (
<Space size={2}>
{index !== 0 && (
<Tooltip title="设为封面">
<Button size="small" type="text" icon={<SwapOutlined />} onClick={() => setCover(key)} />
</Tooltip>
)}
<Popconfirm
title="去掉这张图?"
description="对象不会立刻删除,交给孤儿清理脚本"
onConfirm={() => remove(key)}
>
<Tooltip title="移除">
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
)}
</Space>
))}
</Space>
</Image.PreviewGroup>
)}
{!disabled && (
<Upload
accept="image/*"
multiple
showUploadList={false}
disabled={busy || full}
beforeUpload={(file) => {
void uploadOne(file as File);
return false;
}}
>
<Button icon={<CloudUploadOutlined />} loading={busy} disabled={full}>
{full ? `最多 ${max} 张` : '上传主页截图'}
</Button>
</Upload>
)}
</Space>
);
}
@@ -0,0 +1,276 @@
import { useEffect, useState } from 'react';
import {
Button, Checkbox, Empty, Input, InputNumber, Modal, Popconfirm, Space, Switch,
Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaCollabShop } from '../api/modules';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
/**
* 店铺管理:维护「新建协作单」下拉里的店铺。
*
* - 加 / 排序 / 备注 / 停用(停用=不再出现在新建下拉,历史订单照旧);
* - **重命名**会级联更新已有订单里的店铺名(弹窗里可取消勾选),
* 否则筛选里会同时出现新旧两个名字;
* - 删除只删字典行、不动订单(需要 `huya:collab:manage`)。
*/
export default function HuyaCollabShopModal({
open,
shops,
canEdit,
canManage,
onClose,
onChanged,
}: {
open: boolean;
shops: HuyaCollabShop[];
canEdit: boolean;
canManage: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const [renameTarget, setRenameTarget] = useState<HuyaCollabShop | null>(null);
const [renameValue, setRenameValue] = useState('');
const [renameOrders, setRenameOrders] = useState(true);
useEffect(() => {
if (!open) {
setNewName('');
setRenameTarget(null);
}
}, [open]);
const create = async () => {
const name = newName.trim();
if (!name) {
message.warning('请先输入店铺名');
return;
}
setBusy(true);
try {
await huyaApi.createCollabShop({ name });
message.success(`已添加店铺「${name}」`);
setNewName('');
onChanged();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const patch = async (shop: HuyaCollabShop, payload: { sort_order?: number; is_active?: boolean; remark?: string }) => {
setBusy(true);
try {
await huyaApi.updateCollabShop(shop.id, payload);
onChanged();
} catch (e: unknown) {
message.error(getErrorMessage(e));
onChanged();
} finally {
setBusy(false);
}
};
const submitRename = async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.name) {
setRenameTarget(null);
return;
}
setBusy(true);
try {
await huyaApi.updateCollabShop(renameTarget.id, { name, rename_orders: renameOrders });
message.success(renameOrders ? `已重命名为「${name}」,已有订单里的旧名字也同步了` : `已重命名为「${name}」`);
setRenameTarget(null);
onChanged();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const remove = async (shop: HuyaCollabShop) => {
setBusy(true);
try {
await huyaApi.deleteCollabShop(shop.id);
message.success(`已从字典删除「${shop.name}」`);
onChanged();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setBusy(false);
}
};
const columns: TableProps<HuyaCollabShop>['columns'] = [
{
title: '店铺名', dataIndex: 'name', width: 180,
render: (value: string, row) => (
<Space size={4}>
<Text strong>{value}</Text>
{!row.is_active && <Tag>已停用</Tag>}
{canEdit && (
<Tooltip title="重命名">
<Button
size="small"
type="text"
icon={<EditOutlined />}
disabled={busy}
onClick={() => {
setRenameTarget(row);
setRenameValue(row.name);
setRenameOrders(true);
}}
/>
</Tooltip>
)}
</Space>
),
},
{
title: '排序', dataIndex: 'sort_order', width: 100,
render: (value: number, row) => (
<InputNumber
size="small"
value={value}
disabled={!canEdit || busy}
onChange={(next) => {
if (next === null || next === value) return;
void patch(row, { sort_order: Number(next) });
}}
/>
),
},
{
title: '新建时可选', dataIndex: 'is_active', width: 110, align: 'center',
render: (value: boolean, row) => (
<Switch
size="small"
checked={value}
disabled={!canEdit || busy}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => void patch(row, { is_active: checked })}
/>
),
},
{
title: '备注', dataIndex: 'remark', width: 200,
render: (value: string, row) => (
<Input
size="small"
defaultValue={value}
disabled={!canEdit || busy}
placeholder="备注(失焦保存)"
onBlur={(event) => {
const next = event.target.value.trim();
if (next !== value) void patch(row, { remark: next });
}}
/>
),
},
{
title: '最后改动', width: 150,
render: (_, row) => (
<Text type="secondary" style={{ fontSize: 12 }}>
{row.updated_by_name || '-'}
{row.updated_at ? ` · ${new Date(row.updated_at).toLocaleString()}` : ''}
</Text>
),
},
{
title: '操作', width: 90, align: 'center',
render: (_, row) =>
canManage ? (
<Popconfirm
title={`从字典删除「${row.name}」?`}
description="只删字典行,已有订单里的店铺名不变"
onConfirm={() => void remove(row)}
disabled={busy}
>
<Button size="small" danger type="text" icon={<DeleteOutlined />} disabled={busy} />
</Popconfirm>
) : (
<Text type="secondary">-</Text>
),
},
];
return (
<>
<Modal
title="店铺管理"
open={open}
onCancel={onClose}
footer={<Button onClick={onClose}>关闭</Button>}
width={860}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Text type="secondary">
这里的店铺就是新建协作单时下拉里能点选的那些。停用只是不再出现在新建下拉里,
历史订单不受影响;重命名可以选择是否同步已有订单。
</Text>
{canEdit && (
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入店铺名,回车添加"
value={newName}
maxLength={64}
onChange={(event) => setNewName(event.target.value)}
onPressEnter={() => void create()}
/>
<Button type="primary" icon={<PlusOutlined />} loading={busy} onClick={() => void create()}>
添加
</Button>
</Space.Compact>
)}
<Table<HuyaCollabShop>
rowKey="id"
size="small"
columns={columns}
dataSource={shops}
pagination={false}
scroll={{ y: 320 }}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有店铺,先加一个" />,
}}
/>
</Space>
</Modal>
<Modal
title={renameTarget ? `重命名「${renameTarget.name}」` : '重命名店铺'}
open={!!renameTarget}
onCancel={() => setRenameTarget(null)}
onOk={() => void submitRename()}
confirmLoading={busy}
okText="保存"
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
value={renameValue}
maxLength={64}
autoFocus
onChange={(event) => setRenameValue(event.target.value)}
onPressEnter={() => void submitRename()}
/>
<Checkbox checked={renameOrders} onChange={(event) => setRenameOrders(event.target.checked)}>
同时更新已有订单里的店铺名(不勾选的话,筛选里会同时出现新旧两个名字)
</Checkbox>
</Space>
</Modal>
</>
);
}
@@ -0,0 +1,126 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Button, Input, Space, Tag, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { HuyaCollabShop } from '../api/modules';
const { Text } = Typography;
/**
* 店铺选择器:**一个店铺一个独立按钮**(中间留白),不做下拉。
*
* - 选中那个按钮变主题蓝(primary),没选的是白底描边,一眼看得出选的是谁;
* - `allowClear` 为真时多一个「不填 / 全部」按钮(筛选里传"全部"),
* 店铺必填的地方传 `false` 把空值这条路堵掉;
* - `allowCustom`:点「+ 新店」直接输新名字,选中即用(后端会自动登记进字典);
* - `includeInactive`:停用的店铺也列出来(筛选/详情里要能看到历史值);
* - 当前值不在字典里(老数据/刚自定义的名字)也会显示成一个按钮,不会被吞掉。
*/
export default function HuyaCollabShopPicker({
value,
shops,
disabled,
includeInactive = false,
allowCustom = true,
allowClear = true,
clearLabel = '不填',
size = 'small',
onChange,
}: {
value?: string | null;
shops: HuyaCollabShop[];
disabled?: boolean;
includeInactive?: boolean;
allowCustom?: boolean;
/** 是否给一个「清空」按钮:店铺必填的地方传 false,别让人点出个空值 */
allowClear?: boolean;
clearLabel?: string;
/** 按钮尺寸:列表单元格 / 筛选条用小号,新建弹窗用大号(更好点) */
size?: 'small' | 'middle' | 'large';
/** 放进 Form.Item 时由 antd 注入;单独用时自己传 */
onChange?: (value: string) => void;
}) {
const [customOpen, setCustomOpen] = useState(false);
const [customName, setCustomName] = useState('');
useEffect(() => {
if (!customOpen) setCustomName('');
}, [customOpen]);
const current = value ?? '';
const visible = shops.filter((shop) => includeInactive || shop.is_active);
const options: { value: string; label: ReactNode }[] = visible.map((shop) => ({
value: shop.name,
label: (
<Space size={4}>
{shop.name}
{!shop.is_active && <Tag style={{ margin: 0 }}>停用</Tag>}
</Space>
),
}));
if (current && !visible.some((shop) => shop.name === current)) {
// 老数据 / 刚自定义的:照样给个可点的按钮,不然界面上看不出选中了啥
options.unshift({ value: current, label: current });
}
const submitCustom = () => {
const name = customName.trim();
if (!name) {
setCustomOpen(false);
return;
}
onChange?.(name);
setCustomOpen(false);
};
return (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space wrap size={size === 'large' ? 12 : 8}>
{/* 一个店铺一个独立按钮,中间留白(原来是连成一排的分段控件,太小不好点) */}
{options.map((option) => (
<Button
key={option.value}
size={size}
type={option.value === current ? 'primary' : 'default'}
disabled={disabled}
onClick={() => onChange?.(option.value)}
>
{option.label}
</Button>
))}
{allowClear && (
<Button
size={size}
type={current === '' ? 'primary' : 'default'}
disabled={disabled}
onClick={() => onChange?.('')}
>
<Text type={current === '' ? undefined : 'secondary'}>{clearLabel}</Text>
</Button>
)}
{allowCustom && !disabled && !customOpen && (
<Button size={size} type="link" icon={<PlusOutlined />} onClick={() => setCustomOpen(true)}>
新店
</Button>
)}
</Space>
{customOpen && (
<Input
size={size}
autoFocus
maxLength={64}
placeholder="输入新店铺名,回车确认"
value={customName}
onChange={(event) => setCustomName(event.target.value)}
onPressEnter={submitCustom}
onBlur={submitCustom}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setCustomName('');
setCustomOpen(false);
}
}}
/>
)}
</Space>
);
}
+9
View File
@@ -20,3 +20,12 @@ html[data-theme='dark'] body {
.huya-task-account-table .ant-table-placeholder:hover > .ant-table-cell {
background: transparent;
}
/* 新建协作单弹窗:表单项间距压紧一点,配合紧凑布局尽量不出现滚动条 */
.huya-collab-create .ant-form-item {
margin-bottom: 14px;
}
.huya-collab-create .ant-form-item-label {
padding-bottom: 2px;
}
+548 -81
View File
@@ -6,30 +6,36 @@
* 2. **绑定账号 / 绑定角色**来自「验证 CK」的只读探针快照(人工不可编辑,每次验证覆盖);
* 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
App, Button, Card, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form, Image,
Input, Modal, Popconfirm, Progress, Row, Select, Space, Statistic, Switch, Table, Tag,
Timeline, Tooltip, Typography, Upload,
Alert, App, AutoComplete, Button, Card, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form,
Image, Input, Modal, Popconfirm, Popover, Progress, Row, Select, Space, Statistic, Switch, Table,
Tag, Timeline, Tooltip, Typography, Upload,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, StopOutlined,
SwapOutlined, UndoOutlined,
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
StopOutlined, SwapOutlined, UndoOutlined,
} from '@ant-design/icons';
import {
fileApi,
huyaApi,
type HuyaCollabAuditItem,
type HuyaCollabCkPreview,
type HuyaCollabGift,
type HuyaCollabImageSlot,
type HuyaCollabOrder,
type HuyaCollabOrderQuery,
type HuyaCollabOrderUpdate,
type HuyaCollabOwner,
type HuyaCollabShop,
type HuyaCollabStatus,
type HuyaCollabSummary,
} from '../api/modules';
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
import { usePermissions } from '../hooks/usePermissions';
@@ -64,13 +70,42 @@ const ACTION_LABELS: Record<string, string> = {
};
const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_images' | 'home_images' }> = {
bind_images: { label: '绑定图', keysKey: 'bind_images' },
home_images: { label: '主页账号图', keysKey: 'home_images' },
// 绑定图不再人工上传:绑定关系由探针自动读(2026-10-16 用户拍板),只留老数据只读展示
bind_images: { label: '历史绑定图', keysKey: 'bind_images' },
home_images: { label: '主页截图', keysKey: 'home_images' },
};
const IMPORT_PREVIEW_ROWS = 20;
// 「记住最近选的礼包」的本地存储键(同一个礼包连开几单时不用反复选)
const LAST_GIFT_KEY = 'huya.collab.lastGift';
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
// 要更新就点「强制刷新礼包」(活动换 SID 后必点一次)。
const GIFTS_CACHE_KEY = 'huya.collab.gifts';
const MAX_IMAGES_PER_SLOT = 20;
/** 读礼包缓存(读不到 / 解析坏 / 空列表都当没缓存) */
function readGiftCache(): { gifts: HuyaCollabGift[]; at: number } | null {
try {
const raw = window.localStorage.getItem(GIFTS_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { at?: number; gifts?: HuyaCollabGift[] };
const gifts = Array.isArray(parsed.gifts) ? parsed.gifts : [];
if (gifts.length === 0) return null;
return { gifts, at: Number(parsed.at) || 0 };
} catch {
return null;
}
}
/** 写礼包缓存:写不进去(隐私模式 / 配额满)也不影响功能,静静跳过 */
function writeGiftCache(gifts: HuyaCollabGift[]): void {
try {
window.localStorage.setItem(GIFTS_CACHE_KEY, JSON.stringify({ at: Date.now(), gifts }));
} catch {
// 忽略:缓存只是体验优化
}
}
function formatTime(value: string | null | undefined): string {
if (!value) return '-';
const time = new Date(value);
@@ -98,7 +133,8 @@ function EditableText({
return (
<div
onClick={() => { if (!disabled) setEditing(true); }}
style={{ cursor: disabled ? 'default' : 'text', minHeight: 22 }}
// wordBreak:礼品名 / 角色名里偶发的一长串不带空格的字符也不会顶穿这一列
style={{ cursor: disabled ? 'default' : 'text', minHeight: 22, wordBreak: 'break-word' }}
title={disabled ? undefined : '点击编辑'}
>
{value || <Text type="secondary">{placeholder || '—'}</Text>}
@@ -208,7 +244,7 @@ function ImageSlotEditor({
export default function HuyaCollabPage() {
const { modal } = App.useApp();
const { can } = usePermissions();
const { can, user } = usePermissions();
const canEdit = can('huya:collab:edit');
const canManage = can('huya:collab:manage');
@@ -220,7 +256,8 @@ export default function HuyaCollabPage() {
const [busy, setBusy] = useState(false);
const [summary, setSummary] = useState<HuyaCollabSummary | null>(null);
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
const [shops, setShops] = useState<string[]>([]);
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -231,6 +268,22 @@ export default function HuyaCollabPage() {
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<Record<string, unknown>>();
const [ckPreview, setCkPreview] = useState<HuyaCollabCkPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
// 这次预览有没有带「拉礼包」(没有就是纯探针 + 用本地礼包缓存),只影响提示文案
const [previewPullsGifts, setPreviewPullsGifts] = useState(false);
const ckReady = !!ckPreview?.ok && !!ckPreview?.has_role;
// 礼包列表:优先用浏览器缓存那份 —— 有数据就不去虎牙拉,要更新点「强制刷新礼包」。
// 直接拿缓存做初值(而不是等 useEffect):贴第一份 CK 时就已经有数据了,不会白拉一次。
const [giftOptions, setGiftOptions] = useState<HuyaCollabGift[]>(
() => readGiftCache()?.gifts ?? [],
);
const [giftsCachedAt, setGiftsCachedAt] = useState<number | null>(
() => readGiftCache()?.at ?? null,
);
const giftsRef = useRef<HuyaCollabGift[]>(giftOptions);
// 记忆里的礼包不在「这次拉到的列表」里时的提示(会顺手清空,免得提交一个拉不到的礼包名)
const [giftHint, setGiftHint] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState('');
const [importing, setImporting] = useState(false);
@@ -253,7 +306,7 @@ export default function HuyaCollabPage() {
huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }),
huyaApi.collabOrdersSummary(),
huyaApi.collabOrderOwners(),
huyaApi.collabOrderShops(),
huyaApi.listCollabShops(),
]);
setRows(list.items);
setTotal(list.total);
@@ -276,6 +329,88 @@ export default function HuyaCollabPage() {
setDetail((current) => (current && current.id === updated.id ? updated : current));
}, []);
const previewTimer = useRef<number | null>(null);
// 预览请求序号:贴了新 CK 之后,旧请求迟到回来的结论一律丢掉(否则会显示上一份 CK 的"有效")
const previewSeq = useRef(0);
useEffect(() => {
giftsRef.current = giftOptions;
}, [giftOptions]);
const emptyPreview = useCallback(
(message: string): HuyaCollabCkPreview => ({
ok: false, message, uid: '', yyuid: '', hy_username: '', ck_status: 'invalid',
bind_game_account: '', bind_game_role: '', bind_game_channel: '',
has_role: false, gifts: [], gifts_message: '', uid_used_by: [],
}),
[],
);
/** 跑一次预览(探针 + 可选拉礼包),只读,不写库 */
const runCkPreview = useCallback(
async (text: string, withGifts: boolean) => {
const seq = previewSeq.current;
try {
const preview = await huyaApi.previewCollabCk(text, withGifts);
if (seq !== previewSeq.current) return; // 已经贴了新的 CK,旧结论丢掉
setCkPreview(preview);
if (preview.gifts.length > 0) {
// 真拉到列表了(首次或强制刷新):更新浏览器缓存,接下来几单直接用
setGiftOptions(preview.gifts);
setGiftsCachedAt(Date.now());
writeGiftCache(preview.gifts);
}
if (preview.ok && preview.has_role) {
message.success(`CK 有效:绑定角色 ${preview.bind_game_role}`);
// 「记住的礼包」如果不在当前列表里就清掉重选;列表为空时不动它(允许手填)
const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current;
const picked = String(createForm.getFieldValue('gift_name') ?? '').trim();
if (picked && options.length > 0 && !options.some((gift) => gift.name === picked)) {
createForm.setFieldValue('gift_name', undefined);
setGiftHint(`上次选的「${picked}」不在当前的礼包列表里,已清空,请重新点选`);
} else {
setGiftHint('');
}
}
} catch (e: unknown) {
if (seq !== previewSeq.current) return;
setCkPreview(emptyPreview(getErrorMessage(e)));
} finally {
if (seq === previewSeq.current) setPreviewing(false);
}
},
[emptyPreview, createForm],
);
/**
* 输入 CK 后防抖预览;改动了就先作废旧结论(避免拿旧的"有效"去提交)。
*
* ``forceGifts=true``(点了「强制刷新礼包」)才重新去虎牙拉列表;否则**本地有数据就用本地的** ——
* 一个活动期间礼包清单基本不变,每次贴 CK 都拉一遍纯属白挨接口。
*/
const scheduleCkPreview = useCallback(
(ckText: string, immediate = false, forceGifts = false) => {
if (previewTimer.current) window.clearTimeout(previewTimer.current);
const text = (ckText || '').trim();
previewSeq.current += 1; // 无论跑不跑,都让在途请求作废
if (!text) {
setCkPreview(null);
setPreviewing(false);
return;
}
setCkPreview(null);
setPreviewing(true);
// 有缓存(giftsRef 非空)就不带 with_gifts,只有没数据或用户强制刷新才真去拉
const withGifts = forceGifts || giftsRef.current.length === 0;
setPreviewPullsGifts(withGifts);
previewTimer.current = window.setTimeout(
() => void runCkPreview(text, withGifts),
immediate ? 0 : 600,
);
},
[runCkPreview],
);
const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => {
try {
const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version });
@@ -293,7 +428,6 @@ export default function HuyaCollabPage() {
order_no: row.order_no,
shop: row.shop,
owner_id: row.owner_id,
bind_douyu_account: row.bind_douyu_account,
gift_name: row.gift_name,
remark: row.remark,
});
@@ -308,6 +442,15 @@ export default function HuyaCollabPage() {
}
};
/** 打开「同一买家的另一单」:先按 id 取最新数据,再复用详情抽屉 */
const openSiblingOrder = async (id: number) => {
try {
await openDetail(await huyaApi.getCollabOrder(id));
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const verifyOne = async (row: HuyaCollabOrder) => {
setBusy(true);
try {
@@ -511,19 +654,27 @@ export default function HuyaCollabPage() {
};
const submitCreate = async () => {
if (!ckReady) {
message.warning('这份 CK 没通过验证(或读不到绑定角色),不能提交');
return;
}
try {
const values = await createForm.validateFields();
const created = await huyaApi.createCollabOrder({
order_no: String(values.order_no ?? '').trim(),
shop: String(values.shop ?? ''),
owner_id: (values.owner_id as number | undefined) ?? null,
ck_text: String(values.ck_text ?? ''),
bind_douyu_account: String(values.bind_douyu_account ?? ''),
// 「绑定斗鱼号」不再录入:绑了谁由探针读(bind_game_*),人工那一列已下线
gift_name: String(values.gift_name ?? ''),
home_images: (values.home_images as string[] | undefined) ?? [],
remark: String(values.remark ?? ''),
require_verified: true, // 服务端再校验一次:CK 必须读到绑定角色才让建
});
if (created.gift_name) window.localStorage.setItem(LAST_GIFT_KEY, created.gift_name);
message.success(`已新建 ${created.order_no}`);
setCreateOpen(false);
setCkPreview(null);
setGiftHint('');
createForm.resetFields();
void load();
} catch (e: unknown) {
@@ -561,10 +712,25 @@ export default function HuyaCollabPage() {
}, [importText]);
const ownerOptions = owners.map((owner) => ({ value: owner.id, label: owner.username }));
const shopOptions = shops.map((shop) => ({ value: shop, label: shop }));
// 筛选里的店铺也是点选(含停用店铺:历史订单还在用它们)
const finished = summary ? (summary.by_status.done ?? 0) : 0;
// 礼包来源提示:这次真去虎牙拉了就说拉了,用的是缓存就把缓存时间摆出来(让人知道要不要点强制刷新)
const giftCacheLabel = giftsCachedAt ? formatTime(new Date(giftsCachedAt).toISOString()) : '';
const giftSourceText = ckPreview?.gifts?.length
? ckPreview.gifts_message
: giftOptions.length > 0
? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」`
: (ckPreview?.gifts_message || '');
const columns: TableProps<HuyaCollabOrder>['columns'] = [
{
title: '序号', width: 70, fixed: 'left', align: 'center',
render: (_, __, index) => (
// 跨页连续:第 2 页第 1 行 = pageSize + 1
<Text type="secondary">{(page - 1) * pageSize + index + 1}</Text>
),
},
{
title: '订单编号', dataIndex: 'order_no', width: 170, fixed: 'left',
render: (value: string, row) => (
@@ -575,10 +741,26 @@ export default function HuyaCollabPage() {
),
},
{
title: '店铺', dataIndex: 'shop', width: 110,
title: '店铺', dataIndex: 'shop', width: 120,
render: (value: string, row) => (
<EditableText value={value} disabled={!canEdit} placeholder="+ 店铺"
onCommit={(next) => void patchOrder(row, { shop: next })} />
<Popover
trigger="click"
title="选择店铺"
placement="bottomLeft"
content={
<HuyaCollabShopPicker
value={value}
shops={shops}
allowCustom={canEdit}
allowClear={false}
onChange={(next) => { if (next !== value) void patchOrder(row, { shop: next }); }}
/>
}
>
<Button size="small" type={value ? 'text' : 'link'} disabled={!canEdit}>
{value || '+ 选店铺'}
</Button>
</Popover>
),
},
{
@@ -612,31 +794,75 @@ export default function HuyaCollabPage() {
),
},
{
title: '绑定图 / 主页图', width: 170,
render: (_, row) => (
<Space size={12}>
{(['bind_images', 'home_images'] as HuyaCollabImageSlot[]).map((slot) => {
const keys = slot === 'bind_images' ? row.bind_images : row.home_images;
const urls = slot === 'bind_images' ? row.bind_image_urls : row.home_image_urls;
const thumbs = slot === 'bind_images' ? row.bind_image_thumb_urls : row.home_image_thumb_urls;
if (keys.length === 0) {
return (
<Text key={slot} type="secondary" style={{ fontSize: 12 }}>
{SLOT_META[slot].label} 0
title: '同买家', width: 120,
render: (_, row) => {
const cluster = row.buyer_cluster;
if (!cluster || cluster.others.length === 0) {
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
}
return (
<Popover
trigger="click"
placement="bottomLeft"
title={
<Space direction="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}>同一个买家,共 {cluster.total} 单</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{cluster.key}</Text>
</Space>
}
content={
<Space direction="vertical" size={8} style={{ maxWidth: 340 }}>
{cluster.others.map((item) => (
<Space key={item.id} size={6} wrap>
<Button size="small" type="link" style={{ padding: 0 }}
onClick={() => void openSiblingOrder(item.id)}>
{item.order_no}
</Button>
<Tag color={STATUS_META[item.status]?.color}>{item.status_label || item.status}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.gift_name || '未填礼包'} · {item.shop || '未填店铺'}
</Text>
</Space>
))}
<Text type="secondary" style={{ fontSize: 12 }}>
识别口径:探针读到的「绑定账号 + 绑定角色」相同(不是人工绑定关系)
</Text>
);
</Space>
}
>
<Button size="small" type="link" style={{ padding: 0 }}>
<SwapOutlined /> 还有 {cluster.others.length} 单
</Button>
</Popover>
);
},
},
{
title: '主页截图', width: 170,
render: (_, row) => (
<Space size={8} wrap>
{(() => {
const keys = row.home_images;
const urls = row.home_image_urls;
const thumbs = row.home_image_thumb_urls;
if (keys.length === 0) {
return <Text type="secondary" style={{ fontSize: 12 }}>还没传 0</Text>;
}
return (
<Space key={slot} size={4}>
<Space size={4}>
<Image.PreviewGroup items={urls}>
<Image src={thumbs[0] || urls[0]} width={32} height={32}
style={{ objectFit: 'cover', borderRadius: 4 }}
alt={`${SLOT_META[slot].label}封面`} />
style={{ objectFit: 'cover', borderRadius: 4 }} alt="主页截图封面" />
</Image.PreviewGroup>
<Text type="secondary" style={{ fontSize: 12 }}>{keys.length} 张</Text>
</Space>
);
})}
})()}
{row.bind_images.length > 0 && (
<Tooltip title="绑定关系现在由「验证 CK」的探针自动读,绑定图已停用;这是老数据">
<Text type="secondary" style={{ fontSize: 12 }}>历史绑定图 {row.bind_images.length} 张</Text>
</Tooltip>
)}
</Space>
),
},
@@ -677,13 +903,27 @@ export default function HuyaCollabPage() {
),
},
{
title: 'CK 状态', dataIndex: 'ck_status', width: 150,
title: 'CK 状态', dataIndex: 'ck_status', width: 170,
render: (value: string, row) => (
<Space direction="vertical" size={2}>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Tag color={CK_META[value]?.color}>{row.ck_status_label || CK_META[value]?.label || value}</Tag>
{row.ck_check_message && (
// 探针结论可能很长(账号 / 角色 / 平台·区服全在里面):**必须截断**,
// 否则长文案会顶穿这一列压到「最后改动」上(2026-10-16 用户反馈)。全文放 Tooltip。
<Tooltip title={`${row.ck_check_message}(${formatTime(row.ck_checked_at)})`}>
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.ck_check_message}</Text>
<Text
type="secondary"
style={{
display: 'block',
fontSize: 12,
maxWidth: 150,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{row.ck_check_message}
</Text>
</Tooltip>
)}
</Space>
@@ -788,10 +1028,17 @@ export default function HuyaCollabPage() {
<Card size="small">
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space wrap>
<Select allowClear showSearch style={{ width: 150 }} placeholder="店铺"
options={shopOptions}
value={filters.shop || undefined}
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, shop: value })); }} />
<Space size={4}>
<Text type="secondary">店铺</Text>
<HuyaCollabShopPicker
value={filters.shop || ''}
shops={shops}
includeInactive
allowCustom={false}
clearLabel="全部"
onChange={(value) => { setPage(1); setFilters((prev) => ({ ...prev, shop: value })); }}
/>
</Space>
<Select allowClear showSearch optionFilterProp="label" style={{ width: 150 }} placeholder="负责人"
options={ownerOptions}
value={filters.owner_id ?? undefined}
@@ -804,7 +1051,8 @@ export default function HuyaCollabPage() {
options={Object.entries(CK_META).map(([key, meta]) => ({ value: key, label: meta.label }))}
value={filters.ck_status || undefined}
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, ck_status: value })); }} />
<Input.Search allowClear placeholder="订单号 / 绑定账号 / 角色 / 虎牙号" style={{ width: 260 }}
<Input.Search allowClear style={{ width: 340 }}
placeholder="搜订单号 / 店铺 / 绑定账号 / 角色 / 虎牙号 / 礼包 / 备注"
onSearch={(value) => { setPage(1); setFilters((prev) => ({ ...prev, keyword: value })); }} />
<Space size={4}>
<Text type="secondary">显示已作废</Text>
@@ -826,7 +1074,26 @@ export default function HuyaCollabPage() {
</Button>
)}
{canEdit && (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新建</Button>
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
)}
{canEdit && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setCkPreview(null);
setPreviewing(false);
setGiftHint('');
previewSeq.current += 1; // 丢掉在途的预览结论
createForm.resetFields();
// 记住最近选的礼包:同一个礼包连开几单时不用反复选
const lastGift = window.localStorage.getItem(LAST_GIFT_KEY);
if (lastGift) createForm.setFieldsValue({ gift_name: lastGift });
setCreateOpen(true);
}}
>
新建
</Button>
)}
</Space>
</Space>
@@ -838,7 +1105,7 @@ export default function HuyaCollabPage() {
loading={loading}
columns={columns}
dataSource={rows}
scroll={{ x: 1680, y: 'calc(100vh - 460px)' }}
scroll={{ x: 1800, y: 'calc(100vh - 460px)' }}
rowSelection={canEdit ? {
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(Number)),
@@ -887,12 +1154,45 @@ export default function HuyaCollabPage() {
children: `${formatTime(detail.scripted_at)} / ${formatTime(detail.done_at)}` },
{ key: 'updated', label: '最后改动',
children: `${detail.updated_by_name || '-'} · ${formatTime(detail.updated_at)}` },
// 老数据可能还留着人工填的「绑定斗鱼号」:只读展示,不再给人填(绑定关系看探针)
...(detail.bind_douyu_account
? [{
key: 'bind_douyu_account',
label: '绑定斗鱼号(历史字段,不再录入)',
children: detail.bind_douyu_account,
}]
: []),
]} />
{detail.ck_check_message && (
<Text type="secondary">探针结论:{detail.ck_check_message}</Text>
)}
{detail.buyer_cluster && detail.buyer_cluster.others.length > 0 && (
// 同一个买家(绑定账号 + 绑定角色相同)名下的其它单:软识别,点一下直接切过去
<>
<Divider style={{ margin: '4px 0' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
同一个买家的其它单({detail.buyer_cluster.key} · 共 {detail.buyer_cluster.total} 单)
</Text>
</Divider>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{detail.buyer_cluster.others.map((item) => (
<Space key={item.id} size={8} wrap>
<Button size="small" onClick={() => void openSiblingOrder(item.id)}>
{item.order_no}
</Button>
<Tag color={STATUS_META[item.status]?.color}>{item.status_label || item.status}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.gift_name || '未填礼包'} · {item.shop || '未填店铺'}
{item.uid ? ` · 虎牙号 ${item.uid}` : ''}
</Text>
</Space>
))}
</Space>
</>
)}
{canEdit && (
<>
<Divider style={{ margin: '4px 0' }}>可编辑字段</Divider>
@@ -902,7 +1202,6 @@ export default function HuyaCollabPage() {
size="small"
initialValues={{
order_no: detail.order_no, shop: detail.shop, owner_id: detail.owner_id,
bind_douyu_account: detail.bind_douyu_account,
gift_name: detail.gift_name, remark: detail.remark,
}}
onFinish={(values) => void patchOrder(detail, values)}
@@ -914,19 +1213,15 @@ export default function HuyaCollabPage() {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="shop" label="店铺"><Input maxLength={64} /></Form.Item>
<Form.Item name="shop" label="店铺" tooltip="点一下选;点「新店」可以直接输个新名字">
<HuyaCollabShopPicker shops={shops} allowClear={false} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="owner_id" label="负责人">
<Select allowClear showSearch optionFilterProp="label" options={ownerOptions} placeholder="未指派" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="bind_douyu_account" label="绑定斗鱼号(手工填,Excel 导入列)"
tooltip="这一列是人工自由文本;「绑定账号 / 绑定角色」由「验证 CK」的探针写入">
<Input maxLength={128} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="gift_name" label="礼包名"><Input maxLength={128} /></Form.Item>
</Col>
@@ -949,19 +1244,40 @@ export default function HuyaCollabPage() {
</>
)}
<Divider style={{ margin: '4px 0' }}>截图(第一张是封面)</Divider>
{(['bind_images', 'home_images'] as HuyaCollabImageSlot[]).map((slot) => (
<ImageSlotEditor
key={slot}
order={detail}
slot={slot}
canEdit={canEdit}
busy={busy}
onUpload={(targetSlot, file) => void uploadImage(detail, targetSlot, file)}
onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)}
onSetCover={(targetSlot, key) => void setCoverImage(detail, targetSlot, key)}
/>
))}
<Divider style={{ margin: '4px 0' }}>主页截图(第一张是封面)</Divider>
<ImageSlotEditor
order={detail}
slot="home_images"
canEdit={canEdit}
busy={busy}
onUpload={(targetSlot, file) => void uploadImage(detail, targetSlot, file)}
onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)}
onSetCover={(targetSlot, key) => void setCoverImage(detail, targetSlot, key)}
/>
{detail.bind_images.length > 0 && (
// 老数据只读展示:绑定图已停用,但已经传过的图不该从界面上凭空消失
<>
<Divider style={{ margin: '4px 0' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
历史绑定图(只读,已停用 —— 绑定关系改由「验证 CK」的探针读取)
</Text>
</Divider>
<Image.PreviewGroup items={detail.bind_image_urls}>
<Space wrap>
{detail.bind_images.map((key, index) => (
<Image
key={key}
src={detail.bind_image_thumb_urls[index] || detail.bind_image_urls[index]}
width={64}
height={64}
style={{ objectFit: 'cover', borderRadius: 4 }}
alt={`历史绑定图${index + 1}`}
/>
))}
</Space>
</Image.PreviewGroup>
</>
)}
{canEdit && (
<Space>
@@ -1012,39 +1328,181 @@ export default function HuyaCollabPage() {
<Modal
title="新建协作单"
open={createOpen}
width={920}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={busy}
okText="新建"
okButtonProps={{ disabled: !ckReady || previewing }}
// 内容比一屏高:给个滚动区,别让底部按钮被顶出可视范围
styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }}
destroyOnHidden
>
<Form form={createForm} layout="vertical" size="small">
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '订单号不能为空' }]}>
<Input maxLength={64} placeholder="Excel B 列" />
</Form.Item>
<Row gutter={12}>
<Col span={12}><Form.Item name="shop" label="店铺"><Input maxLength={64} /></Form.Item></Col>
<Form
className="huya-collab-create"
form={createForm}
layout="vertical"
size="middle"
onValuesChange={(changed: Record<string, unknown>) => {
if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text);
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="owner_id" label="负责人">
<Select allowClear showSearch optionFilterProp="label" options={ownerOptions} placeholder="未指派" />
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '订单号不能为空' }]}>
<Input maxLength={64} placeholder="Excel B 列" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="负责人" tooltip="默认就是当前登录账号;要转给别人,在列表里改这一行的负责人">
<Text>{user?.username || '-'}(默认)</Text>
</Form.Item>
</Col>
</Row>
<Form.Item name="ck_text" label="外部 CK(可选,支持「纯CK」或「账号----密码----CK」)">
<Input.TextArea rows={3} placeholder="贴进来就会解析出虎牙 uid" />
{/* 店铺独占一行:按钮要够宽才不会被挤到第二行(「新店」跟在同一行后面) */}
<Form.Item
name="shop"
label="店铺"
tooltip="必填:点一下选;点「新店」可以直接输个新名字(会自动进店铺字典)"
rules={[{ required: true, message: '请点一下选店铺' }]}
>
<HuyaCollabShopPicker shops={shops} allowClear={false} size="large" />
</Form.Item>
<Row gutter={12}>
<Col span={12}><Form.Item name="bind_douyu_account" label="绑定斗鱼号(可选)"><Input maxLength={128} /></Form.Item></Col>
<Col span={12}><Form.Item name="gift_name" label="礼包名(可选)"><Input maxLength={128} /></Form.Item></Col>
<Form.Item
name="ck_text"
label="外部 CK"
tooltip="必填:支持「纯CK」或「账号----密码----CK」。贴进来即自动探针验证 + 读绑定角色;礼包列表本地有缓存就不再重复拉,要更新点旁边的「强制刷新礼包」"
rules={[{ required: true, message: '请贴一份外部 CK' }]}
>
{/* 高度随内容走:短 CK 就 3 行,别固定 5 行把弹窗撑高 */}
<Input.TextArea
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder="贴进来即自动验证 + 读绑定角色(礼包有缓存就不再拉)"
/>
</Form.Item>
{(previewing || ckPreview) && (
<div style={{ marginBottom: 12 }}>
{previewing ? (
<Alert
type="info"
showIcon
message={previewPullsGifts
? '正在验证 CK 并拉取礼包…'
: '正在验证 CK + 读绑定角色…(礼包用本地缓存,不重复拉)'}
/>
) : ckPreview?.ok && ckPreview.has_role ? (
<Alert
type="success"
showIcon
message={`CK 有效 · 绑定角色 ${ckPreview.bind_game_role}`}
description={
<>
<div>
{`订单编号里的账号:${ckPreview.bind_game_account || '-'}`
+ `(${ckPreview.bind_game_channel || '-'})`
+ `;虎牙号 ${ckPreview.uid}`
+ (giftSourceText ? `;${giftSourceText}` : '')}
</div>
{ckPreview.uid_used_by.length > 0 && (
// 放开 uid 唯一性后同一份 CK 可以服务多单,这里只提示已经挂在哪些单上
<div style={{ marginTop: 4 }}>
{`这份 CK 的虎牙号已经挂在:${ckPreview.uid_used_by
.map((item) => `${item.order_no}(${item.status_label || item.status})`)
.join('、')} —— 同一个号服务多单是允许的,照常提交`}
</div>
)}
</>
}
/>
) : (
<Alert
type="error"
showIcon
message="这份 CK 不能用,禁止提交"
description={`${ckPreview?.message || ''}${ckPreview?.ok && !ckPreview.has_role ? '(探针通了但读不到绑定角色)' : ''}`}
/>
)}
</div>
)}
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="gift_name"
label="礼包名(用户购买的物品)"
tooltip="必填:先贴有效 CK,再从礼包列表里点选(列表有本地缓存,不用每次重拉;拉不到时可以直接手输)"
rules={[{ required: true, message: '请选一个礼包' }]}
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
: undefined)}
validateStatus={giftHint ? 'warning' : undefined}
>
<AutoComplete
allowClear
placeholder={ckReady ? '从礼包里点选(可直接输)' : '先贴一份有效 CK'}
options={giftOptions.map((gift) => ({
value: gift.name,
label: [
gift.category_name ? `[${gift.category_name}]` : '',
gift.name,
gift.price ? `· ${gift.price} 积分` : '',
gift.remain_text ? `· 剩 ${gift.remain_text}` : '',
].filter(Boolean).join(' '),
}))}
filterOption={(input, option) =>
String(option?.value ?? '').toLowerCase().includes(input.toLowerCase())}
onChange={(value: string) => {
// 记住最近选的礼包:连开几单同一个礼包就不用反复点
setGiftHint('');
if (value) window.localStorage.setItem(LAST_GIFT_KEY, value);
}}
/>
</Form.Item>
</Col>
<Col span={10}>
<Form.Item
label="礼包列表"
tooltip="平时用本地缓存那份(一个活动期间基本不变);换了活动 / 想拿最新库存,点这里强制去虎牙重拉"
>
<Button
icon={<ReloadOutlined />}
disabled={!ckReady || previewing}
onClick={() => scheduleCkPreview(createForm.getFieldValue('ck_text') || '', true, true)}
>
强制刷新礼包{giftOptions.length ? `(${giftOptions.length})` : ''}
</Button>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Form.Item
name="home_images"
label="主页截图(必填,第一张作封面)"
tooltip="上传主页截图;绑定图不用再传了 —— 绑了谁由「验证 CK」的探针自动读"
rules={[{
validator: (_rule, value: string[] | undefined) => (
value && value.length > 0
? Promise.resolve()
: Promise.reject(new Error('请至少上传 1 张主页截图'))
),
}]}
>
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="remark" label="备注(可选)">
<Input.TextArea rows={2} maxLength={512} />
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注(可选)"><Input.TextArea rows={2} maxLength={512} /></Form.Item>
</Form>
</Modal>
<Modal
title="粘贴导入(Excel 复制出来的 TSV)"
open={importOpen}
width={760}
width={900}
onCancel={() => setImportOpen(false)}
onOk={() => void submitImport()}
confirmLoading={importing}
@@ -1145,6 +1603,15 @@ export default function HuyaCollabPage() {
)}
</Space>
</Modal>
<HuyaCollabShopModal
open={shopModalOpen}
shops={shops}
canEdit={canEdit}
canManage={canManage}
onClose={() => setShopModalOpen(false)}
onChanged={() => void load()}
/>
</Space>
);
}