Files
live-hub-py/tests/test_huya_collab_orders.py
T
yml2213 cad06ae789 feat(huya): 协作台账增加 WebSocket 实时同步
- 后端新增进程内事件总线与 /api/huya/external-orders/ws\n- 所有写接口成功后发布轻量失效事件\n- 前端自动重连、静默刷新、断线兜底轮询
2026-10-17 13:44:27 +08:00

2044 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙外部 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.routers.huya import collab as collab_api
from web.backend.schemas import HuyaExternalOrderUpdate
from web.backend.services import huya_collab_service as collab
from web.backend.services.huya_collab_events import collab_events
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 TestCollabGiftFilter(TestCollabBase):
"""按礼包/皮肤筛选:下拉选项去重,查询按礼包名精确匹配。"""
def test_list_orders_filters_by_exact_gift_name(self):
target = self._create(order_no="G-1", gift_name="天网终结者大礼包")
self._create(order_no="G-2", gift_name="套装-纯白跃动")
rows, total = collab.list_orders(self.db, gift_name="天网终结者大礼包")
assert total == 1
assert [row.id for row in rows] == [target.id]
# 筛选项是点选值,不做模糊匹配(模糊搜索仍走 keyword)
rows, total = collab.list_orders(self.db, gift_name="天网")
assert total == 0
assert rows == []
def test_gift_names_are_unique_sorted_and_ignore_empty(self):
self._create(order_no="G-3", gift_name="套装-纯白跃动")
self._create(order_no="G-4", gift_name="套装-纯白跃动")
self._create(order_no="G-5", gift_name="天网终结者大礼包")
self._create(order_no="G-6", gift_name="")
assert collab.list_gift_names(self.db) == sorted(
["套装-纯白跃动", "天网终结者大礼包"]
)
class TestCollabRealtimeEvents(TestCollabBase):
def test_update_order_publishes_invalidate_event(self, monkeypatch):
order = self._create(order_no="RT-1")
captured: dict = {}
def fake_publish(**kwargs):
captured.update(kwargs)
return kwargs
monkeypatch.setattr(collab_events, "publish", fake_publish)
updated = collab_api.update_external_order(
order.id,
HuyaExternalOrderUpdate(remark="实时更新", expected_version=order.version),
self.db,
self.user,
)
assert updated["remark"] == "实时更新"
assert captured == {
"scope": "orders",
"action": "order.update",
"actor_id": self.user.id,
}
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 TestCollabCkExport(TestCollabBase):
"""勾选导出 CK(写剪贴板):凭据外带要留痕,但审计里不许出现 CK 本体。"""
def test_export_returns_plaintext_and_audits_each_exported_row(self):
first = self._create(order_no="EXP-1", ck_text=fake_ck("1199647000001"))
second = self._create(order_no="EXP-2", ck_text=fake_ck("1199647000002"))
blank = self._create(order_no="EXP-3") # 没贴 CK
result = collab.export_cks(self.db, self.user, [first, second, blank])
assert result["total"] == 3
assert result["exported"] == 2
assert [item["order_no"] for item in result["items"]] == ["EXP-1", "EXP-2"]
# 明文就是库里的那一份(不是脱敏/预览;库里存的是**归一化后**的 CK,
# 所以跟 `fake_ck()` 的原始串不完全一样:重复的 guid 已被去掉)
assert result["items"][0]["ck_text"] == first.cookie
assert "udb_uid=1199647000001" in result["items"][0]["ck_text"]
assert result["items"][0]["ck_text"].count("guid=") == 1
assert result["items"][0]["uid"] == "1199647000001"
# 没 CK 的行显式跳过并回原因,不静默丢
assert [row["order_no"] for row in result["skipped"]] == ["EXP-3"]
assert "CK" in result["skipped"][0]["reason"]
def test_export_audit_never_contains_the_credential_body(self):
order = self._create(order_no="EXP-4", ck_text=fake_ck("1199647000004"))
collab.export_cks(self.db, self.user, [order])
audits = self._audits(order)
assert audits[-1].action == "huya:collab:ck_export"
assert audits[-1].username == self.user.username
assert order.order_no in audits[-1].detail # 只记「导出了哪一单」
# 凭据本体(udb_cred / udb_biztoken 那两段超长值)绝不能进审计
assert "udb_cred" not in audits[-1].detail
assert "C" * 152 not in audits[-1].detail
assert "T" * 200 not in audits[-1].detail
def test_export_skips_blank_rows_without_audit(self):
blank = self._create(order_no="EXP-5")
collab.export_cks(self.db, self.user, [blank])
assert [row.action for row in self._audits(blank)] == ["huya:collab:create"]
def test_export_keeps_void_and_disabled_rows_with_labels(self):
"""作废 / 停用的单只要还有 CK 就照导 —— 但状态标签要一起给,由人自己判断。"""
disabled = self._create(order_no="EXP-6", ck_text=fake_ck("1199647000006"))
collab.set_ck_status(
self.db, self.user, disabled, "disabled", message="不再使用"
)
voided = self._create(order_no="EXP-7", ck_text=fake_ck("1199647000007"))
collab.set_status(self.db, self.user, voided, "void", reason="客户退单")
result = collab.export_cks(self.db, self.user, [disabled, voided])
assert result["exported"] == 2
by_no = {item["order_no"]: item for item in result["items"]}
assert by_no["EXP-6"]["ck_status_label"] == "已停用"
assert by_no["EXP-7"]["status_label"] == "已作废"
assert by_no["EXP-7"]["status"] == "void"
def test_export_rejects_empty_and_too_many(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.export_cks(self.db, self.user, [])
assert excinfo.value.code == "ck_export_empty"
with pytest.raises(collab.CollabError) as excinfo:
collab.export_cks(self.db, self.user, [None] * (collab.PROBE_BATCH_MAX + 1))
assert excinfo.value.code == "ck_export_too_large"
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_ck_export_is_operation_only_by_default(self):
"""导出 CK = 把凭据带出系统:默认只给运营(要发给客服就在权限页单独勾)。"""
assert "huya:collab:ck_export" in ROLE_PERMISSIONS["operation"]
assert "huya:collab:ck_export" not in ROLE_PERMISSIONS["support"]
assert "huya:collab:ck_export" in ROLE_PERMISSIONS["super_admin"]
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"