diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 58b9a35..afec521 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -79,7 +79,7 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重 | `order_no` | varchar(64) **unique** | 订单编号(Excel B 列),唯一 | | `shop` | varchar(64) index | 店铺(A 列) | | `owner_id` | int FK `users.id` index | 负责人(C 列);**不填默认 = 创建人**(新建时不用选,要转单在列表里改) | -| `uid` | varchar(32) **unique, nullable** | 虎牙 uid(从 CK 解析;空 = 还没贴 CK) | +| `uid` | varchar(32) **普通索引, nullable** | 虎牙 uid(从 CK 解析;空 = 还没贴 CK)。2026-10-16 起**不再唯一**:同一买家多件常继续用同一份 CK(迁移 0058) | | `yyuid` | varchar(32) | 虎牙 yyuid | | `hy_username` | varchar(128) | CK 里的 `udb_passport`(登录名) | | `nickname` | varchar(128) | 昵称(探针查到后补全) | @@ -102,8 +102,11 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重 | `bound_at` | datetime | 绑定完成时间 | | `recharged_at` | datetime | 充值完成时间 | | `scripted_at` | datetime | 上脚本时间 | -| `done_at` | datetime | 归档完成时间 | +| `done_at` | datetime | 抢购成功时间 | | `void_reason` | varchar(256) | 作废原因(`status=void` 时必填) | +| `archived_at` | datetime index | **归档时间**;非空 = 已归档(可见性状态,不是业务状态,§4.4) | +| `archived_by` | int FK `users.id` | 归档人 | +| `archive_reason` | varchar(256) | 归档备注;取消归档时清空,审计里保留原文 | | `points` | int nullable | 最近一次只读查询的积分快照 | | `elite_opened` | bool nullable | 最近一次只读查询的宝典开通状态 | | `snapshot_at` | datetime | 上面两个快照的查询时间 | @@ -112,7 +115,7 @@ CK 自带完整生命周期(导入 → 验证 → 使用/失效 → 重贴/重 | `version` | int | **乐观锁版本号**,每次写入 +1(见 §7.2 为什么不用 `updated_at`) | | `created_at` / `updated_at` | datetime | `updated_at` 只用于显示与排序 | -索引:`order_no`(unique)、`uid`(**普通索引**,2026-10-16 由 unique 降级,见 §3.2)、`shop`、`owner_id`、`status`、`ck_status`、`updated_at`。 +索引:`order_no`(unique)、`uid`(**普通索引**,2026-10-16 由 unique 降级,见 §3.2)、`shop`、`owner_id`、`status`、`ck_status`、`archived_at`、`updated_at`。 ### 3.2 两个 key 设计上的选择 @@ -277,6 +280,27 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 - 每次状态变化必须同时写 `*_at` 时间戳 + `audit_logs`; - 加一条测试:**任何**状态写入都必须带审计行(漏了就是 bug)。 +### 4.4 归档(可见性,不是第三种状态,2026-10-19) + +抢购成功的单会越积越多,但它们不该一直挤在活跃台账里;「归档」把行从默认视图里 +**藏起来**,但**不改变业务状态**(`status` 仍是 `done` / 抢购成功),也不删 CK、 +截图、积分快照或审计记录。 + +| 项 | 口径 | +| --- | --- | +| 可归档 | **只有 `status=done` 的活跃行**;`failed` 不归档(先处理或作废) | +| 判定 | `archived_at IS NULL` = 活跃;非空 = 已归档 | +| 列表/统计 | `archive_scope=active`(默认)/ `archived` / `all`;默认列表与统计都排除归档行 | +| 只读 | 归档行不能改状态、CK、字段、图片、删除,也不能查积分/兑换记录;接口统一 409 `order_archived`,必须先取消归档 | +| 备注 | 归档时可写 `archive_reason`(≤256 字);取消归档清空该字段,但审计保留原始备注 | +| 批量 | 单行 / 勾选 / 「归档当前筛选结果」三种入口;按筛选归档强制 `active + done`,前端传 `expected_count`,数量变化返回 409 `archive_count_changed`;单次上限 `ARCHIVE_BATCH_MAX=5000` | +| 审计 | `huya:collab:archive` / `huya:collab:unarchive`,写单号、状态与备注;归档/取消归档都会 `_touch()` 递增 `version` | +| 权限 | 复用 `huya:collab:edit`,不新增权限点 | +| 实时 | 归档变化发布 `scope=orders` 的 `order.archive` / `order.unarchive` 失效通知 | + +归档视图仍支持原有筛选与关键词搜索(订单号、店铺、绑定账号、角色、虎牙号、礼包、备注), +只是所有写操作隐藏,仅保留「详情」与「取消归档」。 + --- ## 5. CK 的 CRUD 与全生命周期 @@ -329,7 +353,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 ### 5.4 删除 / 作废 - `void` 作废:保留行、留 `void_reason`,列表默认过滤掉(可切换显示)。 -- 物理删除:`huya:collab:manage` 才能做,且要**级联清理图片**(§6.4),并记审计。 +- **归档**(2026-10-19):只针对抢购成功的活跃行,保留全部数据、默认隐藏、取消归档前只读(§4.4)。 +- 物理删除:`huya:collab:manage` 才能做,且要**级联清理图片**(§6.4),并记审计;归档行必须先取消归档。 ### 5.5 新建协作单的必填项与礼包(2026-10-16 用户拍板) @@ -503,8 +528,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 | 方法 | 路径 | 权限 | 说明 | | --- | --- | --- | --- | -| GET | `/api/huya/external-orders` | `huya:collab:view` | 列表;筛选 `shop/owner_id/status/ck_status/keyword`,分页,默认排除 `void` | -| GET | `/api/huya/external-orders/summary` | `huya:collab:view` | 各状态计数(页头统计条) | +| GET | `/api/huya/external-orders` | `huya:collab:view` | 列表;筛选 `shop/owner_id/status/ck_status/keyword/include_void/archive_scope`,分页,默认排除 `void` 与归档行 | +| GET | `/api/huya/external-orders/summary` | `huya:collab:view` | 各状态计数(页头统计条),口径跟随 `include_void/archive_scope`;另返回 `archived_total` | | POST | `/api/huya/external-orders` | `huya:collab:edit` | 新建(可带 CK 文本) | | GET | `/api/huya/external-orders/{id}` | `huya:collab:view` | 详情(含图片 key、审计时间线) | | PUT | `/api/huya/external-orders/{id}` | `huya:collab:edit` | 编辑(带 `expected_version`) | @@ -515,6 +540,8 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 | POST | `/api/huya/external-orders/ck/preview` | `huya:collab:edit` | 粘贴 CK 后的即时预览(探针 + 拉活动礼包,**只读不写库**) | | POST | `/api/huya/external-orders/ck/verify` | `huya:collab:edit` | 批量验证 CK(探针,body `{ids: [...]}`,最多 50 条) | | POST | `/api/huya/external-orders/status/batch` | `huya:collab:edit` | 勾选多行切成同一个状态(逐行 SAVEPOINT 隔离,最多 50 条;§4.1) | +| POST | `/api/huya/external-orders/archive` | `huya:collab:edit` | 归档抢购成功订单:`ids` 单条/勾选,或 `archive_filtered=true` + 筛选条件按当前筛选批量;可带 `reason`,`expected_count` 校验数量(§4.4) | +| POST | `/api/huya/external-orders/unarchive` | `huya:collab:edit` | 取消归档(`{ids: [...]}`),恢复回活跃台账 | | POST | `/api/huya/external-orders/activity/query` | `huya:collab:edit` | 勾选多行查积分 + 兑换记录(串行带间隔,每行写一条生命周期快照;§5.7) | | POST | `/api/huya/external-orders/{id}/activity/query` | `huya:collab:edit` | 查这一行的积分 + 兑换记录(单行「查询」按钮) | | GET | `/api/huya/external-orders/{id}/metrics` | `huya:collab:view` | 账号全生命周期时间线(每次查询的快照,新→旧;§3.5) | @@ -549,15 +576,16 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 - **内联编辑**:店铺 / 负责人 / 礼包名 / 备注(点开即改,失焦提交,走 §7.2 乐观锁); - **图片列**:缩略图 + 张数,点击开大图(`Image.PreviewGroup`); -- **状态**:Tag 展示,行内"下一步"按钮推进(带二次确认); +- **状态**:**只展示当前状态 Tag**;行内不再放「→ 下一步」快捷按钮(2026-10-19),推进统一走详情抽屉或批量改状态; - **CK 状态**:Tag + 一键"验证"; -- **工具栏批量动作**(都要先勾选):批量验证 CK / 批量改状态 / 批量查积分·兑换 / **导出 CK**(勾选几行导几行,写剪贴板;§11.8); +- **工具栏批量动作**(都要先勾选):批量验证 CK / 批量改状态 / 批量查积分·兑换 / 批量归档(仅全部选中行都是活跃 `done`)/ **导出 CK**(勾选几行导几行,写剪贴板;§11.8); +- **归档入口**(2026-10-19,§4.4):筛选行右侧「查看归档」开关 + 已归档总数角标;活跃视图提供「批量归档」和(状态筛选为 `done` 时)「归档当前筛选结果(N)」;归档视图提供「批量取消归档」,写操作全部隐藏; - **顶部工具区固定两行**(2026-10-16 用户反馈「不要选中后两行」): - 第一行永远筛选 = 店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、店铺、绑定账号、角色、虎牙号、礼包、备注)/ 显示已作废; - 第二行永远动作 = 刷新 + 上面那四个批量动作 + 粘贴导入 / 店铺管理 / 新建。 + 第一行永远筛选 = 店铺 / 负责人 / 业务状态 / CK 状态 / 关键词(订单号、店铺、绑定账号、角色、虎牙号、礼包、备注)/ 显示已作废 / 查看归档; + 第二行永远动作 = 刷新 + 上面那些批量动作 + 粘贴导入 / 店铺管理 / 新建。 **不要**用「筛选 + 动作放在同一个 `wrap` 里靠 `space-between` 排」那种写法 —— 不勾选时两组挤成一行、勾选后按钮多了「(N)」就被顶到第二行,列表跟着往下跳; -- 页头统计条:`待处理 N / 已确认绑定 N / 已充值·已上脚本 N / 抢购成功 N / 抢购失败 N / 已作废 N`(`done` / `failed` 的展示名 2026-10-16 由「已完成」/「兑换失败」改成「抢购成功」/「抢购失败」,枚举值与库里数据不变)。 +- 页头统计条:`全部 N / 待补资料 N / 待处理 N / 已确认绑定 N / 已充值 N / 已上脚本 N / 抢购成功 N / 抢购失败 N / CK 失效 N`,右上角是「实时同步」开关(可手动暂停;暂停后不建 WebSocket、不轮询,只能手动刷新);已归档总数显示在筛选行的「查看归档」开关旁(`done` / `failed` 的展示名 2026-10-16 由「已完成」/「兑换失败」改成「抢购成功」/「抢购失败」,枚举值与库里数据不变)。 > 交互细节(2026-10-16 用户拍板):**店铺是点选按钮组**(列表里点单元格弹出同一组按钮, > 筛选条里也是),不是下拉;**新建时不用选负责人**——默认就是当前登录账号 @@ -573,6 +601,9 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 右侧:字段编辑 + 操作按钮(验证 CK / 重建 CK / 推进状态 / 作废 / 删除); 底部:审计时间线(谁在什么时候改了什么)。 +**归档行**(§4.4):标题与描述区显示「已归档」+ 归档时间/归档人/备注;字段编辑、图片编辑、 +状态推进、CK 操作与积分查询**全部隐藏**,只保留审计时间线与「取消归档」。 + ### 9.3 批量导入弹窗 - 大文本框贴 Excel 区域(TSV,`\t` 分隔,直接 Ctrl+V 即可); @@ -588,7 +619,7 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 | # | 问题 | 决定 | | --- | --- | --- | -| 1 | 一行(一单)跟虎牙 CK 的基数关系 | **1 单 = 1 个 CK**(`uid` 在台账内唯一) | +| 1 | 一行(一单)跟虎牙 CK 的基数关系 | **1 单 = 1 个 CK**;`uid` 2026-10-16 起**不再唯一**(同一买家多件可复用同一份 CK,迁移 0058) | | 2 | 台账与 `huya_accounts` 的关系 | **完全独立一套表,自己存 CK,不碰现有账号体系** | | 3 | 店铺 / 订单编号来源 | **全部人工录入 + 支持 Excel 粘贴批量导入** | | 4 | 协作范围与权限 | **所有登录用户共享同一张表,都能编辑**(靠审计留痕) | @@ -932,6 +963,24 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 **没有动的地方**:详情抽屉里编辑既有订单的礼包名仍是"显示这一单自己的值"(本来就该如此); 补资料弹窗的初值仍是**这一单自己的礼包**(不是"上次选的"),没有改成空白。 +### 11.11 2026-10-19 第八轮:统计拆分、暂停实时同步、状态列清理与归档 + +需求原文:"拆分已充值和已上脚本统计;实时同步放右上角并可手动暂停;状态列只显示当前状态; +抢购成功的单太多,需要归档(可备注),归档后默认不显示但能搜索查看,取消归档前只读"。 + +| 面 | 落地 | +| --- | --- | +| 统计条 | 「已充值」「已上脚本」拆成两张卡;右上角新增「实时同步」卡,开关状态存 `localStorage['huya.collab.realtime-paused']`。暂停后**不建 WebSocket、不排轮询、丢弃待刷新**,恢复时立即重拉一次 | +| 状态列 | 删除行内「→ 下一步」`Popconfirm`,只保留当前状态 Tag(归档行额外显示「已归档」)。推进仍在详情抽屉与批量改状态里 | +| 数据模型 | 迁移 `20261019_0060`:`archived_at` / `archived_by`(FK `users.id`)/ `archive_reason`(varchar 256)+ `archived_at` 索引。`status=done` 保持不变,归档只是可见性(§4.4) | +| 后端 | `list_orders` / `summary` 增加 `archive_scope`(`active` 默认 / `archived` / `all`);新增 `POST /external-orders/archive` 与 `/unarchive`;归档行所有写入口(状态、CK、字段、图片、删除、积分查询)统一 409 `order_archived` | +| 批量归档 | 单行 / 勾选 / 「归档当前筛选结果」;按筛选强制 `active + done` 并校验 `expected_count`(409 `archive_count_changed`),单次上限 5000;返回逐行 `results`(成功/跳过原因) | +| 前端 | 筛选行「查看归档」开关 + 已归档总数;归档弹窗可填 ≤256 字备注;归档视图只读,提供详情与单行/批量「取消归档」;归档行在列表与详情里的内联编辑、图片编辑、状态与 CK 操作全部隐藏 | +| 审计 | `huya:collab:archive` / `huya:collab:unarchive`,记录单号、状态与备注;取消归档清空行上的 `archive_reason`,审计保留原文;两个动作都递增 `version` 并清掉实时缓存 | + +**验收口径**:默认列表/统计不含归档行;归档视图能按原有关键词与筛选搜到;归档行改任何东西都提示先取消归档; +取消归档后回到原位置(业务状态未变),归档备注仍能在审计时间线里查到。 + --- ## 12. 风险 diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index d7f5ef8..34f1726 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -1817,6 +1817,128 @@ class TestCollabStatusBatch(TestCollabBase): assert excinfo.value.code == "status_batch_too_large" +class TestCollabArchive(TestCollabBase): + """归档是可见性状态:不影响业务状态,默认隐藏,归档视图可搜索。""" + + def _done(self, order_no: str, **kwargs) -> HuyaExternalOrder: + order = self._create(order_no=order_no, **kwargs) + order.status = collab.STATUS_DONE + self.db.commit() + self.db.refresh(order) + return order + + def test_archive_hides_by_default_but_is_searchable_in_archive_scope(self): + archived = self._done("ARCH-1", remark="客户已验收") + active = self._create(order_no="ARCH-2") + + result = collab.archive_orders( + self.db, self.user, [archived], reason="活动结束,完成对账" + ) + + assert result["archived"] == 1 + assert archived.archived_at is not None + assert archived.archive_reason == "活动结束,完成对账" + assert archived.status == collab.STATUS_DONE + + rows, total = collab.list_orders(self.db) + assert total == 1 + assert [row.id for row in rows] == [active.id] + + rows, total = collab.list_orders( + self.db, archive_scope="archived", keyword="客户已验收" + ) + assert total == 1 + assert [row.id for row in rows] == [archived.id] + + default_summary = collab.summary(self.db) + archived_summary = collab.summary(self.db, archive_scope="archived") + assert default_summary["by_status"]["done"] == 0 + assert default_summary["archived_total"] == 1 + assert archived_summary["total"] == 1 + + audit = self._audits(archived)[-1] + assert audit.action == "huya:collab:archive" + assert "完成对账" in audit.detail + + def test_only_done_orders_can_be_archived(self): + pending = self._create(order_no="ARCH-PENDING") + + result = collab.archive_orders(self.db, self.user, [pending]) + + assert result["archived"] == 0 + assert result["skipped"] == 1 + assert "抢购成功" in result["results"][0]["message"] + assert pending.archived_at is None + + def test_archived_order_is_read_only_until_unarchived(self): + order = self._done("ARCH-READONLY") + collab.archive_orders(self.db, self.user, [order]) + + with pytest.raises(collab.CollabError) as excinfo: + collab.set_status( + self.db, + self.user, + order, + collab.STATUS_PENDING, + rollback=True, + ) + assert excinfo.value.code == "order_archived" + + with pytest.raises(collab.CollabError) as excinfo: + collab.update_order( + self.db, + self.user, + order, + {"remark": "归档后不应该能改"}, + expected_version=order.version, + ) + assert excinfo.value.code == "order_archived" + + def test_unarchive_restores_row_and_clears_archive_note(self): + order = self._done("ARCH-UNARCHIVE") + collab.archive_orders(self.db, self.user, [order], reason="临时隐藏") + + result = collab.unarchive_orders(self.db, self.user, [order]) + + assert result["unarchived"] == 1 + assert order.archived_at is None + assert order.archived_by is None + assert order.archive_reason == "" + rows, total = collab.list_orders(self.db) + assert total == 1 + assert [row.id for row in rows] == [order.id] + assert self._audits(order)[-1].action == "huya:collab:unarchive" + + def test_archive_by_filter_checks_expected_count_and_only_archives_done(self): + first = self._done("ARCH-FILTER-1", shop="佳佳") + second = self._done("ARCH-FILTER-2", shop="佳佳") + self._create(order_no="ARCH-FILTER-PENDING", shop="佳佳") + + with pytest.raises(collab.CollabError) as excinfo: + collab.archive_orders_by_filter( + self.db, + self.user, + expected_count=1, + shop="佳佳", + status=collab.STATUS_DONE, + ) + assert excinfo.value.code == "archive_count_changed" + + result = collab.archive_orders_by_filter( + self.db, + self.user, + expected_count=2, + shop="佳佳", + status=collab.STATUS_DONE, + reason="批量清理", + ) + assert result["archived"] == 2 + assert first.archived_at is not None + assert second.archived_at is not None + _, total = collab.list_orders(self.db, shop="佳佳", archive_scope="active") + assert total == 1 + + class TestCollabActivityLifecycle(TestCollabBase): """抢购之后:查积分 + 兑换记录 → 落生命周期快照 → 按结果自动改状态。""" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index d6c65a6..bd84dab 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261016_0059" +HEAD_REVISION = "20261019_0060" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20261019_0060_huya_order_archive.py b/web/backend/migrations/versions/20261019_0060_huya_order_archive.py new file mode 100644 index 0000000..79861ba --- /dev/null +++ b/web/backend/migrations/versions/20261019_0060_huya_order_archive.py @@ -0,0 +1,74 @@ +"""虎牙协作台账增加归档字段。 + +归档是**可见性状态**,不是业务状态:`status=done`(抢购成功)保持不变, +只把 `archived_at` 置为当前时间,默认列表即不再展示;取消归档后恢复。 + +只归档终态「抢购成功」;归档不删除 CK、截图、积分快照或审计记录。 +可重复执行:逐列检查,已存在就跳过。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261019_0060" +down_revision: str | None = "20261016_0059" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "huya_external_orders" +USERS = "users" +INDEX = "ix_huya_external_orders_archived_at" +FK = "fk_huya_external_orders_archived_by" + + +def _columns(bind) -> set[str]: + return {column["name"] for column in sa.inspect(bind).get_columns(TABLE)} + + +def upgrade() -> None: + bind = op.get_bind() + if TABLE not in set(sa.inspect(bind).get_table_names()): + return + columns = _columns(bind) + if "archived_at" not in columns: + op.add_column(TABLE, sa.Column("archived_at", sa.DateTime(), nullable=True)) + if "archived_by" not in columns: + op.add_column(TABLE, sa.Column("archived_by", sa.Integer(), nullable=True)) + if "archive_reason" not in columns: + op.add_column( + TABLE, + sa.Column( + "archive_reason", + sa.String(length=256), + nullable=False, + server_default="", + ), + ) + + indexes = {index.get("name") for index in sa.inspect(bind).get_indexes(TABLE)} + if INDEX not in indexes: + op.create_index(INDEX, TABLE, ["archived_at"]) + + foreign_keys = {fk.get("name") for fk in sa.inspect(bind).get_foreign_keys(TABLE)} + if FK not in foreign_keys: + op.create_foreign_key(FK, TABLE, USERS, ["archived_by"], ["id"]) + + +def downgrade() -> None: + bind = op.get_bind() + if TABLE not in set(sa.inspect(bind).get_table_names()): + return + indexes = {index.get("name") for index in sa.inspect(bind).get_indexes(TABLE)} + if INDEX in indexes: + op.drop_index(INDEX, table_name=TABLE) + foreign_keys = {fk.get("name") for fk in sa.inspect(bind).get_foreign_keys(TABLE)} + if FK in foreign_keys: + op.drop_constraint(FK, TABLE, type_="foreignkey") + columns = _columns(bind) + for column in ("archive_reason", "archived_by", "archived_at"): + if column in columns: + op.drop_column(TABLE, column) diff --git a/web/backend/models.py b/web/backend/models.py index 990771c..d20cd68 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -1169,6 +1169,18 @@ class HuyaExternalOrder(Base): done_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) void_reason: Mapped[str] = mapped_column(String(256), default="", server_default="") + # ---- 归档(与业务状态正交:归档后仍是「抢购成功」,只是默认不出现在主台账)---- + # NULL = 未归档;软隐藏不删数据,取消归档即可恢复。 + archived_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True, index=True + ) + archived_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + archive_reason: Mapped[str] = mapped_column( + String(256), default="", server_default="" + ) + # ---- 只读查询快照(不写 huya_accounts)---- points: Mapped[int | None] = mapped_column(Integer, nullable=True) elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True) diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index acedf76..0017438 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -44,6 +44,8 @@ from ...schemas import ( HuyaExternalOrderActivityItemOut, HuyaExternalOrderActivityQueryOut, HuyaExternalOrderActivityQueryRequest, + HuyaExternalOrderArchiveOut, + HuyaExternalOrderArchiveRequest, HuyaExternalOrderAuditOut, HuyaExternalOrderCkExportOut, HuyaExternalOrderCkExportRequest, @@ -59,6 +61,7 @@ from ...schemas import ( HuyaExternalOrderStatusBatchRequest, HuyaExternalOrderStatusRequest, HuyaExternalOrderSummaryOut, + HuyaExternalOrderUnarchiveRequest, HuyaExternalOrderUpdate, HuyaExternalOrderVerifyOut, HuyaExternalOrderVerifyRequest, @@ -99,11 +102,15 @@ def _publish_change(current: User, *, scope: str, action: str) -> None: @router.get("/external-orders/summary", response_model=HuyaExternalOrderSummaryOut) def external_orders_summary( + include_void: bool = Query(False, description="是否包含已作废的行"), + archive_scope: str = Query( + "active", description="归档范围:active / archived / all" + ), db: Session = Depends(get_db), _current: User = Depends(require_permission("huya:collab:view")), ): - """页头统计条:各业务状态计数 + CK 失效数。""" - return collab.summary(db) + """页头统计条:计数口径与当前列表的归档/作废过滤一致。""" + return collab.summary(db, include_void=include_void, archive_scope=archive_scope) @router.get("/external-orders/owners", response_model=list[HuyaExternalOrderOwnerOut]) @@ -239,6 +246,9 @@ def list_external_orders( gift_name: str = Query("", max_length=128), keyword: str = Query("", max_length=128), include_void: bool = Query(False, description="是否包含已作废的行(默认隐藏)"), + archive_scope: str = Query( + "active", description="归档范围:active / archived / all" + ), sort_by: str = Query("seq", max_length=32, description="排序字段(白名单)"), sort_order: str = Query("asc", max_length=8, description="asc / desc"), page: int = Query(1, ge=1), @@ -256,6 +266,7 @@ def list_external_orders( gift_name=gift_name, keyword=keyword, include_void=include_void, + archive_scope=archive_scope, sort_by=sort_by, sort_order=sort_order, page=page, @@ -393,6 +404,58 @@ def batch_update_external_order_status( return result +@router.post("/external-orders/archive", response_model=HuyaExternalOrderArchiveOut) +def archive_external_orders( + req: HuyaExternalOrderArchiveRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """归档「抢购成功」的活跃行(单条/勾选/按当前筛选),可带归档备注。 + + 归档只改可见性,不改变业务状态;默认列表随即隐藏,归档视图仍可搜索和查看。 + """ + try: + if req.archive_filtered: + result = collab.archive_orders_by_filter( + db, + current, + reason=req.reason, + expected_count=req.expected_count, + shop=req.shop, + owner_id=req.owner_id, + status=req.status, + ck_status=req.ck_status, + gift_name=req.gift_name, + keyword=req.keyword, + include_void=req.include_void, + ) + else: + orders = collab.list_orders_for_archive(db, req.ids) + result = collab.archive_orders(db, current, orders, reason=req.reason) + except collab.CollabError as exc: + raise _http_error(exc) from exc + if result.get("archived"): + _publish_change(current, scope="orders", action="order.archive") + return result + + +@router.post("/external-orders/unarchive", response_model=HuyaExternalOrderArchiveOut) +def unarchive_external_orders( + req: HuyaExternalOrderUnarchiveRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:collab:edit")), +): + """取消归档,恢复回主台账。""" + try: + orders = collab.list_orders_for_archive(db, req.ids) + result = collab.unarchive_orders(db, current, orders) + except collab.CollabError as exc: + raise _http_error(exc) from exc + if result.get("unarchived"): + _publish_change(current, scope="orders", action="order.unarchive") + return result + + @router.post( "/external-orders/activity/query", response_model=HuyaExternalOrderActivityQueryOut ) diff --git a/web/backend/schemas.py b/web/backend/schemas.py index c660a79..a2e6f6d 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1213,6 +1213,29 @@ class HuyaExternalOrderStatusRequest(BaseModel): expected_version: int | None = None +class HuyaExternalOrderArchiveRequest(BaseModel): + """归档抢购成功的订单(单条/勾选/按当前筛选)。""" + + ids: list[int] = Field(default_factory=list) + reason: str = Field("", max_length=256) + # true 时忽略 ids,按下面的筛选条件归档所有匹配的「抢购成功」活跃行。 + archive_filtered: bool = False + expected_count: int | None = Field(None, ge=0) + shop: str = Field("", max_length=64) + owner_id: int | None = None + status: str = Field("", max_length=16) + ck_status: str = Field("", max_length=16) + gift_name: str = Field("", max_length=128) + keyword: str = Field("", max_length=128) + include_void: bool = False + + +class HuyaExternalOrderUnarchiveRequest(BaseModel): + """取消归档(恢复回主台账)。""" + + ids: list[int] = Field(default_factory=list) + + class HuyaExternalOrderCkRequest(BaseModel): """贴入/换 CK,或从 raw_cookie 重建。""" @@ -1302,6 +1325,25 @@ class HuyaExternalOrderStatusBatchOut(BaseModel): results: list[HuyaExternalOrderStatusBatchItemOut] = Field(default_factory=list) +class HuyaExternalOrderArchiveItemOut(BaseModel): + """归档/取消归档里的一行结论。""" + + id: int + order_no: str = "" + ok: bool = False + message: str = "" + + +class HuyaExternalOrderArchiveOut(BaseModel): + """归档/取消归档汇总:``archived`` 或 ``unarchived`` + skipped = total。""" + + total: int + archived: int = 0 + unarchived: int = 0 + skipped: int = 0 + results: list[HuyaExternalOrderArchiveItemOut] = Field(default_factory=list) + + class HuyaExternalOrderActivityQueryRequest(BaseModel): """勾选多行查积分 / 兑换记录(一次最多 50 条,串行带间隔)。 @@ -1503,6 +1545,13 @@ class HuyaExternalOrderOut(BaseModel): done_at: datetime | None = None void_reason: str = "" + # 归档是可见性状态,不影响上面的业务状态。 + is_archived: bool = False + archived_at: datetime | None = None + archived_by: int | None = None + archived_by_name: str = "" + archive_reason: str = "" + points: int | None = None elite_opened: bool | None = None snapshot_at: datetime | None = None @@ -1540,11 +1589,12 @@ class HuyaExternalOrderListOut(BaseModel): class HuyaExternalOrderSummaryOut(BaseModel): - """页头统计条。""" + """页头统计条(口径与当前 archive_scope / include_void 一致)。""" total: int by_status: dict[str, int] ck_invalid: int + archived_total: int = 0 class HuyaExternalOrderOwnerOut(BaseModel): diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 50919ba..b898f5e 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -123,6 +123,12 @@ STATUS_TIMESTAMP_FIELDS = { STATUS_DONE: "done_at", } +# 归档(可见性)与业务状态正交:只允许归档已经抢购成功、不再需要日常操作的终态行。 +ARCHIVE_SCOPES: tuple[str, ...] = ("active", "archived", "all") +ARCHIVABLE_STATUSES: tuple[str, ...] = (STATUS_DONE,) +# DB-only 的批量归档不需要像外部探针那样限 50 条。 +ARCHIVE_BATCH_MAX = 5000 + def is_forward_status(from_status: str, target: str) -> bool: """这一步算不算「向前」(人工切换允许往前跳步,回退必须显式声明)。 @@ -186,6 +192,7 @@ FIELD_LIMITS = { "gift_name": 128, "remark": 512, "void_reason": 256, + "archive_reason": 256, } FIELD_LABELS = { @@ -198,6 +205,7 @@ FIELD_LABELS = { "gift_name": "礼包名", "remark": "备注", "void_reason": "作废原因", + "archive_reason": "归档备注", } IMAGE_SLOTS: tuple[str, ...] = ("bind_images", "home_images") @@ -325,6 +333,16 @@ def _audit_target(order: HuyaExternalOrder) -> str: return f"huya_external_order:{order.id}" +def assert_order_mutable(order: HuyaExternalOrder) -> None: + """归档后整行只读;要改状态/CK/备注必须先取消归档。""" + if order.archived_at is not None: + raise CollabError( + "这单已归档,只能查看;需要修改请先取消归档", + status_code=409, + code="order_archived", + ) + + def assert_version(order: HuyaExternalOrder, expected_version: int | None) -> None: """乐观锁:客户端提交时带的版本必须与库里一致,否则说明别人先改了。""" if expected_version is None: @@ -524,6 +542,7 @@ def set_status( """ if target not in ORDER_STATUSES: raise CollabError(f"未知状态:{target}", code="status_unknown") + assert_order_mutable(order) if target == order.status: return {} @@ -681,6 +700,7 @@ def set_ck( 同一个虎牙号**允许**挂在多张单上(2026-10-16 用户拍板放开 uid 唯一性): 复用到的那些单号只记一笔审计,不挡操作 —— 挡了就变成"同一个买家的第二单建不了"。 """ + assert_order_mutable(order) before = str(order.cookie or "") detail = _apply_ck(order, cookie_text) reused = [ @@ -726,6 +746,7 @@ def set_ck_status( """ if status not in CK_STATUSES: raise CollabError(f"未知 CK 状态:{status}", code="ck_status_unknown") + assert_order_mutable(order) previous = order.ck_status order.ck_status = status order.ck_check_message = clean_text( @@ -756,6 +777,7 @@ def rebuild_ck( db: Session, current: User | None, order: HuyaExternalOrder, *, commit: bool = True ) -> dict: """用 ``raw_cookie`` 重建网页运行 CK(复用 huya_service 的纯函数)。""" + assert_order_mutable(order) if not str(order.raw_cookie or "").strip(): raise CollabError( "这行没有 App 原始凭据,无法重建网页 CK(请重新贴一份)", @@ -826,6 +848,7 @@ def set_images( commit: bool = True, ) -> dict: """整槽替换图片(前端上传完拿到 object_key 后一次性提交)。""" + assert_order_mutable(order) if slot not in IMAGE_SLOTS: raise CollabError(f"未知图片槽位:{slot}", code="image_slot_unknown") before = images_of(order, slot) @@ -1409,6 +1432,7 @@ def query_order_activity( """ if scope not in ACTIVITY_SCOPES: raise CollabError(f"未知的查询范围:{scope}", code="activity_scope_unknown") + assert_order_mutable(order) want_score = scope in (ACTIVITY_SCOPE_BOTH, ACTIVITY_SCOPE_SCORE) want_records = scope in (ACTIVITY_SCOPE_BOTH, ACTIVITY_SCOPE_RECORDS) @@ -1734,6 +1758,7 @@ def verify_ck( - 人工填的那些列(含 ``bind_douyu_account``)**一律不碰**; - CK 失效**不改业务状态**(设计文档 §4.2:已绑定/已充值的单子业务上可能仍然有效)。 """ + assert_order_mutable(order) if not str(order.cookie or "").strip(): raise CollabError("这行还没有 CK,先贴一份再验证", code="no_cookie") if order.status == STATUS_VOID: @@ -1868,6 +1893,21 @@ 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] +def list_orders_for_archive(db: Session, ids: Sequence[int]) -> list[HuyaExternalOrder]: + """按 id 取归档/取消归档目标(不受探针 50 条上限约束)。""" + wanted = list(dict.fromkeys(int(value) for value in ids)) + if not wanted: + raise CollabError("请先选择要归档的行", code="archive_empty") + if len(wanted) > ARCHIVE_BATCH_MAX: + raise CollabError( + f"一次最多处理 {ARCHIVE_BATCH_MAX} 行,请分批操作", + code="archive_too_large", + ) + rows = db.query(HuyaExternalOrder).filter(HuyaExternalOrder.id.in_(wanted)).all() + by_id = {int(row.id): row for row in rows} + return [by_id[value] for value in wanted if value in by_id] + + # 导出 CK 的审计动作名(导出=把凭据带出系统,必须留痕) CK_EXPORT_ACTION = "huya:collab:ck_export" @@ -2395,6 +2435,7 @@ def update_order( 乐观锁:``expected_version`` 与库里不一致 → 409,绝不静默覆盖 (这正是 Excel 协同最痛的地方)。 """ + assert_order_mutable(order) assert_version(order, expected_version) changes: dict[str, dict[str, Any]] = {} @@ -2446,6 +2487,7 @@ def delete_order( 只删行,**不删对象存储里的图**:撤图不可逆,交给孤儿清理脚本按保留期处理 (设计文档 §6.4)。 """ + assert_order_mutable(order) snapshot = { "order_no": order.order_no, "uid": order.uid or "", @@ -2509,7 +2551,13 @@ ORDER_SORT_KEYS: tuple[str, ...] = ( ) -def list_orders( +def normalize_archive_scope(value: str | None) -> str: + """归档可见范围:默认只看活跃行,归档视图只返回已归档行。""" + scope = str(value or "active").strip().lower() + return scope if scope in ARCHIVE_SCOPES else "active" + + +def _filtered_orders_query( db: Session, *, shop: str = "", @@ -2519,15 +2567,9 @@ def list_orders( gift_name: str = "", keyword: str = "", include_void: bool = False, - sort_by: str = "seq", - sort_order: str = "asc", - page: int = 1, - page_size: int = 50, -) -> tuple[list[HuyaExternalOrder], int]: - """共享视图:默认**不按人过滤**(负责人只是筛选维度,不是权限边界)。 - - 默认按 ``id`` 升序(即创建顺序),新建的行永远追加在最后;表头排序走白名单, - 不允许前端把任意列名拼进 SQL。""" + archive_scope: str = "active", +): + """列表与「按筛选归档」共用同一套筛选,保证两边口径一致。""" query = db.query(HuyaExternalOrder) if shop: query = query.filter(HuyaExternalOrder.shop == shop) @@ -2542,6 +2584,11 @@ def list_orders( query = query.filter(HuyaExternalOrder.gift_name == skin) if not include_void: query = query.filter(HuyaExternalOrder.status != STATUS_VOID) + scope = normalize_archive_scope(archive_scope) + if scope == "active": + query = query.filter(HuyaExternalOrder.archived_at.is_(None)) + elif scope == "archived": + query = query.filter(HuyaExternalOrder.archived_at.is_not(None)) text = (keyword or "").strip() if text: like = f"%{text}%" @@ -2560,6 +2607,41 @@ def list_orders( HuyaExternalOrder.remark.like(like), ) ) + return query + + +def list_orders( + db: Session, + *, + shop: str = "", + owner_id: int | None = None, + status: str = "", + ck_status: str = "", + gift_name: str = "", + keyword: str = "", + include_void: bool = False, + archive_scope: str = "active", + sort_by: str = "seq", + sort_order: str = "asc", + page: int = 1, + page_size: int = 50, +) -> tuple[list[HuyaExternalOrder], int]: + """共享视图:默认**不按人过滤**(负责人只是筛选维度,不是权限边界)。 + + 默认按 ``id`` 升序(即创建顺序),新建的行永远追加在最后;表头排序走白名单, + 不允许前端把任意列名拼进 SQL。归档行只有在 ``archive_scope=archived/all`` 时出现。 + """ + query = _filtered_orders_query( + db, + shop=shop, + owner_id=owner_id, + status=status, + ck_status=ck_status, + gift_name=gift_name, + keyword=keyword, + include_void=include_void, + archive_scope=archive_scope, + ) total = query.count() page = max(1, int(page or 1)) @@ -2594,23 +2676,236 @@ def list_orders( return rows, total -def summary(db: Session) -> dict: - """页头统计条:各状态计数 + CK 失效数。""" +def archive_orders( + db: Session, + current: User | None, + orders: Sequence[HuyaExternalOrder], + *, + reason: str = "", + commit: bool = True, +) -> dict: + """归档抢购成功的行:只改可见性字段,不影响业务状态。 + + 已有归档的行、非「抢购成功」的行都跳过并逐条回原因;归档备注写入每行, + 取消归档时清空,审计里始终保留原始备注。 + """ + rows = list(orders) + if not rows: + raise CollabError("请先选择要归档的行", code="archive_empty") + if len(rows) > ARCHIVE_BATCH_MAX: + raise CollabError( + f"一次最多归档 {ARCHIVE_BATCH_MAX} 行,请分批操作", + code="archive_too_large", + ) + note = clean_text(reason, field="archive_reason", max_length=256) + now = _now() + results: list[dict] = [] + archived = 0 + for order in rows: + if order.archived_at is not None: + results.append( + { + "id": int(order.id), + "order_no": order.order_no, + "ok": False, + "message": "已经归档", + } + ) + continue + if order.status not in ARCHIVABLE_STATUSES: + results.append( + { + "id": int(order.id), + "order_no": order.order_no, + "ok": False, + "message": "只有「抢购成功」的单可以归档", + } + ) + continue + order.archived_at = now + order.archived_by = current.id if current else None + order.archive_reason = note + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:archive", + target=_audit_target(order), + detail={ + "order_no": order.order_no, + "status": order.status, + "reason": note, + }, + ) + archived += 1 + results.append( + { + "id": int(order.id), + "order_no": order.order_no, + "ok": True, + "message": "已归档", + } + ) + if commit: + db.commit() + for order in rows: + db.refresh(order) + return { + "total": len(rows), + "archived": archived, + "unarchived": 0, + "skipped": len(rows) - archived, + "results": results, + } + + +def archive_orders_by_filter( + db: Session, + current: User | None, + *, + reason: str = "", + expected_count: int | None = None, + shop: str = "", + owner_id: int | None = None, + status: str = "", + ck_status: str = "", + gift_name: str = "", + keyword: str = "", + include_void: bool = False, +) -> dict: + """把当前筛选范围内的「抢购成功」活跃行一次归档。 + + ``expected_count`` 是前端确认弹窗里展示的数量;执行前再数一次,避免确认期间 + 新完成/被其他人归档导致多隐藏了行。 + """ + query = _filtered_orders_query( + db, + shop=shop, + owner_id=owner_id, + status=status, + ck_status=ck_status, + gift_name=gift_name, + keyword=keyword, + include_void=include_void, + archive_scope="active", + ).filter(HuyaExternalOrder.status == STATUS_DONE) + total = query.count() + if expected_count is not None and int(expected_count) != total: + raise CollabError( + f"匹配数量已变化(确认时 {expected_count} 条,现在 {total} 条),请刷新后重新确认", + status_code=409, + code="archive_count_changed", + ) + if total > ARCHIVE_BATCH_MAX: + raise CollabError( + f"一次最多归档 {ARCHIVE_BATCH_MAX} 行,请缩小筛选范围", + code="archive_too_large", + ) + rows = query.order_by(HuyaExternalOrder.id.asc()).all() + return archive_orders(db, current, rows, reason=reason) + + +def unarchive_orders( + db: Session, + current: User | None, + orders: Sequence[HuyaExternalOrder], + *, + commit: bool = True, +) -> dict: + """取消归档,恢复回主台账;归档备注清空但审计保留。""" + rows = list(orders) + if not rows: + raise CollabError("请先选择要取消归档的行", code="unarchive_empty") + if len(rows) > ARCHIVE_BATCH_MAX: + raise CollabError( + f"一次最多取消归档 {ARCHIVE_BATCH_MAX} 行,请分批操作", + code="unarchive_too_large", + ) + results: list[dict] = [] + unarchived = 0 + for order in rows: + if order.archived_at is None: + results.append( + { + "id": int(order.id), + "order_no": order.order_no, + "ok": False, + "message": "没有归档", + } + ) + continue + previous_reason = order.archive_reason or "" + order.archived_at = None + order.archived_by = None + order.archive_reason = "" + _touch(order, current) + record_audit( + db, + current, + action="huya:collab:unarchive", + target=_audit_target(order), + detail={ + "order_no": order.order_no, + "status": order.status, + "reason": previous_reason, + }, + ) + unarchived += 1 + results.append( + { + "id": int(order.id), + "order_no": order.order_no, + "ok": True, + "message": "已取消归档", + } + ) + if commit: + db.commit() + for order in rows: + db.refresh(order) + return { + "total": len(rows), + "archived": 0, + "unarchived": unarchived, + "skipped": len(rows) - unarchived, + "results": results, + } + + +def summary( + db: Session, *, include_void: bool = False, archive_scope: str = "active" +) -> dict: + """页头统计条:统计口径与当前列表的归档/作废过滤保持一致。""" + scope = normalize_archive_scope(archive_scope) + query = db.query(HuyaExternalOrder) + if not include_void: + query = query.filter(HuyaExternalOrder.status != STATUS_VOID) + if scope == "active": + query = query.filter(HuyaExternalOrder.archived_at.is_(None)) + elif scope == "archived": + query = query.filter(HuyaExternalOrder.archived_at.is_not(None)) + status_rows = ( - db.query(HuyaExternalOrder.status, func.count(HuyaExternalOrder.id)) + query.with_entities(HuyaExternalOrder.status, func.count(HuyaExternalOrder.id)) .group_by(HuyaExternalOrder.status) .all() ) by_status = {str(status): int(count) for status, count in status_rows} ck_invalid = ( - db.query(func.count(HuyaExternalOrder.id)) + query.with_entities(func.count(HuyaExternalOrder.id)) .filter(HuyaExternalOrder.ck_status == CK_INVALID) .scalar() ) + archived_total = ( + db.query(func.count(HuyaExternalOrder.id)) + .filter(HuyaExternalOrder.archived_at.is_not(None)) + .scalar() + ) return { "total": sum(by_status.values()), "by_status": {code: by_status.get(code, 0) for code in ORDER_STATUSES}, "ck_invalid": int(ck_invalid or 0), + "archived_total": int(archived_total or 0), } @@ -2630,7 +2925,12 @@ def users_by_id(db: Session, orders: Sequence[HuyaExternalOrder]) -> dict[int, s [ value for order in orders - for value in (order.owner_id, order.created_by, order.updated_by) + for value in ( + order.owner_id, + order.created_by, + order.updated_by, + order.archived_by, + ) ], ) @@ -2710,6 +3010,11 @@ def serialize_order( "scripted_at": order.scripted_at, "done_at": order.done_at, "void_reason": order.void_reason or "", + "is_archived": order.archived_at is not None, + "archived_at": order.archived_at, + "archived_by": order.archived_by, + "archived_by_name": name_of(order.archived_by), + "archive_reason": order.archive_reason or "", "points": order.points, "elite_opened": order.elite_opened, "snapshot_at": order.snapshot_at, diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index 69a89e6..e2b46bf 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -35,6 +35,9 @@ import type { HuyaCollabActivityBatchResult, HuyaCollabActivityItem, HuyaCollabActivityScope, + HuyaCollabArchiveRequest, + HuyaCollabArchiveResult, + HuyaCollabArchiveScope, HuyaCollabCkExportResult, HuyaCollabCkPreview, HuyaCollabCkRequest, @@ -203,9 +206,12 @@ export const huyaApi = { /** 列表:共享视图,默认不按人过滤(负责人只是筛选维度) */ listCollabOrders: (params: HuyaCollabOrderQuery = {}) => api.get('/huya/external-orders', { params }), - /** 页头统计条:各业务状态计数 + CK 失效数 */ - collabOrdersSummary: () => - api.get('/huya/external-orders/summary'), + /** 页头统计条:口径与当前归档/作废过滤一致 */ + collabOrdersSummary: (params: { + include_void?: boolean; + archive_scope?: HuyaCollabArchiveScope; + } = {}) => + api.get('/huya/external-orders/summary', { params }), /** 负责人候选(全部活跃用户) */ collabOrderOwners: () => api.get('/huya/external-orders/owners'), @@ -278,6 +284,18 @@ export const huyaApi = { '/huya/external-orders/status/batch', data, ), + /** 归档抢购成功的订单(可带备注;archive_filtered 时按当前筛选批量归档) */ + archiveCollabOrders: (data: HuyaCollabArchiveRequest) => + api.post( + '/huya/external-orders/archive', + data, + ), + /** 取消归档,恢复回主台账 */ + unarchiveCollabOrders: (ids: number[]) => + api.post( + '/huya/external-orders/unarchive', + { ids }, + ), /** 查这一单的积分 + 兑换记录(写一条生命周期快照,已上脚本的单会按结果自动改状态) */ queryCollabOrderActivity: (id: number) => api.post( diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index b1c8a9a..f204c8c 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1214,6 +1214,9 @@ export type HuyaCollabStatus = /** 凭据状态:CK 本身还有没有效 */ export type HuyaCollabCkStatus = 'unverified' | 'valid' | 'invalid' | 'disabled'; +/** 归档可见范围:默认活跃;查看归档时只返回已归档行 */ +export type HuyaCollabArchiveScope = 'active' | 'archived' | 'all'; + export type HuyaCollabImageSlot = 'bind_images' | 'home_images'; /** 一行 = 一单 = 一个外部虎牙 CK */ @@ -1258,6 +1261,12 @@ export interface HuyaCollabOrder { scripted_at: string | null; done_at: string | null; void_reason: string; + /** 归档是可见性状态,不改业务状态;归档行只读 */ + is_archived: boolean; + archived_at: string | null; + archived_by: number | null; + archived_by_name: string; + archive_reason: string; points: number | null; elite_opened: boolean | null; snapshot_at: string | null; @@ -1421,6 +1430,36 @@ export interface HuyaCollabStatusBatchResult { results: HuyaCollabStatusBatchItem[]; } +/** 归档请求:ids 为空且 archive_filtered=true 时按筛选范围归档 */ +export interface HuyaCollabArchiveRequest { + ids?: number[]; + reason?: string; + archive_filtered?: boolean; + expected_count?: number; + shop?: string; + owner_id?: number | null; + status?: string; + ck_status?: string; + gift_name?: string; + keyword?: string; + include_void?: boolean; +} + +export interface HuyaCollabArchiveItem { + id: number; + order_no: string; + ok: boolean; + message: string; +} + +export interface HuyaCollabArchiveResult { + total: number; + archived: number; + unarchived: number; + skipped: number; + results: HuyaCollabArchiveItem[]; +} + /** 导出 CK 里的一行(ck_text 是**明文凭据**,只有导出接口会返回) */ export interface HuyaCollabCkExportItem { id: number; @@ -1460,6 +1499,8 @@ export interface HuyaCollabSummary { total: number; by_status: Record; ck_invalid: number; + /** 全量已归档数量(不受当前 archive_scope 影响,用于入口角标) */ + archived_total: number; } export interface HuyaCollabOwner { @@ -1484,6 +1525,7 @@ export interface HuyaCollabOrderQuery { gift_name?: string; keyword?: string; include_void?: boolean; + archive_scope?: HuyaCollabArchiveScope; /** 服务端排序字段(后端白名单) */ sort_by?: string; sort_order?: 'asc' | 'desc'; diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 68ba97c..38345ea 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -15,7 +15,7 @@ import { import type { TableProps } from 'antd'; import { CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileAddOutlined, - FileExcelOutlined, GiftOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, + FileExcelOutlined, GiftOutlined, InboxOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined, } from '@ant-design/icons'; import { @@ -500,7 +500,10 @@ export default function HuyaCollabPage() { // 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求) const [frequentGifts, setFrequentGifts] = useState([]); const [shopModalOpen, setShopModalOpen] = useState(false); - const [filters, setFilters] = useState({ include_void: false }); + const [filters, setFilters] = useState({ + include_void: false, + archive_scope: 'active', + }); // 默认按固定创建序号正序:老单在上、新单永远追加在最后。 const [sortKey, setSortKey] = useState('seq'); const [sortOrder, setSortOrder] = useState('ascend'); @@ -563,6 +566,13 @@ export default function HuyaCollabPage() { const [batchStatusTarget, setBatchStatusTarget] = useState('bound'); const [batchStatusReason, setBatchStatusReason] = useState(''); const [batchStatusRollback, setBatchStatusRollback] = useState(false); + // 归档弹窗:ids=null 表示按当前筛选结果批量归档 + const [archiveOpen, setArchiveOpen] = useState(false); + const [archiveIds, setArchiveIds] = useState(null); + const [archiveFiltered, setArchiveFiltered] = useState(false); + const [archiveExpectedCount, setArchiveExpectedCount] = useState(0); + const [archiveReason, setArchiveReason] = useState(''); + const [archiving, setArchiving] = useState(false); // 积分 / 兑换记录(「账号全生命周期」) const [querying, setQuerying] = useState(false); const [metrics, setMetrics] = useState([]); @@ -593,7 +603,10 @@ export default function HuyaCollabPage() { page, page_size: pageSize, }), - huyaApi.collabOrdersSummary(), + huyaApi.collabOrdersSummary({ + include_void: filters.include_void, + archive_scope: filters.archive_scope ?? 'active', + }), withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null), withMeta ? huyaApi.listCollabShops() : Promise.resolve(null), withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null), @@ -621,7 +634,7 @@ export default function HuyaCollabPage() { // 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。 const realtimeSyncEnabled = realtimeEnabled && !realtimePaused; const collabModalOpen = Boolean( - createOpen || supplementTarget || detail || statusAction || batchStatusOpen || ckTarget || ckExportOpen || shopModalOpen, + createOpen || supplementTarget || detail || statusAction || batchStatusOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen, ); const toggleRealtimeSync = (nextEnabled: boolean) => { @@ -774,6 +787,122 @@ export default function HuyaCollabPage() { [runCkPreview], ); + const isArchiveView = filters.archive_scope === 'archived'; + const selectedRows = useMemo( + () => rows.filter((row) => selectedIds.includes(row.id)), + [rows, selectedIds], + ); + const selectedArchivable = selectedRows.length > 0 + && selectedRows.every((row) => row.status === 'done' && !row.is_archived); + const canArchiveFiltered = canEdit && !isArchiveView && filters.status === 'done' && total > 0; + + const toggleArchiveView = (checked: boolean) => { + setPage(1); + setSelectedIds([]); + setFilters((prev) => ({ + ...prev, + archive_scope: checked ? 'archived' : 'active', + // 归档视图目前只有「抢购成功」;保留原来的状态筛选会看起来像空表。 + status: undefined, + })); + }; + + const openArchiveModal = ( + ids: number[] | null, + options: { filtered?: boolean; count?: number } = {}, + ) => { + setArchiveIds(ids); + setArchiveFiltered(options.filtered === true); + setArchiveExpectedCount(options.count ?? ids?.length ?? 0); + setArchiveReason(''); + setArchiveOpen(true); + }; + + const submitArchive = async () => { + if (!archiveFiltered && (!archiveIds || archiveIds.length === 0)) return; + setArchiving(true); + try { + const result = await huyaApi.archiveCollabOrders( + archiveFiltered + ? { + archive_filtered: true, + expected_count: archiveExpectedCount, + reason: archiveReason, + shop: filters.shop || '', + owner_id: filters.owner_id ?? null, + status: filters.status || '', + ck_status: filters.ck_status || '', + gift_name: filters.gift_name || '', + keyword: filters.keyword || '', + include_void: !!filters.include_void, + } + : { ids: archiveIds ?? [], reason: archiveReason }, + ); + const failures = result.results.filter((item) => !item.ok); + if (result.archived > 0) { + message.success(`已归档 ${result.archived} 条抢购成功订单`); + } else { + message.warning('没有可归档的订单'); + } + if (failures.length > 0) { + modal.info({ + title: `归档完成:成功 ${result.archived} / 跳过 ${result.skipped}`, + width: 640, + content: ( + + {failures.map((item) => ( + + {item.order_no}:{item.message} + + ))} + + ), + }); + } + setArchiveOpen(false); + setArchiveIds(null); + setArchiveFiltered(false); + setArchiveReason(''); + setSelectedIds([]); + setPage(1); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setArchiving(false); + } + }; + + const unarchiveMany = async (ids: number[]) => { + if (ids.length === 0) return; + setBusy(true); + try { + const result = await huyaApi.unarchiveCollabOrders(ids); + if (result.unarchived > 0) { + message.success(`已取消归档 ${result.unarchived} 条`); + } else { + message.warning('没有可取消归档的订单'); + } + setSelectedIds([]); + if (detail && ids.includes(detail.id)) setDetail(null); + void load(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setBusy(false); + } + }; + + const confirmUnarchive = (ids: number[]) => { + if (ids.length === 0) return; + modal.confirm({ + title: ids.length === 1 ? '取消归档这一单?' : `取消归档 ${ids.length} 条?`, + content: '取消后会恢复到活跃台账,之前的归档备注仍保留在审计时间线里。', + okText: '取消归档', + onOk: () => unarchiveMany(ids), + }); + }; + /** 勾选多行一次改状态(往前可跳步;回退要在弹窗里勾「回退」) */ const submitBatchStatus = async () => { setBusy(true); @@ -1707,7 +1836,7 @@ export default function HuyaCollabPage() { /> } > - @@ -1719,7 +1848,7 @@ export default function HuyaCollabPage() { render: (value: number | null, row) => (