feat: 协作台账支持归档并精简状态列

This commit is contained in:
yml2213
2026-10-19 13:04:27 +08:00
parent 375567cbd2
commit 781b27e63a
11 changed files with 1040 additions and 66 deletions
+61 -12
View File
@@ -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. 风险
+122
View File
@@ -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):
"""抢购之后:查积分 + 兑换记录 → 落生命周期快照 → 按结果自动改状态。"""
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261016_0059"
HEAD_REVISION = "20261019_0060"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
@@ -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)
+12
View File
@@ -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)
+65 -2
View File
@@ -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
)
+51 -1
View File
@@ -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):
+320 -15
View File
@@ -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,
+21 -3
View File
@@ -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<HuyaCollabOrderList, HuyaCollabOrderList>('/huya/external-orders', { params }),
/** 页头统计条:各业务状态计数 + CK 失效数 */
collabOrdersSummary: () =>
api.get<HuyaCollabSummary, HuyaCollabSummary>('/huya/external-orders/summary'),
/** 页头统计条:口径与当前归档/作废过滤一致 */
collabOrdersSummary: (params: {
include_void?: boolean;
archive_scope?: HuyaCollabArchiveScope;
} = {}) =>
api.get<HuyaCollabSummary, HuyaCollabSummary>('/huya/external-orders/summary', { params }),
/** 负责人候选(全部活跃用户) */
collabOrderOwners: () =>
api.get<HuyaCollabOwner[], HuyaCollabOwner[]>('/huya/external-orders/owners'),
@@ -278,6 +284,18 @@ export const huyaApi = {
'/huya/external-orders/status/batch',
data,
),
/** 归档抢购成功的订单(可带备注;archive_filtered 时按当前筛选批量归档) */
archiveCollabOrders: (data: HuyaCollabArchiveRequest) =>
api.post<HuyaCollabArchiveResult, HuyaCollabArchiveResult>(
'/huya/external-orders/archive',
data,
),
/** 取消归档,恢复回主台账 */
unarchiveCollabOrders: (ids: number[]) =>
api.post<HuyaCollabArchiveResult, HuyaCollabArchiveResult>(
'/huya/external-orders/unarchive',
{ ids },
),
/** 查这一单的积分 + 兑换记录(写一条生命周期快照,已上脚本的单会按结果自动改状态) */
queryCollabOrderActivity: (id: number) =>
api.post<HuyaCollabActivityItem, HuyaCollabActivityItem>(
+42
View File
@@ -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<string, number>;
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';
+271 -32
View File
@@ -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<HuyaCollabFrequentGift[]>([]);
const [shopModalOpen, setShopModalOpen] = useState(false);
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({ include_void: false });
const [filters, setFilters] = useState<HuyaCollabOrderQuery>({
include_void: false,
archive_scope: 'active',
});
// 默认按固定创建序号正序:老单在上、新单永远追加在最后。
const [sortKey, setSortKey] = useState<CollabSortKey>('seq');
const [sortOrder, setSortOrder] = useState<CollabSortOrder>('ascend');
@@ -563,6 +566,13 @@ export default function HuyaCollabPage() {
const [batchStatusTarget, setBatchStatusTarget] = useState<HuyaCollabStatus>('bound');
const [batchStatusReason, setBatchStatusReason] = useState('');
const [batchStatusRollback, setBatchStatusRollback] = useState(false);
// 归档弹窗:ids=null 表示按当前筛选结果批量归档
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveIds, setArchiveIds] = useState<number[] | null>(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<HuyaCollabMetric[]>([]);
@@ -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: (
<Space direction="vertical" style={{ width: '100%' }}>
{failures.map((item) => (
<Text key={item.id} type="secondary">
{item.order_no}:{item.message}
</Text>
))}
</Space>
),
});
}
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() {
/>
}
>
<Button size="small" type={value ? 'text' : 'link'} disabled={!canEdit}>
<Button size="small" type={value ? 'text' : 'link'} disabled={!canEdit || row.is_archived}>
{value || '+ 选店铺'}
</Button>
</Popover>
@@ -1719,7 +1848,7 @@ export default function HuyaCollabPage() {
render: (value: number | null, row) => (
<Select
size="small" variant="borderless" style={{ width: '100%' }}
value={value ?? undefined} options={ownerOptions} disabled={!canEdit}
value={value ?? undefined} options={ownerOptions} disabled={!canEdit || row.is_archived}
placeholder="+ 指派" allowClear showSearch optionFilterProp="label"
onChange={(next: number | undefined) => void patchOrder(row, { owner_id: next ?? null })}
/>
@@ -1832,7 +1961,7 @@ export default function HuyaCollabPage() {
optionFilterProp="label"
options={giftSelectOptions(row.gift_name)}
value={row.gift_name || undefined}
disabled={!canEdit}
disabled={!canEdit || row.is_archived}
placeholder="+ 点选礼包"
popupMatchSelectWidth={false}
// 前缀单独一行、礼包名完整换行,别让单行省略号把名字吃掉
@@ -1841,7 +1970,7 @@ export default function HuyaCollabPage() {
notFoundContent={giftOptions.length === 0 ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' : '没有匹配的礼包'}
onChange={(next: string) => void patchOrder(row, { gift_name: next })}
/>
<EditableText value={row.remark} disabled={!canEdit} placeholder="+ 备注"
<EditableText value={row.remark} disabled={!canEdit || row.is_archived} placeholder="+ 备注"
onCommit={(next) => void patchOrder(row, { remark: next })} />
</Space>
),
@@ -1850,15 +1979,15 @@ export default function HuyaCollabPage() {
title: '状态', dataIndex: 'status', width: 130,
...sortProps('status'),
render: (value: HuyaCollabStatus, row) => (
<Space direction="vertical" size={2}>
<Space size={4} wrap>
<Tag color={STATUS_META[value]?.color}>{row.status_label || STATUS_META[value]?.label || value}</Tag>
{row.next_status && canEdit && (
<Popconfirm title={`标记为「${STATUS_META[row.next_status].label}」?`}
onConfirm={() => void advance(row)} disabled={busy}>
<Button size="small" type="link" style={{ padding: 0 }}>
→ {STATUS_META[row.next_status].label}
</Button>
</Popconfirm>
{row.is_archived && (
<Tooltip
title={`归档于 ${formatTime(row.archived_at)} · ${row.archived_by_name || '-'}`
+ (row.archive_reason ? `\n备注:${row.archive_reason}` : '')}
>
<Tag color="default">已归档</Tag>
</Tooltip>
)}
</Space>
),
@@ -1941,13 +2070,13 @@ export default function HuyaCollabPage() {
title: '操作', width: 300, fixed: 'right', align: 'center',
render: (_, row) => (
<Space size={4} wrap>
{canEdit && row.status === 'incomplete' && (
{canEdit && row.status === 'incomplete' && !row.is_archived && (
<Button size="small" type="primary" ghost icon={<FileAddOutlined />}
onClick={() => openSupplement(row)}>
补资料
</Button>
)}
{isAdmin && (
{isAdmin && !row.is_archived && (
<Tooltip title={row.has_cookie ? '复制这一行的 CK 明文(仅管理员)' : '这行还没贴 CK'}>
<Button size="small" icon={<CopyOutlined />}
disabled={busy || !row.has_cookie}
@@ -1957,7 +2086,13 @@ export default function HuyaCollabPage() {
</Tooltip>
)}
<Button size="small" icon={<EditOutlined />} onClick={() => void openDetail(row)}>详情</Button>
{canEdit && (
{canEdit && row.is_archived && (
<Button size="small" icon={<UndoOutlined />} disabled={busy}
onClick={() => confirmUnarchive([row.id])}>
取消归档
</Button>
)}
{canEdit && !row.is_archived && (
<Dropdown
menu={{
items: [
@@ -1970,6 +2105,12 @@ export default function HuyaCollabPage() {
{ key: 'set', label: '改状态…', icon: <CheckCircleOutlined /> },
{ key: 'rollback', label: '回退状态…', icon: <UndoOutlined /> },
{ key: 'void', label: '作废…', icon: <StopOutlined />, danger: true },
...(row.status === 'done'
? [
{ type: 'divider' as const },
{ key: 'archive', label: '归档…', icon: <InboxOutlined /> },
]
: []),
...(canManage
? [{ key: 'delete', label: '删除这一行', icon: <DeleteOutlined />, danger: true }]
: []),
@@ -1980,6 +2121,7 @@ export default function HuyaCollabPage() {
if (key === 'ck') setCkTarget({ order: row, text: '' });
if (key === 'rebuild') setCkTarget({ order: row, text: '' });
if (key === 'disable') void toggleCkDisabled(row);
if (key === 'archive') openArchiveModal([row.id], { count: 1 });
if (key === 'set') {
setRollbackTarget(row.next_status ?? row.status);
setStatusReason('');
@@ -2146,18 +2288,50 @@ export default function HuyaCollabPage() {
<Switch size="small" checked={!!filters.include_void}
onChange={(checked) => { setPage(1); setFilters((prev) => ({ ...prev, include_void: checked })); }} />
</Space>
<Space size={4}>
<Text type="secondary">查看归档</Text>
<Switch size="small" checked={isArchiveView} onChange={toggleArchiveView} />
<Tag>{summary?.archived_total ?? 0}</Tag>
</Space>
</Space>
{/* 第二行:动作(固定在这一行;勾选后加「(N)」只是按钮变宽,不会把列表顶下去) */}
<Space wrap style={{ width: '100%' }}>
<Button icon={<ReloadOutlined />} onClick={() => void load()} disabled={loading}>刷新</Button>
{realtimePending && <Tag color="processing">有更新待同步</Tag>}
{canEdit && (
{canEdit && !isArchiveView && (
<Button
icon={<InboxOutlined />}
disabled={!selectedArchivable || busy}
onClick={() => openArchiveModal(selectedIds, { count: selectedRows.length })}
>
批量归档{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canArchiveFiltered && (
<Button
icon={<InboxOutlined />}
disabled={busy}
onClick={() => openArchiveModal(null, { filtered: true, count: total })}
>
归档当前筛选结果({total})
</Button>
)}
{canEdit && isArchiveView && (
<Button
icon={<UndoOutlined />}
disabled={selectedIds.length === 0 || busy}
onClick={() => confirmUnarchive(selectedIds)}
>
批量取消归档{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && !isArchiveView && (
<Button icon={<SafetyCertificateOutlined />} disabled={selectedIds.length === 0 || busy}
onClick={() => void verifySelected()}>
批量验证 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && (
{canEdit && !isArchiveView && (
// 勾选多行一次改状态(往前可跳步;回退在弹窗里勾「回退」)
<Dropdown
disabled={selectedIds.length === 0 || busy}
@@ -2179,7 +2353,7 @@ export default function HuyaCollabPage() {
</Button>
</Dropdown>
)}
{canEdit && (
{canEdit && !isArchiveView && (
// 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照,
// 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。
// 合成一个按钮时「只想要积分」也会顺带把状态改了,两边互相干扰。
@@ -2196,7 +2370,7 @@ export default function HuyaCollabPage() {
</Button>
</>
)}
{canExportCk && (
{canExportCk && !isArchiveView && (
// 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现)
<Button icon={<CopyOutlined />} disabled={selectedIds.length === 0 || busy}
loading={ckExporting}
@@ -2204,15 +2378,15 @@ export default function HuyaCollabPage() {
导出 CK{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
)}
{canEdit && (
{canEdit && !isArchiveView && (
<Button icon={<FileExcelOutlined />} onClick={() => { setImportResult(null); setImportOpen(true); }}>
粘贴导入
</Button>
)}
{canEdit && (
{canEdit && !isArchiveView && (
<Button icon={<ShopOutlined />} onClick={() => setShopModalOpen(true)}>店铺管理</Button>
)}
{canEdit && (
{canEdit && !isArchiveView && (
<Button
type="primary"
icon={<PlusOutlined />}
@@ -2246,6 +2420,14 @@ export default function HuyaCollabPage() {
</Button>
)}
</Space>
{isArchiveView && (
<Alert
type="info"
showIcon
message={`正在查看已归档台账(${summary?.total ?? 0} 条,只读)`}
description="归档行仍可搜索、看详情和审计;需要修改或重新操作时,先点「取消归档」恢复回活跃台账。"
/>
)}
</Space>
</Card>
@@ -2293,11 +2475,29 @@ export default function HuyaCollabPage() {
width={680}
open={!!detail}
onClose={() => setDetail(null)}
title={detail ? `订单 ${detail.order_no}` : ''}
title={detail
? <Space>订单 {detail.order_no}{detail.is_archived && <Tag color="default">已归档</Tag>}</Space>
: ''}
destroyOnHidden
>
{detail && (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{detail.is_archived && (
<Alert
type="info"
showIcon
message={`这单已归档 · ${formatTime(detail.archived_at)} · ${detail.archived_by_name || '-'}`}
description={detail.archive_reason
? `归档备注:${detail.archive_reason}。归档期间只读;需要继续操作请先取消归档。`
: '归档期间只读;需要继续操作请先取消归档。'}
action={canEdit ? (
<Button size="small" icon={<UndoOutlined />} disabled={busy}
onClick={() => confirmUnarchive([detail.id])}>
取消归档
</Button>
) : undefined}
/>
)}
{detail.status === 'incomplete' && (
<Alert
type="warning"
@@ -2334,9 +2534,16 @@ export default function HuyaCollabPage() {
{ key: 'snapshot_at', label: '绑定快照时间', children: formatTime(detail.bind_snapshot_at) },
{ key: 'status', label: '业务状态',
children: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
...(detail.is_archived
? [
{ key: 'archived_at', label: '归档时间', children: formatTime(detail.archived_at) },
{ key: 'archived_by', label: '归档人 / 备注',
children: `${detail.archived_by_name || '-'}${detail.archive_reason ? ` · ${detail.archive_reason}` : ''}` },
]
: []),
{ key: 'bound_at', label: '绑定 / 充值时间',
children: `${formatTime(detail.bound_at)} / ${formatTime(detail.recharged_at)}` },
{ key: 'scripted_at', label: '上脚本 / 归档时间',
{ key: 'scripted_at', label: '上脚本 / 抢购成功时间',
children: `${formatTime(detail.scripted_at)} / ${formatTime(detail.done_at)}` },
{ key: 'updated', label: '最后改动',
children: `${detail.updated_by_name || '-'} · ${formatTime(detail.updated_at)}` },
@@ -2379,7 +2586,7 @@ export default function HuyaCollabPage() {
</>
)}
{canEdit && (
{canEdit && !detail.is_archived && (
<>
<Divider style={{ margin: '4px 0' }}>可编辑字段</Divider>
<Form
@@ -2446,7 +2653,7 @@ export default function HuyaCollabPage() {
<ImageSlotEditor
order={detail}
slot="home_images"
canEdit={canEdit}
canEdit={canEdit && !detail.is_archived}
busy={busy}
onUpload={(targetSlot, file) => void uploadImage(detail, targetSlot, file)}
onRemove={(targetSlot, key) => void removeImage(detail, targetSlot, key)}
@@ -2477,7 +2684,7 @@ export default function HuyaCollabPage() {
</>
)}
{canEdit && (
{canEdit && !detail.is_archived && (
<Space wrap>
{detail.next_status && (
<Button icon={<CheckCircleOutlined />} disabled={busy}
@@ -2531,7 +2738,7 @@ export default function HuyaCollabPage() {
<Divider style={{ margin: '4px 0' }}>账号全生命周期(积分 / 兑换记录)</Divider>
<Space wrap size={8}>
{canEdit && (
{canEdit && !detail.is_archived && (
<Button icon={<SyncOutlined />} disabled={busy || querying || !detail.has_cookie}
loading={querying}
onClick={() => void queryActivityOne(detail)}>
@@ -3028,6 +3235,38 @@ export default function HuyaCollabPage() {
</Space>
</Modal>
<Modal
title={archiveFiltered
? `归档当前筛选结果(${archiveExpectedCount} 条)`
: `归档抢购成功订单(${archiveIds?.length ?? 0} 条)`}
open={archiveOpen}
onCancel={() => setArchiveOpen(false)}
onOk={() => void submitArchive()}
confirmLoading={archiving}
okText={`归档 ${archiveFiltered ? archiveExpectedCount : (archiveIds?.length ?? 0)} 条`}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="归档只是从活跃台账隐藏,不会删除数据"
description="业务状态仍保持「抢购成功」;归档后只读,可在「查看归档」里搜索,取消归档即可恢复。"
/>
<Input.TextArea
rows={3}
maxLength={256}
showCount
value={archiveReason}
onChange={(event) => setArchiveReason(event.target.value)}
placeholder="归档备注(可选):例如 已完成对账、活动结束、客户已验收…"
/>
<Text type="secondary" style={{ fontSize: 12 }}>
只有「抢购成功」的活跃订单可以归档;失败的订单请先处理或作废。
</Text>
</Space>
</Modal>
<Modal
title={`批量改状态(已选 ${selectedIds.length} 条)`}
open={batchStatusOpen}