feat(huya): 协作台账状态机新口径(跳步/已确认绑定/兑换失败)+ 账号全生命周期快照(迁移 0059)

This commit is contained in:
yml2213
2026-10-16 22:00:34 +08:00
parent aac264b639
commit e8abd60734
11 changed files with 1897 additions and 84 deletions
+369 -15
View File
@@ -81,17 +81,28 @@ class TestCollabBase:
)
def _bind_ready(self, order: HuyaExternalOrder) -> HuyaExternalOrder:
"""把一行推到「可以标记已绑定」的状态。"""
"""把一行推到「可以标记为已确认绑定」的状态。
2026-10-16 口径:进 `bound` 需要 **探针读到的绑定角色 + 主页截图**(人工核对这两样一致),
绑定图与「绑定斗鱼号」都不再要求。
"""
collab.set_ck(self.db, self.user, order, fake_ck("1199647239697"))
collab.update_order(
self.db, self.user, order, {"bind_douyu_account": "用户4241534208"}
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(
FakeBindResp(
nick="微信用户", role="吃鸡199706", plat="苹果(iOS)", area="微信"
)
),
)
collab.set_images(
self.db,
self.user,
order,
"bind_images",
["collab/2026/10/16/bind01.png"],
"home_images",
["collab/2026/10/16/home01.png"],
)
return order
@@ -251,23 +262,52 @@ class TestCollabCrud(TestCollabBase):
class TestCollabStatusMachine(TestCollabBase):
def test_cannot_skip_forward_without_explicit_rollback(self):
"""pending 只能走到 bound,直接跳到 recharged 要被挡下。"""
order = self._create()
def test_forward_jump_is_allowed_but_backward_needs_rollback(self):
"""2026-10-16 口径:人工切换允许**往前跳步**,回退必须显式 rollback。"""
order = self._bind_ready(self._create())
# pending → scripted(跳过 已确认绑定/已充值)是允许的
collab.set_status(self.db, self.user, order, "scripted")
assert order.status == "scripted"
# 跳步时中间阶段的时间戳补上,别让时间线断档
assert order.bound_at is not None
assert order.recharged_at is not None
assert order.scripted_at is not None
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, "recharged")
assert excinfo.value.code == "status_not_forward"
assert order.status == "pending"
assert order.status == "scripted"
def test_bound_requires_ck_bind_account_and_image(self):
def test_bound_requires_ck_probe_role_and_home_screenshot(self):
order = self._create()
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, "bound")
message = excinfo.value.detail
# 错误信息必须说清缺什么,否则客服只能猜
assert "CK" in message
assert "绑定斗鱼号" in message
assert "绑定图" in message
assert "绑定角色" in message
assert "主页截图" in message
# 只贴 CK + 读到角色,但没传主页截图 → 还是不让进
collab.set_ck(self.db, self.user, order, fake_ck("1199647001234"))
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")),
)
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, "bound")
assert "主页截图" in excinfo.value.detail
assert "绑定图" not in excinfo.value.detail # 绑定图已停用,不该再被要求
collab.set_images(
self.db, self.user, order, "home_images", ["collab/2026/10/16/home01.png"]
)
collab.set_status(self.db, self.user, order, "bound")
assert order.status == "bound"
assert order.bound_at is not None
def test_forward_flow_sets_timestamps(self):
order = self._bind_ready(self._create())
@@ -858,14 +898,14 @@ class TestCollabCkProbe(TestCollabBase):
assert detail["after"]["bind_game_role"] == "新角色"
def test_binding_role_from_probe_satisfies_the_bound_precondition(self):
"""只验证过 CK、读到绑定角色的行,不该再被「绑定斗鱼号」挡在 bound 外面。"""
"""探针读到绑定角色 + 有主页截图 → 可以进「已确认绑定」(绑定斗鱼号/绑定图都不再要求)。"""
order = self._row_with_ck()
collab.set_images(
self.db,
self.user,
order,
"bind_images",
["collab/2026/10/16/bind01.png"],
"home_images",
["collab/2026/10/16/home01.png"],
)
collab.verify_ck(
self.db,
@@ -874,6 +914,7 @@ class TestCollabCkProbe(TestCollabBase):
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")),
)
assert order.bind_douyu_account == ""
assert order.bind_images == []
collab.set_status(self.db, self.user, order, "bound")
@@ -1494,6 +1535,319 @@ class TestCollabBuyerCluster(TestCollabBase):
assert alone["buyer_cluster"] is None
class FakeScoreResp:
"""合成 ``getUserScore`` 响应(积分)。"""
def __init__(
self,
*,
status: int = 200,
msg: str = "",
score: int = 1234,
gained: int = 5000,
used: int = 3766,
):
self.status = status
self.msg = msg
self.score = score
self.gainScore = gained
self.usedScore = used
self.newGainScore = gained
self.newUsedScore = used
@property
def available_score(self) -> int:
return max(0, int(self.newGainScore or 0) - int(self.newUsedScore or 0))
def to_dict(self) -> dict:
return {
"status": self.status,
"msg": self.msg,
"score": self.score,
"gain_score": self.gainScore,
"used_score": self.usedScore,
}
class FakeRecordsResp:
"""合成 ``getUserPrizeRecords`` 响应(兑换记录)。"""
def __init__(
self, *, status: int = 200, msg: str = "", records: list | None = None
):
self.status = status
self.msg = msg
self._records = (
records
if records is not None
else [
{
"prize_name": "不朽伊人大礼包",
"score": 200,
"status_label": "已发放",
"order_id": "H-ORDER-1",
"exchange_time": 1792000000,
}
]
)
def to_dict(self) -> dict:
return {
"status": self.status,
"msg": self.msg,
"record_count": len(self._records),
"records": self._records,
}
class FakeActivityClient:
"""离线「积分 + 兑换记录」客户端:两个接口分别可按脚本给出响应 / 异常。"""
def __init__(self, *, score: object = None, records: object = None):
self.score = score
self.records = records
self.score_calls: list[dict] = []
self.record_calls: list[dict] = []
def query_user_score(self, **kwargs):
self.score_calls.append(kwargs)
if isinstance(self.score, Exception):
raise self.score
return self.score if self.score is not None else FakeScoreResp()
def get_user_prize_records(self, **kwargs):
self.record_calls.append(kwargs)
if isinstance(self.records, Exception):
raise self.records
return self.records if self.records is not None else FakeRecordsResp()
class TestCollabStatusBatch(TestCollabBase):
"""勾选多行一次改状态:部分失败不影响其它行(2026-10-16 用户要的批量切换)。"""
def test_batch_updates_selected_rows(self):
first = self._bind_ready(self._create(order_no="BATCH-1"))
second = self._bind_ready(self._create(order_no="BATCH-2"))
result = collab.set_status_batch(
self.db, self.user, [first, second], "scripted"
)
assert result["updated"] == 2 and result["failed"] == 0
assert first.status == second.status == "scripted"
def test_batch_reports_per_row_failure_without_touching_others(self):
ready = self._bind_ready(self._create(order_no="BATCH-3"))
not_ready = self._create(order_no="BATCH-4") # 没 CK / 没角色 / 没主页截图
result = collab.set_status_batch(
self.db, self.user, [ready, not_ready], "bound"
)
assert result["updated"] == 1 and result["failed"] == 1
by_no = {item["order_no"]: item for item in result["results"]}
assert by_no["BATCH-3"]["ok"] is True
assert by_no["BATCH-4"]["ok"] is False
assert "主页截图" in by_no["BATCH-4"]["message"]
assert ready.status == "bound"
assert not_ready.status == "pending"
def test_batch_backward_needs_rollback_flag(self):
order = self._bind_ready(self._create(order_no="BATCH-5"))
collab.set_status(self.db, self.user, order, "scripted")
rejected = collab.set_status_batch(self.db, self.user, [order], "recharged")
assert rejected["failed"] == 1
assert order.status == "scripted"
accepted = collab.set_status_batch(
self.db, self.user, [order], "recharged", rollback=True
)
assert accepted["updated"] == 1
assert order.status == "recharged"
def test_batch_rejects_empty_and_too_many(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status_batch(self.db, self.user, [], "bound")
assert excinfo.value.code == "status_batch_empty"
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status_batch(
self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1), "bound"
)
assert excinfo.value.code == "status_batch_too_large"
class TestCollabActivityLifecycle(TestCollabBase):
"""抢购之后:查积分 + 兑换记录 → 落生命周期快照 → 按结果自动改状态。"""
def _scripted_order(
self,
order_no: str = "ACT-1",
*,
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 _metrics(self, order: HuyaExternalOrder) -> list:
return collab.list_metrics(self.db, order)
def test_success_verdict_promotes_scripted_order_to_done(self):
order = self._scripted_order()
client = FakeActivityClient()
result = collab.query_order_activity(self.db, self.user, order, client=client)
assert result["verdict"] == collab.VERDICT_SUCCESS
assert result["records_matched"] == 1
assert result["auto_status"] == "done"
assert order.status == "done"
assert order.done_at is not None
assert client.score_calls[0]["uid"] == 1199647239697
assert client.record_calls[0]["sid"] == collab.GIFTS_SID
metrics = self._metrics(order)
assert len(metrics) == 1
assert metrics[0].score == 1234
assert metrics[0].verdict == "success"
assert metrics[0].records_total == 1
assert "huya:collab:activity_query" in [
row.action for row in self._audits(order)
]
def test_missing_gift_marks_failed_without_clearing_scripted_time(self):
order = self._scripted_order("ACT-2")
client = FakeActivityClient(
records=FakeRecordsResp(
records=[{"prize_name": "别的礼包", "status_label": "已发放"}]
)
)
result = collab.query_order_activity(self.db, self.user, order, client=client)
assert result["verdict"] == collab.VERDICT_FAILED
assert result["auto_status"] == "failed"
assert order.status == "failed"
# failed 是旁路终态:上脚本的时间戳必须留着(不然不知道什么时候上的)
assert order.scripted_at is not None
assert order.done_at is None
# 从「兑换失败」回流程内要显式回退
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, "scripted")
assert excinfo.value.code == "status_not_forward"
collab.set_status(self.db, self.user, order, "scripted", rollback=True)
assert order.status == "scripted"
def test_status_untouched_when_order_is_not_scripted(self):
order = self._bind_ready(
self._create(order_no="ACT-3", gift_name="不朽伊人大礼包")
)
collab.set_status(self.db, self.user, order, "bound")
assert order.status == "bound"
result = collab.query_order_activity(
self.db, self.user, order, client=FakeActivityClient()
)
assert result["verdict"] == collab.VERDICT_SUCCESS
assert result["auto_status"] == ""
assert order.status == "bound"
assert "只有「已上脚本」" in result["message"]
def test_unknown_verdict_when_interface_fails(self):
order = self._scripted_order("ACT-4")
client = FakeActivityClient(
score=RuntimeError("boom"),
records=FakeRecordsResp(status=500, msg="活动已结束"),
)
result = collab.query_order_activity(self.db, self.user, order, client=client)
assert result["verdict"] == collab.VERDICT_UNKNOWN
assert result["auto_status"] == ""
assert order.status == "scripted" # 数据不够,不许瞎判
metrics = self._metrics(order)
assert len(metrics) == 1 and metrics[0].verdict == "unknown"
def test_unknown_when_gift_name_missing(self):
order = self._scripted_order("ACT-5", gift_name="")
result = collab.query_order_activity(
self.db, self.user, order, client=FakeActivityClient()
)
assert result["verdict"] == collab.VERDICT_UNKNOWN
assert "没填礼包名" in result["message"]
assert order.status == "scripted"
def test_query_requires_cookie_and_rejects_void(self):
order = self._create(order_no="ACT-6")
with pytest.raises(collab.CollabError) as excinfo:
collab.query_order_activity(
self.db, self.user, order, client=FakeActivityClient()
)
assert excinfo.value.code == "no_cookie"
void = self._scripted_order("ACT-7")
collab.set_status(self.db, self.user, void, "void", reason="客户退款")
with pytest.raises(collab.CollabError) as excinfo:
collab.query_order_activity(
self.db, self.user, void, client=FakeActivityClient()
)
assert excinfo.value.code == "void_order"
def test_batch_query_isolates_failures(self):
first = self._scripted_order("ACT-8")
broken = self._create(order_no="ACT-9") # 没 CK
result = collab.query_activity_batch(
self.db,
self.user,
[first, broken],
client=FakeActivityClient(),
interval_seconds=0,
)
assert result["total"] == 2
assert result["success"] == 1 and result["unknown"] == 1
assert result["auto_changed"] == 1
assert first.status == "done"
assert broken.status == "pending"
assert any("还没有 CK" in item["message"] for item in result["results"])
def test_metrics_timeline_is_newest_first(self):
order = self._scripted_order("ACT-10")
collab.query_order_activity(
self.db,
self.user,
order,
client=FakeActivityClient(
score=FakeScoreResp(score=1500, gained=5000, used=3500)
),
)
collab.query_order_activity(
self.db,
self.user,
order,
client=FakeActivityClient(
score=FakeScoreResp(score=800, gained=5000, used=4200)
),
)
metrics = self._metrics(order)
assert [row.score for row in metrics] == [800, 1500]
latest = collab.latest_metrics(self.db, [order])[int(order.id)]
assert latest.score == 800
payload = collab.serialize_order(order, last_metric=latest)
assert payload["last_score"] == 800
assert payload["last_verdict"] == "success"
class TestProbeCache(TestCollabBase):
def test_same_ck_probed_twice_only_hits_the_api_once(self, monkeypatch):
calls: list[dict] = []
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261016_0058"
HEAD_REVISION = "20261016_0059"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (