1914 lines
74 KiB
Python
1914 lines
74 KiB
Python
"""虎牙外部 CK 协作台账:CRUD / 两套状态机 / 审计 / 图片 / 批量导入。
|
||
|
||
设计见 ``docs/设计-虎牙外部CK协作台账.md``。
|
||
|
||
用例里的 CK 都是**合成**的(形态对齐真机:分号后无空格、重复 ``guid``、
|
||
超长 ``udb_biztoken``/``udb_cred``)——真实凭据绝不写进仓库,
|
||
一旦提交就永久留在 git 历史里。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from tests.support.db import get_test_engine, make_test_session
|
||
from web.backend.huya_defaults import (
|
||
HUYA_DEFAULT_BIND_ACT_ID,
|
||
HUYA_DEFAULT_OUTER_ACT_ID,
|
||
)
|
||
from web.backend.models import (
|
||
AuditLog,
|
||
HuyaAccount,
|
||
HuyaConfig,
|
||
HuyaExternalOrder,
|
||
HuyaWorkbenchAccount,
|
||
User,
|
||
)
|
||
from web.backend.permissions import ROLE_PERMISSIONS
|
||
from web.backend.services import huya_collab_service as collab
|
||
|
||
|
||
def fake_ck(uid: str, *, web: bool = True) -> str:
|
||
"""合成一份形态对齐真机的虎牙 CK。
|
||
|
||
``web=True`` 模拟浏览器里导出的网页 CK(带 guid/_qimei_uuid42,且真机里
|
||
``guid`` 会重复出现);``web=False`` 模拟 App 原始凭据(没有网页设备字段)。
|
||
"""
|
||
parts = [
|
||
f"udb_uid={uid}",
|
||
f"udb_passport=hy_{uid}",
|
||
"udb_version=1.0",
|
||
"huya_ua=webh5&0.0.1&websocket&&h5_/30596253",
|
||
f"hdid={'a' * 40}",
|
||
f"username=hy_{uid}",
|
||
f"yyuid={uid}",
|
||
"udb_passdata=3",
|
||
]
|
||
if web:
|
||
# 真机 CK 里 guid 确实重复出现两次,归一化后应只剩一个。
|
||
parts += [f"guid={'b' * 32}", f"guid={'b' * 32}"]
|
||
parts.append("_qimei_uuid42=qc_123456")
|
||
parts += [f"udb_biztoken={'T' * 200}", f"udb_cred={'C' * 152}", "udb_status=1"]
|
||
return ";".join(parts) # 注意:真机是分号后无空格
|
||
|
||
|
||
class TestCollabBase:
|
||
def setup_method(self):
|
||
self.engine = get_test_engine()
|
||
self.db = make_test_session(self.engine)
|
||
self.user = User(username="collab_op", password_hash="h", role="operation")
|
||
self.other = User(username="collab_support", password_hash="h", role="support")
|
||
self.db.add_all([self.user, self.other])
|
||
self.db.commit()
|
||
|
||
def teardown_method(self):
|
||
self.db.close()
|
||
|
||
def _create(
|
||
self, order_no: str = "2627501604891357", **kwargs
|
||
) -> HuyaExternalOrder:
|
||
return collab.create_order(self.db, self.user, order_no=order_no, **kwargs)
|
||
|
||
def _audits(self, order: HuyaExternalOrder) -> list[AuditLog]:
|
||
return (
|
||
self.db.query(AuditLog)
|
||
.filter(AuditLog.target == f"huya_external_order:{order.id}")
|
||
.order_by(AuditLog.id.asc())
|
||
.all()
|
||
)
|
||
|
||
def _bind_ready(self, order: HuyaExternalOrder) -> HuyaExternalOrder:
|
||
"""把一行推到「可以标记为已确认绑定」的状态。
|
||
|
||
2026-10-16 口径:进 `bound` 需要 **探针读到的绑定角色 + 主页截图**(人工核对这两样一致),
|
||
绑定图与「绑定斗鱼号」都不再要求。
|
||
"""
|
||
collab.set_ck(self.db, self.user, order, fake_ck("1199647239697"))
|
||
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,
|
||
"home_images",
|
||
["collab/2026/10/16/home01.png"],
|
||
)
|
||
return order
|
||
|
||
|
||
class TestCollabCrud(TestCollabBase):
|
||
def test_create_parses_ck_and_writes_audit(self):
|
||
order = self._create(ck_text=fake_ck("1199647239697"), shop="佳佳")
|
||
|
||
assert order.uid == "1199647239697"
|
||
assert order.yyuid == "1199647239697"
|
||
assert order.hy_username == "hy_1199647239697"
|
||
assert order.status == "pending"
|
||
assert order.ck_status == "unverified"
|
||
assert order.version == 0
|
||
assert order.created_by == self.user.id
|
||
|
||
audits = self._audits(order)
|
||
assert [row.action for row in audits] == ["huya:collab:create"]
|
||
|
||
def test_create_without_ck_leaves_uid_empty(self):
|
||
order = self._create(ck_text="")
|
||
assert order.uid is None
|
||
|
||
def test_create_without_owner_defaults_to_the_creator(self):
|
||
"""负责人不填 = 当前登录用户(新建时不用选)。"""
|
||
order = self._create(order_no="OWNER1")
|
||
assert order.owner_id == self.user.id
|
||
|
||
def test_create_can_still_assign_someone_else(self):
|
||
order = self._create(order_no="OWNER2", owner_id=self.other.id)
|
||
assert order.owner_id == self.other.id
|
||
|
||
def test_multiple_rows_without_ck_are_allowed(self):
|
||
"""uid 唯一但可空:多个 NULL 不冲突,所以「先建单、后贴 CK」走得通。"""
|
||
self._create(order_no="A1")
|
||
self._create(order_no="A2")
|
||
assert self.db.query(HuyaExternalOrder).count() == 2
|
||
|
||
def test_create_rejects_duplicate_order_no(self):
|
||
self._create(order_no="DUP1")
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
self._create(order_no="DUP1")
|
||
assert excinfo.value.status_code == 409
|
||
assert excinfo.value.code == "order_no_conflict"
|
||
|
||
def test_same_uid_can_serve_multiple_orders(self):
|
||
"""2026-10-16 用户拍板**放开** uid 唯一性:同一个虎牙号可以挂多单。
|
||
|
||
原来这里是 409(``uid_conflict``)—— 但真机上"同一个买家买两件"常常继续用同一份 CK,
|
||
挡了就变成第二单建不了。现在只把「这个号还挂在哪些单上」记进创建审计。
|
||
"""
|
||
first = self._create(order_no="A1", ck_text=fake_ck("1001"))
|
||
second = self._create(order_no="A2", ck_text=fake_ck("1001"))
|
||
|
||
assert first.uid == second.uid == "1001"
|
||
assert self.db.query(HuyaExternalOrder).count() == 2
|
||
assert "A1" in str(self._audits(second)[-1].detail) # 复用痕迹留在审计里
|
||
|
||
def test_uid_used_orders_lists_other_rows(self):
|
||
self._create(order_no="A3", ck_text=fake_ck("2001"))
|
||
second = self._create(order_no="A4", ck_text=fake_ck("2001"))
|
||
|
||
assert [row.order_no for row in collab.uid_used_orders(self.db, "2001")] == [
|
||
"A3",
|
||
"A4",
|
||
]
|
||
assert [
|
||
row.order_no
|
||
for row in collab.uid_used_orders(self.db, "2001", exclude_id=second.id)
|
||
] == ["A3"]
|
||
assert collab.uid_used_orders(self.db, "") == []
|
||
assert collab.uids_in_use(self.db, "2001")[0]["order_no"] == "A3"
|
||
|
||
def test_web_ck_does_not_fill_raw_cookie(self):
|
||
"""网页 CK(有 guid)不塞进 raw_cookie —— 它是 App 原始凭据的专用位。"""
|
||
order = self._create(ck_text=fake_ck("1001", web=True))
|
||
assert order.cookie
|
||
assert order.raw_cookie == ""
|
||
|
||
def test_app_ck_also_kept_as_raw_cookie_for_rebuild(self):
|
||
order = self._create(ck_text=fake_ck("1002", web=False))
|
||
assert order.cookie
|
||
assert order.raw_cookie == order.cookie
|
||
|
||
def test_update_returns_field_diff_and_bumps_version(self):
|
||
order = self._create(shop="佳佳")
|
||
changes = collab.update_order(
|
||
self.db, self.user, order, {"shop": "小鱼儿", "remark": "熟人单"}
|
||
)
|
||
|
||
assert set(changes) == {"shop", "remark"}
|
||
assert changes["shop"] == {"old": "佳佳", "new": "小鱼儿"}
|
||
assert order.version == 1
|
||
assert order.updated_by == self.user.id
|
||
|
||
actions = [row.action for row in self._audits(order)]
|
||
assert actions[-1] == "huya:collab:update"
|
||
|
||
def test_update_only_touches_submitted_fields(self):
|
||
order = self._create(shop="佳佳", gift_name="不朽伊人大礼包")
|
||
collab.update_order(self.db, self.user, order, {"remark": "只改备注"})
|
||
assert order.shop == "佳佳"
|
||
assert order.gift_name == "不朽伊人大礼包"
|
||
|
||
def test_update_rejects_stale_version(self):
|
||
"""乐观锁:别人先改过就 409,绝不静默覆盖(这正是 Excel 最痛的点)。"""
|
||
order = self._create(shop="佳佳")
|
||
collab.update_order(self.db, self.user, order, {"shop": "小鱼儿"})
|
||
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.update_order(
|
||
self.db,
|
||
self.other,
|
||
order,
|
||
{"remark": "并发写"},
|
||
expected_version=0,
|
||
)
|
||
assert excinfo.value.status_code == 409
|
||
assert excinfo.value.code == "version_conflict"
|
||
assert order.remark == ""
|
||
|
||
def test_update_accepts_matching_version(self):
|
||
order = self._create(shop="佳佳")
|
||
collab.update_order(self.db, self.user, order, {"shop": "小鱼儿"})
|
||
collab.update_order(
|
||
self.db, self.other, order, {"remark": "跟版本走"}, expected_version=1
|
||
)
|
||
assert order.remark == "跟版本走"
|
||
assert order.version == 2
|
||
|
||
def test_update_rejects_order_no_taken_by_another_row(self):
|
||
self._create(order_no="A1")
|
||
order = self._create(order_no="A2")
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.update_order(self.db, self.user, order, {"order_no": "A1"})
|
||
assert excinfo.value.code == "order_no_conflict"
|
||
|
||
def test_rejects_overlong_field_instead_of_silently_truncating(self):
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
self._create(order_no="A1", remark="x" * 600)
|
||
assert excinfo.value.code == "field_too_long"
|
||
|
||
def test_delete_keeps_audit_row(self):
|
||
order = self._create(order_no="DEL1")
|
||
order_id = order.id
|
||
snapshot = collab.delete_order(self.db, self.user, order)
|
||
|
||
assert snapshot["order_no"] == "DEL1"
|
||
assert self.db.query(HuyaExternalOrder).count() == 0
|
||
actions = [
|
||
row.action
|
||
for row in self.db.query(AuditLog)
|
||
.filter(AuditLog.target == f"huya_external_order:{order_id}")
|
||
.all()
|
||
]
|
||
assert "huya:collab:delete" in actions
|
||
|
||
|
||
class TestCollabStatusMachine(TestCollabBase):
|
||
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 == "scripted"
|
||
|
||
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
|
||
|
||
# 只贴 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())
|
||
|
||
collab.set_status(self.db, self.user, order, "bound")
|
||
assert order.status == "bound"
|
||
assert order.bound_at is not None
|
||
|
||
collab.set_status(self.db, self.user, order, "recharged")
|
||
collab.set_status(self.db, self.user, order, "scripted")
|
||
collab.set_status(self.db, self.user, order, "done")
|
||
|
||
assert order.status == "done"
|
||
assert order.recharged_at is not None
|
||
assert order.scripted_at is not None
|
||
assert order.done_at is not None
|
||
|
||
def test_every_status_change_writes_audit(self):
|
||
"""设计文档 §4.3 防漂移:状态写入必须留痕,漏一条就是 bug。"""
|
||
order = self._bind_ready(self._create())
|
||
before = len(self._audits(order))
|
||
for target in ("bound", "recharged", "scripted", "done"):
|
||
collab.set_status(self.db, self.user, order, target)
|
||
after = self._audits(order)
|
||
assert len(after) - before == 4
|
||
status_audits = [row for row in after if row.action == "huya:collab:status"]
|
||
assert len(status_audits) == 4
|
||
|
||
def test_rollback_requires_flag_and_clears_later_timestamps(self):
|
||
order = self._bind_ready(self._create())
|
||
for target in ("bound", "recharged", "scripted", "done"):
|
||
collab.set_status(self.db, self.user, order, target)
|
||
assert order.scripted_at is not None
|
||
|
||
# 不给 rollback 标记 → 拒
|
||
with pytest.raises(collab.CollabError):
|
||
collab.set_status(self.db, self.user, order, "recharged")
|
||
|
||
collab.set_status(self.db, self.user, order, "recharged", rollback=True)
|
||
assert order.status == "recharged"
|
||
# 回退后后面阶段的时间戳必须清掉,否则状态与时间戳自相矛盾
|
||
assert order.scripted_at is None
|
||
assert order.done_at is None
|
||
assert order.recharged_at is not None
|
||
|
||
def test_void_requires_reason_and_blocks_returning(self):
|
||
order = self._create()
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.set_status(self.db, self.user, order, "void")
|
||
assert excinfo.value.code == "field_required"
|
||
|
||
collab.set_status(self.db, self.user, order, "void", reason="客户退款")
|
||
assert order.status == "void"
|
||
assert order.void_reason == "客户退款"
|
||
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.set_status(self.db, self.user, order, "pending", rollback=True)
|
||
assert excinfo.value.code == "status_from_void"
|
||
|
||
def test_same_status_is_noop(self):
|
||
order = self._bind_ready(self._create())
|
||
before = len(self._audits(order))
|
||
assert collab.set_status(self.db, self.user, order, "pending") == {}
|
||
# 幂等点击不该刷出审计噪声
|
||
assert len(self._audits(order)) == before
|
||
|
||
def test_done_has_no_next_status(self):
|
||
order = self._create()
|
||
collab.set_status(self.db, self.user, order, "void", reason="x")
|
||
assert collab.NEXT_STATUS.get("done") is None
|
||
|
||
|
||
class TestCollabCkLifecycle(TestCollabBase):
|
||
def test_valid_sets_checked_at_but_disabled_does_not(self):
|
||
order = self._create(ck_text=fake_ck("1001"))
|
||
|
||
collab.set_ck_status(self.db, self.user, order, "valid")
|
||
assert order.ck_status == "valid"
|
||
assert order.ck_checked_at is not None
|
||
checked_at = order.ck_checked_at
|
||
assert order.ck_check_message == ""
|
||
|
||
collab.set_ck_status(self.db, self.user, order, "disabled", message="不再使用")
|
||
assert order.ck_status == "disabled"
|
||
# 人工停用不是「刚跑过探针」,时间戳不该被刷
|
||
assert order.ck_checked_at == checked_at
|
||
|
||
def test_invalid_records_reason(self):
|
||
order = self._create(ck_text=fake_ck("1001"))
|
||
collab.set_ck_status(
|
||
self.db, self.user, order, "invalid", message="登录态已过期"
|
||
)
|
||
assert order.ck_status == "invalid"
|
||
assert order.ck_check_message == "登录态已过期"
|
||
|
||
def test_ck_status_rejects_unknown_value(self):
|
||
order = self._create()
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.set_ck_status(self.db, self.user, order, "whatever")
|
||
assert excinfo.value.code == "ck_status_unknown"
|
||
|
||
def test_pasting_new_ck_resets_verification(self):
|
||
order = self._create(ck_text=fake_ck("1001"))
|
||
collab.set_ck_status(self.db, self.user, order, "valid")
|
||
collab.set_ck(self.db, self.user, order, fake_ck("1002"))
|
||
assert order.uid == "1002"
|
||
assert order.ck_status == "unverified"
|
||
assert order.ck_checked_at is None
|
||
|
||
def test_ck_audit_never_leaks_the_credential(self):
|
||
"""审计 detail 里绝不能出现凭据本身(脱敏规则一旦变动就是泄漏)。"""
|
||
ck = fake_ck("1001")
|
||
order = self._create(ck_text=ck)
|
||
|
||
create_detail = self._audits(order)[0].detail
|
||
assert "udb_cred" not in create_detail
|
||
assert ck not in create_detail
|
||
|
||
# 「换 CK」这条最有风险:逐条审计都查一遍
|
||
collab.set_ck(self.db, self.user, order, fake_ck("1003"))
|
||
for entry in self._audits(order):
|
||
assert "udb_cred" not in entry.detail
|
||
assert "cookie" not in entry.detail
|
||
assert ck not in entry.detail
|
||
|
||
paste_entry = next(
|
||
entry for entry in self._audits(order) if entry.action == "huya:collab:ck"
|
||
)
|
||
# uid 与「内容变了」要留痕,否则查不出换过 CK
|
||
assert "1003" in paste_entry.detail
|
||
assert "credential_changed" in paste_entry.detail
|
||
|
||
def test_rebuild_without_raw_cookie_is_rejected(self):
|
||
order = self._create(ck_text=fake_ck("1001", web=True))
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.rebuild_ck(self.db, self.user, order)
|
||
assert excinfo.value.code == "no_raw_cookie"
|
||
|
||
def test_rebuild_from_raw_cookie_replaces_running_cookie(self):
|
||
order = self._create(ck_text=fake_ck("1002", web=False))
|
||
raw = order.raw_cookie
|
||
collab.rebuild_ck(self.db, self.user, order)
|
||
assert order.cookie # 重建后的运行态
|
||
assert order.raw_cookie == raw # 原始凭据要原样留着,可反复重建
|
||
assert order.ck_status == "unverified"
|
||
|
||
def test_ck_parse_failure_is_readable(self):
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
self._create(ck_text="这不是一份 CK")
|
||
assert excinfo.value.code == "ck_invalid"
|
||
|
||
|
||
class TestCollabImages(TestCollabBase):
|
||
def test_set_images_normalizes_and_dedupes(self):
|
||
order = self._create()
|
||
result = collab.set_images(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
"bind_images",
|
||
["collab/2026/10/16/a.png", "collab/2026/10/16/a.png", " "],
|
||
)
|
||
assert result["images"] == ["collab/2026/10/16/a.png"]
|
||
|
||
def test_images_reject_path_traversal(self):
|
||
order = self._create()
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.set_images(
|
||
self.db, self.user, order, "bind_images", ["../etc/passwd"]
|
||
)
|
||
assert excinfo.value.code == "image_key_invalid"
|
||
|
||
def test_images_reject_unknown_slot_and_too_many(self):
|
||
order = self._create()
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.set_images(self.db, self.user, order, "other_images", [])
|
||
assert excinfo.value.code == "image_slot_unknown"
|
||
|
||
too_many = [f"collab/2026/10/16/{index}.png" for index in range(21)]
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.set_images(self.db, self.user, order, "bind_images", too_many)
|
||
assert excinfo.value.code == "images_too_many"
|
||
|
||
def test_image_change_is_audited_with_added_and_removed(self):
|
||
order = self._create()
|
||
collab.set_images(
|
||
self.db, self.user, order, "home_images", ["collab/2026/10/16/a.png"]
|
||
)
|
||
collab.set_images(
|
||
self.db, self.user, order, "home_images", ["collab/2026/10/16/b.png"]
|
||
)
|
||
entry = self._audits(order)[-1]
|
||
assert entry.action == "huya:collab:image"
|
||
assert "b.png" in entry.detail and "a.png" in entry.detail
|
||
|
||
def test_serialize_builds_urls_from_keys_not_stored_urls(self):
|
||
order = self._create()
|
||
collab.set_images(
|
||
self.db, self.user, order, "bind_images", ["collab/2026/10/16/a.png"]
|
||
)
|
||
payload = collab.serialize_order(order)
|
||
|
||
assert payload["bind_images"] == ["collab/2026/10/16/a.png"]
|
||
assert payload["bind_image_urls"] == [
|
||
"/api/files/object?key=collab%2F2026%2F10%2F16%2Fa.png"
|
||
]
|
||
assert payload["bind_image_thumb_urls"] == [
|
||
"/api/files/object?key=collab%2F2026%2F10%2F16%2Fa.png&variant=thumb"
|
||
]
|
||
|
||
def test_serialize_never_exposes_cookie(self):
|
||
order = self._create(ck_text=fake_ck("1001"))
|
||
payload = collab.serialize_order(order)
|
||
assert payload["has_cookie"] is True
|
||
assert "cookie" not in payload
|
||
assert "raw_cookie" not in payload
|
||
|
||
|
||
class TestCollabImport(TestCollabBase):
|
||
def test_import_with_header_creates_rows(self):
|
||
rows = [
|
||
"店铺\t订单编号\t负责人\t绑定斗鱼号\t礼包\t备注",
|
||
"佳佳\t2627501604891357\tcollab_op\t用户4241534208\t不朽伊人大礼包\t熟人",
|
||
"小鱼儿\t2627501761915541\tcollab_support\t愚人节记得来骗我756\t激萌罗小黑\t",
|
||
]
|
||
text = "\n".join(rows)
|
||
result = collab.import_orders(
|
||
self.db,
|
||
self.user,
|
||
text,
|
||
owner_by_name={"collab_op": self.user.id, "collab_support": self.other.id},
|
||
)
|
||
|
||
assert result["created"] == 2
|
||
assert result["errors"] == []
|
||
orders = (
|
||
self.db.query(HuyaExternalOrder)
|
||
.filter(HuyaExternalOrder.order_no == "2627501604891357")
|
||
.all()
|
||
)
|
||
assert orders[0].shop == "佳佳"
|
||
assert orders[0].owner_id == self.user.id
|
||
assert orders[0].gift_name == "不朽伊人大礼包"
|
||
|
||
def test_import_without_header_uses_fixed_column_order(self):
|
||
text = "佳佳\t2627501604891357\tcollab_op\t用户4241534208\t礼包A\t备注B"
|
||
result = collab.import_orders(
|
||
self.db, self.user, text, owner_by_name={"collab_op": self.user.id}
|
||
)
|
||
assert result["created"] == 1
|
||
order = self.db.query(HuyaExternalOrder).one()
|
||
assert order.shop == "佳佳"
|
||
assert order.bind_douyu_account == "用户4241534208"
|
||
assert order.gift_name == "礼包A"
|
||
|
||
def test_import_unknown_owner_only_fails_that_row(self):
|
||
"""单行失败不能带崩整批(AGENTS §2:单项失败要如实暴露,但不整体失败)。"""
|
||
rows = [
|
||
"佳佳\tA1\t不认识的人\t用户1\t礼包\t",
|
||
"佳佳\tA2\tcollab_op\t用户2\t礼包\t",
|
||
]
|
||
text = "\n".join(rows)
|
||
result = collab.import_orders(
|
||
self.db, self.user, text, owner_by_name={"collab_op": self.user.id}
|
||
)
|
||
assert result["created"] == 1
|
||
assert len(result["errors"]) == 1
|
||
assert result["errors"][0]["line"] == 1
|
||
assert "不认识的人" in result["errors"][0]["message"]
|
||
assert self.db.query(HuyaExternalOrder).one().order_no == "A2"
|
||
|
||
def test_import_single_bad_row_does_not_poison_the_batch(self):
|
||
"""超长字段会让那一行报错;用 SAVEPOINT 隔离后,后面几行必须照常写入。"""
|
||
rows = [
|
||
f"佳佳\tA1\tcollab_op\t用户1\t{'礼包' * 200}\t",
|
||
"佳佳\tA2\tcollab_op\t用户2\t礼包\t",
|
||
"佳佳\tA3\tcollab_op\t用户3\t礼包\t",
|
||
]
|
||
text = "\n".join(rows)
|
||
result = collab.import_orders(
|
||
self.db, self.user, text, owner_by_name={"collab_op": self.user.id}
|
||
)
|
||
assert result["created"] == 2
|
||
assert len(result["errors"]) == 1
|
||
order_nos = {row.order_no for row in self.db.query(HuyaExternalOrder).all()}
|
||
assert order_nos == {"A2", "A3"}
|
||
|
||
def test_import_survives_a_row_that_fails_at_the_database_level(self):
|
||
"""「写库时才炸」的错误(这里用不存在的 owner_id 触发外键失败)必须被
|
||
SAVEPOINT 隔离:不加 SAVEPOINT 时,第一次约束冲突会把会话打成失败态,
|
||
后面每一行都会跟着报错 —— 单行失败就带崩了整批。
|
||
"""
|
||
rows = [
|
||
"佳佳\tA1\tghost_owner\t用户1\t礼包\t",
|
||
"佳佳\tA2\tcollab_op\t用户2\t礼包\t",
|
||
]
|
||
text = "\n".join(rows)
|
||
result = collab.import_orders(
|
||
self.db,
|
||
self.user,
|
||
text,
|
||
owner_by_name={"ghost_owner": 999999, "collab_op": self.user.id},
|
||
)
|
||
assert result["created"] == 1
|
||
assert len(result["errors"]) == 1
|
||
assert self.db.query(HuyaExternalOrder).one().order_no == "A2"
|
||
|
||
def test_import_duplicate_in_same_paste_keeps_first_only(self):
|
||
rows = [
|
||
"佳佳\tA1\tcollab_op\t用户1\t礼包\t",
|
||
"小鱼儿\tA1\tcollab_op\t用户2\t礼包\t",
|
||
]
|
||
text = "\n".join(rows)
|
||
result = collab.import_orders(
|
||
self.db, self.user, text, owner_by_name={"collab_op": self.user.id}
|
||
)
|
||
assert result["created"] == 1
|
||
assert len(result["errors"]) == 1
|
||
assert self.db.query(HuyaExternalOrder).one().shop == "佳佳"
|
||
|
||
def test_import_updates_existing_row(self):
|
||
order = self._create(order_no="A1", shop="佳佳")
|
||
result = collab.import_orders(
|
||
self.db,
|
||
self.user,
|
||
"小鱼儿\tA1\tcollab_op\t用户9\t礼包\t",
|
||
owner_by_name={"collab_op": self.user.id},
|
||
)
|
||
assert result["updated"] == 1
|
||
self.db.refresh(order)
|
||
assert order.shop == "小鱼儿"
|
||
assert order.bind_douyu_account == "用户9"
|
||
|
||
def test_import_skips_rows_without_order_no(self):
|
||
result = collab.import_orders(
|
||
self.db, self.user, "佳佳\t\tcollab_op\t用户1\t\t"
|
||
)
|
||
assert result["skipped"] == 1
|
||
assert result["created"] == 0
|
||
|
||
def test_import_without_owner_defaults_to_the_importer(self):
|
||
"""导入时负责人留空 = 导入人自己(与手建同一套默认)。"""
|
||
result = collab.import_orders(self.db, self.user, "佳佳\tO1\t\t用户1\t礼包\t")
|
||
assert result["created"] == 1
|
||
assert self.db.query(HuyaExternalOrder).one().owner_id == self.user.id
|
||
|
||
|
||
class FakeBindResp:
|
||
"""合成一份 ``check_user_bind_game_account`` 响应(字段名对齐真机响应结构)。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
status: int = 200,
|
||
msg: str = "",
|
||
nick: str = "",
|
||
role: str = "",
|
||
role_id: str = "",
|
||
open_id: str = "",
|
||
plat: str = "",
|
||
area: str = "",
|
||
game_name: str = "和平精英",
|
||
):
|
||
self.status = status
|
||
self.msg = msg
|
||
self.gameName = game_name
|
||
self.accountData = SimpleNamespace(
|
||
gameAccount=SimpleNamespace(nick=nick, openId=open_id),
|
||
gameRole=SimpleNamespace(
|
||
roleName=role, roleId=role_id, platName=plat, areaName=area
|
||
),
|
||
)
|
||
|
||
|
||
class FakeGiftResp:
|
||
"""合成一份 ``getActPrizeList`` 响应(礼包列表)。"""
|
||
|
||
def __init__(self, *, status: int = 200, msg: str = "", goods: list | None = None):
|
||
self.status = status
|
||
self.msg = msg
|
||
self._goods = (
|
||
goods
|
||
if goods is not None
|
||
else [
|
||
{
|
||
"product_id": "hy-1001",
|
||
"name": "不朽伊人大礼包",
|
||
"price": 200,
|
||
"remain_text": "39%",
|
||
"category_name": "礼包",
|
||
},
|
||
{"product_id": "", "name": "缺 product_id 的脏数据"},
|
||
{"product_id": "hy-1002", "name": ""},
|
||
]
|
||
)
|
||
|
||
def to_dict(self) -> dict:
|
||
return {"status": self.status, "msg": self.msg, "goods": self._goods}
|
||
|
||
|
||
class FakeProbeClient:
|
||
"""离线探针客户端:按脚本依次给出 ``None`` / 异常 / 响应,并记录调用参数。"""
|
||
|
||
def __init__(self, *outcomes, gifts: list | None = None):
|
||
self.outcomes = list(outcomes)
|
||
self.gift_outcomes = list(gifts or [])
|
||
self.calls: list[dict] = []
|
||
self.gift_calls: list[dict] = []
|
||
|
||
def check_user_bind_game_account(self, **kwargs):
|
||
self.calls.append(kwargs)
|
||
if not self.outcomes:
|
||
raise AssertionError("探针被调用的次数超出用例预期")
|
||
outcome = self.outcomes.pop(0)
|
||
if isinstance(outcome, Exception):
|
||
raise outcome
|
||
return outcome
|
||
|
||
def get_act_prize_list(self, **kwargs):
|
||
self.gift_calls.append(kwargs)
|
||
outcome = self.gift_outcomes.pop(0) if self.gift_outcomes else FakeGiftResp()
|
||
if isinstance(outcome, Exception):
|
||
raise outcome
|
||
return outcome
|
||
|
||
|
||
class TestCollabCkProbe(TestCollabBase):
|
||
"""S5:只读探针。判据(真机对照 2026-10-16)= 响应非 None 且 status == 200。"""
|
||
|
||
def _row_with_ck(self, order_no: str = "P1", uid: str = "1199647239697"):
|
||
return self._create(order_no=order_no, ck_text=fake_ck(uid))
|
||
|
||
def test_verify_success_marks_valid_and_writes_audit(self):
|
||
order = self._row_with_ck()
|
||
client = FakeProbeClient(
|
||
FakeBindResp(
|
||
nick="随遇而安",
|
||
role="吃鸡199706",
|
||
role_id="1234567",
|
||
plat="手Q",
|
||
area="苹果(iOS)",
|
||
)
|
||
)
|
||
|
||
result = collab.verify_ck(self.db, self.user, order, client=client)
|
||
|
||
assert result["ok"] is True
|
||
assert order.ck_status == "valid"
|
||
assert order.ck_checked_at is not None
|
||
assert "随遇而安" in order.ck_check_message
|
||
assert "吃鸡199706" in order.ck_check_message
|
||
assert order.version == 1
|
||
assert "huya:collab:ck_status" in [row.action for row in self._audits(order)]
|
||
|
||
def test_verify_uses_this_rows_ck_and_the_default_activity(self):
|
||
"""探针必须拿**本行的** CK 去问默认活动,不能借用别的行/别的活动。"""
|
||
order = self._row_with_ck(uid="1199647239697")
|
||
client = FakeProbeClient(FakeBindResp(nick="随遇而安"))
|
||
|
||
collab.verify_ck(self.db, self.user, order, client=client)
|
||
|
||
call = client.calls[0]
|
||
assert call["uid"] == 1199647239697
|
||
assert isinstance(call["uid"], int)
|
||
assert call["cookie"] == order.cookie
|
||
assert (
|
||
call["b_act_id"]
|
||
== collab.PROBE_BIND_ACT_ID
|
||
== int(HUYA_DEFAULT_BIND_ACT_ID)
|
||
)
|
||
assert call["is_use_outer_act_id"] == 1
|
||
assert (
|
||
call["outer_act_id"]
|
||
== collab.PROBE_OUTER_ACT_ID
|
||
== HUYA_DEFAULT_OUTER_ACT_ID
|
||
)
|
||
|
||
def test_verify_marks_invalid_when_status_is_not_200(self):
|
||
order = self._row_with_ck()
|
||
client = FakeProbeClient(FakeBindResp(status=401, msg="登录态失效"))
|
||
|
||
result = collab.verify_ck(self.db, self.user, order, client=client)
|
||
|
||
assert result["ok"] is False
|
||
assert order.ck_status == "invalid"
|
||
assert order.ck_checked_at is not None
|
||
assert "401" in order.ck_check_message
|
||
assert "登录态失效" in order.ck_check_message
|
||
|
||
def test_verify_treats_empty_response_as_invalid(self):
|
||
"""裸 cookie / 别人的 uid 在真机上都返回 None —— 必须是「失效」。"""
|
||
order = self._row_with_ck()
|
||
|
||
result = collab.verify_ck(
|
||
self.db, self.user, order, client=FakeProbeClient(None)
|
||
)
|
||
|
||
assert result["ok"] is False
|
||
assert order.ck_status == "invalid"
|
||
assert "没有返回数据" in order.ck_check_message
|
||
|
||
def test_verify_does_not_accept_payload_without_200(self):
|
||
"""别把判据改成「有数据就算成功」:status=0 却带数据也必须判失效。"""
|
||
order = self._row_with_ck()
|
||
client = FakeProbeClient(
|
||
FakeBindResp(status=0, nick="随遇而安", role="吃鸡199706")
|
||
)
|
||
|
||
result = collab.verify_ck(self.db, self.user, order, client=client)
|
||
|
||
assert result["ok"] is False
|
||
assert order.ck_status == "invalid"
|
||
# 失效响应里的字段一个都不许落到台账上(绑定快照也不许被清空/覆盖)
|
||
assert order.bind_game_account == ""
|
||
assert order.bind_game_role == ""
|
||
assert order.bind_game_channel == ""
|
||
assert order.bind_snapshot_at is None
|
||
|
||
def test_verify_survives_probe_exception(self):
|
||
order = self._row_with_ck()
|
||
|
||
result = collab.verify_ck(
|
||
self.db, self.user, order, client=FakeProbeClient(RuntimeError("boom"))
|
||
)
|
||
|
||
assert result["ok"] is False
|
||
assert order.ck_status == "invalid"
|
||
assert "RuntimeError" in order.ck_check_message
|
||
|
||
def test_verify_writes_binding_snapshot(self):
|
||
"""绑定账号 + **绑定角色**(用户强调这条重要)+ 平台区服都落到快照列。"""
|
||
order = self._row_with_ck()
|
||
client = FakeProbeClient(
|
||
FakeBindResp(
|
||
nick="随遇而安",
|
||
role="吃鸡199706",
|
||
plat="手Q",
|
||
area="苹果(iOS)",
|
||
)
|
||
)
|
||
|
||
result = collab.verify_ck(self.db, self.user, order, client=client)
|
||
|
||
assert order.bind_game_account == "随遇而安"
|
||
assert order.bind_game_role == "吃鸡199706"
|
||
assert order.bind_game_channel == "手Q · 苹果(iOS)"
|
||
assert order.bind_snapshot_at is not None
|
||
assert result["binding"] == {
|
||
"bind_game_account": "随遇而安",
|
||
"bind_game_role": "吃鸡199706",
|
||
"bind_game_channel": "手Q · 苹果(iOS)",
|
||
}
|
||
# 人工填的列一个都不碰;nickname 是「虎牙号昵称」,这个探针给不出
|
||
assert order.bind_douyu_account == ""
|
||
assert order.nickname == ""
|
||
assert "huya:collab:probe_snapshot" in [
|
||
row.action for row in self._audits(order)
|
||
]
|
||
|
||
def test_verify_overwrites_previous_binding_snapshot(self):
|
||
"""快照列是探针独占的「现在绑了谁」,重验要覆盖,并把旧值记进审计。"""
|
||
order = self._row_with_ck()
|
||
collab.verify_ck(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
client=FakeProbeClient(FakeBindResp(nick="旧账号", role="旧角色")),
|
||
)
|
||
|
||
collab.verify_ck(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
client=FakeProbeClient(FakeBindResp(nick="新账号", role="新角色")),
|
||
)
|
||
|
||
assert order.bind_game_account == "新账号"
|
||
assert order.bind_game_role == "新角色"
|
||
snapshots = [
|
||
row
|
||
for row in self._audits(order)
|
||
if row.action == "huya:collab:probe_snapshot"
|
||
]
|
||
# 两次验证各记一条(都在「快照变了」时才记),最后一条带 before/after
|
||
assert len(snapshots) == 2
|
||
detail = json.loads(snapshots[-1].detail)
|
||
assert detail["before"]["bind_game_role"] == "旧角色"
|
||
assert detail["after"]["bind_game_role"] == "新角色"
|
||
|
||
def test_binding_role_from_probe_satisfies_the_bound_precondition(self):
|
||
"""探针读到绑定角色 + 有主页截图 → 可以进「已确认绑定」(绑定斗鱼号/绑定图都不再要求)。"""
|
||
order = self._row_with_ck()
|
||
collab.set_images(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
"home_images",
|
||
["collab/2026/10/16/home01.png"],
|
||
)
|
||
collab.verify_ck(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")),
|
||
)
|
||
assert order.bind_douyu_account == ""
|
||
assert order.bind_images == []
|
||
|
||
collab.set_status(self.db, self.user, order, "bound")
|
||
|
||
assert order.status == "bound"
|
||
assert order.bound_at is not None
|
||
|
||
def test_verify_never_touches_manually_entered_columns(self):
|
||
order = self._row_with_ck()
|
||
collab.update_order(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
{"bind_douyu_account": "人工填的号", "bind_douyu_uid": "999"},
|
||
)
|
||
|
||
collab.verify_ck(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")),
|
||
)
|
||
|
||
assert order.bind_douyu_account == "人工填的号"
|
||
assert order.bind_douyu_uid == "999"
|
||
assert order.bind_game_account == "随遇而安"
|
||
assert order.bind_game_role == "吃鸡199706"
|
||
|
||
def test_verify_without_cookie_raises_instead_of_marking_invalid(self):
|
||
order = self._create(order_no="P-NOCK")
|
||
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.verify_ck(self.db, self.user, order)
|
||
|
||
assert excinfo.value.code == "no_cookie"
|
||
assert order.ck_status == "unverified"
|
||
|
||
def test_verify_refuses_void_row(self):
|
||
order = self._row_with_ck()
|
||
collab.set_status(self.db, self.user, order, "void", reason="客户取消")
|
||
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.verify_ck(self.db, self.user, order, client=FakeProbeClient(None))
|
||
|
||
assert excinfo.value.code == "void_order"
|
||
|
||
def test_manual_ck_status_only_allows_disable_and_reset(self):
|
||
"""人工只能停用/恢复;「有效/失效」必须由探针给(人工标有效=绕过验证)。"""
|
||
order = self._row_with_ck()
|
||
|
||
collab.set_manual_ck_status(
|
||
self.db, self.user, order, "disabled", message="这个号不打算再用"
|
||
)
|
||
assert order.ck_status == "disabled"
|
||
# 人工停用不算「刚跑过探针」,不刷 ck_checked_at
|
||
assert order.ck_checked_at is None
|
||
|
||
collab.set_manual_ck_status(self.db, self.user, order, "unverified")
|
||
assert order.ck_status == "unverified"
|
||
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.set_manual_ck_status(self.db, self.user, order, "valid")
|
||
assert excinfo.value.code == "ck_status_manual_only"
|
||
assert order.ck_status == "unverified"
|
||
|
||
def test_verify_never_touches_business_status(self):
|
||
"""CK 失效不改业务状态:已绑定的单子业务上可能仍然有效(设计文档 §4.2)。"""
|
||
order = self._row_with_ck()
|
||
self._bind_ready(order)
|
||
collab.set_status(self.db, self.user, order, "bound")
|
||
|
||
collab.verify_ck(
|
||
self.db, self.user, order, client=FakeProbeClient(FakeBindResp(status=401))
|
||
)
|
||
|
||
assert order.ck_status == "invalid"
|
||
assert order.status == "bound"
|
||
assert order.bound_at is not None
|
||
|
||
def test_verify_never_creates_huya_account(self):
|
||
"""边界(设计文档 §2.3):探针只读,不建 HuyaAccount、不写工作台。"""
|
||
order = self._row_with_ck()
|
||
|
||
collab.verify_ck(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
client=FakeProbeClient(FakeBindResp(nick="随遇而安")),
|
||
)
|
||
|
||
assert self.db.query(HuyaAccount).count() == 0
|
||
assert self.db.query(HuyaWorkbenchAccount).count() == 0
|
||
|
||
def test_verify_audit_never_carries_the_cookie(self):
|
||
order = self._row_with_ck()
|
||
collab.verify_ck(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
client=FakeProbeClient(FakeBindResp(nick="随遇而安")),
|
||
)
|
||
|
||
payload = json.dumps(
|
||
[row.detail for row in self._audits(order)], ensure_ascii=False
|
||
)
|
||
assert "udb_cred" not in payload
|
||
assert "udb_biztoken" not in payload
|
||
assert order.cookie not in payload
|
||
|
||
def test_batch_isolates_single_row_failure(self):
|
||
rows = [
|
||
self._row_with_ck(order_no="B1", uid="1001"),
|
||
self._row_with_ck(order_no="B2", uid="1002"),
|
||
self._row_with_ck(order_no="B3", uid="1003"),
|
||
]
|
||
client = FakeProbeClient(
|
||
FakeBindResp(nick="A"),
|
||
RuntimeError("boom"),
|
||
FakeBindResp(status=500, msg="服务端异常"),
|
||
)
|
||
|
||
result = collab.verify_ck_batch(
|
||
self.db, self.user, rows, client=client, interval_seconds=0
|
||
)
|
||
|
||
assert result["total"] == 3
|
||
assert result["valid"] == 1
|
||
assert result["failed"] == 2
|
||
assert [row.ck_status for row in rows] == ["valid", "invalid", "invalid"]
|
||
assert "RuntimeError" in result["results"][1]["message"]
|
||
|
||
def test_batch_reports_rows_without_ck_without_lying(self):
|
||
with_ck = self._row_with_ck(order_no="B1", uid="1001")
|
||
without_ck = self._create(order_no="B2")
|
||
client = FakeProbeClient(FakeBindResp(nick="A"))
|
||
|
||
result = collab.verify_ck_batch(
|
||
self.db, self.user, [with_ck, without_ck], client=client, interval_seconds=0
|
||
)
|
||
|
||
assert result["valid"] == 1
|
||
assert result["failed"] == 1
|
||
# 没 CK 的行保持「未验证」,不能谎报成「已失效」
|
||
assert without_ck.ck_status == "unverified"
|
||
assert "CK" in result["results"][1]["message"]
|
||
assert len(client.calls) == 1 # 没 CK 的行不发请求
|
||
|
||
def test_batch_keeps_interval_between_rows(self, monkeypatch):
|
||
"""人工触发也要串行 + 间隔,别把账号送进风控名单。"""
|
||
rows = [
|
||
self._row_with_ck(order_no="B1", uid="1001"),
|
||
self._row_with_ck(order_no="B2", uid="1002"),
|
||
self._row_with_ck(order_no="B3", uid="1003"),
|
||
]
|
||
client = FakeProbeClient(*[FakeBindResp(nick="A") for _ in rows])
|
||
slept: list[float] = []
|
||
monkeypatch.setattr(
|
||
collab, "time", SimpleNamespace(sleep=lambda seconds: slept.append(seconds))
|
||
)
|
||
|
||
collab.verify_ck_batch(
|
||
self.db, self.user, rows, client=client, interval_seconds=0.5
|
||
)
|
||
|
||
assert slept == [0.5, 0.5] # 3 条之间睡 2 次,第一条前不睡
|
||
|
||
def test_batch_rejects_too_many_rows(self):
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.verify_ck_batch(
|
||
self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1)
|
||
)
|
||
|
||
assert excinfo.value.code == "verify_batch_too_large"
|
||
|
||
def test_list_orders_by_ids_keeps_request_order_and_ignores_missing(self):
|
||
first = self._row_with_ck(order_no="B1", uid="1001")
|
||
second = self._row_with_ck(order_no="B2", uid="1002")
|
||
|
||
rows = collab.list_orders_by_ids(self.db, [second.id, first.id, 99999999])
|
||
|
||
assert [row.id for row in rows] == [second.id, first.id]
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.list_orders_by_ids(self.db, [])
|
||
assert excinfo.value.code == "verify_empty"
|
||
|
||
|
||
class TestCollabCkPreview(TestCollabBase):
|
||
"""粘贴 CK 后的即时预览:探针 + 顺带拉礼包,一行都不写库。"""
|
||
|
||
def test_preview_returns_binding_and_gifts(self):
|
||
client = FakeProbeClient(
|
||
FakeBindResp(
|
||
nick="随遇而安", role="吃鸡199706", plat="手Q", area="苹果(iOS)"
|
||
)
|
||
)
|
||
|
||
preview = collab.preview_ck(fake_ck("1199647239697"), client=client)
|
||
|
||
assert preview["ok"] is True
|
||
assert preview["has_role"] is True
|
||
assert preview["uid"] == "1199647239697"
|
||
assert preview["hy_username"] == "hy_1199647239697"
|
||
assert preview["bind_game_role"] == "吃鸡199706"
|
||
assert preview["gifts"] == [
|
||
{
|
||
"product_id": "hy-1001",
|
||
"name": "不朽伊人大礼包",
|
||
"price": 200,
|
||
"remain_text": "39%",
|
||
"stock": None,
|
||
"score": None,
|
||
"category_name": "礼包",
|
||
"icon": "",
|
||
}
|
||
]
|
||
assert "已拉取 1 个礼包" in preview["gifts_message"]
|
||
assert client.gift_calls[0]["sid"] == collab.GIFTS_SID
|
||
|
||
def test_preview_of_invalid_ck_does_not_fetch_gifts(self):
|
||
client = FakeProbeClient(None)
|
||
|
||
preview = collab.preview_ck(fake_ck("1199647239697"), client=client)
|
||
|
||
assert preview["ok"] is False
|
||
assert preview["has_role"] is False
|
||
assert preview["gifts"] == []
|
||
assert client.gift_calls == [] # CK 都没通,不去拉礼包
|
||
|
||
def test_preview_of_probe_without_role_is_not_submittable(self):
|
||
"""探针通了但读不到绑定角色 → 不允许提交(has_role=False)。"""
|
||
preview = collab.preview_ck(
|
||
fake_ck("1199647239697"),
|
||
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="")),
|
||
)
|
||
assert preview["ok"] is True
|
||
assert preview["has_role"] is False
|
||
|
||
def test_preview_writes_nothing(self):
|
||
order = self._create(order_no="PREVIEW-1", shop="佳佳")
|
||
before = order.version
|
||
collab.preview_ck(
|
||
fake_ck("1199647239697"),
|
||
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")),
|
||
)
|
||
self.db.refresh(order)
|
||
assert order.version == before
|
||
assert self.db.query(HuyaExternalOrder).count() == 1
|
||
|
||
def test_preview_rejects_unparsable_ck(self):
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.preview_ck("这不是一份 Cookie", client=FakeProbeClient(None))
|
||
assert excinfo.value.code == "ck_invalid"
|
||
|
||
|
||
class TestCollabGifts(TestCollabBase):
|
||
def test_gift_fetch_skips_dirty_rows(self):
|
||
gifts, message = collab.fetch_activity_gifts(
|
||
1199647239697, fake_ck("1199647239697"), client=FakeProbeClient()
|
||
)
|
||
assert [gift["name"] for gift in gifts] == ["不朽伊人大礼包"]
|
||
assert message == "已拉取 1 个礼包"
|
||
|
||
def test_gift_fetch_reports_failure_without_raising(self):
|
||
gifts, message = collab.fetch_activity_gifts(
|
||
1199647239697,
|
||
fake_ck("1199647239697"),
|
||
client=FakeProbeClient(gifts=[FakeGiftResp(status=500, msg="活动已结束")]),
|
||
)
|
||
assert gifts == []
|
||
assert "拉取礼包失败" in message and "500" in message
|
||
|
||
def test_gift_fetch_survives_exception(self):
|
||
gifts, message = collab.fetch_activity_gifts(
|
||
1199647239697,
|
||
fake_ck("1199647239697"),
|
||
client=FakeProbeClient(gifts=[RuntimeError("boom")]),
|
||
)
|
||
assert gifts == []
|
||
assert "RuntimeError" in message
|
||
|
||
def test_gift_fetch_empty_list_hints_stale_sid(self):
|
||
"""200 但 0 个商品 = SID 不是当前活动(真机 2026-10-16:2203 → 0,2277 → 30)。"""
|
||
gifts, message = collab.fetch_activity_gifts(
|
||
1199647239697,
|
||
fake_ck("1199647239697"),
|
||
sid=2203,
|
||
client=FakeProbeClient(gifts=[FakeGiftResp(goods=[])]),
|
||
)
|
||
assert gifts == []
|
||
assert "SID=2203" in message and "工作台" in message
|
||
|
||
def test_resolve_gifts_sid_prefers_workbench_config(self):
|
||
self.db.add(HuyaConfig(sid="2277"))
|
||
self.db.commit()
|
||
|
||
assert collab.resolve_gifts_sid(self.db) == 2277
|
||
|
||
def test_resolve_gifts_sid_falls_back_when_unset_or_garbage(self):
|
||
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID # 没有配置行
|
||
|
||
row = HuyaConfig(sid="")
|
||
self.db.add(row)
|
||
self.db.commit()
|
||
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID
|
||
|
||
row.sid = "不是数字"
|
||
self.db.commit()
|
||
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID
|
||
|
||
def test_preview_uses_configured_sid(self):
|
||
self.db.add(HuyaConfig(sid="2277"))
|
||
self.db.commit()
|
||
client = FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706"))
|
||
|
||
preview = collab.preview_ck(fake_ck("1199647239697"), sid=2277, client=client)
|
||
|
||
assert client.gift_calls[0]["sid"] == 2277
|
||
assert preview["has_role"] is True
|
||
|
||
def test_preview_endpoint_passes_configured_sid(self, monkeypatch):
|
||
"""接口层必须把工作台配置的 SID 传下去,别再退回写死默认值。"""
|
||
from web.backend.routers.huya import collab as collab_router
|
||
from web.backend.schemas import HuyaCollabCkPreviewRequest
|
||
|
||
self.db.add(HuyaConfig(sid="2277"))
|
||
self.db.commit()
|
||
client = FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706"))
|
||
monkeypatch.setattr(collab, "_build_probe_client", lambda: client)
|
||
|
||
out = collab_router.preview_external_order_ck(
|
||
HuyaCollabCkPreviewRequest(ck_text=fake_ck("1199647239697")),
|
||
db=self.db,
|
||
_current=self.user,
|
||
)
|
||
|
||
# 直接调路由函数时没走 pydantic 出参,拿到的就是 dict
|
||
assert client.gift_calls[0]["sid"] == 2277
|
||
assert out["has_role"] is True
|
||
|
||
|
||
class TestCollabCreateStrict(TestCollabBase):
|
||
"""人工新建表单:订单编号 / 店铺 / CK / 礼包名 / 主页截图必填,且 CK 必须读到绑定角色。"""
|
||
|
||
HOME_IMAGE = "collab/2026/10/16/home01.png"
|
||
|
||
def _row_count(self) -> int:
|
||
return self.db.query(HuyaExternalOrder).count()
|
||
|
||
def test_missing_required_fields_blocks_creation(self):
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.create_order(
|
||
self.db,
|
||
self.user,
|
||
order_no="STRICT-1",
|
||
require_verified=True,
|
||
client=FakeProbeClient(
|
||
FakeBindResp(nick="随遇而安", role="吃鸡199706")
|
||
),
|
||
)
|
||
assert excinfo.value.code == "create_field_required"
|
||
assert "店铺" in excinfo.value.detail and "礼包名" in excinfo.value.detail
|
||
assert "主页截图" in excinfo.value.detail
|
||
assert self._row_count() == 0 # 失败不留半行
|
||
|
||
def test_missing_home_screenshot_blocks_creation(self):
|
||
"""绑定图不再要求,但主页截图是必填(2026-10-16 用户拍板)。"""
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.create_order(
|
||
self.db,
|
||
self.user,
|
||
order_no="STRICT-NOIMG",
|
||
shop="佳佳",
|
||
gift_name="不朽伊人大礼包",
|
||
ck_text=fake_ck("1199647239697"),
|
||
require_verified=True,
|
||
client=FakeProbeClient(
|
||
FakeBindResp(nick="随遇而安", role="吃鸡199706")
|
||
),
|
||
)
|
||
assert excinfo.value.code == "create_field_required"
|
||
assert "主页截图" in excinfo.value.detail
|
||
assert self._row_count() == 0
|
||
|
||
def test_bad_home_image_key_is_rejected_before_insert(self):
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.create_order(
|
||
self.db,
|
||
self.user,
|
||
order_no="STRICT-BADIMG",
|
||
shop="佳佳",
|
||
gift_name="不朽伊人大礼包",
|
||
ck_text=fake_ck("1199647239697"),
|
||
home_images=["../../etc/passwd"],
|
||
require_verified=True,
|
||
client=FakeProbeClient(
|
||
FakeBindResp(nick="随遇而安", role="吃鸡199706")
|
||
),
|
||
)
|
||
assert excinfo.value.code == "image_key_invalid"
|
||
assert self._row_count() == 0
|
||
|
||
def test_probe_without_role_blocks_creation(self):
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.create_order(
|
||
self.db,
|
||
self.user,
|
||
order_no="STRICT-2",
|
||
shop="佳佳",
|
||
gift_name="不朽伊人大礼包",
|
||
ck_text=fake_ck("1199647239697"),
|
||
home_images=[self.HOME_IMAGE],
|
||
require_verified=True,
|
||
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="")),
|
||
)
|
||
assert excinfo.value.code == "ck_not_verified"
|
||
assert "读不到绑定角色" in excinfo.value.detail
|
||
assert self._row_count() == 0
|
||
|
||
def test_invalid_ck_blocks_creation(self):
|
||
with pytest.raises(collab.CollabError) as excinfo:
|
||
collab.create_order(
|
||
self.db,
|
||
self.user,
|
||
order_no="STRICT-3",
|
||
shop="佳佳",
|
||
gift_name="不朽伊人大礼包",
|
||
ck_text=fake_ck("1199647239697"),
|
||
home_images=[self.HOME_IMAGE],
|
||
require_verified=True,
|
||
client=FakeProbeClient(None),
|
||
)
|
||
assert excinfo.value.code == "ck_not_verified"
|
||
assert self._row_count() == 0
|
||
|
||
def test_valid_ck_creates_row_with_snapshot_and_audit(self):
|
||
order = collab.create_order(
|
||
self.db,
|
||
self.user,
|
||
order_no="STRICT-4",
|
||
shop="佳佳",
|
||
gift_name="不朽伊人大礼包",
|
||
ck_text=fake_ck("1199647239697"),
|
||
home_images=[self.HOME_IMAGE, "collab/2026/10/16/home02.png"],
|
||
require_verified=True,
|
||
client=FakeProbeClient(
|
||
FakeBindResp(
|
||
nick="随遇而安", role="吃鸡199706", plat="手Q", area="苹果(iOS)"
|
||
)
|
||
),
|
||
)
|
||
|
||
assert order.ck_status == "valid"
|
||
assert order.ck_checked_at is not None
|
||
assert order.bind_game_account == "随遇而安"
|
||
assert order.bind_game_role == "吃鸡199706"
|
||
assert order.bind_game_channel == "手Q · 苹果(iOS)"
|
||
assert order.bind_snapshot_at is not None
|
||
assert order.gift_name == "不朽伊人大礼包"
|
||
assert order.home_images == [self.HOME_IMAGE, "collab/2026/10/16/home02.png"]
|
||
assert order.bind_images == [] # 绑定图不再人工上传
|
||
assert order.owner_id == self.user.id
|
||
actions = [row.action for row in self._audits(order)]
|
||
assert "huya:collab:create" in actions
|
||
assert "huya:collab:ck_status" in actions
|
||
|
||
def test_import_path_is_not_forced_to_verify(self):
|
||
"""粘贴导入没有 CK 列,不能被严格模式挡住(走的是默认 require_verified=False)。"""
|
||
result = collab.import_orders(
|
||
self.db, self.user, "佳佳\tIMP-1\t\t用户1\t礼包A\t"
|
||
)
|
||
assert result["created"] == 1
|
||
assert self.db.query(HuyaExternalOrder).one().ck_status == "unverified"
|
||
|
||
|
||
class TestCollabKeywordSearch(TestCollabBase):
|
||
"""列表搜索:一个输入框打穿订单号 / 店铺 / 绑定账号·角色 / 平台区服 / 虎牙号 / 礼包 / 备注。"""
|
||
|
||
def _search(self, text: str) -> list[HuyaExternalOrder]:
|
||
rows, total = collab.list_orders(self.db, keyword=text)
|
||
assert total == len(rows)
|
||
return rows
|
||
|
||
def test_keyword_hits_each_searchable_column(self):
|
||
order = self._create(
|
||
order_no="K-1",
|
||
shop="向日葵",
|
||
gift_name="天网终结者大礼包",
|
||
remark="熟人介绍",
|
||
ck_text=fake_ck("1199647123456"),
|
||
)
|
||
collab.verify_ck(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
client=FakeProbeClient(
|
||
FakeBindResp(
|
||
nick="微信用户", role="不打PG打老公", plat="苹果(iOS)", area="微信"
|
||
)
|
||
),
|
||
)
|
||
|
||
for text in (
|
||
"K-1", # 订单编号
|
||
"向日葵", # 店铺
|
||
"微信用户", # 绑定账号(探针)
|
||
"不打PG打老公", # 绑定角色(探针)
|
||
"苹果(iOS)", # 平台 · 区服
|
||
"1199647123456", # 虎牙号
|
||
"天网终结者", # 礼包名
|
||
"熟人", # 备注
|
||
):
|
||
assert [row.id for row in self._search(text)] == [order.id], text
|
||
|
||
def test_keyword_miss_returns_empty_page(self):
|
||
self._create(order_no="K-2", shop="佳佳")
|
||
|
||
assert self._search("查不到的东西") == []
|
||
|
||
|
||
class TestCollabBuyerCluster(TestCollabBase):
|
||
"""同一买家的多单:按「绑定账号 + 绑定角色」软识别(只读派生,不动 uid 唯一性)。
|
||
|
||
真机业务口径:一单一份外部 CK,同一个买家买两件往往是**两份不同的 CK**(uid 不同),
|
||
所以买家指纹只能用探针读到的绑定对象,不能用 uid。
|
||
"""
|
||
|
||
def _order_with_binding(
|
||
self,
|
||
order_no: str,
|
||
uid: str,
|
||
*,
|
||
account: str,
|
||
role: str,
|
||
gift_name: str = "礼包A",
|
||
) -> HuyaExternalOrder:
|
||
order = self._create(
|
||
order_no=order_no, shop="佳佳", gift_name=gift_name, ck_text=fake_ck(uid)
|
||
)
|
||
collab.verify_ck(
|
||
self.db,
|
||
self.user,
|
||
order,
|
||
client=FakeProbeClient(
|
||
FakeBindResp(nick=account, role=role, plat="手Q", area="苹果(iOS)")
|
||
),
|
||
)
|
||
return order
|
||
|
||
def _cluster_of(self, order: HuyaExternalOrder) -> dict | None:
|
||
return collab.buyer_clusters(self.db, [order]).get(int(order.id))
|
||
|
||
def test_two_orders_of_same_buyer_are_grouped(self):
|
||
first = self._order_with_binding(
|
||
"BUY-1", "1199647000001", account="微信用户", role="不打PG打老公"
|
||
)
|
||
second = self._order_with_binding(
|
||
"BUY-2", "1199647000002", account="微信用户", role="不打PG打老公"
|
||
)
|
||
|
||
first_cluster = self._cluster_of(first)
|
||
second_cluster = self._cluster_of(second)
|
||
|
||
assert first_cluster is not None
|
||
assert first_cluster["total"] == 2
|
||
assert first_cluster["key"] == "微信用户 · 不打PG打老公"
|
||
assert [item["order_no"] for item in first_cluster["others"]] == ["BUY-2"]
|
||
assert second_cluster is not None
|
||
assert [item["order_no"] for item in second_cluster["others"]] == ["BUY-1"]
|
||
|
||
def test_same_account_different_role_is_not_grouped(self):
|
||
first = self._order_with_binding(
|
||
"BUY-3", "1199647000003", account="微信用户", role="角色甲"
|
||
)
|
||
self._order_with_binding(
|
||
"BUY-4", "1199647000004", account="微信用户", role="角色乙"
|
||
)
|
||
|
||
assert self._cluster_of(first) is None
|
||
|
||
def test_single_order_of_a_buyer_has_no_cluster(self):
|
||
only = self._order_with_binding(
|
||
"BUY-5", "1199647000005", account="微信用户", role="独苗角色"
|
||
)
|
||
|
||
assert self._cluster_of(only) is None
|
||
|
||
def test_rows_without_probe_snapshot_are_not_grouped(self):
|
||
"""没验证过 CK(绑定账号/角色为空)的行不参与归组,也不会被别人的快照误伤。"""
|
||
verified = self._order_with_binding(
|
||
"BUY-6", "1199647000006", account="微信用户", role="角色丙"
|
||
)
|
||
blank = self._create(
|
||
order_no="BUY-7", shop="佳佳", ck_text=fake_ck("1199647000007")
|
||
)
|
||
|
||
clusters = collab.buyer_clusters(self.db, [blank, verified])
|
||
|
||
assert clusters == {} or int(blank.id) not in clusters
|
||
assert self._cluster_of(verified) is None
|
||
assert collab.buyer_key_of(blank) is None
|
||
|
||
def test_serialize_order_carries_cluster_and_defaults_to_null(self):
|
||
first = self._order_with_binding(
|
||
"BUY-8", "1199647000008", account="微信用户", role="角色丁"
|
||
)
|
||
second = self._order_with_binding(
|
||
"BUY-9", "1199647000009", account="微信用户", role="角色丁"
|
||
)
|
||
clusters = collab.buyer_clusters(self.db, [first, second])
|
||
|
||
payload = collab.serialize_order(
|
||
first, buyer_cluster=clusters.get(int(first.id))
|
||
)
|
||
assert payload["buyer_cluster"]["total"] == 2
|
||
assert payload["buyer_cluster"]["method"] == collab.BUYER_KEY_METHOD
|
||
assert payload["buyer_cluster"]["others"][0]["gift_name"] == "礼包A"
|
||
|
||
alone = collab.serialize_order(first)
|
||
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] = []
|
||
|
||
class CountingClient:
|
||
def check_user_bind_game_account(self, **kwargs):
|
||
calls.append(kwargs)
|
||
return FakeBindResp(nick="随遇而安", role="吃鸡199706")
|
||
|
||
monkeypatch.setattr(collab, "_build_probe_client", lambda: CountingClient())
|
||
collab._probe_cache.clear()
|
||
|
||
order = self._create(order_no="CACHE-1", ck_text=fake_ck("1199647239697"))
|
||
collab.probe_ck(order)
|
||
collab.probe_ck(order)
|
||
|
||
assert len(calls) == 1
|
||
collab._probe_cache.clear()
|
||
|
||
def test_different_ck_is_probed_separately(self, monkeypatch):
|
||
calls: list[dict] = []
|
||
|
||
class CountingClient:
|
||
def check_user_bind_game_account(self, **kwargs):
|
||
calls.append(kwargs)
|
||
return FakeBindResp(nick="随遇而安", role="吃鸡199706")
|
||
|
||
monkeypatch.setattr(collab, "_build_probe_client", lambda: CountingClient())
|
||
collab._probe_cache.clear()
|
||
|
||
first = self._create(order_no="CACHE-2", ck_text=fake_ck("1001"))
|
||
second = self._create(order_no="CACHE-3", ck_text=fake_ck("1002"))
|
||
collab.probe_ck(first)
|
||
collab.probe_ck(second)
|
||
|
||
assert len(calls) == 2
|
||
collab._probe_cache.clear()
|
||
|
||
|
||
class TestCollabPermissions:
|
||
def test_role_defaults_match_the_agreed_matrix(self):
|
||
"""运营能看能改能删;客服能看能改但不能删。"""
|
||
for role in ("operation", "support"):
|
||
assert "huya:collab:view" in ROLE_PERMISSIONS[role]
|
||
assert "huya:collab:edit" in ROLE_PERMISSIONS[role]
|
||
assert "huya:collab:manage" in ROLE_PERMISSIONS["operation"]
|
||
assert "huya:collab:manage" not in ROLE_PERMISSIONS["support"]
|
||
|
||
def test_upload_permissions_needed_by_the_page(self):
|
||
"""页面要上传图 + 预览图,这两个权限必须给到协作的角色。"""
|
||
for role in ("operation", "support"):
|
||
assert "file:view" in ROLE_PERMISSIONS[role]
|
||
assert "file:upload" in ROLE_PERMISSIONS[role]
|
||
|
||
def test_collab_module_does_not_touch_huya_accounts(self):
|
||
"""边界(设计文档 §2.3):本模块不建 HuyaAccount、不写工作台。"""
|
||
assert collab.HuyaExternalOrder.__tablename__ == "huya_external_orders"
|
||
# 协作台账模型里没有任何指向 huya_accounts 的外键
|
||
targets = {
|
||
fk.target_fullname for fk in collab.HuyaExternalOrder.__table__.foreign_keys
|
||
}
|
||
assert targets == {"users.id"}
|
||
assert HuyaWorkbenchAccount.__tablename__ == "huya_workbench_accounts"
|