feat(huya): 协作台账店铺字典·新建强校验·同买家多单 + uid 放开(迁移 0057/0058)
- 店铺字典(迁移 0057):大按钮点选 + 「新店」自定义自动登记;重命名可级联、 停用/备注;列表行内改、详情、筛选条共用同一个 picker - 新建强校验:订单编号/店铺/CK/礼包名/主页截图必填;贴 CK 即探针预览, 读不到绑定角色禁止提交;礼包用工作台配置的活动 SID 现拉(浏览器缓存 + 强制刷新) - 图片:绑定图停用、「绑定斗鱼号」下线(绑定关系改由探针读 bind_game_*), 主页截图改为新建必填(弹窗内先传 OSS 再随表单落库) - 同买家多单软识别:按「绑定账号 + 绑定角色」整批一次查询,列表「同买家」列 + 详情分组,点单号直接切单 - uid 放开唯一性(迁移 0058):同一份 CK 可服务多单,原 409 冲突改为提示 + 审计 - 列表体验:序号列、搜索扩到 11 个字段、CK 状态列截断、新建弹窗放大并去滚动条 - 验证:pytest 932 passed / 6 skipped;接口冒烟 56 项全绿;tsc+eslint+build 通过
This commit is contained in:
+198
-9
@@ -78,7 +78,7 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
|
||||
| `id` | int PK | |
|
||||
| `order_no` | varchar(64) **unique** | 订单编号(Excel B 列),唯一 |
|
||||
| `shop` | varchar(64) index | 店铺(A 列) |
|
||||
| `owner_id` | int FK `users.id` index | 负责人(C 列),可空 |
|
||||
| `owner_id` | int FK `users.id` index | 负责人(C 列);**不填默认 = 创建人**(新建时不用选,要转单在列表里改) |
|
||||
| `uid` | varchar(32) **unique, nullable** | 虎牙 uid(从 CK 解析;空 = 还没贴 CK) |
|
||||
| `yyuid` | varchar(32) | 虎牙 yyuid |
|
||||
| `hy_username` | varchar(128) | CK 里的 `udb_passport`(登录名) |
|
||||
@@ -112,20 +112,58 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重
|
||||
| `version` | int | **乐观锁版本号**,每次写入 +1(见 §7.2 为什么不用 `updated_at`) |
|
||||
| `created_at` / `updated_at` | datetime | `updated_at` 只用于显示与排序 |
|
||||
|
||||
索引:`order_no`(unique)、`uid`(unique)、`shop`、`owner_id`、`status`、`ck_status`、`updated_at`。
|
||||
索引:`order_no`(unique)、`uid`(**普通索引**,2026-10-16 由 unique 降级,见 §3.2)、`shop`、`owner_id`、`status`、`ck_status`、`updated_at`。
|
||||
|
||||
### 3.2 两个 key 设计上的选择
|
||||
|
||||
- **`uid` 唯一但可空**:MySQL 的唯一索引允许多个 NULL,
|
||||
所以"先建单、后贴 CK"和"一个 CK 只能挂一单"同时成立(1 单 = 1 CK 已确认)。
|
||||
贴 CK 时如果 `uid` 已被别的行占用 → 报冲突,提示"这个虎牙号已在第 N 行(订单 xxx)"。
|
||||
- **`order_no` 唯一**:Excel 里订单号是天然主键,重复导入要能识别。
|
||||
- **`order_no` 唯一**:Excel 里订单号是天然主键,重复导入要能识别(重复 → 409 `order_no_conflict`,
|
||||
导入路径则按"更新"处理)。
|
||||
- **`uid` 可空、且【不再唯一】(2026-10-16 用户拍板改的口径)**:
|
||||
|
||||
| 口径 | 时间 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| 一个虎牙号只能挂一单 | 2026-10-16 上午 | 贴 CK / 建单时若 `uid` 已被占用 → 409 `uid_conflict`;DB 上是 unique 索引 |
|
||||
| **一个虎牙号可以挂多单** | 2026-10-16 晚(当前) | **不拦**。真机上"同一个买家买两件"常常继续用同一份 CK,拦住就变成第二单建不了 |
|
||||
|
||||
落地:迁移 **`20261016_0058`** 把 `ix_huya_external_orders_uid` 从 unique 改成普通索引
|
||||
(降级时会先检查有没有重复 uid,有就报错而不是静默丢数据);服务层
|
||||
`uid_used_orders()` / `uids_in_use()` 只做**提示与审计**:
|
||||
- 新建 / 贴 CK 的审计 detail 里带 `uid_reused_in: [其它单号]`;
|
||||
- `POST /ck/preview` 返回 `uid_used_by`,新建弹窗的 Alert 里会写「这份 CK 的虎牙号已经挂在:
|
||||
xxx —— 同一个号服务多单是允许的,照常提交」。
|
||||
- `uid` 仍可空,「先建单、后贴 CK」不受影响(多个 NULL 不冲突)。
|
||||
- 同一个买家的多单怎么认:见 **§5.6**(按绑定账号 + 绑定角色软识别)。
|
||||
|
||||
### 3.3 不建的表
|
||||
|
||||
- 不建"操作历史表":直接用现有 `audit_logs`(§7.3),避免再造一套事件表。
|
||||
- 不建"图片表":图片本体在 OSS,元数据在 `file_assets`(已落地),本表只存 key。
|
||||
|
||||
### 3.4 表 `huya_collab_shops`(店铺字典,2026-10-16 加)
|
||||
|
||||
原来的"店铺"是手打自由文本,同一个店铺会出现多种写法(错别字、多空格),把筛选打散。
|
||||
改成一个可维护的字典:**新建协作单时点一下选中**(点选按钮组,不做下拉——就这么几个店铺)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | int PK | |
|
||||
| `name` | varchar(64) **unique** | 店铺名(订单里存的就是它) |
|
||||
| `sort_order` | int | 越小越靠前(下拉顺序) |
|
||||
| `is_active` | bool index | 停用 = 不再出现在新建下拉;**历史订单不受影响** |
|
||||
| `remark` | varchar(255) | 备注 |
|
||||
| `created_by` / `updated_by` | int FK `users.id` | 谁加/谁改 |
|
||||
| `created_at` / `updated_at` | datetime | |
|
||||
|
||||
**三个已定的取舍**:
|
||||
|
||||
1. **订单里存的是店铺名字符串**(`huya_external_orders.shop`),不是外键 —— 历史 Excel
|
||||
就是文本,改成外键要动所有读写与导入。代价:改名默认**级联**更新已有订单
|
||||
(`update_shop(..., rename_orders=True)`,前端弹窗里可取消勾选);
|
||||
2. **支持自定义输入**:下拉里输一个字典里没有的名字,选中即用,后端
|
||||
`ensure_shops_registered()` 会把它登记进字典(新建/编辑/粘贴导入共用同一条路);
|
||||
3. **字典从空开始**:不回填历史订单里出现过的店铺名(用户明确"不用看之前的")。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4. 两套生命周期(本设计的核心)
|
||||
@@ -235,6 +273,57 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
|
||||
- `void` 作废:保留行、留 `void_reason`,列表默认过滤掉(可切换显示)。
|
||||
- 物理删除:`huya:collab:manage` 才能做,且要**级联清理图片**(§6.4),并记审计。
|
||||
|
||||
### 5.5 新建协作单的必填项与礼包(2026-10-16 用户拍板)
|
||||
|
||||
人工新建表单(`POST /api/huya/external-orders`,`require_verified=true` 是默认值):
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 订单编号 | ✅ | 台账内唯一 |
|
||||
| 店铺 | ✅ | **大按钮点选**;没有就点「新店」自定义,自动进字典 |
|
||||
| 外部 CK | ✅ | 贴进来即调 `POST /ck/preview` 预览 |
|
||||
| 礼包名 | ✅ | 从**礼包列表**里点选(= 用户购买的物品);列表平时用浏览器缓存,要更新点「强制刷新礼包」 |
|
||||
| 主页截图 | ✅ | 至少 1 张,第一张作封面;绑定图**不要了**(绑了谁由探针读,见 §11.5) |
|
||||
| 负责人 | — | 默认 = 当前登录账号,不用选 |
|
||||
| 绑定斗鱼号 / 备注 | — | 备注可选;**绑定斗鱼号已从界面下线**(老数据在详情里只读展示) |
|
||||
|
||||
**"CK 有效" = 探针通 + 读到绑定角色**:预览与提交都按这条判;读不到角色一律拒绝
|
||||
(`ck_not_verified`)。所有校验都在落库**之前**做完,失败不留半行 —— 前端只是即时反馈,
|
||||
服务端才是最终关口(AGENTS §2)。
|
||||
|
||||
- **礼包来源**:`webActUI.getActPrizeList`(活动可兑换商品,只读、不落库;拉失败只提示
|
||||
`gifts_message`,不影响 CK 的有效性结论)。**SID 必须用工作台配置里的那个**
|
||||
(`huya_config.sid`,走 `resolve_gifts_sid(db)`)—— 见 §11.4 的真机定位:
|
||||
SID 就是活动 ID、会随活动轮换,写死默认值会出现「接口 200 但礼包列表空白」;
|
||||
- **记住最近选的礼包**:前端 localStorage(`huya.collab.lastGift`),同一个礼包连开几单不用重复选;
|
||||
- **礼包列表缓存**:前端 localStorage(`huya.collab.gifts`,存 `{at, gifts}`)—— 一个活动期间礼包清单基本不变,
|
||||
贴 CK 时**不再每次都去虎牙拉**(`with_gifts=false`),要用最新库存点「强制刷新礼包」;
|
||||
缓存时间摆在礼包名下方,活动换了(SID 变了)就点一次强制刷新;
|
||||
- **探针缓存**:同一份 CK 在 `PROBE_CACHE_TTL_SECONDS`(90 秒)内复用结论 ——
|
||||
"预览一次 + 提交校验一次"只打一次虎牙,别为了校验白白翻倍请求;
|
||||
- 粘贴导入**不受**这些必填项约束(Excel 里本来就可能没有 CK),它走 `require_verified=false`。
|
||||
|
||||
### 5.6 同一买家的多单(软识别,2026-10-16 用户提的)
|
||||
|
||||
一个买家买两件 → 台账里就是两行。要不要把它们认成"同一个买家",判据如下:
|
||||
|
||||
| 口径 | 说明 |
|
||||
| --- | --- |
|
||||
| **买家指纹 = 探针读到的「绑定账号 + 绑定角色」** | 采用。同一个游戏角色就是同一个买家:`buyer_key_of()`,两边都有值才参与归组 |
|
||||
| uid(虎牙号) | **不参与归组**:一单一份外部 CK,同一个买家两单往往用**两份不同的 CK**(uid 不同),拿 uid 比永远比不中;反过来说,同一个买家第二单继续用同一份 CK 也是允许的(uid 已放开唯一性,见 §3.2)。uid 只在明细里展示 |
|
||||
| 平台·区服(`bind_game_channel`) | 不做判据(同一角色往往跨端显示不同),只在明细里展示 |
|
||||
|
||||
落地点(**只读派生:不落库、不加表字段、不改订单状态**;`uid` 唯一性是单独一条迁移改的,见 §3.2):
|
||||
|
||||
- 服务层 `buyer_clusters(db, orders)`:整批**一次** `(bind_game_account, bind_game_role) IN (...)`
|
||||
查出同买家的全部行(不按行发 N 条 SQL),只有真 ≥2 单才产出结果;
|
||||
- `serialize_order(..., buyer_cluster=...)` → 出参 `buyer_cluster: {key, method, total, others[]}`
|
||||
(`others` 按创建时间升序,带 order_no / shop / gift_name / uid / status);
|
||||
列表接口整页算一次,单行接口(`_serialize_one`)单独算一次;
|
||||
- 前端:列表多一列「同买家」(显示「还有 N 单」,点开是浮层,列出其它单号 + 状态 + 礼包,
|
||||
**点单号直接切到那一单的详情**);详情抽屉里也有一段「同一个买家的其它单」。
|
||||
- 这是**软识别**:不写库、不改任何状态,识别错了也不会污染数据(`method` 字段在界面上写明口径)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 图片(复用已落地的上传能力)
|
||||
@@ -243,8 +332,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
|
||||
|
||||
| 槽位 | 对应 Excel 列 | 字段 | 张数 |
|
||||
| --- | --- | --- | --- |
|
||||
| 绑定图 | E | `bind_images` | 多张(第一张作封面) |
|
||||
| 主页账号图 | F | `home_images` | 多张(第一张作封面) |
|
||||
| 主页截图 | F | `home_images` | 多张(第一张作封面);**新建必填至少 1 张** |
|
||||
| ~~绑定图~~ | ~~E~~ | `bind_images` | **已停用**:绑定关系由探针自动读,不需要人工截图。列还在(老数据不丢),详情里只读展示 |
|
||||
|
||||
存的是 OSS `object_key` 数组;前端用 `/api/files/object?key=...` 直接 `<img>` 预览。
|
||||
**显示时不在列表里下原图**——用 `thumbnail_url`(缩略图变体),点开才看原图。
|
||||
@@ -332,8 +421,13 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
|
||||
| POST | `/api/huya/external-orders/{id}/ck` | `huya:collab:edit` | 贴/换/重建 CK |
|
||||
| POST | `/api/huya/external-orders/{id}/ck/status` | `huya:collab:edit` | 人工停用/恢复(只能 `disabled`/`unverified`) |
|
||||
| POST | `/api/huya/external-orders/{id}/verify` | `huya:collab:edit` | 验证这一行的 CK(只读探针,§5.2) |
|
||||
| POST | `/api/huya/external-orders/ck/preview` | `huya:collab:edit` | 粘贴 CK 后的即时预览(探针 + 拉活动礼包,**只读不写库**) |
|
||||
| POST | `/api/huya/external-orders/ck/verify` | `huya:collab:edit` | 批量验证 CK(探针,body `{ids: [...]}`,最多 50 条) |
|
||||
| POST | `/api/huya/external-orders/import` | `huya:collab:edit` | 粘贴批量导入 |
|
||||
| GET | `/api/huya/collab-shops` | `huya:collab:view` | 店铺字典(含停用的,管理弹窗用) |
|
||||
| POST | `/api/huya/collab-shops` | `huya:collab:edit` | 新增店铺(重名 409) |
|
||||
| PUT | `/api/huya/collab-shops/{id}` | `huya:collab:edit` | 改名(可级联)/ 排序 / 停用启用 / 备注 |
|
||||
| DELETE | `/api/huya/collab-shops/{id}` | `huya:collab:manage` | 从字典删除(**只给运营**;不动订单) |
|
||||
| DELETE | `/api/huya/external-orders/{id}` | `huya:collab:manage` | 删除(级联清图) |
|
||||
| GET | `/api/huya/external-orders/owners` | `huya:collab:view` | 可选负责人列表(给筛选/下拉用) |
|
||||
|
||||
@@ -361,9 +455,17 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
|
||||
- **图片列**:缩略图 + 张数,点击开大图(`Image.PreviewGroup`);
|
||||
- **状态**:Tag 展示,行内"下一步"按钮推进(带二次确认);
|
||||
- **CK 状态**:Tag + 一键"验证";
|
||||
- 筛选:店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、斗鱼号、uid);
|
||||
- 筛选:店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、绑定账号、角色、uid);
|
||||
- 页头统计条:`待处理 N / 已绑定 N / 已充值 N / 已上脚本 N / 已完成 N / CK失效 N`。
|
||||
|
||||
> 交互细节(2026-10-16 用户拍板):**店铺是点选按钮组**(列表里点单元格弹出同一组按钮,
|
||||
> 筛选条里也是),不是下拉;**新建时不用选负责人**——默认就是当前登录账号
|
||||
> (要转单在列表里点一下改)。
|
||||
|
||||
> 新建弹窗(同日拍板):**订单编号 / 店铺 / CK / 礼包名必填**;贴 CK 后自动验证并显示
|
||||
> 「绑定账号 + 绑定角色 + 平台区服」,**读不到角色就禁止提交**;礼包下拉来自这次 CK 现拉的
|
||||
> 活动礼包(点选,不是手打),并记住上次选的那个(见 §5.5)。
|
||||
|
||||
### 9.2 详情抽屉
|
||||
|
||||
左侧:图片(两个槽位,可上传/删除/换序);
|
||||
@@ -404,6 +506,10 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
|
||||
| — | `uid` 可空 | 可空(唯一索引允许多 NULL)→ 支持"先建单、后贴 CK" |
|
||||
| — | **绑定对象的展示口径**(2026-10-16 用户拍板) | 展示**探针读到的游戏账号 + 绑定角色**(角色最重要);人工的"绑定斗鱼号"不再作列表主列,只留详情抽屉(Excel 导入列) |
|
||||
| — | 人工能否改 CK 状态 | 只能"停用"/"恢复(未验证)";`valid`/`invalid` 归探针 |
|
||||
| — | 店铺怎么填(2026-10-16 用户拍板) | **点选按钮组**(不是下拉);允许"新店"自定义输入,后端自动登记进字典 |
|
||||
| — | 负责人默认值(同日拍板) | **默认 = 当前登录账号**,新建时不用选;要转单在列表/详情里改 |
|
||||
| — | 新建必填项(同日拍板) | **订单编号 / 店铺 / CK / 礼包名**必填;CK 必须探针通且读到绑定角色 |
|
||||
| — | 礼包来源(同日拍板) | 用这份 CK 现拉活动礼包(`getActPrizeList`),点选 + 记住上次选择 |
|
||||
|
||||
> 决定 7 的后果:**本模块不 import 任何 `huya_tasks` / runner 代码**。
|
||||
> 以后要做「一键送进工作台」,那是一个**显式的**新动作(会创建 `HuyaAccount` 并写
|
||||
@@ -450,6 +556,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
|
||||
| **S5** | CK 探针验证(只读 `check_user_bind_game_account`,真机判据已定) | ✅ 2026-10-16 |
|
||||
| **S6** | 前端页面(列表 + 内联编辑 + 图片 + 详情抽屉 + 导入弹窗) | ✅ 2026-10-16 |
|
||||
| **S7** | 权限点接入(`huya:collab:view/edit/manage`) | ✅ 2026-10-16 |
|
||||
| **S8** | 店铺字典(表 `huya_collab_shops` + 管理弹窗 + 新建/编辑/筛选改成点选) | ✅ 2026-10-16 |
|
||||
| **S9** | 新建弹窗强校验(CK 预览 + 必填 + 礼包点选与记忆) | ✅ 2026-10-16 |
|
||||
|
||||
测试:`tests/test_huya_collab_orders.py`(68 例,其中探针 21 例,用假客户端离线跑)+
|
||||
`tests/test_cleanup_orphan_file_assets.py`(6 例,孤儿图清理脚本)。
|
||||
@@ -513,6 +621,86 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
|
||||
7. 迁移 `20261016_0056` 只加列(`bind_game_*` + `bind_snapshot_at`),老列一动不动;
|
||||
应用启动时会自动 `upgrade head`,本地不用手动跑。
|
||||
|
||||
### 11.4 2026-10-16 页面复核后补的点(用户点着看发现的)
|
||||
|
||||
1. **礼包列表空白 = SID 过期,不是解析问题**(真机定位,最值钱的一条):
|
||||
|
||||
| 活动 SID | 接口结论 | 礼包数 |
|
||||
| --- | --- | --- |
|
||||
| `2203`(9 月的活动,曾是 `HUYA_DEFAULT_SID`) | `status=200` + `msg=请求成功` | **0** |
|
||||
| `2277`(库里 `huya_config.sid`,今天的工作台配置) | `status=200` + `请求成功` | **30**(限量返场 / 精英专享) |
|
||||
|
||||
同一份真 CK、同一个 UA,只是 SID 不同 → 一个空一个满。**UA 不是原因**
|
||||
(`web&1.0&huya` 与我们的 `webh5&...` 结果一样),解析器也不是原因
|
||||
(`ActPrizeItem` 的 tag 映射与 huya-go 实测一致,`to_dict()["goods"]` 正常)。
|
||||
|
||||
处理:新增 `resolve_gifts_sid(db)` —— **优先读工作台配置 `huya_config.sid`**,
|
||||
读不到(没有配置行 / 不是数字)才回落到 `GIFTS_SID`;接口层
|
||||
`POST /external-orders/ck/preview` 注入 `db` 后把它传给 `preview_ck(sid=...)`。
|
||||
配置行不存在时**不建行**(预览是只读动作)。空列表的结论也改成人话:
|
||||
「活动 SID=xxx 没返回商品:接口通了但列表是空的,多半是活动换 ID 了 —— 去工作台
|
||||
「虎牙配置」把活动 SID 改成当前的再试」。
|
||||
→ **换活动时只改工作台配置,不用改代码**。
|
||||
2. **店铺必填的地方不再给「不填」按钮**:`HuyaCollabShopPicker` 新增 `allowClear`
|
||||
(默认 `true`,筛选条保持「全部」),新建弹窗 / 详情抽屉 / 列表行内改都传 `false` ——
|
||||
点出一个后端会拒的空值纯属自找麻烦。
|
||||
3. **礼包名改 `AutoComplete`**:列表有货时就是点选,拉不到列表时还能手输 ——
|
||||
否则「礼包名必填 + 拉取失败」会把新建彻底卡死。选项标签带上
|
||||
`[类目] 名称 · 积分 · 剩余`,同名的礼包能分清。
|
||||
4. **记住最近礼包改成「选中即记」**(原来只在创建成功后记):中途取消 / 提交失败也不丢,
|
||||
符合「记住最近选择的礼包」的原意。
|
||||
5. **预览请求加了序号防串**:连贴两份 CK 时,先发的那份迟到返回不再覆盖新结论
|
||||
(之前可能出现「显示上一份 CK 的有效」这种错觉);清空输入也会让在途请求作废。
|
||||
真正的把关仍在服务端:提交时 `require_verified=true` 会拿表单里的 CK 重新探针。
|
||||
6. **记忆里的礼包不在本次列表里**:清空并提示「上次选的「xx」不在这次拉到的礼包列表里,
|
||||
请重新点选」(拉取失败时**不**清,避免把人卡死)。
|
||||
|
||||
### 11.5 2026-10-16 第二轮页面复核(用户点着看又提的)
|
||||
|
||||
1. **弹窗放大了**:新建协作单 `width` 520→**920**、表单 `size` 回到 `middle`、CK 输入框 3→5 行、
|
||||
正文给 `maxHeight: 72vh` 滚动区(内容比一屏高时按钮不会被顶出去);粘贴导入弹窗 760→900。
|
||||
2. **绑定点选改成"一个店铺一个独立按钮、中间留白"**:`HuyaCollabShopPicker` 加 `size`
|
||||
(`small`/`middle`/`large`),新建弹窗用 `large`;原来 antd `Radio.Group optionType="button"`
|
||||
把按钮连成一整排分段控件,小且不好点 —— 现在每个店铺一个 `Button`(选中是 primary,
|
||||
未选是描边),按钮之间留 8–12px 空白;列表单元格与筛选条仍是 `small`。
|
||||
「+ 新店」跟在最后一个店铺后面(同一行):店铺这栏在新建弹窗里**独占一行**,
|
||||
否则半栏宽度放不下「4 个店铺 + 新店」,`Space wrap` 会把新店挤到第二行去。
|
||||
3. **「绑定斗鱼号」从界面下线**(用户原话"绑定斗鱼号不需要了"):绑了谁由探针写 `bind_game_*`,
|
||||
人工那一列已无意义。新建弹窗不再有这个字段(提交也不再带);详情抽屉去掉可编辑项,
|
||||
**老数据非空时只读展示**;`bind_douyu_account` 列与粘贴导入的 Excel 列序**都不动**
|
||||
(导进来的老 Excel 不会解析错位)。
|
||||
4. **礼包列表改为浏览器缓存 + 一个强制刷新**:见 §5.5 的「礼包列表缓存」。
|
||||
贴 CK 时带 `with_gifts=false`(本地没缓存时才自动拉一次),Alert 里会写明"用本地缓存(N 个 · 时间)";
|
||||
按钮从「重新拉取礼包」变成「强制刷新礼包(N)」。
|
||||
细节:礼包 state 的**初值直接读 localStorage**(不是等 `useEffect`)—— 否则"刚打开页面就贴 CK"
|
||||
那一瞬缓存还没进 state,会白拉一次;CK 输入框的 placeholder / tooltip 也改成
|
||||
「贴进来即自动验证 + 读绑定角色(礼包有缓存就不再拉)」,别再让人以为每次都会拉礼包。
|
||||
5. **主页截图必填、绑定图下线**:`create_order` 新增 `home_images` 入参,严格模式下
|
||||
「主页截图」进必填清单(`create_field_required`),key 走 `normalize_images`(防路径穿越);
|
||||
新建弹窗内嵌 `HuyaCollabHomeImageUploader`(先传 `/api/files/upload` 拿 object_key、
|
||||
可预览/设为封面/移除,提交时随表单一起落库)。
|
||||
≥1 张的兜底在前端 `Form.Item` validator + 服务端双重把关。
|
||||
|
||||
### 11.6 2026-10-16 第三轮页面复核(弹窗/列表体验)
|
||||
|
||||
1. **新建弹窗尽量不出滚动条**:
|
||||
- CK 输入框由固定 5 行改成 `autoSize={{ minRows: 3, maxRows: 8 }}`(短 CK 就 3 行,不再白占高度);
|
||||
- 「主页截图」和「备注」并排一行(14/10),备注 3 行 → 2 行;
|
||||
- 表单项间距压到 14px(`src/index.css` 里 `.huya-collab-create .ant-form-item`),
|
||||
正文 maxHeight 72vh → 78vh 只当兜底。
|
||||
2. **列表加「序号」列**:跨页连续(`(page-1)*pageSize + index + 1`),固定在左侧 ——
|
||||
群里沟通时可以直接说"第 12 行"。
|
||||
3. **搜索扩到 11 个字段**:原来只搜订单号 / 绑定斗鱼号 / 绑定账号 / 角色 / uid / 昵称 /
|
||||
hy_username / 礼包 / 备注,现在补上 **店铺** 与 **平台·区服**(`bind_game_channel`),
|
||||
输入框加宽到 340px 并在 placeholder 里逐项写清能搜什么。
|
||||
(其实一个输入框就能打穿这些列,之前是列没写全 + 提示太短,容易被当成"没有搜索"。)
|
||||
4. **放开 `uid` 唯一性 + 同买家识别**:见 §3.2 与 §5.6;迁移 `20261016_0058`。
|
||||
5. **「CK 状态」列的探针结论必须截断**(用户反馈:长文案顶到「最后改动」列上去了):
|
||||
那一格是「标签 + 探针结论」,而结论里塞了账号 / 角色 / 平台·区服,长度不可控。
|
||||
改成单行省略(`display:block` + `maxWidth` + `text-overflow:ellipsis` + `nowrap`),
|
||||
列宽 150 → 170,全文放 Tooltip(附验证时间)。
|
||||
顺带把礼包 / 备注那格的可编辑文本加上 `wordBreak: break-word`,防止一长串无空格字符串顶穿列。
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险
|
||||
@@ -521,6 +709,7 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红
|
||||
| --- | --- |
|
||||
| 与 `huya_accounts` 出现"两份 CK 谁是准的" | §2.3 已写死:本表 CK 只在协作语境有效,不自动同步;需要跑任务时人工导入 |
|
||||
| 探针接口被风控 | 只做人工触发、串行、带间隔;不做定时轮询 |
|
||||
| 活动换 ID(SID 过期)→ 礼包列表空白 | 礼包 SID 一律读 `huya_config.sid`(工作台「虎牙配置」可改,见 §11.4);空列表时结论直接提示去改 SID |
|
||||
| 自由文本的"绑定斗鱼号"没法校验 | 先按文本存(Excel 里就是文本);能拿到结构化 uid 时补 `bind_douyu_uid` |
|
||||
| 图片越攒越多 | §6.4 孤儿清理脚本 + `file_assets` 台账已有 `size_bytes` 可统计 |
|
||||
| 分支 | 按 AGENTS §1,新功能应从 `main` 开 `feat/*`;开工前需确认当前分支与库迁移版本对齐 |
|
||||
|
||||
@@ -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):
|
||||
"""运营能看能改能删;客服能看能改但不能删。"""
|
||||
|
||||
@@ -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"
|
||||
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261016_0056"
|
||||
HEAD_REVISION = "20261016_0058"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""协作台账的店铺字典表 `huya_collab_shops`。
|
||||
|
||||
为什么要有它:新建协作单时"店铺"原来是手打的自由文本,同一个店铺各种写法(错别字、
|
||||
多空格)会把筛选打散。改成一张可维护的字典:新建时**点一下选中**,
|
||||
下拉里也能直接输一个新名字(自定义,会自动登记进字典)。
|
||||
|
||||
**不回填历史订单里的店铺名**(2026-10-16 用户明确"不用看之前的"):字典从空开始,
|
||||
需要哪些店铺就在「店铺管理」里加、或在新建时自定义输入。
|
||||
|
||||
注意:订单里存的仍是店铺**名字**(`huya_external_orders.shop`),不是 id —— 历史 Excel
|
||||
就是文本,本轮不改语义;改名走 `update_shop(..., rename_orders=True)` 级联。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261016_0057"
|
||||
down_revision: str | None = "20261016_0056"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
TABLE = "huya_collab_shops"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if TABLE in set(sa.inspect(bind).get_table_names()):
|
||||
return
|
||||
op.create_table(
|
||||
TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("remark", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column("updated_by", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index(f"ix_{TABLE}_name", TABLE, ["name"], unique=True)
|
||||
op.create_index(f"ix_{TABLE}_is_active", TABLE, ["is_active"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if TABLE in set(sa.inspect(bind).get_table_names()):
|
||||
# MySQL 删表会连索引一起删。
|
||||
op.drop_table(TABLE)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""放开 `huya_external_orders.uid` 的唯一性:一个虎牙号可以挂多单。
|
||||
|
||||
背景(2026-10-16 用户拍板):真机上"同一个买家买两件"往往**继续用同一份外部 CK**,
|
||||
而 0055 建的 `ix_huya_external_orders_uid` 是**唯一索引** —— 第二单会被数据库顶掉
|
||||
(`Duplicate entry ... for key ...uid`)。原来"一个虎牙号只能挂一单"的规则随之作废:
|
||||
服务层不再抛 `uid_conflict`,「这个号还挂在哪些单上」只作为提示/审计信息(见 §5.6)。
|
||||
|
||||
做法:把该索引改成**普通索引**(保留查询性能,不再约束唯一)。
|
||||
uid 仍然可空 —— 「先建单、后贴 CK」不受影响(MySQL 索引对多个 NULL 宽松,普通索引更是)。
|
||||
|
||||
可重复执行:按当前索引的实际唯一性判断,已经是非唯一就直接返回。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20261016_0058"
|
||||
down_revision: str | None = "20261016_0057"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
TABLE = "huya_external_orders"
|
||||
INDEX = "ix_huya_external_orders_uid"
|
||||
COLUMNS = ["uid"]
|
||||
|
||||
|
||||
def _has_table(bind) -> bool:
|
||||
return TABLE in set(sa.inspect(bind).get_table_names())
|
||||
|
||||
|
||||
def _index(bind) -> dict | None:
|
||||
for index in sa.inspect(bind).get_indexes(TABLE):
|
||||
if index.get("name") == INDEX:
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _has_table(bind):
|
||||
return
|
||||
index = _index(bind)
|
||||
if index is None:
|
||||
# 索引丢了(手工库):补一个普通索引就行
|
||||
op.create_index(INDEX, TABLE, COLUMNS, unique=False)
|
||||
return
|
||||
if not index.get("unique"):
|
||||
return
|
||||
op.drop_index(INDEX, table_name=TABLE)
|
||||
op.create_index(INDEX, TABLE, COLUMNS, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _has_table(bind):
|
||||
return
|
||||
index = _index(bind)
|
||||
if index is None:
|
||||
op.create_index(INDEX, TABLE, COLUMNS, unique=True)
|
||||
return
|
||||
if index.get("unique"):
|
||||
return
|
||||
# 回退前先确认没有重复 uid,否则唯一索引建不起来(宁可报错也不要静默丢数据)
|
||||
duplicates = bind.execute(
|
||||
sa.text(
|
||||
f"SELECT uid FROM {TABLE} WHERE uid IS NOT NULL AND uid <> '' "
|
||||
"GROUP BY uid HAVING COUNT(*) > 1 LIMIT 5"
|
||||
)
|
||||
).fetchall()
|
||||
if duplicates:
|
||||
names = ", ".join(str(row[0]) for row in duplicates)
|
||||
raise RuntimeError(f"存在重复 uid({names}),先合并/清理这些订单再降级")
|
||||
op.drop_index(INDEX, table_name=TABLE)
|
||||
op.create_index(INDEX, TABLE, COLUMNS, unique=True)
|
||||
+43
-5
@@ -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="")
|
||||
|
||||
@@ -24,6 +24,11 @@ from ...database import get_db
|
||||
from ...deps import require_permission
|
||||
from ...models import AuditLog, HuyaExternalOrder, User
|
||||
from ...schemas import (
|
||||
HuyaCollabCkPreviewOut,
|
||||
HuyaCollabCkPreviewRequest,
|
||||
HuyaCollabShopCreate,
|
||||
HuyaCollabShopOut,
|
||||
HuyaCollabShopUpdate,
|
||||
HuyaExternalOrderAuditOut,
|
||||
HuyaExternalOrderCkRequest,
|
||||
HuyaExternalOrderCkStatusRequest,
|
||||
@@ -52,7 +57,12 @@ def _http_error(exc: collab.CollabError) -> HTTPException:
|
||||
|
||||
|
||||
def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict:
|
||||
return collab.serialize_order(order, users=collab.users_by_id(db, [order]))
|
||||
clusters = collab.buyer_clusters(db, [order])
|
||||
return collab.serialize_order(
|
||||
order,
|
||||
users=collab.users_by_id(db, [order]),
|
||||
buyer_cluster=clusters.get(int(order.id)),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -89,10 +99,81 @@ def external_orders_shops(
|
||||
db: Session = Depends(get_db),
|
||||
_current: User = Depends(require_permission("huya:collab:view")),
|
||||
):
|
||||
"""已用过的店铺列表(筛选下拉)。"""
|
||||
"""店铺名列表(现在就是**店铺字典**里的名字;筛选下拉用)。"""
|
||||
return collab.list_shops(db)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 店铺字典(新建协作单时点选;也支持自定义输入后自动登记)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/collab-shops", response_model=list[HuyaCollabShopOut])
|
||||
def list_collab_shops(
|
||||
db: Session = Depends(get_db),
|
||||
_current: User = Depends(require_permission("huya:collab:view")),
|
||||
):
|
||||
"""全部店铺(**含停用的**:管理弹窗要看到;新建下拉由前端按 `is_active` 过滤)。"""
|
||||
shops = collab.list_shop_records(db)
|
||||
names = collab.user_names(
|
||||
db,
|
||||
[shop.created_by for shop in shops] + [shop.updated_by for shop in shops],
|
||||
)
|
||||
return [collab.serialize_shop(shop, users=names) for shop in shops]
|
||||
|
||||
|
||||
@router.post("/collab-shops", response_model=HuyaCollabShopOut)
|
||||
def create_collab_shop(
|
||||
req: HuyaCollabShopCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:collab:edit")),
|
||||
):
|
||||
"""新增店铺(重名 409)。"""
|
||||
try:
|
||||
shop = collab.create_shop(
|
||||
db, current, name=req.name, sort_order=req.sort_order, remark=req.remark
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return collab.serialize_shop(shop)
|
||||
|
||||
|
||||
@router.put("/collab-shops/{shop_id}", response_model=HuyaCollabShopOut)
|
||||
def update_collab_shop(
|
||||
shop_id: int,
|
||||
req: HuyaCollabShopUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:collab:edit")),
|
||||
):
|
||||
"""改店铺:名字 / 排序 / 停用启用 / 备注。
|
||||
|
||||
``rename_orders``(默认 true)为真时,改名会级联改掉已有订单里的旧名字。
|
||||
"""
|
||||
payload = req.model_dump(exclude_unset=True)
|
||||
rename_orders = bool(payload.pop("rename_orders", True))
|
||||
try:
|
||||
shop = collab.get_shop(db, shop_id)
|
||||
collab.update_shop(db, current, shop, payload, rename_orders=rename_orders)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return collab.serialize_shop(shop)
|
||||
|
||||
|
||||
@router.delete("/collab-shops/{shop_id}")
|
||||
def delete_collab_shop(
|
||||
shop_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:collab:manage")),
|
||||
):
|
||||
"""从字典里删掉(**只给运营**)。不动订单:订单存的是店铺名,历史单子照旧。"""
|
||||
try:
|
||||
shop = collab.get_shop(db, shop_id)
|
||||
snapshot = collab.delete_shop(db, current, shop)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return {"success": True, "name": snapshot["name"]}
|
||||
|
||||
|
||||
@router.get("/external-orders", response_model=HuyaExternalOrderListOut)
|
||||
def list_external_orders(
|
||||
shop: str = Query("", max_length=64),
|
||||
@@ -119,8 +200,15 @@ def list_external_orders(
|
||||
page_size=page_size,
|
||||
)
|
||||
names = collab.users_by_id(db, rows)
|
||||
# 「同一买家的其它单」整页一次算完(按绑定账号 + 绑定角色软识别,只读派生)
|
||||
clusters = collab.buyer_clusters(db, rows)
|
||||
return {
|
||||
"items": [collab.serialize_order(row, users=names) for row in rows],
|
||||
"items": [
|
||||
collab.serialize_order(
|
||||
row, users=names, buyer_cluster=clusters.get(int(row.id))
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -133,7 +221,12 @@ def create_external_order(
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(require_permission("huya:collab:edit")),
|
||||
):
|
||||
"""新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。"""
|
||||
"""新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。
|
||||
|
||||
``require_verified``(默认 true):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK /
|
||||
礼包名 / 主页截图必填,且 CK 必须探针通并读到绑定角色,否则 400
|
||||
(粘贴导入走另一个接口,不受影响)。
|
||||
"""
|
||||
try:
|
||||
order = collab.create_order(
|
||||
db,
|
||||
@@ -144,7 +237,9 @@ def create_external_order(
|
||||
ck_text=req.ck_text,
|
||||
bind_douyu_account=req.bind_douyu_account,
|
||||
gift_name=req.gift_name,
|
||||
home_images=req.home_images,
|
||||
remark=req.remark,
|
||||
require_verified=req.require_verified,
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
@@ -167,6 +262,33 @@ def import_external_orders(
|
||||
return collab.import_orders(db, current, req.text, owner_by_name=owners)
|
||||
|
||||
|
||||
@router.post("/external-orders/ck/preview", response_model=HuyaCollabCkPreviewOut)
|
||||
def preview_external_order_ck(
|
||||
req: HuyaCollabCkPreviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_current: User = Depends(require_permission("huya:collab:edit")),
|
||||
):
|
||||
"""粘贴 CK 后的**即时预览**(只读):探针 + 顺带拉活动礼包,一行都不写库。
|
||||
|
||||
新建弹窗据此决定能不能提交:``ok`` 且 ``has_role`` 才允许
|
||||
(服务端在创建时还会再校验一次,见 ``POST /external-orders``)。
|
||||
|
||||
礼包用**工作台配置里的活动 SID**(``huya_config.sid``)拉:SID 就是活动 ID,
|
||||
换活动就得换它,写死会出现「接口 200 但礼包列表是空的」(真机 2026-10-16)。
|
||||
"""
|
||||
try:
|
||||
preview = collab.preview_ck(
|
||||
req.ck_text,
|
||||
with_gifts=req.with_gifts,
|
||||
sid=collab.resolve_gifts_sid(db),
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
# 「这个虎牙号已经在哪些单上」:允许同一份 CK 服务多单,这里只提示、不挡
|
||||
preview["uid_used_by"] = collab.uids_in_use(db, preview.get("uid"))
|
||||
return preview
|
||||
|
||||
|
||||
@router.post("/external-orders/ck/verify", response_model=HuyaExternalOrderVerifyOut)
|
||||
def verify_external_orders_ck(
|
||||
req: HuyaExternalOrderVerifyRequest,
|
||||
|
||||
+110
-1
@@ -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):
|
||||
"""列表响应:共享视图,带上总数供分页使用。"""
|
||||
|
||||
@@ -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 粘贴)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
HuyaTaskItem,
|
||||
HuyaBrowserExchangeKit,
|
||||
HuyaCollabAuditItem,
|
||||
HuyaCollabCkPreview,
|
||||
HuyaCollabCkRequest,
|
||||
HuyaCollabCkStatusRequest,
|
||||
HuyaCollabImagesRequest,
|
||||
@@ -42,6 +43,9 @@ import type {
|
||||
HuyaCollabOrderQuery,
|
||||
HuyaCollabOrderUpdate,
|
||||
HuyaCollabOwner,
|
||||
HuyaCollabShop,
|
||||
HuyaCollabShopCreate,
|
||||
HuyaCollabShopUpdate,
|
||||
HuyaCollabStatusRequest,
|
||||
HuyaCollabSummary,
|
||||
HuyaCollabVerifyResult,
|
||||
@@ -199,6 +203,18 @@ export const huyaApi = {
|
||||
api.get<HuyaCollabOwner[], HuyaCollabOwner[]>('/huya/external-orders/owners'),
|
||||
/** 已用过的店铺(筛选下拉) */
|
||||
collabOrderShops: () => api.get<string[], string[]>('/huya/external-orders/shops'),
|
||||
|
||||
// ---- 店铺字典(新建协作单时点选;见业务规则 docs/设计-虎牙外部CK协作台账.md)----
|
||||
/** 全部店铺(含停用的:管理弹窗要看到;新建下拉由页面按 is_active 过滤) */
|
||||
listCollabShops: () => api.get<HuyaCollabShop[], HuyaCollabShop[]>('/huya/collab-shops'),
|
||||
createCollabShop: (data: HuyaCollabShopCreate) =>
|
||||
api.post<HuyaCollabShop, HuyaCollabShop>('/huya/collab-shops', data),
|
||||
updateCollabShop: (id: number, data: HuyaCollabShopUpdate) =>
|
||||
api.put<HuyaCollabShop, HuyaCollabShop>(`/huya/collab-shops/${id}`, data),
|
||||
deleteCollabShop: (id: number) =>
|
||||
api.delete<{ success: boolean; name: string }, { success: boolean; name: string }>(
|
||||
`/huya/collab-shops/${id}`,
|
||||
),
|
||||
getCollabOrder: (id: number) =>
|
||||
api.get<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}`),
|
||||
createCollabOrder: (data: HuyaCollabOrderCreate) =>
|
||||
@@ -218,6 +234,12 @@ export const huyaApi = {
|
||||
/** 整槽替换图片(提交 /api/files/upload 返回的 object_key 数组) */
|
||||
updateCollabOrderImages: (id: number, data: HuyaCollabImagesRequest) =>
|
||||
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/images`, data),
|
||||
/** 粘贴 CK 后的即时预览(只读):探针 + 拉活动礼包,一行都不写库 */
|
||||
previewCollabCk: (ck_text: string, with_gifts = true) =>
|
||||
api.post<HuyaCollabCkPreview, HuyaCollabCkPreview>('/huya/external-orders/ck/preview', {
|
||||
ck_text,
|
||||
with_gifts,
|
||||
}),
|
||||
/** 验证 CK(只读探针):写 ck_status,并刷新绑定账号/角色快照 */
|
||||
verifyCollabOrder: (id: number) =>
|
||||
api.post<HuyaCollabOrder, HuyaCollabOrder>(`/huya/external-orders/${id}/verify`),
|
||||
|
||||
@@ -1259,6 +1259,33 @@ export interface HuyaCollabOrder {
|
||||
version: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
/**
|
||||
* 同一买家的其它单(软识别:绑定账号 + 绑定角色相同,只读派生)。
|
||||
* 只有真的 ≥2 单才有值,否则是 null。
|
||||
*/
|
||||
buyer_cluster: HuyaCollabBuyerCluster | null;
|
||||
}
|
||||
|
||||
/** 「同一买家的其它单」里的一条 */
|
||||
export interface HuyaCollabBuyerOrder {
|
||||
id: number;
|
||||
order_no: string;
|
||||
shop: string;
|
||||
gift_name: string;
|
||||
uid: string;
|
||||
status: HuyaCollabStatus;
|
||||
status_label: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** 同一买家的多单(软识别,不是人工"软绑定"关系) */
|
||||
export interface HuyaCollabBuyerCluster {
|
||||
/** 买家指纹的可读文本:绑定账号 · 绑定角色 */
|
||||
key: string;
|
||||
/** 识别口径(后端说明字段,界面上标注用) */
|
||||
method: string;
|
||||
total: number;
|
||||
others: HuyaCollabBuyerOrder[];
|
||||
}
|
||||
|
||||
export interface HuyaCollabOrderList {
|
||||
@@ -1306,7 +1333,11 @@ export interface HuyaCollabOrderCreate {
|
||||
ck_text?: string;
|
||||
bind_douyu_account?: string;
|
||||
gift_name?: string;
|
||||
/** 主页截图(object_key 数组,第一张作封面);严格模式下必填至少 1 张 */
|
||||
home_images?: string[];
|
||||
remark?: string;
|
||||
/** 严格模式(默认 true):店铺/CK/礼包名/主页截图必填,且 CK 必须读到绑定角色 */
|
||||
require_verified?: boolean;
|
||||
}
|
||||
|
||||
export interface HuyaCollabOrderUpdate {
|
||||
@@ -1386,3 +1417,62 @@ export interface FileUploadResult {
|
||||
content_type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** 活动礼包(用一份有效 CK 现拉) */
|
||||
export interface HuyaCollabGift {
|
||||
product_id: string;
|
||||
name: string;
|
||||
price: number | null;
|
||||
remain_text: string;
|
||||
stock: number | null;
|
||||
score: number | null;
|
||||
category_name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** 粘贴 CK 后的即时预览:ok 且 has_role 才允许提交新建 */
|
||||
export interface HuyaCollabCkPreview {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
uid: string;
|
||||
yyuid: string;
|
||||
hy_username: string;
|
||||
ck_status: HuyaCollabCkStatus;
|
||||
bind_game_account: string;
|
||||
bind_game_role: string;
|
||||
bind_game_channel: string;
|
||||
has_role: boolean;
|
||||
gifts: HuyaCollabGift[];
|
||||
gifts_message: string;
|
||||
/** 这个虎牙号已经挂在哪些单上(允许同一份 CK 服务多单,只是提示) */
|
||||
uid_used_by: HuyaCollabBuyerOrder[];
|
||||
}
|
||||
|
||||
/** 协作台账的店铺字典项 */
|
||||
export interface HuyaCollabShop {
|
||||
id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
/** false = 停用(新建时不再出现在下拉里;历史订单照旧) */
|
||||
is_active: boolean;
|
||||
remark: string;
|
||||
created_by_name: string;
|
||||
updated_by_name: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface HuyaCollabShopCreate {
|
||||
name: string;
|
||||
sort_order?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface HuyaCollabShopUpdate {
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
remark?: string;
|
||||
/** 改名时是否级联更新已有订单里的店铺名(默认 true) */
|
||||
rename_orders?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 新建协作单时的「主页截图」上传(先传 OSS 拿 object_key,随表单一起提交)。
|
||||
*
|
||||
* - 第一张就是封面(列表和详情都按第一张当封面);
|
||||
* - 新建时还没落库,所以预览用的地址来自上传接口返回的 thumbnail_url / url;
|
||||
* - 放进 `Form.Item name="home_images"`,值就是 object_key 数组。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Image, Popconfirm, Space, Tag, Tooltip, Typography, Upload } from 'antd';
|
||||
import { CloudUploadOutlined, DeleteOutlined, SwapOutlined } from '@ant-design/icons';
|
||||
import { fileApi } from '../api/files';
|
||||
import type { FileUploadResult } from '../api/types';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type Preview = Pick<FileUploadResult, 'url' | 'thumbnail_url'>;
|
||||
|
||||
export default function HuyaCollabHomeImageUploader({
|
||||
value = [],
|
||||
max = 20,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value?: string[];
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
onChange?: (value: string[]) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [previews, setPreviews] = useState<Record<string, Preview>>({});
|
||||
// 连传多张时每次都用"上一次的结果 + 新 key",不吃闭包里的旧 value(否则会互相覆盖)
|
||||
const keysRef = useRef<string[]>(value ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
keysRef.current = value ?? [];
|
||||
}, [value]);
|
||||
|
||||
const keys = value ?? [];
|
||||
const full = keys.length >= max;
|
||||
|
||||
const uploadOne = async (file: File) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const uploaded = await fileApi.upload(file, 'collab');
|
||||
setPreviews((prev) => ({
|
||||
...prev,
|
||||
[uploaded.object_key]: { url: uploaded.url, thumbnail_url: uploaded.thumbnail_url },
|
||||
}));
|
||||
const next = [...keysRef.current, uploaded.object_key];
|
||||
keysRef.current = next;
|
||||
onChange?.(next);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (key: string) => {
|
||||
const next = keysRef.current.filter((item) => item !== key);
|
||||
keysRef.current = next;
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
const setCover = (key: string) => {
|
||||
const next = [key, ...keysRef.current.filter((item) => item !== key)];
|
||||
keysRef.current = next;
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{keys.length === 0 ? (
|
||||
<Text type="secondary">还没有图(必填:至少 1 张,第一张作封面)</Text>
|
||||
) : (
|
||||
<Image.PreviewGroup
|
||||
items={keys.map((key) => previews[key]?.url || `/api/files/object?key=${encodeURIComponent(key)}`)}
|
||||
>
|
||||
<Space wrap size={12}>
|
||||
{keys.map((key, index) => (
|
||||
<Space key={key} direction="vertical" size={2} align="center">
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Image
|
||||
src={
|
||||
previews[key]?.thumbnail_url
|
||||
|| previews[key]?.url
|
||||
|| `/api/files/object?key=${encodeURIComponent(key)}&variant=thumb`
|
||||
}
|
||||
width={88}
|
||||
height={88}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
alt={`主页截图${index + 1}`}
|
||||
/>
|
||||
{index === 0 && (
|
||||
<Tag color="blue" style={{ position: 'absolute', left: 0, top: 0, margin: 0 }}>
|
||||
封面
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<Space size={2}>
|
||||
{index !== 0 && (
|
||||
<Tooltip title="设为封面">
|
||||
<Button size="small" type="text" icon={<SwapOutlined />} onClick={() => setCover(key)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="去掉这张图?"
|
||||
description="对象不会立刻删除,交给孤儿清理脚本"
|
||||
onConfirm={() => remove(key)}
|
||||
>
|
||||
<Tooltip title="移除">
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
)}
|
||||
{!disabled && (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
disabled={busy || full}
|
||||
beforeUpload={(file) => {
|
||||
void uploadOne(file as File);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button icon={<CloudUploadOutlined />} loading={busy} disabled={full}>
|
||||
{full ? `最多 ${max} 张` : '上传主页截图'}
|
||||
</Button>
|
||||
</Upload>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Checkbox, Empty, Input, InputNumber, Modal, Popconfirm, Space, Switch,
|
||||
Table, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaCollabShop } from '../api/modules';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 店铺管理:维护「新建协作单」下拉里的店铺。
|
||||
*
|
||||
* - 加 / 排序 / 备注 / 停用(停用=不再出现在新建下拉,历史订单照旧);
|
||||
* - **重命名**会级联更新已有订单里的店铺名(弹窗里可取消勾选),
|
||||
* 否则筛选里会同时出现新旧两个名字;
|
||||
* - 删除只删字典行、不动订单(需要 `huya:collab:manage`)。
|
||||
*/
|
||||
export default function HuyaCollabShopModal({
|
||||
open,
|
||||
shops,
|
||||
canEdit,
|
||||
canManage,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
shops: HuyaCollabShop[];
|
||||
canEdit: boolean;
|
||||
canManage: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<HuyaCollabShop | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [renameOrders, setRenameOrders] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setNewName('');
|
||||
setRenameTarget(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const create = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) {
|
||||
message.warning('请先输入店铺名');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await huyaApi.createCollabShop({ name });
|
||||
message.success(`已添加店铺「${name}」`);
|
||||
setNewName('');
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const patch = async (shop: HuyaCollabShop, payload: { sort_order?: number; is_active?: boolean; remark?: string }) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await huyaApi.updateCollabShop(shop.id, payload);
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRename = async () => {
|
||||
if (!renameTarget) return;
|
||||
const name = renameValue.trim();
|
||||
if (!name || name === renameTarget.name) {
|
||||
setRenameTarget(null);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await huyaApi.updateCollabShop(renameTarget.id, { name, rename_orders: renameOrders });
|
||||
message.success(renameOrders ? `已重命名为「${name}」,已有订单里的旧名字也同步了` : `已重命名为「${name}」`);
|
||||
setRenameTarget(null);
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (shop: HuyaCollabShop) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await huyaApi.deleteCollabShop(shop.id);
|
||||
message.success(`已从字典删除「${shop.name}」`);
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableProps<HuyaCollabShop>['columns'] = [
|
||||
{
|
||||
title: '店铺名', dataIndex: 'name', width: 180,
|
||||
render: (value: string, row) => (
|
||||
<Space size={4}>
|
||||
<Text strong>{value}</Text>
|
||||
{!row.is_active && <Tag>已停用</Tag>}
|
||||
{canEdit && (
|
||||
<Tooltip title="重命名">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setRenameTarget(row);
|
||||
setRenameValue(row.name);
|
||||
setRenameOrders(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排序', dataIndex: 'sort_order', width: 100,
|
||||
render: (value: number, row) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={value}
|
||||
disabled={!canEdit || busy}
|
||||
onChange={(next) => {
|
||||
if (next === null || next === value) return;
|
||||
void patch(row, { sort_order: Number(next) });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '新建时可选', dataIndex: 'is_active', width: 110, align: 'center',
|
||||
render: (value: boolean, row) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={value}
|
||||
disabled={!canEdit || busy}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
onChange={(checked) => void patch(row, { is_active: checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注', dataIndex: 'remark', width: 200,
|
||||
render: (value: string, row) => (
|
||||
<Input
|
||||
size="small"
|
||||
defaultValue={value}
|
||||
disabled={!canEdit || busy}
|
||||
placeholder="备注(失焦保存)"
|
||||
onBlur={(event) => {
|
||||
const next = event.target.value.trim();
|
||||
if (next !== value) void patch(row, { remark: next });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后改动', width: 150,
|
||||
render: (_, row) => (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.updated_by_name || '-'}
|
||||
{row.updated_at ? ` · ${new Date(row.updated_at).toLocaleString()}` : ''}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作', width: 90, align: 'center',
|
||||
render: (_, row) =>
|
||||
canManage ? (
|
||||
<Popconfirm
|
||||
title={`从字典删除「${row.name}」?`}
|
||||
description="只删字典行,已有订单里的店铺名不变"
|
||||
onConfirm={() => void remove(row)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Button size="small" danger type="text" icon={<DeleteOutlined />} disabled={busy} />
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="店铺管理"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
width={860}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Text type="secondary">
|
||||
这里的店铺就是新建协作单时下拉里能点选的那些。停用只是不再出现在新建下拉里,
|
||||
历史订单不受影响;重命名可以选择是否同步已有订单。
|
||||
</Text>
|
||||
{canEdit && (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="输入店铺名,回车添加"
|
||||
value={newName}
|
||||
maxLength={64}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
onPressEnter={() => void create()}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} loading={busy} onClick={() => void create()}>
|
||||
添加
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
)}
|
||||
<Table<HuyaCollabShop>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={shops}
|
||||
pagination={false}
|
||||
scroll={{ y: 320 }}
|
||||
locale={{
|
||||
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有店铺,先加一个" />,
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={renameTarget ? `重命名「${renameTarget.name}」` : '重命名店铺'}
|
||||
open={!!renameTarget}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
onOk={() => void submitRename()}
|
||||
confirmLoading={busy}
|
||||
okText="保存"
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={renameValue}
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onPressEnter={() => void submitRename()}
|
||||
/>
|
||||
<Checkbox checked={renameOrders} onChange={(event) => setRenameOrders(event.target.checked)}>
|
||||
同时更新已有订单里的店铺名(不勾选的话,筛选里会同时出现新旧两个名字)
|
||||
</Checkbox>
|
||||
</Space>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Button, Input, Space, Tag, Typography } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import type { HuyaCollabShop } from '../api/modules';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 店铺选择器:**一个店铺一个独立按钮**(中间留白),不做下拉。
|
||||
*
|
||||
* - 选中那个按钮变主题蓝(primary),没选的是白底描边,一眼看得出选的是谁;
|
||||
* - `allowClear` 为真时多一个「不填 / 全部」按钮(筛选里传"全部"),
|
||||
* 店铺必填的地方传 `false` 把空值这条路堵掉;
|
||||
* - `allowCustom`:点「+ 新店」直接输新名字,选中即用(后端会自动登记进字典);
|
||||
* - `includeInactive`:停用的店铺也列出来(筛选/详情里要能看到历史值);
|
||||
* - 当前值不在字典里(老数据/刚自定义的名字)也会显示成一个按钮,不会被吞掉。
|
||||
*/
|
||||
export default function HuyaCollabShopPicker({
|
||||
value,
|
||||
shops,
|
||||
disabled,
|
||||
includeInactive = false,
|
||||
allowCustom = true,
|
||||
allowClear = true,
|
||||
clearLabel = '不填',
|
||||
size = 'small',
|
||||
onChange,
|
||||
}: {
|
||||
value?: string | null;
|
||||
shops: HuyaCollabShop[];
|
||||
disabled?: boolean;
|
||||
includeInactive?: boolean;
|
||||
allowCustom?: boolean;
|
||||
/** 是否给一个「清空」按钮:店铺必填的地方传 false,别让人点出个空值 */
|
||||
allowClear?: boolean;
|
||||
clearLabel?: string;
|
||||
/** 按钮尺寸:列表单元格 / 筛选条用小号,新建弹窗用大号(更好点) */
|
||||
size?: 'small' | 'middle' | 'large';
|
||||
/** 放进 Form.Item 时由 antd 注入;单独用时自己传 */
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customName, setCustomName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!customOpen) setCustomName('');
|
||||
}, [customOpen]);
|
||||
|
||||
const current = value ?? '';
|
||||
const visible = shops.filter((shop) => includeInactive || shop.is_active);
|
||||
const options: { value: string; label: ReactNode }[] = visible.map((shop) => ({
|
||||
value: shop.name,
|
||||
label: (
|
||||
<Space size={4}>
|
||||
{shop.name}
|
||||
{!shop.is_active && <Tag style={{ margin: 0 }}>停用</Tag>}
|
||||
</Space>
|
||||
),
|
||||
}));
|
||||
if (current && !visible.some((shop) => shop.name === current)) {
|
||||
// 老数据 / 刚自定义的:照样给个可点的按钮,不然界面上看不出选中了啥
|
||||
options.unshift({ value: current, label: current });
|
||||
}
|
||||
|
||||
const submitCustom = () => {
|
||||
const name = customName.trim();
|
||||
if (!name) {
|
||||
setCustomOpen(false);
|
||||
return;
|
||||
}
|
||||
onChange?.(name);
|
||||
setCustomOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space wrap size={size === 'large' ? 12 : 8}>
|
||||
{/* 一个店铺一个独立按钮,中间留白(原来是连成一排的分段控件,太小不好点) */}
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
size={size}
|
||||
type={option.value === current ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange?.(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
{allowClear && (
|
||||
<Button
|
||||
size={size}
|
||||
type={current === '' ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange?.('')}
|
||||
>
|
||||
<Text type={current === '' ? undefined : 'secondary'}>{clearLabel}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{allowCustom && !disabled && !customOpen && (
|
||||
<Button size={size} type="link" icon={<PlusOutlined />} onClick={() => setCustomOpen(true)}>
|
||||
新店
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
{customOpen && (
|
||||
<Input
|
||||
size={size}
|
||||
autoFocus
|
||||
maxLength={64}
|
||||
placeholder="输入新店铺名,回车确认"
|
||||
value={customName}
|
||||
onChange={(event) => setCustomName(event.target.value)}
|
||||
onPressEnter={submitCustom}
|
||||
onBlur={submitCustom}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setCustomName('');
|
||||
setCustomOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,30 +6,36 @@
|
||||
* 2. **绑定账号 / 绑定角色**来自「验证 CK」的只读探针快照(人工不可编辑,每次验证覆盖);
|
||||
* 3. **内联编辑必须带 `expected_version`**:别人先改过会 409,绝不静默覆盖(Excel 最痛的点)。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
App, Button, Card, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form, Image,
|
||||
Input, Modal, Popconfirm, Progress, Row, Select, Space, Statistic, Switch, Table, Tag,
|
||||
Timeline, Tooltip, Typography, Upload,
|
||||
Alert, App, AutoComplete, Button, Card, Col, Descriptions, Divider, Drawer, Dropdown, Empty, Form,
|
||||
Image, Input, Modal, Popconfirm, Popover, Progress, Row, Select, Space, Statistic, Switch, Table,
|
||||
Tag, Timeline, Tooltip, Typography, Upload,
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, CloudUploadOutlined, DeleteOutlined, EditOutlined, FileExcelOutlined,
|
||||
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, StopOutlined,
|
||||
SwapOutlined, UndoOutlined,
|
||||
LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined,
|
||||
StopOutlined, SwapOutlined, UndoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
fileApi,
|
||||
huyaApi,
|
||||
type HuyaCollabAuditItem,
|
||||
type HuyaCollabCkPreview,
|
||||
type HuyaCollabGift,
|
||||
type HuyaCollabImageSlot,
|
||||
type HuyaCollabOrder,
|
||||
type HuyaCollabOrderQuery,
|
||||
type HuyaCollabOrderUpdate,
|
||||
type HuyaCollabOwner,
|
||||
type HuyaCollabShop,
|
||||
type HuyaCollabStatus,
|
||||
type HuyaCollabSummary,
|
||||
} from '../api/modules';
|
||||
import HuyaCollabShopModal from '../components/HuyaCollabShopModal';
|
||||
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
|
||||
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
@@ -64,13 +70,42 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const SLOT_META: Record<HuyaCollabImageSlot, { label: string; keysKey: 'bind_images' | 'home_images' }> = {
|
||||
bind_images: { label: '绑定图', keysKey: 'bind_images' },
|
||||
home_images: { label: '主页账号图', keysKey: 'home_images' },
|
||||
// 绑定图不再人工上传:绑定关系由探针自动读(2026-10-16 用户拍板),只留老数据只读展示
|
||||
bind_images: { label: '历史绑定图', keysKey: 'bind_images' },
|
||||
home_images: { label: '主页截图', keysKey: 'home_images' },
|
||||
};
|
||||
|
||||
const IMPORT_PREVIEW_ROWS = 20;
|
||||
// 「记住最近选的礼包」的本地存储键(同一个礼包连开几单时不用反复选)
|
||||
const LAST_GIFT_KEY = 'huya.collab.lastGift';
|
||||
// 「礼包列表」的本地缓存键:一个活动期间就那一批礼包,没必要每贴一份 CK 都去虎牙拉一次;
|
||||
// 要更新就点「强制刷新礼包」(活动换 SID 后必点一次)。
|
||||
const GIFTS_CACHE_KEY = 'huya.collab.gifts';
|
||||
const MAX_IMAGES_PER_SLOT = 20;
|
||||
|
||||
/** 读礼包缓存(读不到 / 解析坏 / 空列表都当没缓存) */
|
||||
function readGiftCache(): { gifts: HuyaCollabGift[]; at: number } | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(GIFTS_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { at?: number; gifts?: HuyaCollabGift[] };
|
||||
const gifts = Array.isArray(parsed.gifts) ? parsed.gifts : [];
|
||||
if (gifts.length === 0) return null;
|
||||
return { gifts, at: Number(parsed.at) || 0 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写礼包缓存:写不进去(隐私模式 / 配额满)也不影响功能,静静跳过 */
|
||||
function writeGiftCache(gifts: HuyaCollabGift[]): void {
|
||||
try {
|
||||
window.localStorage.setItem(GIFTS_CACHE_KEY, JSON.stringify({ at: Date.now(), gifts }));
|
||||
} catch {
|
||||
// 忽略:缓存只是体验优化
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined): string {
|
||||
if (!value) return '-';
|
||||
const time = new Date(value);
|
||||
@@ -98,7 +133,8 @@ function EditableText({
|
||||
return (
|
||||
<div
|
||||
onClick={() => { if (!disabled) setEditing(true); }}
|
||||
style={{ cursor: disabled ? 'default' : 'text', minHeight: 22 }}
|
||||
// wordBreak:礼品名 / 角色名里偶发的一长串不带空格的字符也不会顶穿这一列
|
||||
style={{ cursor: disabled ? 'default' : 'text', minHeight: 22, wordBreak: 'break-word' }}
|
||||
title={disabled ? undefined : '点击编辑'}
|
||||
>
|
||||
{value || <Text type="secondary">{placeholder || '—'}</Text>}
|
||||
@@ -208,7 +244,7 @@ function ImageSlotEditor({
|
||||
|
||||
export default function HuyaCollabPage() {
|
||||
const { modal } = App.useApp();
|
||||
const { can } = usePermissions();
|
||||
const { can, user } = usePermissions();
|
||||
const canEdit = can('huya:collab:edit');
|
||||
const canManage = can('huya:collab:manage');
|
||||
|
||||
@@ -220,7 +256,8 @@ export default function HuyaCollabPage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [summary, setSummary] = useState<HuyaCollabSummary | null>(null);
|
||||
const [owners, setOwners] = useState<HuyaCollabOwner[]>([]);
|
||||
const [shops, setShops] = useState<string[]>([]);
|
||||
const [shops, setShops] = useState<HuyaCollabShop[]>([]);
|
||||
const [shopModalOpen, setShopModalOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
@@ -231,6 +268,22 @@ export default function HuyaCollabPage() {
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm] = Form.useForm<Record<string, unknown>>();
|
||||
const [ckPreview, setCkPreview] = useState<HuyaCollabCkPreview | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
// 这次预览有没有带「拉礼包」(没有就是纯探针 + 用本地礼包缓存),只影响提示文案
|
||||
const [previewPullsGifts, setPreviewPullsGifts] = useState(false);
|
||||
const ckReady = !!ckPreview?.ok && !!ckPreview?.has_role;
|
||||
// 礼包列表:优先用浏览器缓存那份 —— 有数据就不去虎牙拉,要更新点「强制刷新礼包」。
|
||||
// 直接拿缓存做初值(而不是等 useEffect):贴第一份 CK 时就已经有数据了,不会白拉一次。
|
||||
const [giftOptions, setGiftOptions] = useState<HuyaCollabGift[]>(
|
||||
() => readGiftCache()?.gifts ?? [],
|
||||
);
|
||||
const [giftsCachedAt, setGiftsCachedAt] = useState<number | null>(
|
||||
() => readGiftCache()?.at ?? null,
|
||||
);
|
||||
const giftsRef = useRef<HuyaCollabGift[]>(giftOptions);
|
||||
// 记忆里的礼包不在「这次拉到的列表」里时的提示(会顺手清空,免得提交一个拉不到的礼包名)
|
||||
const [giftHint, setGiftHint] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importText, setImportText] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -253,7 +306,7 @@ export default function HuyaCollabPage() {
|
||||
huyaApi.listCollabOrders({ ...filters, page, page_size: pageSize }),
|
||||
huyaApi.collabOrdersSummary(),
|
||||
huyaApi.collabOrderOwners(),
|
||||
huyaApi.collabOrderShops(),
|
||||
huyaApi.listCollabShops(),
|
||||
]);
|
||||
setRows(list.items);
|
||||
setTotal(list.total);
|
||||
@@ -276,6 +329,88 @@ export default function HuyaCollabPage() {
|
||||
setDetail((current) => (current && current.id === updated.id ? updated : current));
|
||||
}, []);
|
||||
|
||||
const previewTimer = useRef<number | null>(null);
|
||||
// 预览请求序号:贴了新 CK 之后,旧请求迟到回来的结论一律丢掉(否则会显示上一份 CK 的"有效")
|
||||
const previewSeq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
giftsRef.current = giftOptions;
|
||||
}, [giftOptions]);
|
||||
|
||||
const emptyPreview = useCallback(
|
||||
(message: string): HuyaCollabCkPreview => ({
|
||||
ok: false, message, uid: '', yyuid: '', hy_username: '', ck_status: 'invalid',
|
||||
bind_game_account: '', bind_game_role: '', bind_game_channel: '',
|
||||
has_role: false, gifts: [], gifts_message: '', uid_used_by: [],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/** 跑一次预览(探针 + 可选拉礼包),只读,不写库 */
|
||||
const runCkPreview = useCallback(
|
||||
async (text: string, withGifts: boolean) => {
|
||||
const seq = previewSeq.current;
|
||||
try {
|
||||
const preview = await huyaApi.previewCollabCk(text, withGifts);
|
||||
if (seq !== previewSeq.current) return; // 已经贴了新的 CK,旧结论丢掉
|
||||
setCkPreview(preview);
|
||||
if (preview.gifts.length > 0) {
|
||||
// 真拉到列表了(首次或强制刷新):更新浏览器缓存,接下来几单直接用
|
||||
setGiftOptions(preview.gifts);
|
||||
setGiftsCachedAt(Date.now());
|
||||
writeGiftCache(preview.gifts);
|
||||
}
|
||||
if (preview.ok && preview.has_role) {
|
||||
message.success(`CK 有效:绑定角色 ${preview.bind_game_role}`);
|
||||
// 「记住的礼包」如果不在当前列表里就清掉重选;列表为空时不动它(允许手填)
|
||||
const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current;
|
||||
const picked = String(createForm.getFieldValue('gift_name') ?? '').trim();
|
||||
if (picked && options.length > 0 && !options.some((gift) => gift.name === picked)) {
|
||||
createForm.setFieldValue('gift_name', undefined);
|
||||
setGiftHint(`上次选的「${picked}」不在当前的礼包列表里,已清空,请重新点选`);
|
||||
} else {
|
||||
setGiftHint('');
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (seq !== previewSeq.current) return;
|
||||
setCkPreview(emptyPreview(getErrorMessage(e)));
|
||||
} finally {
|
||||
if (seq === previewSeq.current) setPreviewing(false);
|
||||
}
|
||||
},
|
||||
[emptyPreview, createForm],
|
||||
);
|
||||
|
||||
/**
|
||||
* 输入 CK 后防抖预览;改动了就先作废旧结论(避免拿旧的"有效"去提交)。
|
||||
*
|
||||
* ``forceGifts=true``(点了「强制刷新礼包」)才重新去虎牙拉列表;否则**本地有数据就用本地的** ——
|
||||
* 一个活动期间礼包清单基本不变,每次贴 CK 都拉一遍纯属白挨接口。
|
||||
*/
|
||||
const scheduleCkPreview = useCallback(
|
||||
(ckText: string, immediate = false, forceGifts = false) => {
|
||||
if (previewTimer.current) window.clearTimeout(previewTimer.current);
|
||||
const text = (ckText || '').trim();
|
||||
previewSeq.current += 1; // 无论跑不跑,都让在途请求作废
|
||||
if (!text) {
|
||||
setCkPreview(null);
|
||||
setPreviewing(false);
|
||||
return;
|
||||
}
|
||||
setCkPreview(null);
|
||||
setPreviewing(true);
|
||||
// 有缓存(giftsRef 非空)就不带 with_gifts,只有没数据或用户强制刷新才真去拉
|
||||
const withGifts = forceGifts || giftsRef.current.length === 0;
|
||||
setPreviewPullsGifts(withGifts);
|
||||
previewTimer.current = window.setTimeout(
|
||||
() => void runCkPreview(text, withGifts),
|
||||
immediate ? 0 : 600,
|
||||
);
|
||||
},
|
||||
[runCkPreview],
|
||||
);
|
||||
|
||||
const patchOrder = async (row: HuyaCollabOrder, payload: HuyaCollabOrderUpdate) => {
|
||||
try {
|
||||
const updated = await huyaApi.updateCollabOrder(row.id, { ...payload, expected_version: row.version });
|
||||
@@ -293,7 +428,6 @@ export default function HuyaCollabPage() {
|
||||
order_no: row.order_no,
|
||||
shop: row.shop,
|
||||
owner_id: row.owner_id,
|
||||
bind_douyu_account: row.bind_douyu_account,
|
||||
gift_name: row.gift_name,
|
||||
remark: row.remark,
|
||||
});
|
||||
@@ -308,6 +442,15 @@ export default function HuyaCollabPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开「同一买家的另一单」:先按 id 取最新数据,再复用详情抽屉 */
|
||||
const openSiblingOrder = async (id: number) => {
|
||||
try {
|
||||
await openDetail(await huyaApi.getCollabOrder(id));
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const verifyOne = async (row: HuyaCollabOrder) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -511,19 +654,27 @@ export default function HuyaCollabPage() {
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!ckReady) {
|
||||
message.warning('这份 CK 没通过验证(或读不到绑定角色),不能提交');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const values = await createForm.validateFields();
|
||||
const created = await huyaApi.createCollabOrder({
|
||||
order_no: String(values.order_no ?? '').trim(),
|
||||
shop: String(values.shop ?? ''),
|
||||
owner_id: (values.owner_id as number | undefined) ?? null,
|
||||
ck_text: String(values.ck_text ?? ''),
|
||||
bind_douyu_account: String(values.bind_douyu_account ?? ''),
|
||||
// 「绑定斗鱼号」不再录入:绑了谁由探针读(bind_game_*),人工那一列已下线
|
||||
gift_name: String(values.gift_name ?? ''),
|
||||
home_images: (values.home_images as string[] | undefined) ?? [],
|
||||
remark: String(values.remark ?? ''),
|
||||
require_verified: true, // 服务端再校验一次:CK 必须读到绑定角色才让建
|
||||
});
|
||||
if (created.gift_name) window.localStorage.setItem(LAST_GIFT_KEY, created.gift_name);
|
||||
message.success(`已新建 ${created.order_no}`);
|
||||
setCreateOpen(false);
|
||||
setCkPreview(null);
|
||||
setGiftHint('');
|
||||
createForm.resetFields();
|
||||
void load();
|
||||
} catch (e: unknown) {
|
||||
@@ -561,10 +712,25 @@ export default function HuyaCollabPage() {
|
||||
}, [importText]);
|
||||
|
||||
const ownerOptions = owners.map((owner) => ({ value: owner.id, label: owner.username }));
|
||||
const shopOptions = shops.map((shop) => ({ value: shop, label: shop }));
|
||||
// 筛选里的店铺也是点选(含停用店铺:历史订单还在用它们)
|
||||
const finished = summary ? (summary.by_status.done ?? 0) : 0;
|
||||
|
||||
// 礼包来源提示:这次真去虎牙拉了就说拉了,用的是缓存就把缓存时间摆出来(让人知道要不要点强制刷新)
|
||||
const giftCacheLabel = giftsCachedAt ? formatTime(new Date(giftsCachedAt).toISOString()) : '';
|
||||
const giftSourceText = ckPreview?.gifts?.length
|
||||
? ckPreview.gifts_message
|
||||
: giftOptions.length > 0
|
||||
? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」`
|
||||
: (ckPreview?.gifts_message || '');
|
||||
|
||||
const columns: TableProps<HuyaCollabOrder>['columns'] = [
|
||||
{
|
||||
title: '序号', width: 70, fixed: 'left', align: 'center',
|
||||
render: (_, __, index) => (
|
||||
// 跨页连续:第 2 页第 1 行 = pageSize + 1
|
||||
<Text type="secondary">{(page - 1) * pageSize + index + 1}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单编号', dataIndex: 'order_no', width: 170, fixed: 'left',
|
||||
render: (value: string, row) => (
|
||||
@@ -575,10 +741,26 @@ export default function HuyaCollabPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '店铺', dataIndex: 'shop', width: 110,
|
||||
title: '店铺', dataIndex: 'shop', width: 120,
|
||||
render: (value: string, row) => (
|
||||
<EditableText value={value} disabled={!canEdit} placeholder="+ 店铺"
|
||||
onCommit={(next) => void patchOrder(row, { shop: next })} />
|
||||
<Popover
|
||||
trigger="click"
|
||||
title="选择店铺"
|
||||
placement="bottomLeft"
|
||||
content={
|
||||
<HuyaCollabShopPicker
|
||||
value={value}
|
||||
shops={shops}
|
||||
allowCustom={canEdit}
|
||||
allowClear={false}
|
||||
onChange={(next) => { if (next !== value) void patchOrder(row, { shop: next }); }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button size="small" type={value ? 'text' : 'link'} disabled={!canEdit}>
|
||||
{value || '+ 选店铺'}
|
||||
</Button>
|
||||
</Popover>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -612,31 +794,75 @@ export default function HuyaCollabPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '绑定图 / 主页图', width: 170,
|
||||
render: (_, row) => (
|
||||
<Space size={12}>
|
||||
{(['bind_images', 'home_images'] as HuyaCollabImageSlot[]).map((slot) => {
|
||||
const keys = slot === 'bind_images' ? row.bind_images : row.home_images;
|
||||
const urls = slot === 'bind_images' ? row.bind_image_urls : row.home_image_urls;
|
||||
const thumbs = slot === 'bind_images' ? row.bind_image_thumb_urls : row.home_image_thumb_urls;
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<Text key={slot} type="secondary" style={{ fontSize: 12 }}>
|
||||
{SLOT_META[slot].label} 0
|
||||
title: '同买家', width: 120,
|
||||
render: (_, row) => {
|
||||
const cluster = row.buyer_cluster;
|
||||
if (!cluster || cluster.others.length === 0) {
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
title={
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong style={{ fontSize: 13 }}>同一个买家,共 {cluster.total} 单</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{cluster.key}</Text>
|
||||
</Space>
|
||||
}
|
||||
content={
|
||||
<Space direction="vertical" size={8} style={{ maxWidth: 340 }}>
|
||||
{cluster.others.map((item) => (
|
||||
<Space key={item.id} size={6} wrap>
|
||||
<Button size="small" type="link" style={{ padding: 0 }}
|
||||
onClick={() => void openSiblingOrder(item.id)}>
|
||||
{item.order_no}
|
||||
</Button>
|
||||
<Tag color={STATUS_META[item.status]?.color}>{item.status_label || item.status}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.gift_name || '未填礼包'} · {item.shop || '未填店铺'}
|
||||
</Text>
|
||||
</Space>
|
||||
))}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
识别口径:探针读到的「绑定账号 + 绑定角色」相同(不是人工绑定关系)
|
||||
</Text>
|
||||
);
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Button size="small" type="link" style={{ padding: 0 }}>
|
||||
<SwapOutlined /> 还有 {cluster.others.length} 单
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '主页截图', width: 170,
|
||||
render: (_, row) => (
|
||||
<Space size={8} wrap>
|
||||
{(() => {
|
||||
const keys = row.home_images;
|
||||
const urls = row.home_image_urls;
|
||||
const thumbs = row.home_image_thumb_urls;
|
||||
if (keys.length === 0) {
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>还没传 0</Text>;
|
||||
}
|
||||
return (
|
||||
<Space key={slot} size={4}>
|
||||
<Space size={4}>
|
||||
<Image.PreviewGroup items={urls}>
|
||||
<Image src={thumbs[0] || urls[0]} width={32} height={32}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
alt={`${SLOT_META[slot].label}封面`} />
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }} alt="主页截图封面" />
|
||||
</Image.PreviewGroup>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{keys.length} 张</Text>
|
||||
</Space>
|
||||
);
|
||||
})}
|
||||
})()}
|
||||
{row.bind_images.length > 0 && (
|
||||
<Tooltip title="绑定关系现在由「验证 CK」的探针自动读,绑定图已停用;这是老数据">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>历史绑定图 {row.bind_images.length} 张</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -677,13 +903,27 @@ export default function HuyaCollabPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'CK 状态', dataIndex: 'ck_status', width: 150,
|
||||
title: 'CK 状态', dataIndex: 'ck_status', width: 170,
|
||||
render: (value: string, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Tag color={CK_META[value]?.color}>{row.ck_status_label || CK_META[value]?.label || value}</Tag>
|
||||
{row.ck_check_message && (
|
||||
// 探针结论可能很长(账号 / 角色 / 平台·区服全在里面):**必须截断**,
|
||||
// 否则长文案会顶穿这一列压到「最后改动」上(2026-10-16 用户反馈)。全文放 Tooltip。
|
||||
<Tooltip title={`${row.ck_check_message}(${formatTime(row.ck_checked_at)})`}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.ck_check_message}</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
maxWidth: 150,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{row.ck_check_message}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
@@ -788,10 +1028,17 @@ export default function HuyaCollabPage() {
|
||||
<Card size="small">
|
||||
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space wrap>
|
||||
<Select allowClear showSearch style={{ width: 150 }} placeholder="店铺"
|
||||
options={shopOptions}
|
||||
value={filters.shop || undefined}
|
||||
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, shop: value })); }} />
|
||||
<Space size={4}>
|
||||
<Text type="secondary">店铺</Text>
|
||||
<HuyaCollabShopPicker
|
||||
value={filters.shop || ''}
|
||||
shops={shops}
|
||||
includeInactive
|
||||
allowCustom={false}
|
||||
clearLabel="全部"
|
||||
onChange={(value) => { setPage(1); setFilters((prev) => ({ ...prev, shop: value })); }}
|
||||
/>
|
||||
</Space>
|
||||
<Select allowClear showSearch optionFilterProp="label" style={{ width: 150 }} placeholder="负责人"
|
||||
options={ownerOptions}
|
||||
value={filters.owner_id ?? undefined}
|
||||
@@ -804,7 +1051,8 @@ export default function HuyaCollabPage() {
|
||||
options={Object.entries(CK_META).map(([key, meta]) => ({ value: key, label: meta.label }))}
|
||||
value={filters.ck_status || undefined}
|
||||
onChange={(value?: string) => { setPage(1); setFilters((prev) => ({ ...prev, ck_status: value })); }} />
|
||||
<Input.Search allowClear placeholder="订单号 / 绑定账号 / 角色 / 虎牙号" style={{ width: 260 }}
|
||||
<Input.Search allowClear style={{ width: 340 }}
|
||||
placeholder="搜订单号 / 店铺 / 绑定账号 / 角色 / 虎牙号 / 礼包 / 备注"
|
||||
onSearch={(value) => { setPage(1); setFilters((prev) => ({ ...prev, keyword: value })); }} />
|
||||
<Space size={4}>
|
||||
<Text type="secondary">显示已作废</Text>
|
||||
@@ -826,7 +1074,26 @@ export default function HuyaCollabPage() {
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新建</Button>
|
||||
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setCkPreview(null);
|
||||
setPreviewing(false);
|
||||
setGiftHint('');
|
||||
previewSeq.current += 1; // 丢掉在途的预览结论
|
||||
createForm.resetFields();
|
||||
// 记住最近选的礼包:同一个礼包连开几单时不用反复选
|
||||
const lastGift = window.localStorage.getItem(LAST_GIFT_KEY);
|
||||
if (lastGift) createForm.setFieldsValue({ gift_name: lastGift });
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
@@ -838,7 +1105,7 @@ export default function HuyaCollabPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 1680, y: 'calc(100vh - 460px)' }}
|
||||
scroll={{ x: 1800, y: 'calc(100vh - 460px)' }}
|
||||
rowSelection={canEdit ? {
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => setSelectedIds(keys.map(Number)),
|
||||
@@ -887,12 +1154,45 @@ export default function HuyaCollabPage() {
|
||||
children: `${formatTime(detail.scripted_at)} / ${formatTime(detail.done_at)}` },
|
||||
{ key: 'updated', label: '最后改动',
|
||||
children: `${detail.updated_by_name || '-'} · ${formatTime(detail.updated_at)}` },
|
||||
// 老数据可能还留着人工填的「绑定斗鱼号」:只读展示,不再给人填(绑定关系看探针)
|
||||
...(detail.bind_douyu_account
|
||||
? [{
|
||||
key: 'bind_douyu_account',
|
||||
label: '绑定斗鱼号(历史字段,不再录入)',
|
||||
children: detail.bind_douyu_account,
|
||||
}]
|
||||
: []),
|
||||
]} />
|
||||
|
||||
{detail.ck_check_message && (
|
||||
<Text type="secondary">探针结论:{detail.ck_check_message}</Text>
|
||||
)}
|
||||
|
||||
{detail.buyer_cluster && detail.buyer_cluster.others.length > 0 && (
|
||||
// 同一个买家(绑定账号 + 绑定角色相同)名下的其它单:软识别,点一下直接切过去
|
||||
<>
|
||||
<Divider style={{ margin: '4px 0' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
同一个买家的其它单({detail.buyer_cluster.key} · 共 {detail.buyer_cluster.total} 单)
|
||||
</Text>
|
||||
</Divider>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{detail.buyer_cluster.others.map((item) => (
|
||||
<Space key={item.id} size={8} wrap>
|
||||
<Button size="small" onClick={() => void openSiblingOrder(item.id)}>
|
||||
{item.order_no}
|
||||
</Button>
|
||||
<Tag color={STATUS_META[item.status]?.color}>{item.status_label || item.status}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.gift_name || '未填礼包'} · {item.shop || '未填店铺'}
|
||||
{item.uid ? ` · 虎牙号 ${item.uid}` : ''}
|
||||
</Text>
|
||||
</Space>
|
||||
))}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<Divider style={{ margin: '4px 0' }}>可编辑字段</Divider>
|
||||
@@ -902,7 +1202,6 @@ export default function HuyaCollabPage() {
|
||||
size="small"
|
||||
initialValues={{
|
||||
order_no: detail.order_no, shop: detail.shop, owner_id: detail.owner_id,
|
||||
bind_douyu_account: detail.bind_douyu_account,
|
||||
gift_name: detail.gift_name, remark: detail.remark,
|
||||
}}
|
||||
onFinish={(values) => void patchOrder(detail, values)}
|
||||
@@ -914,19 +1213,15 @@ export default function HuyaCollabPage() {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="shop" label="店铺"><Input maxLength={64} /></Form.Item>
|
||||
<Form.Item name="shop" label="店铺" tooltip="点一下选;点「新店」可以直接输个新名字">
|
||||
<HuyaCollabShopPicker shops={shops} allowClear={false} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="owner_id" label="负责人">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={ownerOptions} placeholder="未指派" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="bind_douyu_account" label="绑定斗鱼号(手工填,Excel 导入列)"
|
||||
tooltip="这一列是人工自由文本;「绑定账号 / 绑定角色」由「验证 CK」的探针写入">
|
||||
<Input maxLength={128} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="gift_name" label="礼包名"><Input maxLength={128} /></Form.Item>
|
||||
</Col>
|
||||
@@ -949,19 +1244,40 @@ export default function HuyaCollabPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '4px 0' }}>截图(第一张是封面)</Divider>
|
||||
{(['bind_images', 'home_images'] as HuyaCollabImageSlot[]).map((slot) => (
|
||||
<ImageSlotEditor
|
||||
key={slot}
|
||||
order={detail}
|
||||
slot={slot}
|
||||
canEdit={canEdit}
|
||||
busy={busy}
|
||||
onUpload={(targetSlot, file) => void uploadImage(detail, targetSlot, file)}
|
||||
onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)}
|
||||
onSetCover={(targetSlot, key) => void setCoverImage(detail, targetSlot, key)}
|
||||
/>
|
||||
))}
|
||||
<Divider style={{ margin: '4px 0' }}>主页截图(第一张是封面)</Divider>
|
||||
<ImageSlotEditor
|
||||
order={detail}
|
||||
slot="home_images"
|
||||
canEdit={canEdit}
|
||||
busy={busy}
|
||||
onUpload={(targetSlot, file) => void uploadImage(detail, targetSlot, file)}
|
||||
onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)}
|
||||
onSetCover={(targetSlot, key) => void setCoverImage(detail, targetSlot, key)}
|
||||
/>
|
||||
{detail.bind_images.length > 0 && (
|
||||
// 老数据只读展示:绑定图已停用,但已经传过的图不该从界面上凭空消失
|
||||
<>
|
||||
<Divider style={{ margin: '4px 0' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
历史绑定图(只读,已停用 —— 绑定关系改由「验证 CK」的探针读取)
|
||||
</Text>
|
||||
</Divider>
|
||||
<Image.PreviewGroup items={detail.bind_image_urls}>
|
||||
<Space wrap>
|
||||
{detail.bind_images.map((key, index) => (
|
||||
<Image
|
||||
key={key}
|
||||
src={detail.bind_image_thumb_urls[index] || detail.bind_image_urls[index]}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
alt={`历史绑定图${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Space>
|
||||
@@ -1012,39 +1328,181 @@ export default function HuyaCollabPage() {
|
||||
<Modal
|
||||
title="新建协作单"
|
||||
open={createOpen}
|
||||
width={920}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => void submitCreate()}
|
||||
confirmLoading={busy}
|
||||
okText="新建"
|
||||
okButtonProps={{ disabled: !ckReady || previewing }}
|
||||
// 内容比一屏高:给个滚动区,别让底部按钮被顶出可视范围
|
||||
styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={createForm} layout="vertical" size="small">
|
||||
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '订单号不能为空' }]}>
|
||||
<Input maxLength={64} placeholder="Excel B 列" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}><Form.Item name="shop" label="店铺"><Input maxLength={64} /></Form.Item></Col>
|
||||
<Form
|
||||
className="huya-collab-create"
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
size="middle"
|
||||
onValuesChange={(changed: Record<string, unknown>) => {
|
||||
if (typeof changed.ck_text === 'string') scheduleCkPreview(changed.ck_text);
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="owner_id" label="负责人">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={ownerOptions} placeholder="未指派" />
|
||||
<Form.Item name="order_no" label="订单编号" rules={[{ required: true, message: '订单号不能为空' }]}>
|
||||
<Input maxLength={64} placeholder="Excel B 列" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="负责人" tooltip="默认就是当前登录账号;要转给别人,在列表里改这一行的负责人">
|
||||
<Text>{user?.username || '-'}(默认)</Text>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="ck_text" label="外部 CK(可选,支持「纯CK」或「账号----密码----CK」)">
|
||||
<Input.TextArea rows={3} placeholder="贴进来就会解析出虎牙 uid" />
|
||||
{/* 店铺独占一行:按钮要够宽才不会被挤到第二行(「新店」跟在同一行后面) */}
|
||||
<Form.Item
|
||||
name="shop"
|
||||
label="店铺"
|
||||
tooltip="必填:点一下选;点「新店」可以直接输个新名字(会自动进店铺字典)"
|
||||
rules={[{ required: true, message: '请点一下选店铺' }]}
|
||||
>
|
||||
<HuyaCollabShopPicker shops={shops} allowClear={false} size="large" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}><Form.Item name="bind_douyu_account" label="绑定斗鱼号(可选)"><Input maxLength={128} /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="gift_name" label="礼包名(可选)"><Input maxLength={128} /></Form.Item></Col>
|
||||
<Form.Item
|
||||
name="ck_text"
|
||||
label="外部 CK"
|
||||
tooltip="必填:支持「纯CK」或「账号----密码----CK」。贴进来即自动探针验证 + 读绑定角色;礼包列表本地有缓存就不再重复拉,要更新点旁边的「强制刷新礼包」"
|
||||
rules={[{ required: true, message: '请贴一份外部 CK' }]}
|
||||
>
|
||||
{/* 高度随内容走:短 CK 就 3 行,别固定 5 行把弹窗撑高 */}
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
placeholder="贴进来即自动验证 + 读绑定角色(礼包有缓存就不再拉)"
|
||||
/>
|
||||
</Form.Item>
|
||||
{(previewing || ckPreview) && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{previewing ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={previewPullsGifts
|
||||
? '正在验证 CK 并拉取礼包…'
|
||||
: '正在验证 CK + 读绑定角色…(礼包用本地缓存,不重复拉)'}
|
||||
/>
|
||||
) : ckPreview?.ok && ckPreview.has_role ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={`CK 有效 · 绑定角色 ${ckPreview.bind_game_role}`}
|
||||
description={
|
||||
<>
|
||||
<div>
|
||||
{`订单编号里的账号:${ckPreview.bind_game_account || '-'}`
|
||||
+ `(${ckPreview.bind_game_channel || '-'})`
|
||||
+ `;虎牙号 ${ckPreview.uid}`
|
||||
+ (giftSourceText ? `;${giftSourceText}` : '')}
|
||||
</div>
|
||||
{ckPreview.uid_used_by.length > 0 && (
|
||||
// 放开 uid 唯一性后同一份 CK 可以服务多单,这里只提示已经挂在哪些单上
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{`这份 CK 的虎牙号已经挂在:${ckPreview.uid_used_by
|
||||
.map((item) => `${item.order_no}(${item.status_label || item.status})`)
|
||||
.join('、')} —— 同一个号服务多单是允许的,照常提交`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="这份 CK 不能用,禁止提交"
|
||||
description={`${ckPreview?.message || ''}${ckPreview?.ok && !ckPreview.has_role ? '(探针通了但读不到绑定角色)' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Form.Item
|
||||
name="gift_name"
|
||||
label="礼包名(用户购买的物品)"
|
||||
tooltip="必填:先贴有效 CK,再从礼包列表里点选(列表有本地缓存,不用每次重拉;拉不到时可以直接手输)"
|
||||
rules={[{ required: true, message: '请选一个礼包' }]}
|
||||
extra={giftHint || (giftsCachedAt && giftOptions.length > 0
|
||||
? `礼包列表缓存于 ${giftCacheLabel}(${giftOptions.length} 个)`
|
||||
: undefined)}
|
||||
validateStatus={giftHint ? 'warning' : undefined}
|
||||
>
|
||||
<AutoComplete
|
||||
allowClear
|
||||
placeholder={ckReady ? '从礼包里点选(可直接输)' : '先贴一份有效 CK'}
|
||||
options={giftOptions.map((gift) => ({
|
||||
value: gift.name,
|
||||
label: [
|
||||
gift.category_name ? `[${gift.category_name}]` : '',
|
||||
gift.name,
|
||||
gift.price ? `· ${gift.price} 积分` : '',
|
||||
gift.remain_text ? `· 剩 ${gift.remain_text}` : '',
|
||||
].filter(Boolean).join(' '),
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.value ?? '').toLowerCase().includes(input.toLowerCase())}
|
||||
onChange={(value: string) => {
|
||||
// 记住最近选的礼包:连开几单同一个礼包就不用反复点
|
||||
setGiftHint('');
|
||||
if (value) window.localStorage.setItem(LAST_GIFT_KEY, value);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Form.Item
|
||||
label="礼包列表"
|
||||
tooltip="平时用本地缓存那份(一个活动期间基本不变);换了活动 / 想拿最新库存,点这里强制去虎牙重拉"
|
||||
>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={!ckReady || previewing}
|
||||
onClick={() => scheduleCkPreview(createForm.getFieldValue('ck_text') || '', true, true)}
|
||||
>
|
||||
强制刷新礼包{giftOptions.length ? `(${giftOptions.length})` : ''}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<Form.Item
|
||||
name="home_images"
|
||||
label="主页截图(必填,第一张作封面)"
|
||||
tooltip="上传主页截图;绑定图不用再传了 —— 绑了谁由「验证 CK」的探针自动读"
|
||||
rules={[{
|
||||
validator: (_rule, value: string[] | undefined) => (
|
||||
value && value.length > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请至少上传 1 张主页截图'))
|
||||
),
|
||||
}]}
|
||||
>
|
||||
<HuyaCollabHomeImageUploader max={MAX_IMAGES_PER_SLOT} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Form.Item name="remark" label="备注(可选)">
|
||||
<Input.TextArea rows={2} maxLength={512} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="remark" label="备注(可选)"><Input.TextArea rows={2} maxLength={512} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="粘贴导入(Excel 复制出来的 TSV)"
|
||||
open={importOpen}
|
||||
width={760}
|
||||
width={900}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
onOk={() => void submitImport()}
|
||||
confirmLoading={importing}
|
||||
@@ -1145,6 +1603,15 @@ export default function HuyaCollabPage() {
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<HuyaCollabShopModal
|
||||
open={shopModalOpen}
|
||||
shops={shops}
|
||||
canEdit={canEdit}
|
||||
canManage={canManage}
|
||||
onClose={() => setShopModalOpen(false)}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user