feat(huya): 协作台账状态机新口径(跳步/已确认绑定/兑换失败)+ 账号全生命周期快照(迁移 0059)
This commit is contained in:
@@ -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] = []
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user