diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index e905e30..0f80bf6 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -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=...` 直接 `` 预览。 **显示时不在列表里下原图**——用 `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/*`;开工前需确认当前分支与库迁移版本对齐 | diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index c496eed..1061594 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -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): """运营能看能改能删;客服能看能改但不能删。""" diff --git a/tests/test_huya_collab_shops.py b/tests/test_huya_collab_shops.py new file mode 100644 index 0000000..4e962dc --- /dev/null +++ b/tests/test_huya_collab_shops.py @@ -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" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index f571bc9..dd84391 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = ( diff --git a/web/backend/migrations/versions/20261016_0057_huya_collab_shops.py b/web/backend/migrations/versions/20261016_0057_huya_collab_shops.py new file mode 100644 index 0000000..250ff2e --- /dev/null +++ b/web/backend/migrations/versions/20261016_0057_huya_collab_shops.py @@ -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) diff --git a/web/backend/migrations/versions/20261016_0058_huya_order_uid_not_unique.py b/web/backend/migrations/versions/20261016_0058_huya_order_uid_not_unique.py new file mode 100644 index 0000000..46a1f46 --- /dev/null +++ b/web/backend/migrations/versions/20261016_0058_huya_order_uid_not_unique.py @@ -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) diff --git a/web/backend/models.py b/web/backend/models.py index ce5afa8..77c4023 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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="") diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 4e7b7fe..d8d85a1 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -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, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index e8d0dac..8e27ad2 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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): """列表响应:共享视图,带上总数供分页使用。""" diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 74ea2bd..812589d 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -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 粘贴) # -------------------------------------------------------------------------- diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index bf2c07e..39e578a 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -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('/huya/external-orders/owners'), /** 已用过的店铺(筛选下拉) */ collabOrderShops: () => api.get('/huya/external-orders/shops'), + + // ---- 店铺字典(新建协作单时点选;见业务规则 docs/设计-虎牙外部CK协作台账.md)---- + /** 全部店铺(含停用的:管理弹窗要看到;新建下拉由页面按 is_active 过滤) */ + listCollabShops: () => api.get('/huya/collab-shops'), + createCollabShop: (data: HuyaCollabShopCreate) => + api.post('/huya/collab-shops', data), + updateCollabShop: (id: number, data: HuyaCollabShopUpdate) => + api.put(`/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(`/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(`/huya/external-orders/${id}/images`, data), + /** 粘贴 CK 后的即时预览(只读):探针 + 拉活动礼包,一行都不写库 */ + previewCollabCk: (ck_text: string, with_gifts = true) => + api.post('/huya/external-orders/ck/preview', { + ck_text, + with_gifts, + }), /** 验证 CK(只读探针):写 ck_status,并刷新绑定账号/角色快照 */ verifyCollabOrder: (id: number) => api.post(`/huya/external-orders/${id}/verify`), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 825ba73..bf264cc 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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; +} diff --git a/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx b/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx new file mode 100644 index 0000000..503273e --- /dev/null +++ b/web/frontend/src/components/HuyaCollabHomeImageUploader.tsx @@ -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; + +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>({}); + // 连传多张时每次都用"上一次的结果 + 新 key",不吃闭包里的旧 value(否则会互相覆盖) + const keysRef = useRef(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 ( + + {keys.length === 0 ? ( + 还没有图(必填:至少 1 张,第一张作封面) + ) : ( + previews[key]?.url || `/api/files/object?key=${encodeURIComponent(key)}`)} + > + + {keys.map((key, index) => ( + +
+ {`主页截图${index + {index === 0 && ( + + 封面 + + )} +
+ {!disabled && ( + + {index !== 0 && ( + + + + )} + + ); +} diff --git a/web/frontend/src/components/HuyaCollabShopModal.tsx b/web/frontend/src/components/HuyaCollabShopModal.tsx new file mode 100644 index 0000000..009e4ef --- /dev/null +++ b/web/frontend/src/components/HuyaCollabShopModal.tsx @@ -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(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['columns'] = [ + { + title: '店铺名', dataIndex: 'name', width: 180, + render: (value: string, row) => ( + + {value} + {!row.is_active && 已停用} + {canEdit && ( + + } + width={860} + destroyOnHidden + > + + + 这里的店铺就是新建协作单时下拉里能点选的那些。停用只是不再出现在新建下拉里, + 历史订单不受影响;重命名可以选择是否同步已有订单。 + + {canEdit && ( + + setNewName(event.target.value)} + onPressEnter={() => void create()} + /> + + + )} + + rowKey="id" + size="small" + columns={columns} + dataSource={shops} + pagination={false} + scroll={{ y: 320 }} + locale={{ + emptyText: , + }} + /> + + + + setRenameTarget(null)} + onOk={() => void submitRename()} + confirmLoading={busy} + okText="保存" + destroyOnHidden + > + + setRenameValue(event.target.value)} + onPressEnter={() => void submitRename()} + /> + setRenameOrders(event.target.checked)}> + 同时更新已有订单里的店铺名(不勾选的话,筛选里会同时出现新旧两个名字) + + + + + ); +} diff --git a/web/frontend/src/components/HuyaCollabShopPicker.tsx b/web/frontend/src/components/HuyaCollabShopPicker.tsx new file mode 100644 index 0000000..710a255 --- /dev/null +++ b/web/frontend/src/components/HuyaCollabShopPicker.tsx @@ -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: ( + + {shop.name} + {!shop.is_active && 停用} + + ), + })); + 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 ( + + + {/* 一个店铺一个独立按钮,中间留白(原来是连成一排的分段控件,太小不好点) */} + {options.map((option) => ( + + ))} + {allowClear && ( + + )} + {allowCustom && !disabled && !customOpen && ( + + )} + + {customOpen && ( + setCustomName(event.target.value)} + onPressEnter={submitCustom} + onBlur={submitCustom} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setCustomName(''); + setCustomOpen(false); + } + }} + /> + )} + + ); +} diff --git a/web/frontend/src/index.css b/web/frontend/src/index.css index 65cf442..284d92d 100644 --- a/web/frontend/src/index.css +++ b/web/frontend/src/index.css @@ -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; +} diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index f26914b..8d00bfb 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -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 = { }; const SLOT_META: Record = { - 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 (
{ 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 || {placeholder || '—'}} @@ -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(null); const [owners, setOwners] = useState([]); - const [shops, setShops] = useState([]); + const [shops, setShops] = useState([]); + const [shopModalOpen, setShopModalOpen] = useState(false); const [filters, setFilters] = useState({ include_void: false }); const [selectedIds, setSelectedIds] = useState([]); @@ -231,6 +268,22 @@ export default function HuyaCollabPage() { const [createOpen, setCreateOpen] = useState(false); const [createForm] = Form.useForm>(); + const [ckPreview, setCkPreview] = useState(null); + const [previewing, setPreviewing] = useState(false); + // 这次预览有没有带「拉礼包」(没有就是纯探针 + 用本地礼包缓存),只影响提示文案 + const [previewPullsGifts, setPreviewPullsGifts] = useState(false); + const ckReady = !!ckPreview?.ok && !!ckPreview?.has_role; + // 礼包列表:优先用浏览器缓存那份 —— 有数据就不去虎牙拉,要更新点「强制刷新礼包」。 + // 直接拿缓存做初值(而不是等 useEffect):贴第一份 CK 时就已经有数据了,不会白拉一次。 + const [giftOptions, setGiftOptions] = useState( + () => readGiftCache()?.gifts ?? [], + ); + const [giftsCachedAt, setGiftsCachedAt] = useState( + () => readGiftCache()?.at ?? null, + ); + const giftsRef = useRef(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(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['columns'] = [ + { + title: '序号', width: 70, fixed: 'left', align: 'center', + render: (_, __, index) => ( + // 跨页连续:第 2 页第 1 行 = pageSize + 1 + {(page - 1) * pageSize + index + 1} + ), + }, { 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) => ( - void patchOrder(row, { shop: next })} /> + { if (next !== value) void patchOrder(row, { shop: next }); }} + /> + } + > + + ), }, { @@ -612,31 +794,75 @@ export default function HuyaCollabPage() { ), }, { - title: '绑定图 / 主页图', width: 170, - render: (_, row) => ( - - {(['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 ( - - {SLOT_META[slot].label} 0 + title: '同买家', width: 120, + render: (_, row) => { + const cluster = row.buyer_cluster; + if (!cluster || cluster.others.length === 0) { + return —; + } + return ( + + 同一个买家,共 {cluster.total} 单 + {cluster.key} + + } + content={ + + {cluster.others.map((item) => ( + + + {item.status_label || item.status} + + {item.gift_name || '未填礼包'} · {item.shop || '未填店铺'} + + + ))} + + 识别口径:探针读到的「绑定账号 + 绑定角色」相同(不是人工绑定关系) - ); + + } + > + + + ); + }, + }, + { + title: '主页截图', width: 170, + render: (_, row) => ( + + {(() => { + const keys = row.home_images; + const urls = row.home_image_urls; + const thumbs = row.home_image_thumb_urls; + if (keys.length === 0) { + return 还没传 0; } return ( - + {`${SLOT_META[slot].label}封面`} + style={{ objectFit: 'cover', borderRadius: 4 }} alt="主页截图封面" /> {keys.length} 张 ); - })} + })()} + {row.bind_images.length > 0 && ( + + 历史绑定图 {row.bind_images.length} 张 + + )} ), }, @@ -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) => ( - + {row.ck_status_label || CK_META[value]?.label || value} {row.ck_check_message && ( + // 探针结论可能很长(账号 / 角色 / 平台·区服全在里面):**必须截断**, + // 否则长文案会顶穿这一列压到「最后改动」上(2026-10-16 用户反馈)。全文放 Tooltip。 - {row.ck_check_message} + + {row.ck_check_message} + )} @@ -788,10 +1028,17 @@ export default function HuyaCollabPage() { - ({ value: key, label: meta.label }))} value={filters.ck_status || undefined} onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, ck_status: value })); }} /> - { setPage(1); setFilters((prev) => ({ ...prev, keyword: value })); }} /> 显示已作废 @@ -826,7 +1074,26 @@ export default function HuyaCollabPage() { )} {canEdit && ( - + + )} + {canEdit && ( + )} @@ -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 && ( 探针结论:{detail.ck_check_message} )} + {detail.buyer_cluster && detail.buyer_cluster.others.length > 0 && ( + // 同一个买家(绑定账号 + 绑定角色相同)名下的其它单:软识别,点一下直接切过去 + <> + + + 同一个买家的其它单({detail.buyer_cluster.key} · 共 {detail.buyer_cluster.total} 单) + + + + {detail.buyer_cluster.others.map((item) => ( + + + {item.status_label || item.status} + + {item.gift_name || '未填礼包'} · {item.shop || '未填店铺'} + {item.uid ? ` · 虎牙号 ${item.uid}` : ''} + + + ))} + + + )} + {canEdit && ( <> 可编辑字段 @@ -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() { - + + + - - @@ -949,19 +1244,40 @@ export default function HuyaCollabPage() { )} - 截图(第一张是封面) - {(['bind_images', 'home_images'] as HuyaCollabImageSlot[]).map((slot) => ( - void uploadImage(detail, targetSlot, file)} - onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)} - onSetCover={(targetSlot, key) => void setCoverImage(detail, targetSlot, key)} - /> - ))} + 主页截图(第一张是封面) + 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 && ( + // 老数据只读展示:绑定图已停用,但已经传过的图不该从界面上凭空消失 + <> + + + 历史绑定图(只读,已停用 —— 绑定关系改由「验证 CK」的探针读取) + + + + + {detail.bind_images.map((key, index) => ( + {`历史绑定图${index + ))} + + + + )} {canEdit && ( @@ -1012,39 +1328,181 @@ export default function HuyaCollabPage() { setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={busy} okText="新建" + okButtonProps={{ disabled: !ckReady || previewing }} + // 内容比一屏高:给个滚动区,别让底部按钮被顶出可视范围 + styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }} destroyOnHidden > -
- - - - - + ) => { + if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text); + }} + > + - - + + + + + {user?.username || '-'}(默认) - - + {/* 店铺独占一行:按钮要够宽才不会被挤到第二行(「新店」跟在同一行后面) */} + + - - - + + {/* 高度随内容走:短 CK 就 3 行,别固定 5 行把弹窗撑高 */} + + + {(previewing || ckPreview) && ( +
+ {previewing ? ( + + ) : ckPreview?.ok && ckPreview.has_role ? ( + +
+ {`订单编号里的账号:${ckPreview.bind_game_account || '-'}` + + `(${ckPreview.bind_game_channel || '-'})` + + `;虎牙号 ${ckPreview.uid}` + + (giftSourceText ? `;${giftSourceText}` : '')} +
+ {ckPreview.uid_used_by.length > 0 && ( + // 放开 uid 唯一性后同一份 CK 可以服务多单,这里只提示已经挂在哪些单上 +
+ {`这份 CK 的虎牙号已经挂在:${ckPreview.uid_used_by + .map((item) => `${item.order_no}(${item.status_label || item.status})`) + .join('、')} —— 同一个号服务多单是允许的,照常提交`} +
+ )} + + } + /> + ) : ( + + )} +
+ )} + + + 0 + ? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)` + : undefined)} + validateStatus={giftHint ? 'warning' : undefined} + > + ({ + 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); + }} + /> + + + + + + + + + + + ( + value && value.length > 0 + ? Promise.resolve() + : Promise.reject(new Error('请至少上传 1 张主页截图')) + ), + }]} + > + + + + + + + + -
setImportOpen(false)} onOk={() => void submitImport()} confirmLoading={importing} @@ -1145,6 +1603,15 @@ export default function HuyaCollabPage() { )}
+ + setShopModalOpen(false)} + onChanged={() => void load()} + />
); }