From c441b6c663adc41f65b9b7a244d13bca6e678a99 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 17 Oct 2026 23:32:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E6=89=B9=E9=87=8F=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=8B=86=E6=88=90=E3=80=8C=E6=9F=A5=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E3=80=8D=E4=B8=8E=E3=80=8C=E6=9F=A5=E5=85=91=E6=8D=A2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E3=80=8D=E4=B8=A4=E4=B8=AA=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端工具栏原来一个按钮同时查两件事,拆开后每次只查一半,所以重点在 「没查的那一半不能被当成没查到」: - 接口加 scope(both / score / records),默认 both 保持原行为; 只查积分时不产生判定(空串「未判定」,与 unknown「无法判定」分开), 也**不会**自动改业务状态(连判定都没有,凭什么改)。 - 列表的积分与判定各取「最近一条查到过它的」快照:点「查积分」不再把 整页判定徽标冲成未判定,点「查兑换记录」也不再清空积分;但新的 unknown 仍会盖掉旧的 success(抖动结果不许藏)。 - 结果弹窗 / 积分列 Tooltip / 生命周期时间线都按 scope 显示, 不再把「本次没查」显示成「积分 -」「兑换记录 0 条」。 - 新增 12 例测试(TestCollabActivityScopeSplit),并做了 4 次反向验证 (去掉判定分支 / 判定跟随最新 / 积分跟随最新 / 删掉响应字段),全部确认变红。 顺带修掉文档漂移:AGENTS.md 的迁移 head 早就过期(main 实际 20261016_0059、dev 20261008_0053),并补上「两侧已分叉成两个 head、 合并 dev 前必须先 alembic merge」这条;设计文档补 §11.9。 --- AGENTS.md | 8 +- docs/设计-虎牙外部CK协作台账.md | 32 +++ tests/test_huya_collab_orders.py | 274 +++++++++++++++++++- web/backend/models.py | 6 +- web/backend/routers/huya/collab.py | 23 +- web/backend/schemas.py | 35 ++- web/backend/services/huya_collab_service.py | 256 +++++++++++++++--- web/frontend/src/api/huya.ts | 12 +- web/frontend/src/api/types.ts | 37 ++- web/frontend/src/pages/HuyaCollabPage.tsx | 160 ++++++++---- 10 files changed, 722 insertions(+), 121 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 766950e..fd07acd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,12 @@ - **本地 `dev.sh` 在跑时,改迁移文件会被它自动应用到你正在用的库**。 所以切分支前先确认「库的迁移版本 == 目标分支的 head」,否则应用会因为 alembic 找不到 revision **直接起不来**(`run_migrations` 没有兜底,会带崩 lifespan)。 +- **`main` 与 `dev` 的迁移链已经分叉成两个 head**(共同祖先是 `20261008_0052`): + `main` 走 `20261015_0053` → … → `20261016_0059`,`dev` 走 `20261008_0053`(账号租约)。 + 两个 0053 的 **revision id 不同**,所以 alembic 不会报「重复 revision」, + 而是报 **Multiple head revisions are present** —— 合并 `dev` 时必须先补一个 + `alembic merge` 节点,否则 `alembic upgrade head` 会直接失败(部署会卡在迁移那一步)。 + 顺带:本地 `douyu_login` 现在停在 `main` 的 head,**切到 dev 分支前必须先回退/重建库**。 - 本地库:`douyu_login`(dev)、`douyu_login_test`(测试,**不要**拿别的库跑测试套件)、 `douyu_login_prod`(生产镜像)。 另有 `douyu_login_prodclone`(停在 `20261003_0040`)与 `douyu_login_rehearsal` @@ -112,6 +118,6 @@ docker compose up -d douyu-login | 本地服务 | `./dev.sh`(8800 端口,`--reload`);**启动时会自动跑迁移** | | 本地 MySQL | 容器 `live-hub-py-mysql-1`,端口 3307;库见 §4 | | 生产镜像库 | `douyu_login_prod`(`scripts/refresh_local_prod_mirror.sh` 同步,只读用) | -| 迁移 head | `main` = `20261004_0051`;`dev` = `20261007_0052`(**两侧不一致,切分支前先对齐库**) | +| 迁移 head | `main` = `20261016_0059`;`dev` = `20261008_0053`(**已经分叉成两个 head**,见 §4) | | 前端 | `tsc -b` 开了 `noUnusedLocals`,死代码/未用导入直接报 TS6133;镜像构建时会 `npm run build` | | 测试 | `pytest tests/ -q`(用 `douyu_login_test`);`ruff format` + `ruff check` | diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index b5a7801..8d14668 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -872,6 +872,38 @@ CK 失效了业务上可能仍然有效。列表上只做**显式提示**(红 **待跟进**:导出动作目前只写审计;如果以后要做"谁在什么时候导出了什么"的专门报表, `audit_logs.action = 'huya:collab:ck_export'` 已经可以直接筛(不用加表)。 +### 11.9 2026-10-17 第六轮:批量查询拆成「查积分」+「查兑换记录」两个按钮 + +需求原文:"批量查积分/兑换 改成两个按钮,一个查询积分,一个查询兑换记录。" + +原先是**一个按钮查两件事**(`POST /external-orders/activity/query` 一次打两个接口)。 +拆开之后,**一半的查询必然缺另一半的数据**,所以这一轮的重点不是按钮,而是三件事: + +| 面 | 落地 | +| --- | --- | +| 服务层 | `query_order_activity(scope=...)` / `query_activity_batch(scope=...)`:`both`(默认,原行为)/ `score` / `records`;没查的那一半**不写值** | +| 判定 | 只查积分时 `verdict` 写**空串**(`VERDICT_NOT_QUERIED`),显示名「未判定」——与 `unknown`「无法判定」(查了但判不了)**必须分开**;`ok` 改看"这次要查的东西查到没有" | +| 列表回填 | `order_metric_views()`:`latest`(时间/结论)+ `score`(最近一条查过积分的)+ `verdict`(最近一条**有判定结论**的),三条线索各发一次分组查询 | +| 接口 | 请求加 `scope`;响应条目加 `scope/scope_label`,汇总加 `not_judged`;`serialize_order` 加 `last_verdict_at/last_verdict_message`;`serialize_metric` 加 `scope` | +| 前端 | 工具栏拆成「批量查积分(N)」「批量查兑换记录(N)」;结果弹窗按 scope 换标题与正文;列表「积分」列 Tooltip 分别写清"最新查询"与"判定"各自的时间;详情时间线按 `scope` 决定显示哪几段(不再把"没查"显示成"积分 -""0 条") | +| 测试 | 新增 `TestCollabActivityScopeSplit`(11 例);全量 `pytest` 978 passed / 6 skipped | + +**这一轮定下的四条口径**(改这块前先读): + +1. **只查积分永远不自动改状态**:判定都没有,凭什么改?`target` 为空,连"因为状态不是已上脚本所以没改" + 那套提示都不会出现。反向验证:去掉这个分支后,一个"兑换记录里明明有它礼包"的已上脚本单 + 会被判成 `failed` 并自动改成「抢购失败」—— 6 个用例同时变红。 +2. **「没查」不是「没有」**:只查积分时不许拿"兑换记录是空的"去判失败;`records_total` 保持 0 但 + `verdict` 留空,汇总里那行进 `not_judged`(**不计入 `unknown`**)。 +3. **列表上的判定徽标取「最近一条真的查过兑换记录的快照」**:否则点一次「查积分」就会把整页 + 「兑换成功 / 兑换失败」冲成「未判定」,等于把结论抹掉。**但新的 `unknown` 照样盖掉旧的 `success`** + —— 不能为了"保住徽标"把抖动的结果藏起来(两条都有专门用例钉着,各注入一次反向验证)。 + 同理,列表上的积分取「最近一条查到过积分的快照」,只查兑换记录时不清空。 +4. **时间线按 `raw.scope` 取舍,不靠"字段是不是空"猜**:快照行是只追加的,`raw.scope` 记着当时查了哪一半。 + +**没有动的地方**:单行「查积分 / 兑换记录」与详情抽屉里的按钮仍是 `both`(一个动作查两件事), +`scope` 是**可选参数**、默认值就是原来的行为 —— 老的调用方一个字段不加也照旧。 + --- ## 12. 风险 diff --git a/tests/test_huya_collab_orders.py b/tests/test_huya_collab_orders.py index b289dd1..9d8942e 100644 --- a/tests/test_huya_collab_orders.py +++ b/tests/test_huya_collab_orders.py @@ -29,7 +29,7 @@ from web.backend.models import ( ) from web.backend.permissions import ROLE_PERMISSIONS from web.backend.routers.huya import collab as collab_api -from web.backend.schemas import HuyaExternalOrderUpdate +from web.backend.schemas import HuyaExternalOrderListOut, HuyaExternalOrderUpdate from web.backend.services import huya_collab_service as collab from web.backend.services.huya_collab_events import collab_events @@ -1987,6 +1987,278 @@ class TestCollabActivityLifecycle(TestCollabBase): assert payload["last_verdict"] == "success" +class TestCollabActivityScopeSplit(TestCollabBase): + """批量「查积分 / 查兑换记录」拆成两个按钮(2026-10-17 用户要求)。 + + 重点全在**互相不冲掉**上: + + - 只查积分时:兑换记录一个请求都不发,判定留空(≠「无法判定」),**绝不自动改状态**; + - 只查兑换记录时:不许把列表上的积分清成空; + - 列表上的「判定徽标」取**最近一条真的查过兑换记录**的快照 —— 否则点一次 + 「查积分」就会把整页「兑换成功 / 兑换失败」冲成「未判定」; + - 但**新的 unknown(查了判不了)必须照样盖掉旧的 success**, + 不能"为了保住徽标"就把抖动的结果藏起来。 + """ + + def _scripted_order( + self, order_no: str, *, gift_name: str = "不朽伊人大礼包" + ) -> HuyaExternalOrder: + order = self._bind_ready(self._create(order_no=order_no, gift_name=gift_name)) + collab.set_status(self.db, self.user, order, "scripted") + return order + + def _row_payload(self, order: HuyaExternalOrder) -> dict: + """列表接口给前端的那份输出(走 order_metric_views,与真实路由同一条路径)。""" + view = collab.order_metric_views(self.db, [order]).get(int(order.id)) + return collab.serialize_order(order, metric_view=view) + + def test_score_scope_only_calls_the_score_api(self): + order = self._scripted_order("SCOPE-1") + client = FakeActivityClient() + + result = collab.query_order_activity( + self.db, self.user, order, scope="score", client=client + ) + + assert len(client.score_calls) == 1 + assert client.record_calls == [] # 兑换记录接口一次都没打 + assert result["scope"] == "score" + assert result["verdict"] == collab.VERDICT_NOT_QUERIED == "" + assert result["score"] == 1234 + assert result["ok"] is True + assert result["message"].startswith("积分 1234") + + metric = collab.list_metrics(self.db, order)[0] + assert metric.score == 1234 + assert metric.verdict == "" + assert metric.raw["scope"] == "score" + + def test_score_scope_never_auto_changes_status(self): + """反向验证:这单的兑换记录里**确实有**它的礼包(旧口径会判「抢购成功」), + 但只查积分时连记录都没查 —— 状态必须一动不动,也不许给出任何判定。""" + order = self._scripted_order("SCOPE-2") + client = FakeActivityClient() # 默认记录里就有「不朽伊人大礼包」 + + result = collab.query_order_activity( + self.db, self.user, order, scope="score", client=client + ) + + assert result["auto_status"] == "" + assert order.status == "scripted" + assert order.done_at is None + assert result["verdict"] == "" + + def test_records_scope_only_calls_the_records_api(self): + order = self._scripted_order("SCOPE-3") + client = FakeActivityClient() + + result = collab.query_order_activity( + self.db, self.user, order, scope="records", client=client + ) + + assert client.score_calls == [] # 积分接口一次都没打 + assert len(client.record_calls) == 1 + assert result["verdict"] == collab.VERDICT_SUCCESS + assert result["auto_status"] == "done" + assert order.status == "done" + + metric = collab.list_metrics(self.db, order)[0] + assert metric.score is None # 本次没查积分:不许把旧积分抄一份进来冒充 + assert metric.verdict == "success" + + def test_score_only_query_keeps_the_previous_verdict_on_the_list(self): + order = self._scripted_order("SCOPE-4") + collab.query_order_activity( + self.db, self.user, order, scope="records", client=FakeActivityClient() + ) + assert order.status == "done" + + collab.query_order_activity( + self.db, + self.user, + order, + scope="score", + client=FakeActivityClient( + score=FakeScoreResp(score=200, gained=5000, used=4800) + ), + ) + + payload = self._row_payload(order) + assert payload["last_score"] == 200 # 积分是新的 + assert payload["last_verdict"] == "success" # 判定还是上一次那条 + assert payload["last_verdict_label"] == "兑换成功" + assert payload["last_verdict_at"] is not None + assert payload["last_verdict_message"] + assert "积分 200" in payload["last_metric_message"] + + def test_records_only_query_keeps_the_previous_score_on_the_list(self): + order = self._scripted_order("SCOPE-5") + # 注意 FakeScoreResp 的可用积分是按 gained - used 算的,score 参数不生效 + collab.query_order_activity( + self.db, + self.user, + order, + scope="score", + client=FakeActivityClient(score=FakeScoreResp(gained=5000, used=4223)), + ) + assert self._row_payload(order)["last_score"] == 777 + + collab.query_order_activity( + self.db, self.user, order, scope="records", client=FakeActivityClient() + ) + + payload = self._row_payload(order) + assert payload["last_score"] == 777 # 积分没被这次查询清空 + assert payload["last_verdict"] == "success" # 判定是这次的 + + def test_newer_unknown_verdict_still_overrides_older_success(self): + """反向验证的另一半:这次接口抖了、**判不了**(unknown), + 列表绝不能继续挂着上一次的「兑换成功」—— 那等于把失败藏起来。""" + order = self._scripted_order("SCOPE-6") + collab.query_order_activity( + self.db, self.user, order, scope="records", client=FakeActivityClient() + ) + collab.query_order_activity( + self.db, + self.user, + order, + scope="records", + client=FakeActivityClient( + records=FakeRecordsResp(status=500, msg="活动已结束") + ), + ) + + payload = self._row_payload(order) + assert payload["last_verdict"] == "unknown" + assert payload["last_verdict_label"] == "无法判定" + + def test_metric_timeline_reports_which_half_was_queried(self): + order = self._scripted_order("SCOPE-7") + collab.query_order_activity( + self.db, self.user, order, scope="score", client=FakeActivityClient() + ) + collab.query_order_activity( + self.db, self.user, order, scope="records", client=FakeActivityClient() + ) + + rows = [ + collab.serialize_metric(row) for row in collab.list_metrics(self.db, order) + ] + # 时间线倒序:最近一次在前 + assert [row["scope"] for row in rows] == ["records", "score"] + assert ( + rows[0]["verdict"] == "success" and rows[0]["verdict_label"] == "兑换成功" + ) + # 只查积分那条:判定留空 + 明确叫「未判定」,不是「无法判定」 + assert rows[1]["verdict"] == "" + assert rows[1]["verdict_label"] == "未判定" + assert rows[1]["records_total"] == 0 + assert rows[1]["verdict_label"] != collab.VERDICT_LABELS[collab.VERDICT_UNKNOWN] + + def test_batch_score_scope_counts_rows_as_not_judged(self): + first = self._scripted_order("SCOPE-8") + second = self._scripted_order("SCOPE-9") + + result = collab.query_activity_batch( + self.db, + self.user, + [first, second], + scope="score", + client=FakeActivityClient(), + interval_seconds=0, + ) + + assert result["total"] == 2 + assert result["not_judged"] == 2 + assert result["unknown"] == 0 # 「没查」不算「查了判不了」 + assert result["success"] == 0 and result["failed"] == 0 + assert result["auto_changed"] == 0 + assert first.status == second.status == "scripted" + + def test_batch_rejects_unknown_scope(self): + order = self._scripted_order("SCOPE-10") + + with pytest.raises(collab.CollabError) as excinfo: + collab.query_activity_batch( + self.db, + self.user, + [order], + scope="everything", + client=FakeActivityClient(), + interval_seconds=0, + ) + + assert excinfo.value.code == "activity_scope_unknown" + + def test_router_passes_scope_through_and_rejects_unknown(self, monkeypatch): + order = self._scripted_order("SCOPE-11") + client = FakeActivityClient() + monkeypatch.setattr(collab, "_build_probe_client", lambda: client) + + result = collab_api.batch_query_external_order_activity( + collab_api.HuyaExternalOrderActivityQueryRequest( + ids=[int(order.id)], scope="score" + ), + self.db, + self.user, + ) + + assert client.record_calls == [] + assert result["scope"] == "score" and result["scope_label"] == "积分" + assert result["not_judged"] == 1 + assert result["results"][0]["verdict_label"] == "未判定" + + with pytest.raises(collab_api.HTTPException) as excinfo: + collab_api.batch_query_external_order_activity( + collab_api.HuyaExternalOrderActivityQueryRequest( + ids=[int(order.id)], scope="nope" + ), + self.db, + self.user, + ) + assert excinfo.value.status_code == 400 + + def test_default_scope_still_queries_both(self): + """默认(不传 scope)保持原行为:老的调用方一个字段不加也照旧。""" + order = self._scripted_order("SCOPE-12") + client = FakeActivityClient() + + result = collab.query_order_activity(self.db, self.user, order, client=client) + + assert len(client.score_calls) == 1 and len(client.record_calls) == 1 + assert result["scope"] == "both" + assert result["verdict"] == collab.VERDICT_SUCCESS + assert result["auto_status"] == "done" + + def test_new_list_fields_survive_pydantic_serialization(self): + """过一遍真实的响应模型(坑 #2:字段声明了、`model_dump()` 里没有 = 假绿)。""" + order = self._scripted_order("SCOPE-13") + collab.query_order_activity( + self.db, self.user, order, scope="records", client=FakeActivityClient() + ) + collab.query_order_activity( + self.db, + self.user, + order, + scope="score", + client=FakeActivityClient(score=FakeScoreResp(gained=5000, used=4800)), + ) + + dumped = HuyaExternalOrderListOut( + items=[collab_api._serialize_one(self.db, order)], + total=1, + page=1, + page_size=50, + ).model_dump() + row = dumped["items"][0] + + assert row["last_score"] == 200 # 查积分那次写的 + assert row["last_verdict"] == "success" # 判定停在查兑换记录那次 + assert row["last_verdict_label"] == "兑换成功" + assert row["last_verdict_at"] is not None + assert row["last_verdict_message"] + + class TestCollabCkExport(TestCollabBase): """勾选导出 CK(写剪贴板):凭据外带要留痕,但审计里不许出现 CK 本体。""" diff --git a/web/backend/models.py b/web/backend/models.py index c0486a6..990771c 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -1204,8 +1204,10 @@ class HuyaExternalOrderMetric(Base): 1. **只追加、不更新、不删除**(快照语义):历史要能回放,改一行就等于毁证; 2. **不存凭据**:`raw` 只放积分响应与兑换记录明细(里面没有 cookie), 凭据永远只待在 `huya_external_orders.cookie`; - 3. `verdict` 三态:`success`(兑换记录里有这一单的礼包)/ `failed`(接口通了但没记录)/ - `unknown`(接口没通或没可比的礼包名),**只有 success / failed 会去改业务状态**; + 3. `verdict`:`success`(兑换记录里有这一单的礼包)/ `failed`(接口通了但没记录)/ + `unknown`(查了兑换记录,但接口没通或没可比的礼包名)/**空串**(本次根本没查兑换记录, + 2026-10-17 拆出「只查积分」入口后才有),**只有 success / failed 会去改业务状态**; + `raw.scope` 记着这条快照查了哪一半(`both` / `score` / `records`),前端据此取舍。 4. 订单删掉时这条也跟着删(`ondelete="CASCADE"`)—— 它没有独立价值。 """ diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index 3c0e91e..2cbdc24 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -77,12 +77,12 @@ def _http_error(exc: collab.CollabError) -> HTTPException: def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict: clusters = collab.buyer_clusters(db, [order]) - metrics = collab.latest_metrics(db, [order]) + views = collab.order_metric_views(db, [order]) return collab.serialize_order( order, users=collab.users_by_id(db, [order]), buyer_cluster=clusters.get(int(order.id)), - last_metric=metrics.get(int(order.id)), + metric_view=views.get(int(order.id)), ) @@ -246,14 +246,14 @@ def list_external_orders( names = collab.users_by_id(db, rows) # 「同一买家的其它单」+「最近一次积分/兑换快照」整页一次算完,别按行发 SQL clusters = collab.buyer_clusters(db, rows) - metrics = collab.latest_metrics(db, rows) + views = collab.order_metric_views(db, rows) return { "items": [ collab.serialize_order( row, users=names, buyer_cluster=clusters.get(int(row.id)), - last_metric=metrics.get(int(row.id)), + metric_view=views.get(int(row.id)), ) for row in rows ], @@ -383,14 +383,18 @@ def batch_query_external_order_activity( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:collab:edit")), ): - """勾选多行查询积分 + 兑换记录(**串行带间隔**,防风控)。 + """勾选多行查积分 / 兑换记录(**串行带间隔**,防风控)。 - 每行都会落一条生命周期快照;「已上脚本」的单按查询结果自动改状态 - (查到礼包 → 抢购成功;没查到 → 抢购失败),其它状态只记录不动状态。 + ``scope`` 决定查哪一半(前端是两个独立按钮,见 2026-10-17 需求): + ``both`` 两个接口都查、``score`` 只查积分、``records`` 只查兑换记录。 + + 每行都会落一条生命周期快照;**只有本次查了兑换记录**且结论明确时, + 「已上脚本」的单才会自动改状态(查到礼包 → 抢购成功;没查到 → 抢购失败), + 其它情况只记录不动状态 —— 只查积分时连判定都没有,更不该改状态。 """ try: orders = collab.list_orders_by_ids(db, req.ids) - result = collab.query_activity_batch(db, current, orders) + result = collab.query_activity_batch(db, current, orders, scope=req.scope) except collab.CollabError as exc: raise _http_error(exc) from exc for item in result["results"]: @@ -399,6 +403,9 @@ def batch_query_external_order_activity( ) auto = item.get("auto_status") or "" item["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto) + # 空判定(只查积分)= 「未判定」,与「无法判定」区分开(见 VERDICT_LABELS) + verdict = item.get("verdict") or "" + item["verdict_label"] = collab.VERDICT_LABELS.get(verdict, verdict) _publish_change(current, scope="orders", action="orders.activity_query") return result diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 3e89bed..374e70d 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1303,9 +1303,17 @@ class HuyaExternalOrderStatusBatchOut(BaseModel): class HuyaExternalOrderActivityQueryRequest(BaseModel): - """勾选多行,一次查这些行的积分 + 兑换记录(一次最多 50 条,串行带间隔)。""" + """勾选多行查积分 / 兑换记录(一次最多 50 条,串行带间隔)。 + + ``scope``(2026-10-17 用户要求把批量按钮拆成两个): + + - ``both``(默认,保持原行为):两个接口都查; + - ``score``:只查积分 —— 没有兑换记录就没有判定,**不会**自动改业务状态; + - ``records``:只查兑换记录 —— 积分不更新,判定与自动改状态照旧。 + """ ids: list[int] = Field(default_factory=list) + scope: str = "both" class HuyaExternalOrderCkExportRequest(BaseModel): @@ -1356,19 +1364,22 @@ class HuyaCollabMetricMatchedOut(BaseModel): class HuyaCollabMetricOut(BaseModel): - """一条「账号全生命周期」快照(积分 + 兑换记录 + 判定)。""" + """一条「账号全生命周期」快照(积分 / 兑换记录 + 判定)。""" id: int order_id: int order_no: str = "" uid: str = "" sid: int | None = None + # 这条快照查了哪一半(both / score / records):前端据此决定显示哪几段 + scope: str = "both" score: int | None = None gained_score: int | None = None used_score: int | None = None records_total: int = 0 records_matched: int = 0 - verdict: str = "unknown" + # 空串 = 本次没查兑换记录(只查积分),与 unknown(查了判不了)不同 + verdict: str = "" verdict_label: str = "" message: str = "" source: str = "manual" @@ -1384,6 +1395,10 @@ class HuyaExternalOrderActivityItemOut(BaseModel): ok: bool = False order_id: int order_no: str = "" + # 本次查的是哪一半(``both`` / ``score`` / ``records``) + scope: str = "" + scope_label: str = "" + # 只查积分时为**空串**(未判定),不是 ``unknown``(查了但判不了) verdict: str = "unknown" verdict_label: str = "" score: int | None = None @@ -1400,13 +1415,17 @@ class HuyaExternalOrderActivityItemOut(BaseModel): class HuyaExternalOrderActivityQueryOut(BaseModel): - """批量查询汇总:``success + failed + unknown = total``。""" + """批量查询汇总:``success + failed + unknown + not_judged = total``。""" total: int success: int failed: int - unknown: int + unknown: int = 0 + # 本次没查兑换记录、因此没有判定结论的行数(只查积分时全落这里) + not_judged: int = 0 auto_changed: int = 0 + scope: str = "" + scope_label: str = "" results: list[HuyaExternalOrderActivityItemOut] = Field(default_factory=list) @@ -1489,11 +1508,15 @@ class HuyaExternalOrderOut(BaseModel): buyer_cluster: HuyaCollabBuyerClusterOut | None = None # 最近一次「积分 / 兑换记录」快照(完整时间线走 GET /{id}/metrics) + # 积分与判定是**分开**取最近一条查到过它的:只查积分的那次不产生判定,反之亦然 + # (2026-10-17 拆分查询入口,见 services/huya_collab_service.order_metric_views) last_score: int | None = None last_metric_at: datetime | None = None + last_metric_message: str = "" last_verdict: str = "" last_verdict_label: str = "" - last_metric_message: str = "" + last_verdict_at: datetime | None = None + last_verdict_message: str = "" class HuyaExternalOrderListOut(BaseModel): diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index 33beb3b..f0dec6a 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -1136,11 +1136,33 @@ ACTIVITY_QUERY_TIMEOUT_SECONDS = 15.0 VERDICT_SUCCESS = "success" VERDICT_FAILED = "failed" VERDICT_UNKNOWN = "unknown" +#: 「本次**没查**兑换记录」的判定值:**空串**。 +#: 必须与 ``unknown``(查了、但接口没通/判不了)分开 —— 列表上的判定徽标会跳过空值, +#: 否则点一次「查积分」就会把整页的徽标冲成「无法判定」,把已有结论抹掉。 +VERDICT_NOT_QUERIED = "" VERDICT_LABELS = { VERDICT_SUCCESS: "兑换成功", VERDICT_FAILED: "兑换失败", VERDICT_UNKNOWN: "无法判定", + # 空判定 = 本次没查兑换记录(拆分查询入口后才有),与「无法判定」必须分开 + VERDICT_NOT_QUERIED: "未判定", +} + +# 「积分 / 兑换记录」的查询范围(2026-10-17 用户要求:批量按钮拆成两个)。 +# 只查一半时,**没查的那一半不写值、也不判定** —— 不许拿「没查」冒充「没有」。 +ACTIVITY_SCOPE_BOTH = "both" +ACTIVITY_SCOPE_SCORE = "score" +ACTIVITY_SCOPE_RECORDS = "records" +ACTIVITY_SCOPES: tuple[str, ...] = ( + ACTIVITY_SCOPE_BOTH, + ACTIVITY_SCOPE_SCORE, + ACTIVITY_SCOPE_RECORDS, +) +ACTIVITY_SCOPE_LABELS = { + ACTIVITY_SCOPE_BOTH: "积分 + 兑换记录", + ACTIVITY_SCOPE_SCORE: "积分", + ACTIVITY_SCOPE_RECORDS: "兑换记录", } # metric.raw 里最多留多少条兑换记录(避免 JSON 列被几百条记录撑爆) @@ -1215,7 +1237,11 @@ def _extract_records(resp: Any) -> tuple[list[dict[str, Any]], str]: def serialize_metric( metric: HuyaExternalOrderMetric, *, users: Mapping[int, str] | None = None ) -> dict: - """一条生命周期快照的输出(时间线用)。""" + """一条生命周期快照的输出(时间线用)。 + + ``scope`` 说明这条快照当时查了哪一半 —— 前端的取舍靠它,别靠"字段是不是空"猜: + 只查积分的那条积分有值、兑换记录是 0 条,不能拿 ``records_total == 0`` 当"没兑换到"。 + """ names = users or {} raw = metric.raw if isinstance(metric.raw, dict) else {} matched = raw.get("matched") if isinstance(raw.get("matched"), list) else [] @@ -1225,12 +1251,14 @@ def serialize_metric( "order_no": metric.order_no or "", "uid": metric.uid or "", "sid": metric.sid, + "scope": str(raw.get("scope") or ACTIVITY_SCOPE_BOTH), "score": metric.score, "gained_score": metric.gained_score, "used_score": metric.used_score, "records_total": int(metric.records_total or 0), "records_matched": int(metric.records_matched or 0), - "verdict": metric.verdict or VERDICT_UNKNOWN, + # 空 = 本次没查兑换记录(≠「无法判定」:那个是查了但判不了) + "verdict": metric.verdict or VERDICT_NOT_QUERIED, "verdict_label": VERDICT_LABELS.get(metric.verdict, metric.verdict or ""), "message": metric.message or "", "source": metric.source or "manual", @@ -1263,28 +1291,87 @@ def list_metrics( ) +@dataclasses.dataclass(frozen=True) +class OrderMetricView: + """列表「积分」列的三条线索(见 :func:`order_metric_views`)。 + + ``score`` / ``verdict`` 各自可能比 ``latest`` 旧:只查积分的那次不产生判定, + 只查兑换记录的那次不产生积分 —— 谁都没产出的那次就不该把对方从列表上抹掉。 + """ + + latest: HuyaExternalOrderMetric | None = None + score: HuyaExternalOrderMetric | None = None + verdict: HuyaExternalOrderMetric | None = None + + +def _latest_metric_ids(db: Session, ids: Sequence[int], *filters: Any) -> list[int]: + """这批行各自「最近一条」快照的主键(可选附加过滤条件)。""" + if not ids: + return [] + newest = ( + db.query(func.max(HuyaExternalOrderMetric.id).label("metric_id")) + .filter(HuyaExternalOrderMetric.order_id.in_(list(ids)), *filters) + .group_by(HuyaExternalOrderMetric.order_id) + .subquery() + ) + return [row[0] for row in db.query(newest.c.metric_id).all() if row[0] is not None] + + +def _metrics_by_id( + db: Session, metric_ids: Sequence[int] +) -> dict[int, HuyaExternalOrderMetric]: + if not metric_ids: + return {} + rows = ( + db.query(HuyaExternalOrderMetric) + .filter(HuyaExternalOrderMetric.id.in_(list(metric_ids))) + .all() + ) + return {int(row.order_id): row for row in rows} + + def latest_metrics( db: Session, orders: Sequence[HuyaExternalOrder] ) -> dict[int, HuyaExternalOrderMetric]: """一次查出这批行各自「最近一条」快照(列表要显示最新积分,别按行发 SQL)。""" ids = [int(order.id) for order in orders] + return _metrics_by_id(db, _latest_metric_ids(db, ids)) + + +def order_metric_views( + db: Session, orders: Sequence[HuyaExternalOrder] +) -> dict[int, OrderMetricView]: + """列表「积分」列要显示的三条线索(整页一次算完,别按行发 SQL)。 + + 2026-10-17 拆出「查积分 / 查兑换记录」两个入口之后,**一次查询只写一半**: + 只查积分的那条 ``verdict`` 是空的、只查兑换记录的那条 ``score`` 是空的。所以: + + - ``latest`` —— 最近一条(列上的「最后查询时间」与结论文字); + - ``score`` —— 最近一条**查过积分**的:只查兑换记录时不能让积分显示变成 ``-``; + - ``verdict`` —— 最近一条**有判定结论**的(``success`` / ``failed`` / ``unknown``): + 只查积分时不能把已有的判定徽标冲成「无法判定」。 + 注意 ``unknown``(查了但判不了)**要算结论**,否则接口抖动时会一直挂着旧徽标。 + + 三条线索最多各发一次分组查询;都没有快照时返回空 dict。 + """ + ids = [int(order.id) for order in orders] if not ids: return {} - newest = ( - db.query(func.max(HuyaExternalOrderMetric.id).label("metric_id")) - .filter(HuyaExternalOrderMetric.order_id.in_(ids)) - .group_by(HuyaExternalOrderMetric.order_id) - .subquery() + latest = _metrics_by_id(db, _latest_metric_ids(db, ids)) + scored = _metrics_by_id( + db, _latest_metric_ids(db, ids, HuyaExternalOrderMetric.score.isnot(None)) ) - metric_ids = [row[0] for row in db.query(newest.c.metric_id).all()] - if not metric_ids: - return {} - rows = ( - db.query(HuyaExternalOrderMetric) - .filter(HuyaExternalOrderMetric.id.in_(metric_ids)) - .all() + judged = _metrics_by_id( + db, _latest_metric_ids(db, ids, HuyaExternalOrderMetric.verdict != "") ) - return {int(row.order_id): row for row in rows} + views: dict[int, OrderMetricView] = {} + for order_id in {*latest, *scored, *judged}: + views[order_id] = OrderMetricView( + latest=latest.get(order_id), + score=scored.get(order_id), + verdict=judged.get(order_id), + ) + return views def query_order_activity( @@ -1292,13 +1379,23 @@ def query_order_activity( current: User | None, order: HuyaExternalOrder, *, + scope: str = ACTIVITY_SCOPE_BOTH, client: Any | None = None, source: str = "manual", commit: bool = True, ) -> dict: - """查一次这个账号的**积分 + 兑换记录**,落一条生命周期快照,必要时自动改状态。 + """查一次这个账号的**积分 / 兑换记录**,落一条生命周期快照,必要时自动改状态。 - 判定规则(2026-10-16 用户口径): + ``scope`` 决定这次查哪一半(2026-10-17 用户要求把批量按钮拆成两个): + + - ``both``(默认,保持原行为):两个接口都打,判定按兑换记录来; + - ``score``:**只打积分接口**。没查兑换记录,所以 ``verdict`` 是空串 + (``VERDICT_NOT_QUERIED``,不是 ``unknown``)——「没查」和「查了判不了」是两回事, + 列表上的判定徽标只认有结论的,否则点一次「查积分」会把整页徽标冲成「无法判定」。 + **这种查询永远不会自动改业务状态**(结论都没有,凭什么改); + - ``records``:**只打兑换记录接口**。积分不写(``score`` 留空),判定与自动改状态照旧。 + + 判定规则(2026-10-16 用户口径,仅当本次查了兑换记录时): | 情况 | verdict | 业务状态 | | --- | --- | --- | @@ -1310,6 +1407,11 @@ def query_order_activity( 被跳过时结论里会写清楚原因。每次调用都写一条 metric(哪怕接口失败), 「积分变化 + 兑换记录」两条线索合起来才看得清有没有真的抢到。 """ + if scope not in ACTIVITY_SCOPES: + raise CollabError(f"未知的查询范围:{scope}", code="activity_scope_unknown") + want_score = scope in (ACTIVITY_SCOPE_BOTH, ACTIVITY_SCOPE_SCORE) + want_records = scope in (ACTIVITY_SCOPE_BOTH, ACTIVITY_SCOPE_RECORDS) + if order.status == STATUS_VOID: raise CollabError("已作废的单子不查积分/兑换记录", code="void_order") uid = str(order.uid or "").strip() @@ -1322,24 +1424,46 @@ def query_order_activity( score_resp: Any = None records_resp: Any = None errors: list[str] = [] - try: - score_resp = activity_client.query_user_score( - uid=int(uid), cookie=cookie, sid=sid, timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS - ) - except Exception as exc: # noqa: BLE001 一个接口炸了不影响另一个 - errors.append(f"积分接口异常:{type(exc).__name__}: {_clip(exc)}") - try: - records_resp = activity_client.get_user_prize_records( - uid=int(uid), cookie=cookie, sid=sid, timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS - ) - except Exception as exc: # noqa: BLE001 - errors.append(f"兑换记录接口异常:{type(exc).__name__}: {_clip(exc)}") + if want_score: + try: + score_resp = activity_client.query_user_score( + uid=int(uid), + cookie=cookie, + sid=sid, + timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS, + ) + except Exception as exc: # noqa: BLE001 一个接口炸了不影响另一个 + errors.append(f"积分接口异常:{type(exc).__name__}: {_clip(exc)}") + if want_records: + try: + records_resp = activity_client.get_user_prize_records( + uid=int(uid), + cookie=cookie, + sid=sid, + timeout=ACTIVITY_QUERY_TIMEOUT_SECONDS, + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"兑换记录接口异常:{type(exc).__name__}: {_clip(exc)}") - score, gained, used, score_msg = _extract_score(score_resp) - records, records_msg = _extract_records(records_resp) + score, gained, used, score_msg = ( + _extract_score(score_resp) if want_score else (None, None, None, "") + ) + records, records_msg = _extract_records(records_resp) if want_records else ([], "") matched = gift_matches_records(records, order.gift_name) - if not str(order.gift_name or "").strip(): + if not want_records: + # 只查积分:兑换记录一个字节都没查 —— 不许按"记录是空的"判成「兑换失败」, + # 也不许自动改状态。verdict 用空串表示「本次未判定」。 + # 结论文字写**积分本身**(别写"本次只查了积分"这种每行重复的套话, + # 批量查询时那会把弹窗刷成一屏废话)。 + verdict = VERDICT_NOT_QUERIED + if score_msg: + message = score_msg + elif score is not None: + message = f"积分 {score}(累计获得 {gained} / 已消耗 {used})" + else: + message = "没取到积分,本次也不查兑换记录" + elif not str(order.gift_name or "").strip(): verdict = VERDICT_UNKNOWN message = "这单没填礼包名,没法跟兑换记录比对" elif records_msg or errors: @@ -1380,6 +1504,7 @@ def query_order_activity( raw: dict[str, Any] = { "verdict": verdict, + "scope": scope, "score_available": score, "score_gained": gained, "score_used": used, @@ -1423,6 +1548,7 @@ def query_order_activity( "uid": uid, "sid": sid, "source": source, + "scope": scope, "score": score, "records_total": len(records), "records_matched": len(matched), @@ -1435,11 +1561,18 @@ def query_order_activity( db.refresh(order) db.refresh(metric) return { - "ok": verdict != VERDICT_UNKNOWN, + # `ok` = 「这次要查的东西查到没有」:查了兑换记录就看判定,只查积分就看积分接口通没通 + "ok": ( + verdict not in (VERDICT_UNKNOWN, VERDICT_NOT_QUERIED) + if want_records + else not score_msg + ), "order_id": int(order.id), "order_no": order.order_no, "uid": uid, "sid": sid, + "scope": scope, + "scope_label": ACTIVITY_SCOPE_LABELS.get(scope, scope), "verdict": verdict, "verdict_label": VERDICT_LABELS.get(verdict, verdict), "score": score, @@ -1461,10 +1594,18 @@ def query_activity_batch( current: User | None, orders: Sequence[HuyaExternalOrder], *, + scope: str = ACTIVITY_SCOPE_BOTH, client: Any | None = None, interval_seconds: float = PROBE_BATCH_INTERVAL_SECONDS, ) -> dict: - """勾选批量查询积分 / 兑换记录:**串行 + 间隔**(防风控),单条失败不影响其它。""" + """勾选批量查询:**串行 + 间隔**(防风控),单条失败不影响其它。 + + ``scope`` 与 :func:`query_order_activity` 一致(``both`` / ``score`` / ``records``): + 只查积分时不会有判定结论,所以汇总里那些行进 ``not_judged``, + **不计入 unknown**(unknown 的含义是「查了但判不了」,两者不能混)。 + """ + if scope not in ACTIVITY_SCOPES: + raise CollabError(f"未知的查询范围:{scope}", code="activity_scope_unknown") rows = list(orders) if not rows: raise CollabError("请先勾选要查询的行", code="activity_batch_empty") @@ -1482,7 +1623,13 @@ def query_activity_batch( with db.begin_nested(): results.append( query_order_activity( - db, current, order, client=client, source="batch", commit=False + db, + current, + order, + scope=scope, + client=client, + source="batch", + commit=False, ) ) except CollabError as exc: @@ -1491,6 +1638,8 @@ def query_activity_batch( "ok": False, "order_id": int(order.id), "order_no": order.order_no, + "scope": scope, + "scope_label": ACTIVITY_SCOPE_LABELS.get(scope, scope), "verdict": VERDICT_UNKNOWN, "verdict_label": VERDICT_LABELS[VERDICT_UNKNOWN], "message": exc.detail, @@ -1505,13 +1654,19 @@ def query_activity_batch( valid = [item for item in results if item.get("verdict") == VERDICT_SUCCESS] failed = [item for item in results if item.get("verdict") == VERDICT_FAILED] unknown = [item for item in results if item.get("verdict") == VERDICT_UNKNOWN] + not_judged = [ + item for item in results if item.get("verdict") == VERDICT_NOT_QUERIED + ] auto_changed = [item for item in results if item.get("auto_status")] return { "total": len(results), "success": len(valid), "failed": len(failed), "unknown": len(unknown), + "not_judged": len(not_judged), "auto_changed": len(auto_changed), + "scope": scope, + "scope_label": ACTIVITY_SCOPE_LABELS.get(scope, scope), "results": results, } @@ -2455,6 +2610,7 @@ def serialize_order( users: Mapping[int, str] | None = None, buyer_cluster: Mapping[str, Any] | None = None, last_metric: HuyaExternalOrderMetric | None = None, + metric_view: OrderMetricView | None = None, ) -> dict: """把行对象转成接口输出。 @@ -2462,13 +2618,21 @@ def serialize_order( 全部失效(order_site 踩过「存下来的签名链接过期后到处裂图」)。 ``buyer_cluster`` = 同一买家的其它单(见 :func:`buyer_clusters`), - ``last_metric`` = 最近一次「积分 / 兑换记录」快照(见 :func:`latest_metrics`), + ``metric_view`` = 最近一次「积分 / 兑换记录」快照的三条线索(见 :func:`order_metric_views`), 都由调用方传进来(列表是一次查询算出整页的,别在这里按行查)。 + ``last_metric`` 是兼容入口:只传它时三条线索都取这一条。 """ names = users or {} bind_images = images_of(order, "bind_images") home_images = images_of(order, "home_images") + view = metric_view or OrderMetricView( + latest=last_metric, score=last_metric, verdict=last_metric + ) + latest_metric = view.latest + score_metric = view.score or view.latest + verdict_metric = view.verdict or view.latest + def urls(keys: list[str], variant: str = "") -> list[str]: return [build_file_url(key, variant) for key in keys] @@ -2527,16 +2691,22 @@ def serialize_order( "updated_at": order.updated_at, # 同一买家的其它单(软识别,只读派生;没有就是 null) "buyer_cluster": dict(buyer_cluster) if buyer_cluster else None, - # 最近一次「积分 / 兑换记录」快照(生命周期时间线在详情里,列表只带最新一条) - "last_score": last_metric.score if last_metric else None, - "last_metric_at": last_metric.created_at if last_metric else None, - "last_verdict": (last_metric.verdict or "") if last_metric else "", + # 最近一次「积分 / 兑换记录」快照(生命周期时间线在详情里,列表只带这几条线索) + # 积分与判定分别取「最近一条查到过它的」:拆分两个查询入口后,一次只写一半, + # 不能让「只查积分」把判定徽标抹掉(反之亦然)。 + "last_score": score_metric.score if score_metric else None, + "last_metric_at": latest_metric.created_at if latest_metric else None, + "last_metric_message": (latest_metric.message or "") if latest_metric else "", + "last_verdict": (verdict_metric.verdict or "") if verdict_metric else "", "last_verdict_label": ( - VERDICT_LABELS.get(last_metric.verdict, last_metric.verdict or "") - if last_metric + VERDICT_LABELS.get(verdict_metric.verdict, verdict_metric.verdict or "") + if verdict_metric else "" ), - "last_metric_message": (last_metric.message or "") if last_metric else "", + "last_verdict_at": verdict_metric.created_at if verdict_metric else None, + "last_verdict_message": ( + (verdict_metric.message or "") if verdict_metric else "" + ), } diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index be81dbf..6942d5c 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -34,6 +34,7 @@ import type { HuyaCollabAuditItem, HuyaCollabActivityBatchResult, HuyaCollabActivityItem, + HuyaCollabActivityScope, HuyaCollabCkExportResult, HuyaCollabCkPreview, HuyaCollabCkRequest, @@ -271,11 +272,16 @@ export const huyaApi = { api.post( `/huya/external-orders/${id}/activity/query`, ), - /** 勾选多行查询积分 + 兑换记录(串行带间隔) */ - batchQueryCollabOrderActivity: (ids: number[]) => + /** + * 勾选多行查积分 / 查兑换记录(串行带间隔)。 + * + * `scope` 决定查哪一半(页面上是两个独立按钮):`score` 只查积分、 + * `records` 只查兑换记录、`both` 两个都查(默认,保持旧行为)。 + */ + batchQueryCollabOrderActivity: (ids: number[], scope: HuyaCollabActivityScope = 'both') => api.post( '/huya/external-orders/activity/query', - { ids }, + { ids, scope }, ), /** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */ collabOrderMetrics: (id: number) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index fed1e66..6334abd 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1274,12 +1274,20 @@ export interface HuyaCollabOrder { * 只有真的 ≥2 单才有值,否则是 null。 */ buyer_cluster: HuyaCollabBuyerCluster | null; - /** 最近一次「查积分 + 兑换记录」的快照(完整时间线走 GET /{id}/metrics) */ + /** + * 最近一次「查积分 / 查兑换记录」的快照(完整时间线走 GET /{id}/metrics)。 + * + * 拆分两个查询入口后,积分与判定**各自取最近一条查到过它的**: + * 只查积分的那次不产生判定,只查兑换记录的那次不产生积分。 + */ last_score: number | null; last_metric_at: string | null; + last_metric_message: string; last_verdict: HuyaCollabVerdict | ''; last_verdict_label: string; - last_metric_message: string; + /** 那条判定是什么时候查出来的(与 `last_metric_at` 可能不是同一次) */ + last_verdict_at: string | null; + last_verdict_message: string; } /** 「同一买家的其它单」里的一条 */ @@ -1307,6 +1315,13 @@ export interface HuyaCollabBuyerCluster { /** 系统与接口的判定结论(人工不写,只读) */ export type HuyaCollabVerdict = 'success' | 'failed' | 'unknown'; +/** + * 本次查询查了哪一半(两个批量按钮各用一个): + * `both` 两个接口都查、`score` 只查积分、`records` 只查兑换记录。 + * 只查积分时没有判定结论,条目的 `verdict` 是**空串**(不是 `unknown`)。 + */ +export type HuyaCollabActivityScope = 'both' | 'score' | 'records'; + /** 「账号全生命周期」里匹配到的兑换记录 */ export interface HuyaCollabMetricMatched { prize_name: string; @@ -1316,7 +1331,7 @@ export interface HuyaCollabMetricMatched { exchange_time: number | null; } -/** 一次「查积分 + 兑换记录」的快照(只追加,不更新) */ +/** 一次「查积分 / 兑换记录」的快照(只追加,不更新) */ export interface HuyaCollabMetric { id: number; order_id: number; @@ -1328,7 +1343,10 @@ export interface HuyaCollabMetric { used_score: number | null; records_total: number; records_matched: number; - verdict: HuyaCollabVerdict; + /** 这条快照查了哪一半:时间线据此决定显示「积分」还是「本次没查积分」 */ + scope: HuyaCollabActivityScope; + /** 只查积分的那条快照是**空串**(本次没查兑换记录,不算「无法判定」) */ + verdict: HuyaCollabVerdict | ''; verdict_label: string; message: string; /** manual(人工点)/ batch(勾选批量) */ @@ -1344,7 +1362,10 @@ export interface HuyaCollabActivityItem { ok: boolean; order_id: number; order_no: string; - verdict: HuyaCollabVerdict; + /** 本次查的是哪一半(只查积分时 verdict 是空串) */ + scope: HuyaCollabActivityScope | ''; + scope_label: string; + verdict: HuyaCollabVerdict | ''; verdict_label: string; score: number | null; gained_score: number | null; @@ -1359,13 +1380,17 @@ export interface HuyaCollabActivityItem { status_label: string; } -/** 批量查询汇总 */ +/** 批量查询汇总(`success + failed + unknown + not_judged = total`) */ export interface HuyaCollabActivityBatchResult { total: number; success: number; failed: number; unknown: number; + /** 本次没查兑换记录、因此没有判定结论的行数(只查积分时全落这里) */ + not_judged: number; auto_changed: number; + scope: HuyaCollabActivityScope | ''; + scope_label: string; results: HuyaCollabActivityItem[]; } diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 778c12f..2af4950 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -15,12 +15,13 @@ import { import type { TableProps } from 'antd'; import { CheckCircleOutlined, CloudUploadOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileAddOutlined, - FileExcelOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, ShopOutlined, - StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined, + FileExcelOutlined, GiftOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined, + ShopOutlined, StopOutlined, SwapOutlined, SyncOutlined, UndoOutlined, } from '@ant-design/icons'; import { fileApi, huyaApi, + type HuyaCollabActivityScope, type HuyaCollabAuditItem, type HuyaCollabCkExportResult, type HuyaCollabCkPreview, @@ -788,21 +789,41 @@ export default function HuyaCollabPage() { } }; - /** 勾选批量查询(串行带间隔,防风控) */ - const queryActivitySelected = async () => { + /** + * 勾选批量查询(串行带间隔,防风控)。 + * + * `scope` 决定查哪一半 —— 界面上是**两个独立按钮**(2026-10-17 用户要求): + * 「查积分」只打积分接口,不会产生判定、也**不会自动改状态**; + * 「查兑换记录」只打兑换记录接口,判定与自动改状态照旧。 + */ + const queryActivitySelected = async (scope: HuyaCollabActivityScope) => { if (selectedIds.length === 0) return; setQuerying(true); try { - const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds); + const result = await huyaApi.batchQueryCollabOrderActivity(selectedIds, scope); + const scoreScope = scope === 'score'; + const missed = result.results.filter((item) => !item.ok).length; modal.info({ - title: `查询完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}` - + (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''), + title: scoreScope + ? `查积分完成:${result.total} 条` + (missed ? `(${missed} 条没拿到积分)` : '') + : `查兑换记录完成:成功 ${result.success} / 失败 ${result.failed} / 无法判定 ${result.unknown}` + + (result.auto_changed ? `(${result.auto_changed} 条按结果自动改状态)` : ''), width: 760, content: ( + {scoreScope && ( + + 这一趟只查积分,没有查兑换记录,所以不会改业务状态(判定那栏保持上一次的结论)。 + + )} {result.results.map((item) => ( - - {item.order_no}:{item.verdict_label} · {item.message} + + {item.order_no}: + {/* 只查积分时 verdict 是空的(未判定),没必要每行都挂一个「未判定」标签 */} + {scoreScope ? item.message : `${item.verdict_label} · ${item.message}`} {item.auto_status ? `;状态已改为「${item.auto_status_label}」` : ''} ))} @@ -1767,8 +1788,16 @@ export default function HuyaCollabPage() { return 还没查; } const verdict = VERDICT_META[row.last_verdict]; + // 积分与判定可能是**两次不同的查询**(查积分 / 查兑换记录是两个按钮): + // 徽标保留上一次的判定结论,各自把自己的时间与结论写清楚,别让人以为徽标也是刚刚那次的。 + const tip = [ + `最新查询(${formatTime(row.last_metric_at)}):${row.last_metric_message || '—'}`, + verdict + ? `判定(${formatTime(row.last_verdict_at)}):${row.last_verdict_message || '—'}` + : '', + ].filter(Boolean).join('\n'); return ( - + {tip}}> {row.last_score ?? '-'} {verdict && {verdict.label}} @@ -1996,11 +2025,21 @@ export default function HuyaCollabPage() { )} {canEdit && ( - + // 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照, + // 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。 + // 合成一个按钮时「只想要积分」也会顺带把状态改了,两边互相干扰。 + <> + + + )} {canExportCk && ( // 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现) @@ -2367,45 +2406,64 @@ export default function HuyaCollabPage() { )} ({ - color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray', - children: ( - - - {item.verdict_label} - 积分 {item.score ?? '-'} + items={metrics.map((item) => { + // 一次查询只查一半时,另一半**压根没查** —— 别把「没查」显示成「积分 -」「0 条」 + const scope = item.scope || 'both'; + const hasScore = scope !== 'records'; + const hasRecords = scope !== 'score'; + return { + color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray', + children: ( + + + {item.verdict_label} + {hasScore ? ( + <> + 积分 {item.score ?? '-'} + + 获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'} + + + ) : ( + 本次没查积分 + )} + {hasRecords ? ( + + 兑换记录 {item.records_total} 条(命中 {item.records_matched}) + + ) : ( + 本次没查兑换记录 + )} + + {item.message && ( + + {item.message} + + )} + {item.matched_preview.length > 0 && ( + + {item.matched_preview.map((matched, index) => ( + + {matched.prize_name || '(无礼包名)'} + {' '} + + {matched.status_label} + {matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''} + {matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''} + + + ))} + + )} - 获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'} - {' · '}兑换记录 {item.records_total} 条(命中 {item.records_matched}) + {formatTime(item.created_at)} · {item.created_by_name || '系统'} + {' · '}{item.source === 'batch' ? '批量查询' : '手动查询'} + {' · '}{scope === 'both' ? '积分 + 兑换记录' : scope === 'score' ? '只查积分' : '只查兑换记录'} - {item.message && ( - - {item.message} - - )} - {item.matched_preview.length > 0 && ( - - {item.matched_preview.map((matched, index) => ( - - {matched.prize_name || '(无礼包名)'} - {' '} - - {matched.status_label} - {matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''} - {matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''} - - - ))} - - )} - - {formatTime(item.created_at)} · {item.created_by_name || '系统'} - {' · '}{item.source === 'batch' ? '批量查询' : '手动查询'} - - - ), - }))} + ), + }; + })} /> )}