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:
@@ -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 本体。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user