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
+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 本体。"""