feat(huya): 批量查询拆成「查积分」与「查兑换记录」两个按钮

前端工具栏原来一个按钮同时查两件事,拆开后每次只查一半,所以重点在
「没查的那一半不能被当成没查到」:

- 接口加 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。
This commit is contained in:
yml2213
2026-10-17 23:32:51 +08:00
parent b47dde47e9
commit c441b6c663
10 changed files with 722 additions and 121 deletions
+7 -1
View File
@@ -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` |
+32
View File
@@ -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. 风险
+273 -1
View File
@@ -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 本体。"""
+4 -2
View File
@@ -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"`)—— 它没有独立价值。
"""
+15 -8
View File
@@ -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
+29 -6
View File
@@ -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):
+213 -43
View File
@@ -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 ""
),
}
+9 -3
View File
@@ -34,6 +34,7 @@ import type {
HuyaCollabAuditItem,
HuyaCollabActivityBatchResult,
HuyaCollabActivityItem,
HuyaCollabActivityScope,
HuyaCollabCkExportResult,
HuyaCollabCkPreview,
HuyaCollabCkRequest,
@@ -271,11 +272,16 @@ export const huyaApi = {
api.post<HuyaCollabActivityItem, HuyaCollabActivityItem>(
`/huya/external-orders/${id}/activity/query`,
),
/** 勾选多行查询积分 + 兑换记录(串行带间隔) */
batchQueryCollabOrderActivity: (ids: number[]) =>
/**
* 勾选多行查积分 / 查兑换记录(串行带间隔)。
*
* `scope` 决定查哪一半(页面上是两个独立按钮):`score` 只查积分、
* `records` 只查兑换记录、`both` 两个都查(默认,保持旧行为)。
*/
batchQueryCollabOrderActivity: (ids: number[], scope: HuyaCollabActivityScope = 'both') =>
api.post<HuyaCollabActivityBatchResult, HuyaCollabActivityBatchResult>(
'/huya/external-orders/activity/query',
{ ids },
{ ids, scope },
),
/** 这一单的「账号全生命周期」时间线(每次查询的快照,新→旧) */
collabOrderMetrics: (id: number) =>
+31 -6
View File
@@ -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[];
}
+109 -51
View File
@@ -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: (
<Space direction="vertical" style={{ width: '100%' }}>
{scoreScope && (
<Text type="secondary" style={{ fontSize: 12 }}>
这一趟只查积分,没有查兑换记录,所以不会改业务状态(判定那栏保持上一次的结论)。
</Text>
)}
{result.results.map((item) => (
<Text key={item.order_id} type={item.verdict === 'failed' ? 'danger' : undefined}>
{item.order_no}:{item.verdict_label} · {item.message}
<Text
key={item.order_id}
type={!item.ok ? 'danger' : item.verdict === 'failed' ? 'danger' : undefined}
>
{item.order_no}:
{/* 只查积分时 verdict 是空的(未判定),没必要每行都挂一个「未判定」标签 */}
{scoreScope ? item.message : `${item.verdict_label} · ${item.message}`}
{item.auto_status ? `;状态已改为「${item.auto_status_label}」` : ''}
</Text>
))}
@@ -1767,8 +1788,16 @@ export default function HuyaCollabPage() {
return <Text type="secondary" style={{ fontSize: 12 }}>还没查</Text>;
}
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 (
<Tooltip title={`${row.last_metric_message || ''}(${formatTime(row.last_metric_at)})`}>
<Tooltip title={<span style={{ whiteSpace: 'pre-line' }}>{tip}</span>}>
<Space direction="vertical" size={0}>
<Text strong>{row.last_score ?? '-'}</Text>
{verdict && <Tag color={verdict.color} style={{ marginTop: 2 }}>{verdict.label}</Tag>}
@@ -1996,11 +2025,21 @@ export default function HuyaCollabPage() {
</Dropdown>
)}
{canEdit && (
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected()}>
批量查积分/兑换{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
// 查积分 / 查兑换记录**拆成两个按钮**(2026-10-17):查积分只写积分快照,
// 既不判定也不自动改状态;只有查兑换记录才产出判定并可能自动改状态。
// 合成一个按钮时「只想要积分」也会顺带把状态改了,两边互相干扰。
<>
<Button icon={<SyncOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected('score')}>
批量查积分{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
<Button icon={<GiftOutlined />} disabled={selectedIds.length === 0 || busy}
loading={querying}
onClick={() => void queryActivitySelected('records')}>
批量查兑换记录{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</>
)}
{canExportCk && (
// 导出 CK 到剪贴板(勾选几行就导几行;明文只在这个弹窗里出现)
@@ -2367,45 +2406,64 @@ export default function HuyaCollabPage() {
)}
<Timeline
pending={metricsLoading ? '查询中…' : undefined}
items={metrics.map((item) => ({
color: item.verdict === 'success' ? 'green' : item.verdict === 'failed' ? 'red' : 'gray',
children: (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space size={6} wrap>
<Tag color={VERDICT_META[item.verdict]?.color}>{item.verdict_label}</Tag>
<Text strong>积分 {item.score ?? '-'}</Text>
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: (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Space size={6} wrap>
<Tag color={VERDICT_META[item.verdict]?.color}>{item.verdict_label}</Tag>
{hasScore ? (
<>
<Text strong>积分 {item.score ?? '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
获得 {item.gained_score ?? '-'} / 消耗 {item.used_score ?? '-'}
</Text>
</>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>本次没查积分</Text>
)}
{hasRecords ? (
<Text type="secondary" style={{ fontSize: 12 }}>
兑换记录 {item.records_total} 条(命中 {item.records_matched})
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>本次没查兑换记录</Text>
)}
</Space>
{item.message && (
<Text type={item.verdict === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
{item.message}
</Text>
)}
{item.matched_preview.length > 0 && (
<Space direction="vertical" size={0}>
{item.matched_preview.map((matched, index) => (
<Text key={`${matched.order_id}-${index}`} style={{ fontSize: 12 }}>
<Text code style={{ fontSize: 12 }}>{matched.prize_name || '(无礼包名)'}</Text>
{' '}
<Text type="secondary" style={{ fontSize: 12 }}>
{matched.status_label}
{matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''}
{matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''}
</Text>
</Text>
))}
</Space>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
获得 {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' ? '只查积分' : '只查兑换记录'}
</Text>
</Space>
{item.message && (
<Text type={item.verdict === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
{item.message}
</Text>
)}
{item.matched_preview.length > 0 && (
<Space direction="vertical" size={0}>
{item.matched_preview.map((matched, index) => (
<Text key={`${matched.order_id}-${index}`} style={{ fontSize: 12 }}>
<Text code style={{ fontSize: 12 }}>{matched.prize_name || '(无礼包名)'}</Text>
{' '}
<Text type="secondary" style={{ fontSize: 12 }}>
{matched.status_label}
{matched.order_id ? ` · 兑换单 ${matched.order_id}` : ''}
{matched.exchange_time ? ` · ${formatTime(new Date(matched.exchange_time * 1000).toISOString())}` : ''}
</Text>
</Text>
))}
</Space>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(item.created_at)} · {item.created_by_name || '系统'}
{' · '}{item.source === 'batch' ? '批量查询' : '手动查询'}
</Text>
</Space>
),
}))}
),
};
})}
/>
</Space>
)}