"""斗鱼外部单协作:账号导入、状态机、鱼翅/积分与兑换记录查询。""" import pytest from tests.support.db import get_test_engine, make_test_session from web.backend.models import Account, AuditLog, User from web.backend.routers import douyu_collab as collab_api from web.backend.schemas import ( DouyuCollabAccountLookupOut, DouyuCollabCkExportOut, DouyuCollabOrderOut, DouyuCollabUsernameExportOut, ) from web.backend.services import douyu_collab_service as collab class FakeClient: def __init__(self, *, points=5000, fish=1000, records=None): self.points = points self.fish = fish self.records = records if records is not None else [] self.records_calls = 0 self.orders_calls = 0 def elite_user_info(self, **_): return { "manual_type": 1, "manual_score": self.points, "expire_time": 0, "user_info": {"nickname": "斗鱼昵称"}, } def bind_info(self, **_): return { "role_name": "角色A", "area_name": "微信", "plat_name": "苹果(iOS)", "nickname": "微信昵称", } def gold_account(self): return {"gold": self.fish} def list_goods(self, **_): return { "goods": [ { "commodityId": "gift-a", "commodityName": "礼包A", "score": 100, "status": 0, "openStatus": 1, "mark": "", "count": 10, } ] } def exchange_records(self, **_): self.records_calls += 1 return {"records": self.records} def exchange_orders(self, **_): self.orders_calls += 1 return {"orders": []} class FakeNoBindClient(FakeClient): def bind_info(self, **_): raise RuntimeError("未绑定角色") class TestDouyuCollabOrders: def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.admin = User( username="douyu-collab-admin", password_hash="hash", role="super_admin", is_active=True, ) self.session.add(self.admin) self.session.commit() self.account = Account( username="douyu-account-a", password="password", email="a@example.com", email_password="mail-password", cookie="acf_uid=100; acf_nickname=test", ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def test_lookup_account_resolves_internal_ck_and_role(self): result = collab.lookup_account( self.session, "douyu-account-a", client=FakeClient() ) assert result["found"] is True assert result["ok"] is True assert result["account_id"] == self.account.id assert result["bind_game_role"] == "角色A" assert result["manual_score"] == 5000 assert result["fish_fin_balance"] == 1000 assert result["goods"][0]["commodity_id"] == "gift-a" DouyuCollabAccountLookupOut.model_validate(result) def test_create_strict_order_uses_internal_account_and_advances(self): order = collab.create_order( self.session, self.admin, order_no="DY-001", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-1.png"], client=FakeClient(), ) assert order.account_id == self.account.id assert order.bind_game_role == "角色A" assert order.points_balance == 5000 assert order.fish_fin_balance == 1000 assert order.status == collab.STATUS_TO_RECHARGE DouyuCollabOrderOut.model_validate(collab.serialize_one(self.session, order)) def test_same_account_allows_multiple_orders(self): collab.create_order( self.session, self.admin, order_no="DY-002", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-1.png"], client=FakeClient(), ) second = collab.create_order( self.session, self.admin, order_no="DY-003", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-2.png"], client=FakeClient(), ) assert second.account_id == self.account.id assert collab.group_points_total(self.session, second) == 200 def test_status_flow_and_exchange_record_marks_done(self): order = collab.create_order( self.session, self.admin, order_no="DY-004", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-3.png"], client=FakeClient(), ) collab.set_status( self.session, self.admin, order, status=collab.STATUS_RECHARGED, reason="鱼翅已充值", ) collab.set_status( self.session, self.admin, order, status=collab.STATUS_SCRIPTED, reason="脚本已上", ) result = collab.query_activity( self.session, self.admin, order, client=FakeClient(records=[{"commodityName": "礼包A"}]), ) assert result["verdict"] == collab.VERDICT_SUCCESS assert result["order"].status == collab.STATUS_DONE assert result["order"].done_at is not None def test_points_shortage_enters_points_error(self): order = collab.create_order( self.session, self.admin, order_no="DY-005", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-4.png"], client=FakeClient(), ) collab.set_status( self.session, self.admin, order, status=collab.STATUS_RECHARGED, reason="鱼翅已充值", ) order.points_threshold = 10 self.session.commit() result = collab.query_activity( self.session, self.admin, order, client=FakeClient(points=50, records=[]), ) assert result["points_reason"] == "shortage" assert result["order"].status == collab.STATUS_POINTS_ERROR def test_balance_query_skips_records_and_never_changes_status(self): order = collab.create_order( self.session, self.admin, order_no="DY-106", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-5.png"], client=FakeClient(), ) collab.set_status( self.session, self.admin, order, status=collab.STATUS_RECHARGED, reason="鱼翅已充值", ) order.points_threshold = 10 self.session.commit() client = FakeClient(points=50, records=[{"commodityName": "礼包A"}]) result = collab.query_activity( self.session, self.admin, order, client=client, source="balance_batch", include_records=False, ) assert client.records_calls == 0 assert client.orders_calls == 0 assert result["scope"] == "balances" assert result["verdict"] == collab.VERDICT_UNKNOWN assert result["points_reason"] == "shortage" assert result["order"].status == collab.STATUS_RECHARGED assert result["metric"].source == "balance_batch" assert result["metric"].records_total == 0 def test_balance_query_keeps_last_exchange_verdict_visible(self): order = collab.create_order( self.session, self.admin, order_no="DY-107", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-6.png"], client=FakeClient(), ) collab.set_status( self.session, self.admin, order, status=collab.STATUS_RECHARGED, reason="鱼翅已充值", ) collab.set_status( self.session, self.admin, order, status=collab.STATUS_SCRIPTED, reason="脚本已上", ) record_result = collab.query_activity( self.session, self.admin, order, client=FakeClient(records=[{"commodityName": "礼包A"}]), ) assert record_result["order"].status == collab.STATUS_DONE collab.query_activity( self.session, self.admin, order, client=FakeClient(), source="balance_batch", include_records=False, ) payload = collab.serialize_one(self.session, order) assert payload["status"] == collab.STATUS_DONE assert payload["last_verdict"] == collab.VERDICT_SUCCESS assert payload["last_records_matched"] == 1 assert payload["last_metric_message"] == "仅查询鱼翅与积分,未查询兑换记录" def test_draft_can_be_created_before_account_resolution(self): order = collab.create_order( self.session, self.admin, order_no="DY-006", shop="", account_username="not-exists-yet", draft=True, ) assert order.status == collab.STATUS_INCOMPLETE assert order.account_id is None assert order.account_username == "not-exists-yet" def test_draft_existing_username_links_account_and_can_be_completed(self): order = collab.create_order( self.session, self.admin, order_no="DY-009", shop="店铺A", account_username="douyu-account-a", draft=True, ) assert order.account_id == self.account.id collab.update_order( self.session, self.admin, order, { "account_username": "douyu-account-a", "gift_name": "礼包A", "commodity_id": "gift-a", "required_points": 100, "home_images": ["collab/home-7.png"], }, client=FakeClient(), ) self.session.refresh(order) assert order.bind_game_role == "角色A" assert order.status == collab.STATUS_TO_RECHARGE assert collab.serialize_one(self.session, order)["missing_fields"] == [] def test_missing_bind_role_keeps_incomplete_and_explains_why(self): order = collab.create_order( self.session, self.admin, order_no="DY-011", shop="店铺A", account_username="douyu-account-a", draft=True, ) collab.update_order( self.session, self.admin, order, { "account_username": "douyu-account-a", "gift_name": "礼包A", "commodity_id": "gift-a", "required_points": 100, "home_images": ["collab/home-missing-role.png"], }, client=FakeNoBindClient(), ) self.session.refresh(order) assert order.status == collab.STATUS_INCOMPLETE payload = collab.serialize_one(self.session, order) assert "绑定角色(先校验账号)" in payload["missing_fields"] DouyuCollabOrderOut.model_validate(payload) def test_done_order_is_not_counted_again_in_group_points(self): first = collab.create_order( self.session, self.admin, order_no="DY-007", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-5.png"], client=FakeClient(), ) second = collab.create_order( self.session, self.admin, order_no="DY-008", shop="店铺A", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-6.png"], client=FakeClient(), ) assert collab.group_points_total(self.session, second) == 200 collab.set_status( self.session, self.admin, first, status=collab.STATUS_DONE, reason="已兑换", ) assert collab.group_points_total(self.session, second) == 100 def test_goods_normalization_keeps_zero_values(self): goods = collab.serialize_live_goods( [ { "commodityId": "gift-23", "commodityName": "23返场礼包", "score": 8000, "status": 0, "openStatus": 0, "startTime": "1792749600", "endTime": "1793375999", "mark": "限时", "count": 2000, } ] ) assert goods[0]["status"] == "0" assert goods[0]["open_status"] == 0 assert goods[0]["start_time"] == 1792749600 assert goods[0]["is_limited"] is True def test_shop_rename_cascades_and_delete_rejects_in_use(self): shop = collab.create_shop(self.session, self.admin, name="店铺B", remark="测试") order = collab.create_order( self.session, self.admin, order_no="DY-009", shop="店铺B", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-7.png"], client=FakeClient(), ) collab.update_shop( self.session, self.admin, shop, {"name": "店铺B-改名", "is_active": False}, ) self.session.refresh(order) assert order.shop == "店铺B-改名" assert shop.is_active is False with pytest.raises(collab.CollabError): collab.delete_shop(self.session, self.admin, shop) collab.delete_order(self.session, self.admin, order) assert collab.delete_shop(self.session, self.admin, shop)["success"] is True def test_ck_status_filter_and_summary_count_invalid(self): order = collab.create_order( self.session, self.admin, order_no="DY-010", shop="店铺C", account_username="douyu-account-a", gift_name="礼包A", required_points=100, home_images=["collab/home-8.png"], client=FakeClient(), ) self.account.ck_check_status = collab.CK_INVALID self.session.commit() invalid = collab.list_orders(self.session, ck_status=collab.CK_INVALID) assert [item["id"] for item in invalid["items"]] == [order.id] assert collab.list_orders(self.session, ck_status=collab.CK_VALID)["total"] == 0 assert collab.summary(self.session)["ck_invalid"] == 1 def test_export_cks_reads_linked_account_and_skips_missing_cookie(self): first = collab.create_order( self.session, self.admin, order_no="DY-EXP-1", account_username="douyu-account-a", draft=True, ) blank_account = Account( username="douyu-account-blank", password="password", email="blank@example.com", email_password="", cookie="", ) self.session.add(blank_account) self.session.commit() blank = collab.create_order( self.session, self.admin, order_no="DY-EXP-2", account_username="douyu-account-blank", draft=True, ) missing = collab.create_order( self.session, self.admin, order_no="DY-EXP-3", account_username="douyu-account-missing", draft=True, ) self.account.ck_check_status = collab.CK_VALID self.session.commit() result = collab.export_cks( self.session, self.admin, [first.id, blank.id, missing.id], ) assert result["total"] == 3 assert result["exported"] == 1 assert result["items"][0]["order_no"] == "DY-EXP-1" assert result["items"][0]["ck_text"] == self.account.cookie assert result["items"][0]["account_username"] == self.account.username assert result["items"][0]["account_password"] == "password" assert result["items"][0]["ck_status_label"] == "CK 正常" assert [item["order_no"] for item in result["skipped"]] == [ "DY-EXP-2", "DY-EXP-3", ] assert "没有 CK" in result["skipped"][0]["reason"] assert "还没绑定" in result["skipped"][1]["reason"] DouyuCollabCkExportOut.model_validate(result) def test_export_ck_audit_never_contains_the_cookie_body(self): order = collab.create_order( self.session, self.admin, order_no="DY-EXP-4", account_username="douyu-account-a", draft=True, ) collab.export_cks(self.session, self.admin, [order.id]) audit = ( self.session.query(AuditLog) .filter(AuditLog.action == collab.CK_EXPORT_ACTION) .order_by(AuditLog.id.desc()) .first() ) assert audit is not None assert audit.username == self.admin.username assert order.order_no in audit.detail assert "douyu-account-a" in audit.detail assert self.account.cookie not in audit.detail def test_export_cks_rejects_empty_and_too_many(self): with pytest.raises(collab.CollabError) as excinfo: collab.export_cks(self.session, self.admin, []) assert excinfo.value.code == "ck_export_empty" with pytest.raises(collab.CollabError) as excinfo: collab.export_cks( self.session, self.admin, list(range(1, collab.CK_EXPORT_BATCH_MAX + 2)), ) assert excinfo.value.code == "ck_export_too_large" def test_single_row_copy_is_super_admin_only(self): operator = User( username="douyu-collab-operator", password_hash="hash", role="operation", is_active=True, ) self.session.add(operator) self.session.commit() order = collab.create_order( self.session, self.admin, order_no="DY-EXP-5", account_username="douyu-account-a", draft=True, ) result = collab_api.copy_external_order_ck(order.id, self.session, self.admin) assert result["exported"] == 1 assert result["items"][0]["ck_text"] == self.account.cookie assert result["items"][0]["account_password"] == "password" with pytest.raises(collab_api.HTTPException) as excinfo: collab_api.copy_external_order_ck(order.id, self.session, operator) assert excinfo.value.status_code == 403 def test_export_usernames_dedupes_and_skips_empty(self): first = collab.create_order( self.session, self.admin, order_no="DY-USER-1", account_username="douyu-account-a", draft=True, ) second = collab.create_order( self.session, self.admin, order_no="DY-USER-2", account_username="douyu-account-a", draft=True, ) blank = collab.create_order( self.session, self.admin, order_no="DY-USER-3", draft=True, ) result = collab.export_usernames( self.session, [first.id, second.id, blank.id], ) assert result["total"] == 3 assert result["exported"] == 2 assert result["unique"] == 1 assert result["usernames"] == ["douyu-account-a"] assert [item["order_no"] for item in result["items"]] == [ "DY-USER-1", "DY-USER-2", ] assert result["skipped"] == [ { "id": blank.id, "order_no": "DY-USER-3", "reason": "这单还没填斗鱼用户名", } ] DouyuCollabUsernameExportOut.model_validate(result) def test_export_usernames_rejects_empty_and_too_many(self): with pytest.raises(collab.CollabError) as excinfo: collab.export_usernames(self.session, []) assert excinfo.value.code == "username_export_empty" with pytest.raises(collab.CollabError) as excinfo: collab.export_usernames( self.session, list(range(1, collab.USERNAME_EXPORT_BATCH_MAX + 2)), ) assert excinfo.value.code == "username_export_too_large" def test_ck_export_permission_defaults_to_operation(self): from web.backend.permissions import ROLE_PERMISSIONS assert "douyu:collab:ck_export" in ROLE_PERMISSIONS["operation"] assert "douyu:collab:ck_export" in ROLE_PERMISSIONS["super_admin"] assert "douyu:collab:ck_export" not in ROLE_PERMISSIONS["support"] def test_douyu_collab_routes_registered(self): from web.backend.main import app paths = app.openapi()["paths"] assert "/api/douyu/external-orders" in paths assert "/api/douyu/external-orders/{order_id}/status" in paths assert "/api/douyu/external-orders/status/batch" in paths assert "/api/douyu/external-orders/verify/batch" in paths assert "/api/douyu/external-orders/accounts/lookup" in paths assert "/api/douyu/external-orders/ck/export" in paths assert "/api/douyu/external-orders/{order_id}/ck/copy" in paths assert "/api/douyu/external-orders/usernames/export" in paths assert "/api/douyu/external-orders/balances/query" in paths assert "/api/douyu/external-orders/{order_id}/balances/query" in paths assert "/api/douyu/external-orders/activity/query" in paths assert "/api/douyu/external-orders/{order_id}/activity/query" in paths assert "/api/douyu/collab-shops/{shop_id}" in paths assert "put" in paths["/api/douyu/collab-shops/{shop_id}"] assert "delete" in paths["/api/douyu/collab-shops/{shop_id}"]