feat(huya): 协作台账店铺字典·新建强校验·同买家多单 + uid 放开(迁移 0057/0058)
- 店铺字典(迁移 0057):大按钮点选 + 「新店」自定义自动登记;重命名可级联、 停用/备注;列表行内改、详情、筛选条共用同一个 picker - 新建强校验:订单编号/店铺/CK/礼包名/主页截图必填;贴 CK 即探针预览, 读不到绑定角色禁止提交;礼包用工作台配置的活动 SID 现拉(浏览器缓存 + 强制刷新) - 图片:绑定图停用、「绑定斗鱼号」下线(绑定关系改由探针读 bind_game_*), 主页截图改为新建必填(弹窗内先传 OSS 再随表单落库) - 同买家多单软识别:按「绑定账号 + 绑定角色」整批一次查询,列表「同买家」列 + 详情分组,点单号直接切单 - uid 放开唯一性(迁移 0058):同一份 CK 可服务多单,原 409 冲突改为提示 + 审计 - 列表体验:序号列、搜索扩到 11 个字段、CK 状态列截断、新建弹窗放大并去滚动条 - 验证:pytest 932 passed / 6 skipped;接口冒烟 56 项全绿;tsc+eslint+build 通过
This commit is contained in:
@@ -22,6 +22,7 @@ from web.backend.huya_defaults import (
|
||||
from web.backend.models import (
|
||||
AuditLog,
|
||||
HuyaAccount,
|
||||
HuyaConfig,
|
||||
HuyaExternalOrder,
|
||||
HuyaWorkbenchAccount,
|
||||
User,
|
||||
@@ -114,6 +115,15 @@ class TestCollabCrud(TestCollabBase):
|
||||
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")
|
||||
@@ -127,14 +137,33 @@ class TestCollabCrud(TestCollabBase):
|
||||
assert excinfo.value.status_code == 409
|
||||
assert excinfo.value.code == "order_no_conflict"
|
||||
|
||||
def test_create_rejects_uid_already_used_by_another_order(self):
|
||||
self._create(order_no="A1", ck_text=fake_ck("1001"))
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
self._create(order_no="A2", ck_text=fake_ck("1001"))
|
||||
assert excinfo.value.status_code == 409
|
||||
assert excinfo.value.code == "uid_conflict"
|
||||
# 消息里要说清被谁占了,不然客服不知道该找哪一行
|
||||
assert "A1" in excinfo.value.detail
|
||||
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 原始凭据的专用位。"""
|
||||
@@ -579,6 +608,12 @@ class TestCollabImport(TestCollabBase):
|
||||
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`` 响应(字段名对齐真机响应结构)。"""
|
||||
@@ -607,12 +642,40 @@ class FakeBindResp:
|
||||
)
|
||||
|
||||
|
||||
class FakeGiftResp:
|
||||
"""合成一份 ``getActPrizeList`` 响应(礼包列表)。"""
|
||||
|
||||
def __init__(self, *, status: int = 200, msg: str = "", goods: list | None = None):
|
||||
self.status = status
|
||||
self.msg = msg
|
||||
self._goods = (
|
||||
goods
|
||||
if goods is not None
|
||||
else [
|
||||
{
|
||||
"product_id": "hy-1001",
|
||||
"name": "不朽伊人大礼包",
|
||||
"price": 200,
|
||||
"remain_text": "39%",
|
||||
"category_name": "礼包",
|
||||
},
|
||||
{"product_id": "", "name": "缺 product_id 的脏数据"},
|
||||
{"product_id": "hy-1002", "name": ""},
|
||||
]
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"status": self.status, "msg": self.msg, "goods": self._goods}
|
||||
|
||||
|
||||
class FakeProbeClient:
|
||||
"""离线探针客户端:按脚本依次给出 ``None`` / 异常 / 响应,并记录调用参数。"""
|
||||
|
||||
def __init__(self, *outcomes):
|
||||
def __init__(self, *outcomes, gifts: list | None = None):
|
||||
self.outcomes = list(outcomes)
|
||||
self.gift_outcomes = list(gifts or [])
|
||||
self.calls: list[dict] = []
|
||||
self.gift_calls: list[dict] = []
|
||||
|
||||
def check_user_bind_game_account(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
@@ -623,6 +686,13 @@ class FakeProbeClient:
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
def get_act_prize_list(self, **kwargs):
|
||||
self.gift_calls.append(kwargs)
|
||||
outcome = self.gift_outcomes.pop(0) if self.gift_outcomes else FakeGiftResp()
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
class TestCollabCkProbe(TestCollabBase):
|
||||
"""S5:只读探针。判据(真机对照 2026-10-16)= 响应非 None 且 status == 200。"""
|
||||
@@ -989,6 +1059,480 @@ class TestCollabCkProbe(TestCollabBase):
|
||||
assert excinfo.value.code == "verify_empty"
|
||||
|
||||
|
||||
class TestCollabCkPreview(TestCollabBase):
|
||||
"""粘贴 CK 后的即时预览:探针 + 顺带拉礼包,一行都不写库。"""
|
||||
|
||||
def test_preview_returns_binding_and_gifts(self):
|
||||
client = FakeProbeClient(
|
||||
FakeBindResp(
|
||||
nick="随遇而安", role="吃鸡199706", plat="手Q", area="苹果(iOS)"
|
||||
)
|
||||
)
|
||||
|
||||
preview = collab.preview_ck(fake_ck("1199647239697"), client=client)
|
||||
|
||||
assert preview["ok"] is True
|
||||
assert preview["has_role"] is True
|
||||
assert preview["uid"] == "1199647239697"
|
||||
assert preview["hy_username"] == "hy_1199647239697"
|
||||
assert preview["bind_game_role"] == "吃鸡199706"
|
||||
assert preview["gifts"] == [
|
||||
{
|
||||
"product_id": "hy-1001",
|
||||
"name": "不朽伊人大礼包",
|
||||
"price": 200,
|
||||
"remain_text": "39%",
|
||||
"stock": None,
|
||||
"score": None,
|
||||
"category_name": "礼包",
|
||||
"icon": "",
|
||||
}
|
||||
]
|
||||
assert "已拉取 1 个礼包" in preview["gifts_message"]
|
||||
assert client.gift_calls[0]["sid"] == collab.GIFTS_SID
|
||||
|
||||
def test_preview_of_invalid_ck_does_not_fetch_gifts(self):
|
||||
client = FakeProbeClient(None)
|
||||
|
||||
preview = collab.preview_ck(fake_ck("1199647239697"), client=client)
|
||||
|
||||
assert preview["ok"] is False
|
||||
assert preview["has_role"] is False
|
||||
assert preview["gifts"] == []
|
||||
assert client.gift_calls == [] # CK 都没通,不去拉礼包
|
||||
|
||||
def test_preview_of_probe_without_role_is_not_submittable(self):
|
||||
"""探针通了但读不到绑定角色 → 不允许提交(has_role=False)。"""
|
||||
preview = collab.preview_ck(
|
||||
fake_ck("1199647239697"),
|
||||
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="")),
|
||||
)
|
||||
assert preview["ok"] is True
|
||||
assert preview["has_role"] is False
|
||||
|
||||
def test_preview_writes_nothing(self):
|
||||
order = self._create(order_no="PREVIEW-1", shop="佳佳")
|
||||
before = order.version
|
||||
collab.preview_ck(
|
||||
fake_ck("1199647239697"),
|
||||
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706")),
|
||||
)
|
||||
self.db.refresh(order)
|
||||
assert order.version == before
|
||||
assert self.db.query(HuyaExternalOrder).count() == 1
|
||||
|
||||
def test_preview_rejects_unparsable_ck(self):
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.preview_ck("这不是一份 Cookie", client=FakeProbeClient(None))
|
||||
assert excinfo.value.code == "ck_invalid"
|
||||
|
||||
|
||||
class TestCollabGifts(TestCollabBase):
|
||||
def test_gift_fetch_skips_dirty_rows(self):
|
||||
gifts, message = collab.fetch_activity_gifts(
|
||||
1199647239697, fake_ck("1199647239697"), client=FakeProbeClient()
|
||||
)
|
||||
assert [gift["name"] for gift in gifts] == ["不朽伊人大礼包"]
|
||||
assert message == "已拉取 1 个礼包"
|
||||
|
||||
def test_gift_fetch_reports_failure_without_raising(self):
|
||||
gifts, message = collab.fetch_activity_gifts(
|
||||
1199647239697,
|
||||
fake_ck("1199647239697"),
|
||||
client=FakeProbeClient(gifts=[FakeGiftResp(status=500, msg="活动已结束")]),
|
||||
)
|
||||
assert gifts == []
|
||||
assert "拉取礼包失败" in message and "500" in message
|
||||
|
||||
def test_gift_fetch_survives_exception(self):
|
||||
gifts, message = collab.fetch_activity_gifts(
|
||||
1199647239697,
|
||||
fake_ck("1199647239697"),
|
||||
client=FakeProbeClient(gifts=[RuntimeError("boom")]),
|
||||
)
|
||||
assert gifts == []
|
||||
assert "RuntimeError" in message
|
||||
|
||||
def test_gift_fetch_empty_list_hints_stale_sid(self):
|
||||
"""200 但 0 个商品 = SID 不是当前活动(真机 2026-10-16:2203 → 0,2277 → 30)。"""
|
||||
gifts, message = collab.fetch_activity_gifts(
|
||||
1199647239697,
|
||||
fake_ck("1199647239697"),
|
||||
sid=2203,
|
||||
client=FakeProbeClient(gifts=[FakeGiftResp(goods=[])]),
|
||||
)
|
||||
assert gifts == []
|
||||
assert "SID=2203" in message and "工作台" in message
|
||||
|
||||
def test_resolve_gifts_sid_prefers_workbench_config(self):
|
||||
self.db.add(HuyaConfig(sid="2277"))
|
||||
self.db.commit()
|
||||
|
||||
assert collab.resolve_gifts_sid(self.db) == 2277
|
||||
|
||||
def test_resolve_gifts_sid_falls_back_when_unset_or_garbage(self):
|
||||
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID # 没有配置行
|
||||
|
||||
row = HuyaConfig(sid="")
|
||||
self.db.add(row)
|
||||
self.db.commit()
|
||||
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID
|
||||
|
||||
row.sid = "不是数字"
|
||||
self.db.commit()
|
||||
assert collab.resolve_gifts_sid(self.db) == collab.GIFTS_SID
|
||||
|
||||
def test_preview_uses_configured_sid(self):
|
||||
self.db.add(HuyaConfig(sid="2277"))
|
||||
self.db.commit()
|
||||
client = FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706"))
|
||||
|
||||
preview = collab.preview_ck(fake_ck("1199647239697"), sid=2277, client=client)
|
||||
|
||||
assert client.gift_calls[0]["sid"] == 2277
|
||||
assert preview["has_role"] is True
|
||||
|
||||
def test_preview_endpoint_passes_configured_sid(self, monkeypatch):
|
||||
"""接口层必须把工作台配置的 SID 传下去,别再退回写死默认值。"""
|
||||
from web.backend.routers.huya import collab as collab_router
|
||||
from web.backend.schemas import HuyaCollabCkPreviewRequest
|
||||
|
||||
self.db.add(HuyaConfig(sid="2277"))
|
||||
self.db.commit()
|
||||
client = FakeProbeClient(FakeBindResp(nick="随遇而安", role="吃鸡199706"))
|
||||
monkeypatch.setattr(collab, "_build_probe_client", lambda: client)
|
||||
|
||||
out = collab_router.preview_external_order_ck(
|
||||
HuyaCollabCkPreviewRequest(ck_text=fake_ck("1199647239697")),
|
||||
db=self.db,
|
||||
_current=self.user,
|
||||
)
|
||||
|
||||
# 直接调路由函数时没走 pydantic 出参,拿到的就是 dict
|
||||
assert client.gift_calls[0]["sid"] == 2277
|
||||
assert out["has_role"] is True
|
||||
|
||||
|
||||
class TestCollabCreateStrict(TestCollabBase):
|
||||
"""人工新建表单:订单编号 / 店铺 / CK / 礼包名 / 主页截图必填,且 CK 必须读到绑定角色。"""
|
||||
|
||||
HOME_IMAGE = "collab/2026/10/16/home01.png"
|
||||
|
||||
def _row_count(self) -> int:
|
||||
return self.db.query(HuyaExternalOrder).count()
|
||||
|
||||
def test_missing_required_fields_blocks_creation(self):
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.create_order(
|
||||
self.db,
|
||||
self.user,
|
||||
order_no="STRICT-1",
|
||||
require_verified=True,
|
||||
client=FakeProbeClient(
|
||||
FakeBindResp(nick="随遇而安", role="吃鸡199706")
|
||||
),
|
||||
)
|
||||
assert excinfo.value.code == "create_field_required"
|
||||
assert "店铺" in excinfo.value.detail and "礼包名" in excinfo.value.detail
|
||||
assert "主页截图" in excinfo.value.detail
|
||||
assert self._row_count() == 0 # 失败不留半行
|
||||
|
||||
def test_missing_home_screenshot_blocks_creation(self):
|
||||
"""绑定图不再要求,但主页截图是必填(2026-10-16 用户拍板)。"""
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.create_order(
|
||||
self.db,
|
||||
self.user,
|
||||
order_no="STRICT-NOIMG",
|
||||
shop="佳佳",
|
||||
gift_name="不朽伊人大礼包",
|
||||
ck_text=fake_ck("1199647239697"),
|
||||
require_verified=True,
|
||||
client=FakeProbeClient(
|
||||
FakeBindResp(nick="随遇而安", role="吃鸡199706")
|
||||
),
|
||||
)
|
||||
assert excinfo.value.code == "create_field_required"
|
||||
assert "主页截图" in excinfo.value.detail
|
||||
assert self._row_count() == 0
|
||||
|
||||
def test_bad_home_image_key_is_rejected_before_insert(self):
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.create_order(
|
||||
self.db,
|
||||
self.user,
|
||||
order_no="STRICT-BADIMG",
|
||||
shop="佳佳",
|
||||
gift_name="不朽伊人大礼包",
|
||||
ck_text=fake_ck("1199647239697"),
|
||||
home_images=["../../etc/passwd"],
|
||||
require_verified=True,
|
||||
client=FakeProbeClient(
|
||||
FakeBindResp(nick="随遇而安", role="吃鸡199706")
|
||||
),
|
||||
)
|
||||
assert excinfo.value.code == "image_key_invalid"
|
||||
assert self._row_count() == 0
|
||||
|
||||
def test_probe_without_role_blocks_creation(self):
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.create_order(
|
||||
self.db,
|
||||
self.user,
|
||||
order_no="STRICT-2",
|
||||
shop="佳佳",
|
||||
gift_name="不朽伊人大礼包",
|
||||
ck_text=fake_ck("1199647239697"),
|
||||
home_images=[self.HOME_IMAGE],
|
||||
require_verified=True,
|
||||
client=FakeProbeClient(FakeBindResp(nick="随遇而安", role="")),
|
||||
)
|
||||
assert excinfo.value.code == "ck_not_verified"
|
||||
assert "读不到绑定角色" in excinfo.value.detail
|
||||
assert self._row_count() == 0
|
||||
|
||||
def test_invalid_ck_blocks_creation(self):
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.create_order(
|
||||
self.db,
|
||||
self.user,
|
||||
order_no="STRICT-3",
|
||||
shop="佳佳",
|
||||
gift_name="不朽伊人大礼包",
|
||||
ck_text=fake_ck("1199647239697"),
|
||||
home_images=[self.HOME_IMAGE],
|
||||
require_verified=True,
|
||||
client=FakeProbeClient(None),
|
||||
)
|
||||
assert excinfo.value.code == "ck_not_verified"
|
||||
assert self._row_count() == 0
|
||||
|
||||
def test_valid_ck_creates_row_with_snapshot_and_audit(self):
|
||||
order = collab.create_order(
|
||||
self.db,
|
||||
self.user,
|
||||
order_no="STRICT-4",
|
||||
shop="佳佳",
|
||||
gift_name="不朽伊人大礼包",
|
||||
ck_text=fake_ck("1199647239697"),
|
||||
home_images=[self.HOME_IMAGE, "collab/2026/10/16/home02.png"],
|
||||
require_verified=True,
|
||||
client=FakeProbeClient(
|
||||
FakeBindResp(
|
||||
nick="随遇而安", role="吃鸡199706", plat="手Q", area="苹果(iOS)"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert order.ck_status == "valid"
|
||||
assert order.ck_checked_at is not None
|
||||
assert order.bind_game_account == "随遇而安"
|
||||
assert order.bind_game_role == "吃鸡199706"
|
||||
assert order.bind_game_channel == "手Q · 苹果(iOS)"
|
||||
assert order.bind_snapshot_at is not None
|
||||
assert order.gift_name == "不朽伊人大礼包"
|
||||
assert order.home_images == [self.HOME_IMAGE, "collab/2026/10/16/home02.png"]
|
||||
assert order.bind_images == [] # 绑定图不再人工上传
|
||||
assert order.owner_id == self.user.id
|
||||
actions = [row.action for row in self._audits(order)]
|
||||
assert "huya:collab:create" in actions
|
||||
assert "huya:collab:ck_status" in actions
|
||||
|
||||
def test_import_path_is_not_forced_to_verify(self):
|
||||
"""粘贴导入没有 CK 列,不能被严格模式挡住(走的是默认 require_verified=False)。"""
|
||||
result = collab.import_orders(
|
||||
self.db, self.user, "佳佳\tIMP-1\t\t用户1\t礼包A\t"
|
||||
)
|
||||
assert result["created"] == 1
|
||||
assert self.db.query(HuyaExternalOrder).one().ck_status == "unverified"
|
||||
|
||||
|
||||
class TestCollabKeywordSearch(TestCollabBase):
|
||||
"""列表搜索:一个输入框打穿订单号 / 店铺 / 绑定账号·角色 / 平台区服 / 虎牙号 / 礼包 / 备注。"""
|
||||
|
||||
def _search(self, text: str) -> list[HuyaExternalOrder]:
|
||||
rows, total = collab.list_orders(self.db, keyword=text)
|
||||
assert total == len(rows)
|
||||
return rows
|
||||
|
||||
def test_keyword_hits_each_searchable_column(self):
|
||||
order = self._create(
|
||||
order_no="K-1",
|
||||
shop="向日葵",
|
||||
gift_name="天网终结者大礼包",
|
||||
remark="熟人介绍",
|
||||
ck_text=fake_ck("1199647123456"),
|
||||
)
|
||||
collab.verify_ck(
|
||||
self.db,
|
||||
self.user,
|
||||
order,
|
||||
client=FakeProbeClient(
|
||||
FakeBindResp(
|
||||
nick="微信用户", role="不打PG打老公", plat="苹果(iOS)", area="微信"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
for text in (
|
||||
"K-1", # 订单编号
|
||||
"向日葵", # 店铺
|
||||
"微信用户", # 绑定账号(探针)
|
||||
"不打PG打老公", # 绑定角色(探针)
|
||||
"苹果(iOS)", # 平台 · 区服
|
||||
"1199647123456", # 虎牙号
|
||||
"天网终结者", # 礼包名
|
||||
"熟人", # 备注
|
||||
):
|
||||
assert [row.id for row in self._search(text)] == [order.id], text
|
||||
|
||||
def test_keyword_miss_returns_empty_page(self):
|
||||
self._create(order_no="K-2", shop="佳佳")
|
||||
|
||||
assert self._search("查不到的东西") == []
|
||||
|
||||
|
||||
class TestCollabBuyerCluster(TestCollabBase):
|
||||
"""同一买家的多单:按「绑定账号 + 绑定角色」软识别(只读派生,不动 uid 唯一性)。
|
||||
|
||||
真机业务口径:一单一份外部 CK,同一个买家买两件往往是**两份不同的 CK**(uid 不同),
|
||||
所以买家指纹只能用探针读到的绑定对象,不能用 uid。
|
||||
"""
|
||||
|
||||
def _order_with_binding(
|
||||
self,
|
||||
order_no: str,
|
||||
uid: str,
|
||||
*,
|
||||
account: str,
|
||||
role: str,
|
||||
gift_name: str = "礼包A",
|
||||
) -> HuyaExternalOrder:
|
||||
order = self._create(
|
||||
order_no=order_no, shop="佳佳", gift_name=gift_name, ck_text=fake_ck(uid)
|
||||
)
|
||||
collab.verify_ck(
|
||||
self.db,
|
||||
self.user,
|
||||
order,
|
||||
client=FakeProbeClient(
|
||||
FakeBindResp(nick=account, role=role, plat="手Q", area="苹果(iOS)")
|
||||
),
|
||||
)
|
||||
return order
|
||||
|
||||
def _cluster_of(self, order: HuyaExternalOrder) -> dict | None:
|
||||
return collab.buyer_clusters(self.db, [order]).get(int(order.id))
|
||||
|
||||
def test_two_orders_of_same_buyer_are_grouped(self):
|
||||
first = self._order_with_binding(
|
||||
"BUY-1", "1199647000001", account="微信用户", role="不打PG打老公"
|
||||
)
|
||||
second = self._order_with_binding(
|
||||
"BUY-2", "1199647000002", account="微信用户", role="不打PG打老公"
|
||||
)
|
||||
|
||||
first_cluster = self._cluster_of(first)
|
||||
second_cluster = self._cluster_of(second)
|
||||
|
||||
assert first_cluster is not None
|
||||
assert first_cluster["total"] == 2
|
||||
assert first_cluster["key"] == "微信用户 · 不打PG打老公"
|
||||
assert [item["order_no"] for item in first_cluster["others"]] == ["BUY-2"]
|
||||
assert second_cluster is not None
|
||||
assert [item["order_no"] for item in second_cluster["others"]] == ["BUY-1"]
|
||||
|
||||
def test_same_account_different_role_is_not_grouped(self):
|
||||
first = self._order_with_binding(
|
||||
"BUY-3", "1199647000003", account="微信用户", role="角色甲"
|
||||
)
|
||||
self._order_with_binding(
|
||||
"BUY-4", "1199647000004", account="微信用户", role="角色乙"
|
||||
)
|
||||
|
||||
assert self._cluster_of(first) is None
|
||||
|
||||
def test_single_order_of_a_buyer_has_no_cluster(self):
|
||||
only = self._order_with_binding(
|
||||
"BUY-5", "1199647000005", account="微信用户", role="独苗角色"
|
||||
)
|
||||
|
||||
assert self._cluster_of(only) is None
|
||||
|
||||
def test_rows_without_probe_snapshot_are_not_grouped(self):
|
||||
"""没验证过 CK(绑定账号/角色为空)的行不参与归组,也不会被别人的快照误伤。"""
|
||||
verified = self._order_with_binding(
|
||||
"BUY-6", "1199647000006", account="微信用户", role="角色丙"
|
||||
)
|
||||
blank = self._create(
|
||||
order_no="BUY-7", shop="佳佳", ck_text=fake_ck("1199647000007")
|
||||
)
|
||||
|
||||
clusters = collab.buyer_clusters(self.db, [blank, verified])
|
||||
|
||||
assert clusters == {} or int(blank.id) not in clusters
|
||||
assert self._cluster_of(verified) is None
|
||||
assert collab.buyer_key_of(blank) is None
|
||||
|
||||
def test_serialize_order_carries_cluster_and_defaults_to_null(self):
|
||||
first = self._order_with_binding(
|
||||
"BUY-8", "1199647000008", account="微信用户", role="角色丁"
|
||||
)
|
||||
second = self._order_with_binding(
|
||||
"BUY-9", "1199647000009", account="微信用户", role="角色丁"
|
||||
)
|
||||
clusters = collab.buyer_clusters(self.db, [first, second])
|
||||
|
||||
payload = collab.serialize_order(
|
||||
first, buyer_cluster=clusters.get(int(first.id))
|
||||
)
|
||||
assert payload["buyer_cluster"]["total"] == 2
|
||||
assert payload["buyer_cluster"]["method"] == collab.BUYER_KEY_METHOD
|
||||
assert payload["buyer_cluster"]["others"][0]["gift_name"] == "礼包A"
|
||||
|
||||
alone = collab.serialize_order(first)
|
||||
assert alone["buyer_cluster"] is None
|
||||
|
||||
|
||||
class 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):
|
||||
"""运营能看能改能删;客服能看能改但不能删。"""
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""协作台账的店铺字典:增删改查 / 停用 / 改名级联 / 自定义输入自动登记。
|
||||
|
||||
背景:新建协作单时"店铺"原来是手打自由文本,同一个店铺会出现多种写法。
|
||||
现在改成字典(`huya_collab_shops`):新建时点一下选,也能自定义输入新名字。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.support.db import get_test_engine, make_test_session
|
||||
from web.backend.models import AuditLog, HuyaCollabShop, HuyaExternalOrder, User
|
||||
from web.backend.services import huya_collab_service as collab
|
||||
|
||||
|
||||
class TestShopBase:
|
||||
def setup_method(self):
|
||||
self.engine = get_test_engine()
|
||||
self.db = make_test_session(self.engine)
|
||||
self.user = User(username="shop_op", password_hash="h", role="operation")
|
||||
self.db.add(self.user)
|
||||
self.db.commit()
|
||||
|
||||
def teardown_method(self):
|
||||
self.db.close()
|
||||
|
||||
def _shop(self, name: str, **kwargs) -> HuyaCollabShop:
|
||||
return collab.create_shop(self.db, self.user, name=name, **kwargs)
|
||||
|
||||
def _order(self, order_no: str, shop: str = "") -> HuyaExternalOrder:
|
||||
return collab.create_order(self.db, self.user, order_no=order_no, shop=shop)
|
||||
|
||||
def _shop_audits(self, shop: HuyaCollabShop) -> list[AuditLog]:
|
||||
return (
|
||||
self.db.query(AuditLog)
|
||||
.filter(AuditLog.target == f"huya_collab_shop:{shop.id}")
|
||||
.order_by(AuditLog.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class TestShopCrud(TestShopBase):
|
||||
def test_create_and_list_sorted(self):
|
||||
self._shop("小鱼儿", sort_order=2)
|
||||
self._shop("佳佳", sort_order=1)
|
||||
|
||||
shops = collab.list_shop_records(self.db)
|
||||
|
||||
assert [shop.name for shop in shops] == ["佳佳", "小鱼儿"] # 按 sort_order
|
||||
assert all(shop.is_active for shop in shops)
|
||||
|
||||
def test_create_rejects_duplicate_name(self):
|
||||
self._shop("佳佳")
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
self._shop(" 佳佳 ") # 去空白后同名
|
||||
assert excinfo.value.status_code == 409
|
||||
assert excinfo.value.code == "shop_name_conflict"
|
||||
|
||||
def test_create_rejects_blank_and_too_long_name(self):
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
self._shop(" ")
|
||||
assert excinfo.value.code == "shop_name_required"
|
||||
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.create_shop(self.db, self.user, name="店" * 65)
|
||||
assert excinfo.value.code == "field_too_long"
|
||||
|
||||
def test_create_writes_audit(self):
|
||||
shop = self._shop("佳佳")
|
||||
assert [row.action for row in self._shop_audits(shop)] == [
|
||||
"huya:collab:shop_create"
|
||||
]
|
||||
|
||||
def test_update_returns_field_diff_and_audits(self):
|
||||
shop = self._shop("佳佳")
|
||||
|
||||
changes = collab.update_shop(
|
||||
self.db,
|
||||
self.user,
|
||||
shop,
|
||||
{"sort_order": 5, "remark": "熟人开的", "is_active": False},
|
||||
)
|
||||
|
||||
assert set(changes) == {"sort_order", "remark", "is_active"}
|
||||
assert changes["is_active"] == {"old": True, "new": False}
|
||||
assert shop.remark == "熟人开的"
|
||||
assert "huya:collab:shop_update" in [
|
||||
row.action for row in self._shop_audits(shop)
|
||||
]
|
||||
|
||||
def test_update_without_changes_is_a_noop(self):
|
||||
shop = self._shop("佳佳")
|
||||
assert collab.update_shop(self.db, self.user, shop, {"name": "佳佳"}) == {}
|
||||
assert [row.action for row in self._shop_audits(shop)] == [
|
||||
"huya:collab:shop_create"
|
||||
]
|
||||
|
||||
def test_rename_cascades_to_existing_orders(self):
|
||||
"""改名默认级联:否则筛选里会堆一堆"改名前的老名字"。"""
|
||||
shop = self._shop("佳佳")
|
||||
order = self._order("S1", shop="佳佳")
|
||||
|
||||
changes = collab.update_shop(self.db, self.user, shop, {"name": "佳佳优选"})
|
||||
|
||||
assert changes["name"] == {"old": "佳佳", "new": "佳佳优选"}
|
||||
self.db.refresh(order)
|
||||
assert order.shop == "佳佳优选"
|
||||
detail = self._shop_audits(shop)[-1].detail
|
||||
assert "renamed_orders" in detail and "1" in detail
|
||||
|
||||
def test_rename_can_skip_the_cascade(self):
|
||||
shop = self._shop("佳佳")
|
||||
order = self._order("S1", shop="佳佳")
|
||||
|
||||
collab.update_shop(
|
||||
self.db, self.user, shop, {"name": "佳佳优选"}, rename_orders=False
|
||||
)
|
||||
|
||||
self.db.refresh(order)
|
||||
assert order.shop == "佳佳" # 历史单子保持原名
|
||||
|
||||
def test_rename_rejects_name_taken_by_another_shop(self):
|
||||
self._shop("佳佳")
|
||||
other = self._shop("小鱼儿")
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.update_shop(self.db, self.user, other, {"name": "佳佳"})
|
||||
assert excinfo.value.code == "shop_name_conflict"
|
||||
|
||||
def test_delete_only_removes_the_dictionary_row(self):
|
||||
shop = self._shop("佳佳")
|
||||
order = self._order("S1", shop="佳佳")
|
||||
|
||||
collab.delete_shop(self.db, self.user, shop)
|
||||
|
||||
assert self.db.query(HuyaCollabShop).count() == 0
|
||||
self.db.refresh(order)
|
||||
assert order.shop == "佳佳" # 订单不动
|
||||
|
||||
def test_get_missing_shop_is_404(self):
|
||||
with pytest.raises(collab.CollabError) as excinfo:
|
||||
collab.get_shop(self.db, 999999)
|
||||
assert excinfo.value.status_code == 404
|
||||
assert excinfo.value.code == "shop_not_found"
|
||||
|
||||
|
||||
class TestShopAutoRegister(TestShopBase):
|
||||
def test_creating_an_order_registers_its_shop(self):
|
||||
"""自定义输入一个新店铺名 → 自动进字典,下次下拉里就能点。"""
|
||||
self._order("S1", shop="新店")
|
||||
|
||||
assert [shop.name for shop in collab.list_shop_records(self.db)] == ["新店"]
|
||||
assert collab.list_shops(self.db) == ["新店"]
|
||||
|
||||
def test_order_without_shop_does_not_touch_the_dictionary(self):
|
||||
self._order("S1", shop="")
|
||||
assert collab.list_shop_records(self.db) == []
|
||||
|
||||
def test_editing_an_order_to_a_new_shop_registers_it(self):
|
||||
order = self._order("S1", shop="")
|
||||
collab.update_order(self.db, self.user, order, {"shop": "随手填的店"})
|
||||
assert [shop.name for shop in collab.list_shop_records(self.db)] == [
|
||||
"随手填的店"
|
||||
]
|
||||
|
||||
def test_import_registers_all_shops_and_keeps_partial_failure_isolated(self):
|
||||
result = collab.import_orders(
|
||||
self.db,
|
||||
self.user,
|
||||
"店铺A\tS1\tshop_op\t用户1\t礼包\t\n店铺B\tS2\tshop_op\t用户2\t礼包\t",
|
||||
owner_by_name={"shop_op": self.user.id},
|
||||
)
|
||||
|
||||
assert (result["created"], result["errors"]) == (2, [])
|
||||
assert collab.list_shops(self.db) == ["店铺A", "店铺B"]
|
||||
|
||||
def test_registering_twice_does_not_duplicate(self):
|
||||
self._order("S1", shop="佳佳")
|
||||
self._order("S2", shop="佳佳")
|
||||
assert collab.list_shops(self.db) == ["佳佳"]
|
||||
|
||||
def test_auto_registered_shop_is_recorded_in_audit(self):
|
||||
self._order("S1", shop="新店")
|
||||
rows = (
|
||||
self.db.query(AuditLog).filter(AuditLog.target == "huya_collab_shops").all()
|
||||
)
|
||||
assert [row.action for row in rows] == ["huya:collab:shop_create"]
|
||||
assert "新店" in rows[0].detail
|
||||
|
||||
def test_ensure_shops_registered_ignores_blank_and_oversized_names(self):
|
||||
created = collab.ensure_shops_registered(
|
||||
self.db, self.user, ["", " ", "店" * 65, "正常店"]
|
||||
)
|
||||
assert created == ["正常店"]
|
||||
assert collab.list_shops(self.db) == ["正常店"]
|
||||
|
||||
def test_registered_shop_can_be_picked_by_the_filter(self):
|
||||
self._order("S1", shop="佳佳")
|
||||
rows, total = collab.list_orders(self.db, shop="佳佳")
|
||||
assert total == 1 and rows[0].order_no == "S1"
|
||||
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = TESTS_DIR.parent
|
||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||
|
||||
HEAD_REVISION = "20261016_0056"
|
||||
HEAD_REVISION = "20261016_0058"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
EXTRA_INDEXES = (
|
||||
|
||||
Reference in New Issue
Block a user