Files
live-hub-py/tests/test_huya_collab_orders.py
T

3151 lines
122 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 core.huya.taf_protocol import TafOutputStream, TafType
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 HuyaExternalOrderListOut, 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:
"""把一行补到「待充值」:CK 有效 + 绑定账号/角色 + 礼包 + 主页截图。
2026-10-19 新口径:资料齐全且 CK 有效后由服务层自动推进到 `to_recharge`,
不再存在 `pending` / `bound` 两个人工状态。
"""
if not order.gift_name:
collab.update_order(
self.db,
self.user,
order,
{"gift_name": "不朽伊人大礼包", "required_points": 8000},
)
collab.set_images(
self.db,
self.user,
order,
"home_images",
["collab/2026/10/16/home01.png"],
)
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="微信"
)
),
)
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 == collab.STATUS_INCOMPLETE
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):
"""人工切换允许**往前跳步**,回退必须显式 rollback。"""
order = self._bind_ready(self._create())
assert order.status == collab.STATUS_TO_RECHARGE
# 待充值 → 已上脚本(跳过「已充值」)是允许的
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, "to_recharge")
assert excinfo.value.code == "status_not_forward"
assert order.status == "scripted"
def test_to_recharge_requires_complete_customer_info_and_valid_ck(self):
order = self._create()
# 空单:直接往前会被资料完整性挡住,错误信息必须逐项说清
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
message = excinfo.value.detail
assert "外部 CK" in message
assert "绑定账号" in message
assert "绑定角色" in message
assert "礼包名" in message
assert "主页截图" in message
# 只贴 CK + 读到角色,但没礼包 / 主页截图 → 仍然停在待补资料
collab.set_ck(self.db, self.user, order, fake_ck("1199647001234"))
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")),
)
assert order.status == collab.STATUS_INCOMPLETE
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
assert "礼包名" in excinfo.value.detail
assert "主页截图" in excinfo.value.detail
assert "绑定图" not in excinfo.value.detail # 绑定图已停用,不该再被要求
# 资料补齐后由 set_images 自动推进(不要求用户再点一次状态)
collab.update_order(
self.db,
self.user,
order,
{"gift_name": "不朽伊人大礼包", "required_points": 8000},
)
assert order.status == collab.STATUS_INCOMPLETE
collab.set_images(
self.db, self.user, order, "home_images", ["collab/2026/10/16/home01.png"]
)
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bound_at is not None
def test_auto_advance_waits_for_valid_ck(self):
order = self._create(
order_no="AUTO-CK",
gift_name="不朽伊人大礼包",
required_points=8000,
home_images=["collab/2026/10/16/home01.png"],
)
collab.set_ck(self.db, self.user, order, fake_ck("1199647001234"))
# 只贴 CK 不验证:资料看似齐全,但 CK 仍是 unverified,不能进待充值。
# 绑定快照通常由探针给;这里直接塞进 ORM 对象,单独验证「CK 必须有效」这条。
order.bind_game_account = "微信用户"
order.bind_game_role = "吃鸡199706"
self.db.commit()
assert order.ck_status == "unverified"
assert order.status == collab.STATUS_INCOMPLETE
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
assert "CK 验证有效" in excinfo.value.detail
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(FakeBindResp(nick="微信用户", role="吃鸡199706")),
)
assert order.status == collab.STATUS_TO_RECHARGE
def test_forward_flow_sets_timestamps(self):
order = self._bind_ready(self._create())
assert order.status == collab.STATUS_TO_RECHARGE
collab.set_status(
self.db, self.user, order, collab.STATUS_RECHARGED, allow_recharge=True
)
collab.set_status(self.db, self.user, order, "scripted")
collab.set_status(self.db, self.user, order, "done")
assert order.status == "done"
assert order.bound_at is not None
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))
collab.set_status(
self.db, self.user, order, collab.STATUS_RECHARGED, allow_recharge=True
)
for target in ("scripted", "done"):
collab.set_status(self.db, self.user, order, target)
after = self._audits(order)
assert len(after) - before == 3
status_audits = [row for row in after if row.action == "huya:collab:status"]
assert len(status_audits) == 3
def test_rollback_requires_flag_and_clears_later_timestamps(self):
order = self._bind_ready(self._create())
collab.set_status(
self.db, self.user, order, collab.STATUS_RECHARGED, allow_recharge=True
)
for target in ("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,
collab.STATUS_RECHARGED,
allow_recharge=True,
)
collab.set_status(
self.db,
self.user,
order,
collab.STATUS_RECHARGED,
rollback=True,
allow_recharge=True,
)
assert order.status == collab.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, "incomplete", 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, collab.STATUS_TO_RECHARGE)
== {}
)
# 幂等点击不该刷出审计噪声
assert len(self._audits(order)) == before
def test_direct_recharged_and_points_error_require_recharge_flow(self):
order = self._bind_ready(self._create())
for target in (collab.STATUS_RECHARGED, collab.STATUS_POINTS_ERROR):
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, target)
assert excinfo.value.code == "recharge_check_required"
assert order.status == collab.STATUS_TO_RECHARGE
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}
def points_center_blob(
*,
name: str = "不朽伊人大礼包",
product_id: str = "hy-1001",
points: int = 200,
stock: int = 39,
category: str = "礼包",
) -> bytes:
"""构造 queryAllocation 的 ``tag5`` 最小商品列表(TAF LIST)。"""
os = TafOutputStream()
os.write_head(0, TafType.LIST)
os.write_int32(0, 1)
# 分类
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_string(1, category)
os.write_head(6, TafType.LIST)
os.write_int32(0, 1)
# 商品
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_head(1, TafType.STRUCT_BEGIN)
# 商品基础信息 tag0
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_string(0, product_id)
os.write_string(2, name)
os.write_string(4, category)
os.write_string(5, "")
os.write_int64(9, points * 10)
os.write_int64(13, stock)
# 积分 tag75.tag0.tag2
os.write_head(75, TafType.STRUCT_BEGIN)
os.write_head(0, TafType.STRUCT_BEGIN)
os.write_int64(2, points)
os.write_struct_end()
os.write_struct_end()
os.write_struct_end() # tag0
os.write_struct_end() # tag1
os.write_struct_end() # 商品
os.write_struct_end() # 分类
return os.get_bytes()
class FakePointsCenterResp:
"""合成一份 ``PlayMallUI.queryAllocation`` 响应。"""
def __init__(
self,
*,
code: int = 200,
message: str = "成功",
payload: dict | None = None,
goods: list | None = None,
):
if payload is None:
if goods is None:
goods = [
{
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"points": 200,
"stock": 39,
"category": "礼包",
}
]
blobs = [points_center_blob(**item) for item in goods]
payload = {"tag2": [{"tag5": blob} for blob in blobs]}
self.code = code
self.message = message
self.payload = payload
def to_dict(self) -> dict:
return {"code": self.code, "message": self.message, "payload": self.payload}
class FakeProbeClient:
"""离线探针客户端:按脚本依次给出 ``None`` / 异常 / 响应,并记录调用参数。"""
def __init__(
self, *outcomes, gifts: list | None = None, points_gifts: list | None = None
):
self.outcomes = list(outcomes)
self.gift_outcomes = list(gifts or [])
self.point_outcomes = list(points_gifts or [])
self.calls: list[dict] = []
self.gift_calls: list[dict] = []
self.points_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
def play_mall_query_allocation(self, **kwargs):
self.points_calls.append(kwargs)
outcome = (
self.point_outcomes.pop(0)
if self.point_outcomes
else FakePointsCenterResp()
)
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_recharge_precondition(self):
"""探针读到绑定角色 + 礼包 + 主页截图 → 自动进入「待充值」。"""
order = self._row_with_ck()
collab.update_order(
self.db,
self.user,
order,
{"gift_name": "不朽伊人大礼包", "required_points": 8000},
)
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 == []
assert order.status == collab.STATUS_TO_RECHARGE
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 失效不改业务状态:待充值的单子业务上仍然要等人处理。"""
order = self._row_with_ck()
self._bind_ready(order)
assert order.status == collab.STATUS_TO_RECHARGE
collab.verify_ck(
self.db, self.user, order, client=FakeProbeClient(FakeBindResp(status=401))
)
assert order.ck_status == "invalid"
assert order.status == collab.STATUS_TO_RECHARGE
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": 39,
"score": 200,
"category_name": "礼包",
"icon": "",
}
]
assert "已拉取 1 个礼包(积分中心)" in preview["gifts_message"]
assert client.points_calls[0]["uid"] == 1199647239697
assert client.points_calls[0]["deep"] is True
assert client.gift_calls == []
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.points_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_points_center_gift_fetch_parses_fields(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(),
)
assert gifts == [
{
"product_id": "hy-1001",
"name": "不朽伊人大礼包",
"price": 200,
"remain_text": "剩39",
"stock": 39,
"score": 200,
"category_name": "礼包",
"icon": "",
}
]
assert message == "已拉取 1 个礼包(积分中心)"
def test_points_center_gift_fetch_reports_failure(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(
points_gifts=[FakePointsCenterResp(code=500, message="接口炸了")]
),
)
assert gifts == []
assert "拉取礼包失败" in message and "500" in message
def test_points_center_gift_fetch_survives_exception(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(points_gifts=[RuntimeError("boom")]),
)
assert gifts == []
assert "RuntimeError" in message
def test_points_center_gift_fetch_empty_list(self):
gifts, message = collab.fetch_points_center_gifts(
1199647239697,
fake_ck("1199647239697"),
client=FakeProbeClient(points_gifts=[FakePointsCenterResp(goods=[])]),
)
assert gifts == []
assert "积分中心" in message and "空的" in message
def test_points_center_parser_reads_tag75_points(self):
from core.huya.shop_structs import parse_points_center_gifts
gifts = parse_points_center_gifts(
{"tag2": [{"tag5": points_center_blob(points=888, stock=7)}]}
)
assert gifts[0]["price"] == 888
assert gifts[0]["score"] == 888
assert gifts[0]["stock"] == 7
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_ignores_legacy_sid_and_uses_points_center(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.points_calls
assert "sid" not in client.points_calls[0]
assert preview["has_role"] is True
def test_preview_endpoint_uses_points_center_source(self, monkeypatch):
"""接口层仍可带旧 SID 配置,但预览实际走积分中心 queryAllocation。"""
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.points_calls
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"
def test_draft_needs_only_order_no_and_shop(self):
"""先建单后补资料:只填订单号 + 店铺,状态直接进「待补资料」。"""
order = collab.create_order(
self.db,
self.user,
order_no="DRAFT-1",
shop="佳佳",
draft=True,
require_verified=True, # 草稿模式必须压过严格模式,不跑探针
)
assert order.status == collab.STATUS_INCOMPLETE
assert order.shop == "佳佳"
assert order.cookie == ""
assert order.gift_name == ""
assert order.home_images == []
assert order.ck_status == "unverified"
assert self._row_count() == 1
detail = self._audits(order)[-1].detail
assert '"draft"' in detail and "true" in detail
assert collab.summary(self.db)["by_status"]["incomplete"] == 1
def test_incomplete_cannot_advance_until_customer_info_complete(self):
order = collab.create_order(
self.db, self.user, order_no="DRAFT-2", shop="佳佳", draft=True
)
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(self.db, self.user, order, collab.STATUS_TO_RECHARGE)
assert excinfo.value.code == "incomplete_precondition"
assert "外部 CK" in excinfo.value.detail
assert "礼包名" in excinfo.value.detail
assert "主页截图" in excinfo.value.detail
assert order.status == collab.STATUS_INCOMPLETE
def test_incomplete_advances_after_info_complete(self):
order = collab.create_order(
self.db,
self.user,
order_no="DRAFT-3",
shop="佳佳",
gift_name="不朽伊人大礼包",
required_points=8000,
home_images=[self.HOME_IMAGE],
draft=True,
)
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")),
)
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bound_at is not None
assert order.void_reason == ""
class TestCollabOrderSorting(TestCollabBase):
"""列表默认按创建顺序;表头排序走白名单,升/降序都稳定。"""
def test_default_order_is_creation_order(self):
first = self._create(order_no="SORT-1", shop="乙")
second = self._create(order_no="SORT-2", shop="甲")
rows, total = collab.list_orders(self.db)
assert total == 2
assert [row.id for row in rows] == [first.id, second.id]
def test_sort_by_order_no_ascending_and_descending(self):
self._create(order_no="SORT-B")
self._create(order_no="SORT-A")
rows, _ = collab.list_orders(self.db, sort_by="order_no", sort_order="asc")
assert [row.order_no for row in rows] == ["SORT-A", "SORT-B"]
rows, _ = collab.list_orders(self.db, sort_by="order_no", sort_order="desc")
assert [row.order_no for row in rows] == ["SORT-B", "SORT-A"]
def test_sort_by_owner_username(self):
self._create(order_no="SORT-OWNER-1", owner_id=self.user.id)
self._create(order_no="SORT-OWNER-2", owner_id=self.other.id)
rows, _ = collab.list_orders(self.db, sort_by="owner", sort_order="asc")
assert [row.owner_id for row in rows] == [self.user.id, self.other.id]
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 FakeRechargeClient(FakeProbeClient, FakeActivityClient):
"""充值核账用:同一轮里既要跑绑定探针,又要查积分/礼包目录。
``scores`` 按 uid 调用顺序出队;不传就所有 uid 用同一个 ``score``。
"""
def __init__(
self,
*outcomes,
score: object = None,
scores: list[object] | None = None,
points_gifts: list[object] | None = None,
):
FakeProbeClient.__init__(self, *outcomes, points_gifts=points_gifts)
FakeActivityClient.__init__(self, score=score)
self.scores = list(scores or [])
def query_user_score(self, **kwargs):
if not self.scores:
return super().query_user_score(**kwargs)
self.score_calls.append(kwargs)
outcome = self.scores.pop(0)
if isinstance(outcome, Exception):
raise outcome
return outcome
class TestCollabRecharge(TestCollabBase):
"""充值核账:按 bind_game_account 整组校验 CK + 积分,整组落状态。"""
BIND = "同一个买家"
ROLE = "角色A"
HOME_IMAGE = "collab/2026/10/19/home01.png"
def _ready(
self,
order_no: str,
uid: str,
*,
gift_name: str,
required_points: int | None,
role: str = ROLE,
account: str = BIND,
) -> HuyaExternalOrder:
order = self._create(
order_no=order_no,
ck_text=fake_ck(uid),
gift_name=gift_name,
required_points=required_points,
home_images=[self.HOME_IMAGE],
)
collab.verify_ck(
self.db,
self.user,
order,
client=FakeProbeClient(
FakeBindResp(nick=account, role=role, plat="苹果(iOS)", area="微信")
),
)
assert order.status == collab.STATUS_TO_RECHARGE
assert order.bind_game_account == account
return order
@staticmethod
def _score(value: int) -> FakeScoreResp:
return FakeScoreResp(gained=max(0, value), used=0)
def _check(self, orders, client, *, threshold: int = 100):
return collab.check_recharge_groups(
self.db,
self.user,
orders,
threshold=threshold,
client=client,
)
def test_success_recharges_whole_group_and_snapshots_every_order(self):
first = self._ready("RC-1", "1001", gift_name="礼包A", required_points=200)
second = self._ready("RC-2", "1001", gift_name="礼包B", required_points=300)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(550),
)
result = self._check([first, second], client)
assert result["updated"] == 2 and result["failed"] == 0
assert len(result["groups"]) == 1
group = result["groups"][0]
assert group["status"] == collab.STATUS_RECHARGED
assert group["balance"] == 550
assert group["required_total"] == 500
assert group["surplus"] == 50
assert first.status == second.status == collab.STATUS_RECHARGED
for order in (first, second):
assert order.points_checked_at is not None
assert order.points_balance == 550
assert order.points_required_total == 500
assert order.points_surplus == 50
assert order.points_threshold == 100
assert order.points_check_reason == "ok"
assert (
order.points_check_detail["group_key"] == f"{self.BIND} / {self.ROLE}"
)
# 同一 uid 只验一次 CK、只查一次积分
assert len(client.calls) == 1
assert len(client.score_calls) == 1
assert "huya:collab:recharge_check" in [
row.action for row in self._audits(first)
]
def test_threshold_boundary_is_allowed(self):
order = self._ready("RC-3", "1002", gift_name="礼包A", required_points=500)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(600),
)
result = self._check([order], client, threshold=100)
assert result["groups"][0]["surplus"] == 100
assert order.status == collab.STATUS_RECHARGED
assert order.points_check_reason == "ok"
def test_shortage_marks_whole_group_points_error(self):
first = self._ready("RC-4", "1003", gift_name="礼包A", required_points=200)
second = self._ready("RC-5", "1003", gift_name="礼包B", required_points=300)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(400),
)
result = self._check([first, second], client)
assert result["groups"][0]["reason"] == "shortage"
assert result["groups"][0]["surplus"] == -100
assert first.status == second.status == collab.STATUS_POINTS_ERROR
assert "还差 100 分" in first.points_check_message
assert first.points_check_reason == second.points_check_reason == "shortage"
def test_excess_marks_whole_group_points_error(self):
order = self._ready("RC-6", "1004", gift_name="礼包A", required_points=500)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(650),
)
result = self._check([order], client, threshold=100)
assert result["groups"][0]["reason"] == "excess"
assert result["groups"][0]["surplus"] == 150
assert order.status == collab.STATUS_POINTS_ERROR
assert "多出 150 分" in order.points_check_message
def test_multiple_uids_are_summed_and_each_queried_once(self):
first = self._ready("RC-7", "1005", gift_name="礼包A", required_points=300)
second = self._ready("RC-8", "1006", gift_name="礼包B", required_points=300)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role="角色A"),
FakeBindResp(nick=self.BIND, role="角色A"),
scores=[self._score(300), self._score(400)],
)
result = self._check([first, second], client)
assert result["groups"][0]["balance"] == 700
assert result["groups"][0]["surplus"] == 100
assert first.status == second.status == collab.STATUS_RECHARGED
assert [call["uid"] for call in client.score_calls] == [1005, 1006]
def test_same_generic_nickname_with_different_roles_are_separate_groups(self):
"""生产回归:bind_game_account 可能都是「微信用户」,必须再按角色分组。
只按账号分组会把不同买家合成一组,required_total 被放大成 48000。
"""
first = self._ready(
"RC-NICK-1",
"1013",
gift_name="礼包A",
required_points=200,
role="角色A",
account="微信用户",
)
second = self._ready(
"RC-NICK-2",
"1014",
gift_name="礼包B",
required_points=200,
role="角色B",
account="微信用户",
)
client = FakeRechargeClient(
FakeBindResp(nick="微信用户", role="角色A"),
FakeBindResp(nick="微信用户", role="角色B"),
scores=[self._score(250), self._score(300)],
)
result = self._check([first, second], client)
assert len(result["groups"]) == 2
assert {group["balance"] for group in result["groups"]} == {250, 300}
assert {group["required_total"] for group in result["groups"]} == {200}
assert first.status == second.status == collab.STATUS_RECHARGED
assert first.points_required_total == second.points_required_total == 200
assert first.points_check_detail["group_key"] == "微信用户 / 角色A"
assert second.points_check_detail["group_key"] == "微信用户 / 角色B"
def test_ck_invalid_keeps_group_status_unchanged(self):
order = self._ready("RC-9", "1007", gift_name="礼包A", required_points=200)
client = FakeRechargeClient(
FakeBindResp(status=401, msg="登录态过期"),
score=self._score(200),
)
result = self._check([order], client)
assert result["failed"] == 1
assert result["groups"][0]["status"] == ""
assert "CK 验证失败" in result["groups"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
assert order.points_checked_at is None
def test_points_query_failure_keeps_group_status_unchanged(self):
order = self._ready("RC-10", "1008", gift_name="礼包A", required_points=200)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=RuntimeError("boom"),
)
result = self._check([order], client)
assert result["failed"] == 1
assert "积分查询失败" in result["groups"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
assert order.points_checked_at is None
def test_missing_required_points_can_be_resolved_from_gift_catalog(self):
order = self._ready(
"RC-11", "1009", gift_name="不朽伊人大礼包", required_points=None
)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(200),
points_gifts=[FakePointsCenterResp()],
)
result = self._check([order], client)
assert order.required_points == 200
assert result["groups"][0]["required_total"] == 200
assert result["groups"][0]["surplus"] == 0
assert order.status == collab.STATUS_RECHARGED
assert len(client.points_calls) == 1
def test_unresolved_required_points_keeps_status_unchanged(self):
order = self._ready(
"RC-12", "1010", gift_name="目录里没有的礼包", required_points=None
)
client = FakeRechargeClient(
FakeBindResp(nick=self.BIND, role=self.ROLE),
score=self._score(200),
points_gifts=[FakePointsCenterResp()],
)
result = self._check([order], client)
assert result["failed"] == 1
assert "缺少礼包所需积分" in result["groups"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
assert order.required_points is None
def test_group_order_without_cookie_blocks_whole_group(self):
order = self._ready("RC-14", "1012", gift_name="礼包A", required_points=200)
order.cookie = ""
self.db.commit()
result = self._check([order], FakeRechargeClient())
assert result["failed"] == 1
assert "组内订单缺少 CK" in result["groups"][0]["message"]
assert order.status == collab.STATUS_TO_RECHARGE
def test_missing_bind_account_is_demoted_to_incomplete(self):
order = self._ready("RC-13", "1011", gift_name="礼包A", required_points=200)
order.bind_game_account = ""
self.db.commit()
result = self._check([order], FakeRechargeClient())
assert result["updated"] == 0
assert result["failed"] == 1
assert "缺少绑定账号" in result["failures"][0]["message"]
assert order.status == collab.STATUS_INCOMPLETE
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], "scripted"
)
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 "外部 CK" in by_no["BATCH-4"]["message"]
assert ready.status == "scripted"
assert not_ready.status == collab.STATUS_INCOMPLETE
def test_batch_rejects_reconcile_statuses(self):
order = self._bind_ready(self._create(order_no="BATCH-5"))
for target in (collab.STATUS_RECHARGED, collab.STATUS_POINTS_ERROR):
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status_batch(self.db, self.user, [order], target)
assert excinfo.value.code == "recharge_check_required"
assert order.status == collab.STATUS_TO_RECHARGE
def test_batch_backward_needs_rollback_flag(self):
order = self._bind_ready(self._create(order_no="BATCH-6"))
collab.set_status(self.db, self.user, order, "scripted")
rejected = collab.set_status_batch(
self.db, self.user, [order], collab.STATUS_TO_RECHARGE
)
assert rejected["failed"] == 1
assert order.status == "scripted"
accepted = collab.set_status_batch(
self.db,
self.user,
[order],
collab.STATUS_TO_RECHARGE,
rollback=True,
)
assert accepted["updated"] == 1
assert order.status == collab.STATUS_TO_RECHARGE
def test_batch_rejects_empty_and_too_many(self):
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status_batch(self.db, self.user, [], "scripted")
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),
"scripted",
)
assert excinfo.value.code == "status_batch_too_large"
class TestCollabArchive(TestCollabBase):
"""归档是可见性状态:不影响业务状态,默认隐藏,归档视图可搜索。"""
def _done(self, order_no: str, **kwargs) -> HuyaExternalOrder:
order = self._create(order_no=order_no, **kwargs)
order.status = collab.STATUS_DONE
self.db.commit()
self.db.refresh(order)
return order
def test_archive_hides_by_default_but_is_searchable_in_archive_scope(self):
archived = self._done("ARCH-1", remark="客户已验收")
active = self._create(order_no="ARCH-2")
result = collab.archive_orders(
self.db, self.user, [archived], reason="活动结束,完成对账"
)
assert result["archived"] == 1
assert archived.archived_at is not None
assert archived.archive_reason == "活动结束,完成对账"
assert archived.status == collab.STATUS_DONE
rows, total = collab.list_orders(self.db)
assert total == 1
assert [row.id for row in rows] == [active.id]
rows, total = collab.list_orders(
self.db, archive_scope="archived", keyword="客户已验收"
)
assert total == 1
assert [row.id for row in rows] == [archived.id]
default_summary = collab.summary(self.db)
archived_summary = collab.summary(self.db, archive_scope="archived")
assert default_summary["by_status"]["done"] == 0
assert default_summary["archived_total"] == 1
assert archived_summary["total"] == 1
audit = self._audits(archived)[-1]
assert audit.action == "huya:collab:archive"
assert "完成对账" in audit.detail
def test_only_done_orders_can_be_archived(self):
pending = self._create(order_no="ARCH-PENDING")
result = collab.archive_orders(self.db, self.user, [pending])
assert result["archived"] == 0
assert result["skipped"] == 1
assert "抢购成功" in result["results"][0]["message"]
assert pending.archived_at is None
def test_archived_order_is_read_only_until_unarchived(self):
order = self._done("ARCH-READONLY")
collab.archive_orders(self.db, self.user, [order])
with pytest.raises(collab.CollabError) as excinfo:
collab.set_status(
self.db,
self.user,
order,
collab.STATUS_INCOMPLETE,
rollback=True,
)
assert excinfo.value.code == "order_archived"
with pytest.raises(collab.CollabError) as excinfo:
collab.update_order(
self.db,
self.user,
order,
{"remark": "归档后不应该能改"},
expected_version=order.version,
)
assert excinfo.value.code == "order_archived"
def test_unarchive_restores_row_and_clears_archive_note(self):
order = self._done("ARCH-UNARCHIVE")
collab.archive_orders(self.db, self.user, [order], reason="临时隐藏")
result = collab.unarchive_orders(self.db, self.user, [order])
assert result["unarchived"] == 1
assert order.archived_at is None
assert order.archived_by is None
assert order.archive_reason == ""
rows, total = collab.list_orders(self.db)
assert total == 1
assert [row.id for row in rows] == [order.id]
assert self._audits(order)[-1].action == "huya:collab:unarchive"
def test_archive_by_filter_checks_expected_count_and_only_archives_done(self):
first = self._done("ARCH-FILTER-1", shop="佳佳")
second = self._done("ARCH-FILTER-2", shop="佳佳")
self._create(order_no="ARCH-FILTER-PENDING", shop="佳佳")
with pytest.raises(collab.CollabError) as excinfo:
collab.archive_orders_by_filter(
self.db,
self.user,
expected_count=1,
shop="佳佳",
status=collab.STATUS_DONE,
)
assert excinfo.value.code == "archive_count_changed"
result = collab.archive_orders_by_filter(
self.db,
self.user,
expected_count=2,
shop="佳佳",
status=collab.STATUS_DONE,
reason="批量清理",
)
assert result["archived"] == 2
assert first.archived_at is not None
assert second.archived_at is not None
_, total = collab.list_orders(self.db, shop="佳佳", archive_scope="active")
assert total == 1
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 or "占位礼包")
)
collab.set_status(self.db, self.user, order, "scripted")
if not gift_name:
collab.update_order(self.db, self.user, order, {"gift_name": ""})
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="不朽伊人大礼包")
)
assert order.status == collab.STATUS_TO_RECHARGE
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 == collab.STATUS_TO_RECHARGE
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 == collab.STATUS_INCOMPLETE
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 TestCollabActivityScopeSplit(TestCollabBase):
"""批量「查积分 / 查兑换记录」拆成两个按钮(2026-10-17 用户要求)。
重点全在**互相不冲掉**上:
- 只查积分时:兑换记录一个请求都不发,判定留空(≠「无法判定」),**绝不自动改状态**;
- 只查兑换记录时:不许把列表上的积分清成空;
- 列表上的「判定徽标」取**最近一条真的查过兑换记录**的快照 —— 否则点一次
「查积分」就会把整页「兑换成功 / 兑换失败」冲成「未判定」;
- 但**新的 unknown(查了判不了)必须照样盖掉旧的 success**,
不能"为了保住徽标"就把抖动的结果藏起来。
"""
def _scripted_order(
self, order_no: str, *, gift_name: str = "不朽伊人大礼包"
) -> HuyaExternalOrder:
order = self._bind_ready(self._create(order_no=order_no, gift_name=gift_name))
collab.set_status(self.db, self.user, order, "scripted")
return order
def _row_payload(self, order: HuyaExternalOrder) -> dict:
"""列表接口给前端的那份输出(走 order_metric_views,与真实路由同一条路径)。"""
view = collab.order_metric_views(self.db, [order]).get(int(order.id))
return collab.serialize_order(order, metric_view=view)
def test_score_scope_only_calls_the_score_api(self):
order = self._scripted_order("SCOPE-1")
client = FakeActivityClient()
result = collab.query_order_activity(
self.db, self.user, order, scope="score", client=client
)
assert len(client.score_calls) == 1
assert client.record_calls == [] # 兑换记录接口一次都没打
assert result["scope"] == "score"
assert result["verdict"] == collab.VERDICT_NOT_QUERIED == ""
assert result["score"] == 1234
assert result["ok"] is True
assert result["message"].startswith("积分 1234")
metric = collab.list_metrics(self.db, order)[0]
assert metric.score == 1234
assert metric.verdict == ""
assert metric.raw["scope"] == "score"
def test_score_scope_never_auto_changes_status(self):
"""反向验证:这单的兑换记录里**确实有**它的礼包(旧口径会判「抢购成功」),
但只查积分时连记录都没查 —— 状态必须一动不动,也不许给出任何判定。"""
order = self._scripted_order("SCOPE-2")
client = FakeActivityClient() # 默认记录里就有「不朽伊人大礼包」
result = collab.query_order_activity(
self.db, self.user, order, scope="score", client=client
)
assert result["auto_status"] == ""
assert order.status == "scripted"
assert order.done_at is None
assert result["verdict"] == ""
def test_records_scope_only_calls_the_records_api(self):
order = self._scripted_order("SCOPE-3")
client = FakeActivityClient()
result = collab.query_order_activity(
self.db, self.user, order, scope="records", client=client
)
assert client.score_calls == [] # 积分接口一次都没打
assert len(client.record_calls) == 1
assert result["verdict"] == collab.VERDICT_SUCCESS
assert result["auto_status"] == "done"
assert order.status == "done"
metric = collab.list_metrics(self.db, order)[0]
assert metric.score is None # 本次没查积分:不许把旧积分抄一份进来冒充
assert metric.verdict == "success"
def test_score_only_query_keeps_the_previous_verdict_on_the_list(self):
order = self._scripted_order("SCOPE-4")
collab.query_order_activity(
self.db, self.user, order, scope="records", client=FakeActivityClient()
)
assert order.status == "done"
collab.query_order_activity(
self.db,
self.user,
order,
scope="score",
client=FakeActivityClient(
score=FakeScoreResp(score=200, gained=5000, used=4800)
),
)
payload = self._row_payload(order)
assert payload["last_score"] == 200 # 积分是新的
assert payload["last_verdict"] == "success" # 判定还是上一次那条
assert payload["last_verdict_label"] == "兑换成功"
assert payload["last_verdict_at"] is not None
assert payload["last_verdict_message"]
assert "积分 200" in payload["last_metric_message"]
def test_records_only_query_keeps_the_previous_score_on_the_list(self):
order = self._scripted_order("SCOPE-5")
# 注意 FakeScoreResp 的可用积分是按 gained - used 算的,score 参数不生效
collab.query_order_activity(
self.db,
self.user,
order,
scope="score",
client=FakeActivityClient(score=FakeScoreResp(gained=5000, used=4223)),
)
assert self._row_payload(order)["last_score"] == 777
collab.query_order_activity(
self.db, self.user, order, scope="records", client=FakeActivityClient()
)
payload = self._row_payload(order)
assert payload["last_score"] == 777 # 积分没被这次查询清空
assert payload["last_verdict"] == "success" # 判定是这次的
def test_newer_unknown_verdict_still_overrides_older_success(self):
"""反向验证的另一半:这次接口抖了、**判不了**(unknown),
列表绝不能继续挂着上一次的「兑换成功」—— 那等于把失败藏起来。"""
order = self._scripted_order("SCOPE-6")
collab.query_order_activity(
self.db, self.user, order, scope="records", client=FakeActivityClient()
)
collab.query_order_activity(
self.db,
self.user,
order,
scope="records",
client=FakeActivityClient(
records=FakeRecordsResp(status=500, msg="活动已结束")
),
)
payload = self._row_payload(order)
assert payload["last_verdict"] == "unknown"
assert payload["last_verdict_label"] == "无法判定"
def test_metric_timeline_reports_which_half_was_queried(self):
order = self._scripted_order("SCOPE-7")
collab.query_order_activity(
self.db, self.user, order, scope="score", client=FakeActivityClient()
)
collab.query_order_activity(
self.db, self.user, order, scope="records", client=FakeActivityClient()
)
rows = [
collab.serialize_metric(row) for row in collab.list_metrics(self.db, order)
]
# 时间线倒序:最近一次在前
assert [row["scope"] for row in rows] == ["records", "score"]
assert (
rows[0]["verdict"] == "success" and rows[0]["verdict_label"] == "兑换成功"
)
# 只查积分那条:判定留空 + 明确叫「未判定」,不是「无法判定」
assert rows[1]["verdict"] == ""
assert rows[1]["verdict_label"] == "未判定"
assert rows[1]["records_total"] == 0
assert rows[1]["verdict_label"] != collab.VERDICT_LABELS[collab.VERDICT_UNKNOWN]
def test_batch_score_scope_counts_rows_as_not_judged(self):
first = self._scripted_order("SCOPE-8")
second = self._scripted_order("SCOPE-9")
result = collab.query_activity_batch(
self.db,
self.user,
[first, second],
scope="score",
client=FakeActivityClient(),
interval_seconds=0,
)
assert result["total"] == 2
assert result["not_judged"] == 2
assert result["unknown"] == 0 # 「没查」不算「查了判不了」
assert result["success"] == 0 and result["failed"] == 0
assert result["auto_changed"] == 0
assert first.status == second.status == "scripted"
def test_batch_rejects_unknown_scope(self):
order = self._scripted_order("SCOPE-10")
with pytest.raises(collab.CollabError) as excinfo:
collab.query_activity_batch(
self.db,
self.user,
[order],
scope="everything",
client=FakeActivityClient(),
interval_seconds=0,
)
assert excinfo.value.code == "activity_scope_unknown"
def test_router_passes_scope_through_and_rejects_unknown(self, monkeypatch):
order = self._scripted_order("SCOPE-11")
client = FakeActivityClient()
monkeypatch.setattr(collab, "_build_probe_client", lambda: client)
result = collab_api.batch_query_external_order_activity(
collab_api.HuyaExternalOrderActivityQueryRequest(
ids=[int(order.id)], scope="score"
),
self.db,
self.user,
)
assert client.record_calls == []
assert result["scope"] == "score" and result["scope_label"] == "积分"
assert result["not_judged"] == 1
assert result["results"][0]["verdict_label"] == "未判定"
with pytest.raises(collab_api.HTTPException) as excinfo:
collab_api.batch_query_external_order_activity(
collab_api.HuyaExternalOrderActivityQueryRequest(
ids=[int(order.id)], scope="nope"
),
self.db,
self.user,
)
assert excinfo.value.status_code == 400
def test_default_scope_still_queries_both(self):
"""默认(不传 scope)保持原行为:老的调用方一个字段不加也照旧。"""
order = self._scripted_order("SCOPE-12")
client = FakeActivityClient()
result = collab.query_order_activity(self.db, self.user, order, client=client)
assert len(client.score_calls) == 1 and len(client.record_calls) == 1
assert result["scope"] == "both"
assert result["verdict"] == collab.VERDICT_SUCCESS
assert result["auto_status"] == "done"
def test_new_list_fields_survive_pydantic_serialization(self):
"""过一遍真实的响应模型(坑 #2:字段声明了、`model_dump()` 里没有 = 假绿)。"""
order = self._scripted_order("SCOPE-13")
collab.query_order_activity(
self.db, self.user, order, scope="records", client=FakeActivityClient()
)
collab.query_order_activity(
self.db,
self.user,
order,
scope="score",
client=FakeActivityClient(score=FakeScoreResp(gained=5000, used=4800)),
)
dumped = HuyaExternalOrderListOut(
items=[collab_api._serialize_one(self.db, order)],
total=1,
page=1,
page_size=50,
).model_dump()
row = dumped["items"][0]
assert row["last_score"] == 200 # 查积分那次写的
assert row["last_verdict"] == "success" # 判定停在查兑换记录那次
assert row["last_verdict_label"] == "兑换成功"
assert row["last_verdict_at"] is not None
assert row["last_verdict_message"]
class TestCollabFrequentGifts(TestCollabBase):
"""「常用礼包」:礼包名默认空白,只在上面留几个用得最多的备选(2026-10-18 用户要求)。
以前新建会自动带上「上次选的那个」礼包,每开一单都顶着上一次的选择、
没注意就建错单。现在改成:**字段默认空白**,想要少点两下就点「常用礼包」按钮。
"""
def test_orders_by_usage_count_then_recency(self):
for index in range(3):
self._create(order_no=f"FREQ-A{index}", gift_name="甲礼包")
self._create(order_no="FREQ-B1", gift_name="乙礼包")
self._create(order_no="FREQ-B2", gift_name="乙礼包")
self._create(order_no="FREQ-C1", gift_name="丙礼包")
rows = collab.frequent_gift_names(self.db)
assert [row["name"] for row in rows] == ["甲礼包", "乙礼包", "丙礼包"]
assert [row["used"] for row in rows] == [3, 2, 1]
def test_blank_gift_name_is_not_a_gift(self):
self._create(order_no="FREQ-EMPTY1")
self._create(order_no="FREQ-EMPTY2")
self._create(order_no="FREQ-ONE", gift_name="真礼包")
rows = collab.frequent_gift_names(self.db)
assert [row["name"] for row in rows] == ["真礼包"]
def test_same_count_falls_back_to_most_recently_used(self):
self._create(order_no="FREQ-R1", gift_name="老礼包")
self._create(order_no="FREQ-R2", gift_name="新礼包")
rows = collab.frequent_gift_names(self.db)
assert [row["name"] for row in rows] == ["新礼包", "老礼包"]
def test_limit_caps_the_list(self):
for index in range(4):
self._create(order_no=f"FREQ-L{index}", gift_name="同次数礼包")
rows = collab.frequent_gift_names(self.db, limit=1)
assert [row["name"] for row in rows] == ["同次数礼包"]
def test_api_returns_name_and_used(self):
self._create(order_no="FREQ-API1", gift_name="接口礼包")
self._create(order_no="FREQ-API2", gift_name="接口礼包")
rows = collab_api.external_orders_frequent_gifts(
limit=8, db=self.db, _current=self.user
)
assert rows[0]["name"] == "接口礼包"
assert rows[0]["used"] == 2
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"
def test_single_row_copy_is_super_admin_only(self):
"""操作列「复制 CK」只给管理员;运营/客服即使有导出权限也不能走这个入口。"""
order = self._create(order_no="EXP-ADMIN-1", ck_text=fake_ck("1199647000099"))
with pytest.raises(collab_api.HTTPException) as excinfo:
collab_api.copy_external_order_ck(order.id, self.db, self.user)
assert excinfo.value.status_code == 403
admin = User(username="collab_admin", password_hash="h", role="super_admin")
self.db.add(admin)
self.db.commit()
result = collab_api.copy_external_order_ck(order.id, self.db, admin)
assert result["exported"] == 1
assert result["items"][0]["order_no"] == "EXP-ADMIN-1"
assert result["items"][0]["ck_text"] == order.cookie
assert self._audits(order)[-1].username == "collab_admin"
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"