From 71574908fffad8a86918b92a8fd0b0e37ca4cca0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 20 Oct 2026 20:11:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=97=E9=B1=BC=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E5=8D=95=E5=8D=8F=E4=BD=9C=E5=8F=B0=E8=B4=A6=E4=B8=8E=E5=BA=97?= =?UTF-8?q?=E9=93=BA=E5=AE=9E=E6=97=B6=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_douyu_collab_orders.py | 395 ++++ tests/test_migrations.py | 2 +- web/backend/main.py | 2 + .../20261020_0062_douyu_external_orders.py | 245 ++ web/backend/models.py | 166 ++ web/backend/permissions.py | 9 + web/backend/routers/douyu_collab.py | 573 +++++ web/backend/schemas.py | 244 ++ web/backend/services/douyu_collab_events.py | 89 + web/backend/services/douyu_collab_service.py | 1988 +++++++++++++++++ web/frontend/src/App.tsx | 2 + web/frontend/src/api/douyuCollab.ts | 326 +++ web/frontend/src/api/modules.ts | 1 + .../src/components/DouyuCollabShopModal.tsx | 286 +++ .../src/components/DouyuCollabShopPicker.tsx | 119 + web/frontend/src/hooks/useCollabRealtime.ts | 9 +- web/frontend/src/layouts/MainLayout.tsx | 5 + web/frontend/src/pages/DouyuCollabPage.tsx | 1730 ++++++++++++++ 18 files changed, 6187 insertions(+), 4 deletions(-) create mode 100644 tests/test_douyu_collab_orders.py create mode 100644 web/backend/migrations/versions/20261020_0062_douyu_external_orders.py create mode 100644 web/backend/routers/douyu_collab.py create mode 100644 web/backend/services/douyu_collab_events.py create mode 100644 web/backend/services/douyu_collab_service.py create mode 100644 web/frontend/src/api/douyuCollab.ts create mode 100644 web/frontend/src/components/DouyuCollabShopModal.tsx create mode 100644 web/frontend/src/components/DouyuCollabShopPicker.tsx create mode 100644 web/frontend/src/pages/DouyuCollabPage.tsx diff --git a/tests/test_douyu_collab_orders.py b/tests/test_douyu_collab_orders.py new file mode 100644 index 0000000..33ece04 --- /dev/null +++ b/tests/test_douyu_collab_orders.py @@ -0,0 +1,395 @@ +"""斗鱼外部单协作:账号导入、状态机、鱼翅/积分与兑换记录查询。""" + +import pytest + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, User +from web.backend.schemas import DouyuCollabAccountLookupOut, DouyuCollabOrderOut +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 [] + + 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, **_): + return {"records": self.records} + + def exchange_orders(self, **_): + 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_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_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/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}"] diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 87d3fc3..b54c62d 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = "20261019_0061" +HEAD_REVISION = "20261020_0062" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/main.py b/web/backend/main.py index 2ef9de5..1366b49 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -24,6 +24,7 @@ from .routers import ( cookies, dashboard, douyu, + douyu_collab, files, huya, login, @@ -153,6 +154,7 @@ app.include_router(proxy.router) app.include_router(cookies.router) app.include_router(huya.router) app.include_router(douyu.router) +app.include_router(douyu_collab.router) app.include_router(yyb.router) app.include_router(audit.router) app.include_router(automation.router) diff --git a/web/backend/migrations/versions/20261020_0062_douyu_external_orders.py b/web/backend/migrations/versions/20261020_0062_douyu_external_orders.py new file mode 100644 index 0000000..9bd7c7a --- /dev/null +++ b/web/backend/migrations/versions/20261020_0062_douyu_external_orders.py @@ -0,0 +1,245 @@ +"""斗鱼外部单协作台账:订单、店铺字典、查询快照。 + +一行 = 一单 = 一个内部斗鱼账号。**不复制 CK**:协作单只存 account_id 与用户名快照, +所有查询实时读 ``accounts.cookie``,账号换 CK 后协作单自动使用最新凭据。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261020_0062" +down_revision: str | None = "20261019_0061" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +ORDERS = "douyu_external_orders" +METRICS = "douyu_external_order_metrics" +SHOPS = "douyu_collab_shops" +USERS = "users" +ACCOUNTS = "accounts" + + +def _tables(bind) -> set[str]: + return set(sa.inspect(bind).get_table_names()) + + +def upgrade() -> None: + bind = op.get_bind() + tables = _tables(bind) + + if SHOPS not in tables: + op.create_table( + SHOPS, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column( + "remark", sa.String(length=255), nullable=False, server_default="" + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("updated_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["created_by"], [f"{USERS}.id"], name=f"fk_{SHOPS}_created_by" + ), + sa.ForeignKeyConstraint( + ["updated_by"], [f"{USERS}.id"], name=f"fk_{SHOPS}_updated_by" + ), + ) + op.create_index(f"ix_{SHOPS}_name", SHOPS, ["name"], unique=True) + op.create_index(f"ix_{SHOPS}_is_active", SHOPS, ["is_active"]) + + if ORDERS not in tables: + op.create_table( + ORDERS, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("order_no", sa.String(length=64), nullable=False), + sa.Column("shop", sa.String(length=64), nullable=False, server_default=""), + sa.Column("owner_id", sa.Integer(), nullable=True), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column( + "account_username", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column( + "account_uid", sa.String(length=32), nullable=False, server_default="" + ), + sa.Column( + "account_nickname", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column( + "bind_game_account", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column( + "bind_game_role", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column( + "bind_game_channel", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column("bind_snapshot_at", sa.DateTime(), nullable=True), + sa.Column("home_images", sa.JSON(), nullable=True), + sa.Column( + "gift_name", sa.String(length=128), nullable=False, server_default="" + ), + sa.Column( + "commodity_id", sa.String(length=64), nullable=False, server_default="" + ), + sa.Column("required_points", sa.Integer(), nullable=True), + sa.Column( + "remark", sa.String(length=512), nullable=False, server_default="" + ), + sa.Column( + "status", + sa.String(length=16), + nullable=False, + server_default="incomplete", + ), + sa.Column("recharged_at", sa.DateTime(), nullable=True), + sa.Column("scripted_at", sa.DateTime(), nullable=True), + sa.Column("done_at", sa.DateTime(), nullable=True), + sa.Column( + "void_reason", sa.String(length=256), nullable=False, server_default="" + ), + sa.Column("fish_fin_balance", sa.Integer(), nullable=True), + sa.Column("points_balance", sa.Integer(), nullable=True), + sa.Column("points_required_total", sa.Integer(), nullable=True), + sa.Column("points_surplus", sa.Integer(), nullable=True), + sa.Column("points_threshold", sa.Integer(), nullable=True), + sa.Column("points_checked_at", sa.DateTime(), nullable=True), + sa.Column( + "points_check_reason", + sa.String(length=16), + nullable=False, + server_default="", + ), + sa.Column( + "points_check_message", + sa.String(length=512), + nullable=False, + server_default="", + ), + sa.Column("points_check_detail", sa.JSON(), nullable=True), + sa.Column("archived_at", sa.DateTime(), nullable=True), + sa.Column("archived_by", sa.Integer(), nullable=True), + sa.Column( + "archive_reason", + sa.String(length=256), + nullable=False, + server_default="", + ), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("updated_by", sa.Integer(), nullable=True), + sa.Column("version", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["owner_id"], [f"{USERS}.id"], name=f"fk_{ORDERS}_owner_id" + ), + sa.ForeignKeyConstraint( + ["account_id"], + [f"{ACCOUNTS}.id"], + name=f"fk_{ORDERS}_account_id", + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["archived_by"], [f"{USERS}.id"], name=f"fk_{ORDERS}_archived_by" + ), + sa.ForeignKeyConstraint( + ["created_by"], [f"{USERS}.id"], name=f"fk_{ORDERS}_created_by" + ), + sa.ForeignKeyConstraint( + ["updated_by"], [f"{USERS}.id"], name=f"fk_{ORDERS}_updated_by" + ), + ) + op.create_index(f"ix_{ORDERS}_order_no", ORDERS, ["order_no"], unique=True) + op.create_index(f"ix_{ORDERS}_shop", ORDERS, ["shop"]) + op.create_index(f"ix_{ORDERS}_owner_id", ORDERS, ["owner_id"]) + op.create_index(f"ix_{ORDERS}_account_id", ORDERS, ["account_id"]) + op.create_index(f"ix_{ORDERS}_account_username", ORDERS, ["account_username"]) + op.create_index(f"ix_{ORDERS}_status", ORDERS, ["status"]) + op.create_index(f"ix_{ORDERS}_created_by", ORDERS, ["created_by"]) + op.create_index(f"ix_{ORDERS}_archived_at", ORDERS, ["archived_at"]) + op.create_index(f"ix_{ORDERS}_updated_at", ORDERS, ["updated_at"]) + + if METRICS not in tables: + op.create_table( + METRICS, + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("order_id", sa.Integer(), nullable=False), + sa.Column( + "order_no", sa.String(length=64), nullable=False, server_default="" + ), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column( + "account_username", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column("fish_fin_balance", sa.Integer(), nullable=True), + sa.Column("points_balance", sa.Integer(), nullable=True), + sa.Column("points_required_total", sa.Integer(), nullable=True), + sa.Column("points_surplus", sa.Integer(), nullable=True), + sa.Column( + "records_total", sa.Integer(), nullable=False, server_default="0" + ), + sa.Column( + "records_matched", sa.Integer(), nullable=False, server_default="0" + ), + sa.Column( + "verdict", + sa.String(length=16), + nullable=False, + server_default="unknown", + ), + sa.Column( + "message", sa.String(length=512), nullable=False, server_default="" + ), + sa.Column( + "source", sa.String(length=16), nullable=False, server_default="manual" + ), + sa.Column("raw", sa.JSON(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["order_id"], + [f"{ORDERS}.id"], + name=f"fk_{METRICS}_order_id", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["created_by"], [f"{USERS}.id"], name=f"fk_{METRICS}_created_by" + ), + ) + op.create_index(f"ix_{METRICS}_order_id", METRICS, ["order_id"]) + op.create_index(f"ix_{METRICS}_account_id", METRICS, ["account_id"]) + op.create_index(f"ix_{METRICS}_verdict", METRICS, ["verdict"]) + op.create_index(f"ix_{METRICS}_created_at", METRICS, ["created_at"]) + + +def downgrade() -> None: + bind = op.get_bind() + tables = _tables(bind) + for table in (METRICS, ORDERS, SHOPS): + if table in tables: + op.drop_table(table) diff --git a/web/backend/models.py b/web/backend/models.py index 1e794e6..28d8182 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -514,6 +514,172 @@ class DouyuEsportsGoodsSnapshot(Base): ) +class DouyuCollabShop(Base): + """斗鱼外部单协作的店铺字典(新建单时点选,历史文本自动登记)。""" + + __tablename__ = "douyu_collab_shops" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column( + String(64), unique=True, nullable=False, index=True + ) + sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="1", index=True + ) + remark: Mapped[str] = mapped_column(String(255), default="", server_default="") + created_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + updated_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow) + updated_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=_utcnow, onupdate=_utcnow + ) + + +class DouyuExternalOrder(Base): + """斗鱼外部单协作台账(一行 = 一单 = 一个内部斗鱼账号)。 + + 和虎牙外部 CK 台账的关键区别:**不复制 CK**。协作单只存 ``account_id`` + (可空,账号删除后置空)与用户名/UID 快照,任何查询都实时读 + ``accounts.cookie``。账号 CK 更新后,协作单自动使用最新凭据。 + """ + + __tablename__ = "douyu_external_orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + order_no: Mapped[str] = mapped_column( + String(64), unique=True, nullable=False, index=True + ) + shop: Mapped[str] = mapped_column( + String(64), default="", server_default="", index=True + ) + owner_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True, index=True + ) + + # ---- 内部斗鱼账号(CK 不落本表)---- + account_id: Mapped[int | None] = mapped_column( + ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True + ) + account_username: Mapped[str] = mapped_column( + String(128), default="", server_default="", index=True + ) + account_uid: Mapped[str] = mapped_column(String(32), default="", server_default="") + account_nickname: Mapped[str] = mapped_column( + String(128), default="", server_default="" + ) + + # ---- 绑定角色只读快照(来自 memberthird/tencent/bindInfo)---- + bind_game_account: Mapped[str] = mapped_column( + String(128), default="", server_default="" + ) + bind_game_role: Mapped[str] = mapped_column( + String(128), default="", server_default="" + ) + bind_game_channel: Mapped[str] = mapped_column( + String(128), default="", server_default="" + ) + bind_snapshot_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) + + # ---- 订单资料 ---- + home_images: Mapped[list | None] = mapped_column(JSON, nullable=True) + gift_name: Mapped[str] = mapped_column(String(128), default="", server_default="") + commodity_id: Mapped[str] = mapped_column(String(64), default="", server_default="") + required_points: Mapped[int | None] = mapped_column(Integer, nullable=True) + remark: Mapped[str] = mapped_column(String(512), default="", server_default="") + + # ---- 业务进度 ---- + status: Mapped[str] = mapped_column( + String(16), default="incomplete", server_default="incomplete", index=True + ) + recharged_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) + scripted_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) + done_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) + void_reason: Mapped[str] = mapped_column(String(256), default="", server_default="") + + # ---- 鱼翅 / 积分 / 核账快照 ---- + fish_fin_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) + points_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) + points_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + points_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True) + points_threshold: Mapped[int | None] = mapped_column(Integer, nullable=True) + points_checked_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) + points_check_reason: Mapped[str] = mapped_column( + String(16), default="", server_default="" + ) + points_check_message: Mapped[str] = mapped_column( + String(512), default="", server_default="" + ) + points_check_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + # ---- 归档 ---- + archived_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True, index=True + ) + archived_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + archive_reason: Mapped[str] = mapped_column( + String(256), default="", server_default="" + ) + + created_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True, index=True + ) + updated_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + version: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow) + updated_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=_utcnow, onupdate=_utcnow, index=True + ) + + +class DouyuExternalOrderMetric(Base): + """斗鱼协作单的鱼翅/积分/兑换记录快照(只追加,不存 CK)。""" + + __tablename__ = "douyu_external_order_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + order_id: Mapped[int] = mapped_column( + ForeignKey("douyu_external_orders.id", ondelete="CASCADE"), index=True + ) + order_no: Mapped[str] = mapped_column(String(64), default="", server_default="") + account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + account_username: Mapped[str] = mapped_column( + String(128), default="", server_default="" + ) + fish_fin_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) + points_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) + points_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + points_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True) + records_total: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + records_matched: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + verdict: Mapped[str] = mapped_column( + String(16), default="unknown", server_default="unknown", index=True + ) + message: Mapped[str] = mapped_column(String(512), default="", server_default="") + source: Mapped[str] = mapped_column( + String(16), default="manual", server_default="manual" + ) + raw: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + UTCDateTime, default=_utcnow, index=True + ) + + class HuyaAccount(Base): """虎牙账号""" diff --git a/web/backend/permissions.py b/web/backend/permissions.py index b502f80..6905fdc 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -31,6 +31,10 @@ PERMISSIONS = { # 斗鱼活动 "douyu:task": "斗鱼任务管理", "douyu:config": "斗鱼配置管理", + # 斗鱼外部单协作(订单挂内部账号,不复制 CK) + "douyu:collab:view": "查看斗鱼外部单协作", + "douyu:collab:edit": "编辑斗鱼外部单协作", + "douyu:collab:manage": "删除斗鱼外部单协作", "yyb:session": "应用宝扫码登录与角色查询", "yyb:recharge": "应用宝充值与付款", "yyb:manage": "接管并操作他人的应用宝任务", @@ -87,6 +91,9 @@ ROLE_PERMISSIONS = { "cookie:export", "douyu:task", "douyu:config", + "douyu:collab:view", + "douyu:collab:edit", + "douyu:collab:manage", "yyb:session", "yyb:recharge", "yyb:manage", @@ -120,6 +127,8 @@ ROLE_PERMISSIONS = { "account:tag", "account:remark", "douyu:task", + "douyu:collab:view", + "douyu:collab:edit", "yyb:session", "yyb:history", "huya:view_assigned", diff --git a/web/backend/routers/douyu_collab.py b/web/backend/routers/douyu_collab.py new file mode 100644 index 0000000..4c0647b --- /dev/null +++ b/web/backend/routers/douyu_collab.py @@ -0,0 +1,573 @@ +"""斗鱼外部单协作接口。 + +页面只做参数校验、权限和异常翻译;业务规则见 +``web/backend/services/douyu_collab_service.py``。 +""" + +from __future__ import annotations + +import asyncio + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + WebSocket, + WebSocketDisconnect, +) +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import authenticate_websocket, require_permission +from ..models import User +from ..permissions import get_user_permissions +from ..schemas import ( + DouyuCollabAccountLookupOut, + DouyuCollabAccountLookupRequest, + DouyuCollabAccountRequest, + DouyuCollabArchiveRequest, + DouyuCollabGoodsOut, + DouyuCollabImagesRequest, + DouyuCollabMetricOut, + DouyuCollabOrderCreate, + DouyuCollabOrderListOut, + DouyuCollabOrderOut, + DouyuCollabOrderUpdate, + DouyuCollabOwnerOut, + DouyuCollabShopCreate, + DouyuCollabShopOut, + DouyuCollabShopUpdate, + DouyuCollabStatusBatchRequest, + DouyuCollabStatusRequest, + DouyuCollabSummaryOut, + DouyuCollabTimelineOut, +) +from ..services import douyu_collab_service as collab +from ..services.douyu_collab_events import collab_events + +router = APIRouter(prefix="/api/douyu", tags=["斗鱼外部单协作"]) + + +def _http_error(exc: collab.CollabError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail=exc.detail) + + +def _one(db: Session, order) -> dict: + return collab.serialize_one(db, order) + + +def _shop_out(shop) -> dict: + return { + "id": int(shop.id), + "name": shop.name, + "sort_order": int(shop.sort_order or 0), + "is_active": bool(shop.is_active), + "remark": shop.remark or "", + "created_by": shop.created_by, + "updated_by": shop.updated_by, + "created_at": shop.created_at, + "updated_at": shop.updated_at, + } + + +def _publish_change(current: User, *, scope: str, action: str) -> None: + collab_events.publish(scope=scope, action=action, actor_id=int(current.id)) + + +@router.websocket("/external-orders/ws") +async def ws_external_orders(websocket: WebSocket): + """协作台账实时失效通知:前端收到后按自己的筛选静默重拉。""" + user = authenticate_websocket(websocket) + if not user or "douyu:collab:view" not in get_user_permissions(user): + await websocket.close(code=1008, reason="未授权") + return + await websocket.accept() + queue = collab_events.subscribe() + try: + await websocket.send_json(collab_events.ready_event()) + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=25) + await websocket.send_json(event) + except TimeoutError: + await websocket.send_json(collab_events.ping_event()) + except (WebSocketDisconnect, RuntimeError): + pass + finally: + collab_events.unsubscribe(queue) + + +@router.get("/external-orders/summary", response_model=DouyuCollabSummaryOut) +def external_orders_summary( + include_void: bool = Query(False), + archive_scope: str = Query("active"), + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + return collab.summary(db, include_void=include_void, archive_scope=archive_scope) + + +@router.get("/external-orders/owners", response_model=list[DouyuCollabOwnerOut]) +def external_orders_owners( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + rows = ( + db.query(User.id, User.username) + .filter(User.is_active.is_(True)) + .order_by(User.username) + .all() + ) + return [ + {"id": int(user_id), "username": str(username)} for user_id, username in rows + ] + + +@router.get("/external-orders/shops", response_model=list[str]) +def external_orders_shops( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + return [shop.name for shop in collab.list_shops(db, active_only=False)] + + +@router.get("/external-orders/gifts", response_model=list[str]) +def external_orders_gifts( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + return collab.list_gift_names(db) + + +@router.get("/external-orders/goods", response_model=list[DouyuCollabGoodsOut]) +def external_orders_goods( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + return collab.serialized_goods(db) + + +@router.post("/external-orders/goods/refresh") +def external_orders_goods_refresh( + account_id: int | None = Query(None), + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + result = collab.refresh_activity_goods(db, current, account_id=account_id) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="shops", action="goods.refresh") + return result + + +@router.post( + "/external-orders/accounts/lookup", + response_model=DouyuCollabAccountLookupOut, +) +def external_orders_account_lookup( + req: DouyuCollabAccountLookupRequest, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:edit")), +): + return collab.lookup_account(db, req.username, with_goods=req.with_goods) + + +@router.get("/external-orders", response_model=DouyuCollabOrderListOut) +def list_external_orders( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), + include_void: bool = Query(False), + archive_scope: str = Query("active"), + status: str = Query(""), + shop: str = Query(""), + owner_id: int | None = Query(None), + gift_name: str = Query(""), + keyword: str = Query(""), + ck_status: str = Query(""), + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + return collab.list_orders( + db, + page=page, + page_size=page_size, + include_void=include_void, + archive_scope=archive_scope, + status=status, + shop=shop, + owner_id=owner_id, + gift_name=gift_name, + keyword=keyword, + ck_status=ck_status, + ) + + +@router.post("/external-orders", response_model=DouyuCollabOrderOut) +def create_external_order( + req: DouyuCollabOrderCreate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + order = collab.create_order( + db, + current, + order_no=req.order_no, + shop=req.shop, + owner_id=req.owner_id, + account_username=req.account_username, + gift_name=req.gift_name, + commodity_id=req.commodity_id, + required_points=req.required_points, + home_images=req.home_images, + remark=req.remark, + draft=req.draft, + require_verified=req.require_verified, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.create") + return _one(db, order) + + +@router.get("/external-orders/{order_id}", response_model=DouyuCollabOrderOut) +def get_external_order( + order_id: int, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + try: + return _one(db, collab.get_order(db, order_id)) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.put("/external-orders/{order_id}", response_model=DouyuCollabOrderOut) +def update_external_order( + order_id: int, + req: DouyuCollabOrderUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + collab.update_order( + db, + current, + order, + req.model_dump(exclude_unset=True), + expected_version=req.expected_version, + ) + _publish_change(current, scope="orders", action="order.update") + return _one(db, order) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.post("/external-orders/{order_id}/account", response_model=DouyuCollabOrderOut) +def set_external_order_account( + order_id: int, + req: DouyuCollabAccountRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + collab.set_account( + db, + current, + order, + req.account_username, + expected_version=req.expected_version, + ) + _publish_change(current, scope="orders", action="order.account") + return _one(db, order) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.post("/external-orders/{order_id}/verify") +def verify_external_order( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + result = collab.verify_order(db, current, order) + _publish_change(current, scope="orders", action="order.verify") + return {"order": _one(db, result["order"]), "probe": result["probe"]} + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.post("/external-orders/verify/batch") +def verify_external_orders_batch( + ids: list[int], + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + results: list[dict] = [] + for order_id in ids: + try: + order = collab.get_order(db, order_id) + result = collab.verify_order(db, current, order) + probe = result["probe"] + results.append( + { + "id": order_id, + "success": True, + "ck_status": probe.get("ck_status"), + "role": probe.get("bind_game_role"), + } + ) + except Exception as exc: # noqa: BLE001 - 批量逐行隔离 + db.rollback() + results.append({"id": order_id, "success": False, "message": str(exc)}) + if any(item["success"] for item in results): + _publish_change(current, scope="orders", action="order.verify_batch") + return {"results": results, "success": all(item["success"] for item in results)} + + +@router.post("/external-orders/{order_id}/activity/query") +def query_external_order_activity( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + result = collab.query_activity(db, current, order, source="manual") + _publish_change(current, scope="orders", action="order.activity") + return { + "order": _one(db, result["order"]), + "metric": collab.serialize_metric(result["metric"]), + "verdict": result["verdict"], + "message": result["message"], + "points_reason": result["points_reason"], + "fish_fin_balance": result["fish_fin_balance"], + "points_balance": result["points_balance"], + "required_total": result["required_total"], + "surplus": result["surplus"], + "errors": result["errors"], + } + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.post("/external-orders/{order_id}/status", response_model=DouyuCollabOrderOut) +def set_external_order_status( + order_id: int, + req: DouyuCollabStatusRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + collab.set_status( + db, + current, + order, + status=req.status, + reason=req.reason, + rollback=req.rollback, + expected_version=req.expected_version, + ) + _publish_change(current, scope="orders", action="order.status") + return _one(db, order) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.post("/external-orders/status/batch") +def set_external_order_status_batch( + req: DouyuCollabStatusBatchRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + results: list[dict] = [] + for order_id in req.ids: + try: + order = collab.get_order(db, order_id) + collab.set_status( + db, + current, + order, + status=req.status, + reason=req.reason, + rollback=req.rollback, + ) + results.append({"id": order_id, "success": True}) + except Exception as exc: # noqa: BLE001 - 批量逐行隔离 + db.rollback() + results.append({"id": order_id, "success": False, "message": str(exc)}) + if any(item["success"] for item in results): + _publish_change(current, scope="orders", action="order.status_batch") + return {"results": results, "success": all(item["success"] for item in results)} + + +@router.post("/external-orders/activity/query") +def query_external_orders_activity( + ids: list[int], + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + results: list[dict] = [] + for order_id in ids: + try: + order = collab.get_order(db, order_id) + result = collab.query_activity(db, current, order, source="batch") + results.append( + { + "id": order_id, + "success": True, + "verdict": result["verdict"], + "message": result["message"], + } + ) + except Exception as exc: # noqa: BLE001 + db.rollback() + results.append({"id": order_id, "success": False, "message": str(exc)}) + if any(item["success"] for item in results): + _publish_change(current, scope="orders", action="order.activity_batch") + return {"results": results, "success": all(item["success"] for item in results)} + + +@router.post("/external-orders/{order_id}/images", response_model=DouyuCollabOrderOut) +def update_external_order_images( + order_id: int, + req: DouyuCollabImagesRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + collab.update_order( + db, + current, + order, + {"home_images": req.home_images}, + expected_version=req.expected_version, + ) + _publish_change(current, scope="orders", action="order.images") + return _one(db, order) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.post("/external-orders/archive") +def archive_external_orders( + req: DouyuCollabArchiveRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + result = collab.archive_orders(db, current, req.ids, reason=req.reason) + if result.get("archived"): + _publish_change(current, scope="orders", action="order.archive") + return result + + +@router.post("/external-orders/unarchive") +def unarchive_external_orders( + ids: list[int], + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + result = collab.unarchive_orders(db, current, ids) + if result.get("unarchived"): + _publish_change(current, scope="orders", action="order.unarchive") + return result + + +@router.get( + "/external-orders/{order_id}/timeline", + response_model=list[DouyuCollabTimelineOut], +) +def external_order_timeline( + order_id: int, + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + return collab.order_timeline(db, order_id, limit=limit) + + +@router.get( + "/external-orders/{order_id}/metrics", + response_model=list[DouyuCollabMetricOut], +) +def external_order_metrics( + order_id: int, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + return collab.order_metrics(db, order_id) + + +@router.delete("/external-orders/{order_id}") +def delete_external_order( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:manage")), +): + try: + result = collab.delete_order(db, current, collab.get_order(db, order_id)) + _publish_change(current, scope="orders", action="order.delete") + return result + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.get("/collab-shops", response_model=list[DouyuCollabShopOut]) +def list_collab_shops( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("douyu:collab:view")), +): + rows = collab.list_shops(db) + return [_shop_out(shop) for shop in rows] + + +@router.post("/collab-shops", response_model=DouyuCollabShopOut) +def create_collab_shop( + req: DouyuCollabShopCreate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + shop = collab.create_shop(db, current, name=req.name, remark=req.remark) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="shops", action="shop.create") + return _shop_out(shop) + + +@router.put("/collab-shops/{shop_id}", response_model=DouyuCollabShopOut) +def update_collab_shop( + shop_id: int, + req: DouyuCollabShopUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:edit")), +): + try: + shop = collab.get_shop(db, shop_id) + collab.update_shop(db, current, shop, req.model_dump(exclude_unset=True)) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="shops", action="shop.update") + return _shop_out(shop) + + +@router.delete("/collab-shops/{shop_id}") +def delete_collab_shop( + shop_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("douyu:collab:manage")), +): + try: + result = collab.delete_shop(db, current, collab.get_shop(db, shop_id)) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="shops", action="shop.delete") + return {**result, "id": shop_id} diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 85e06be..7cea2db 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -1669,3 +1669,247 @@ class HuyaExternalOrderAuditOut(BaseModel): detail: str = "" success: bool | None = None created_at: datetime | None = None + + +# ==================== 斗鱼外部单协作 ==================== + + +class DouyuCollabShopCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=64) + remark: str = Field("", max_length=255) + + +class DouyuCollabShopUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=64) + sort_order: int | None = None + is_active: bool | None = None + remark: str | None = Field(None, max_length=255) + + +class DouyuCollabShopOut(BaseModel): + id: int + name: str + sort_order: int = 0 + is_active: bool = True + remark: str = "" + created_by: int | None = None + updated_by: int | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class DouyuCollabGoodsOut(BaseModel): + commodity_id: str = "" + name: str = "" + score: int | None = None + status: str = "" + open_status: int | None = None + start_time: int | None = None + end_time: int | None = None + mark: str = "" + count: int | None = None + elite_limited: int | None = None + is_limited: bool = False + raw: dict[str, Any] | None = None + + +class DouyuCollabAccountLookupRequest(BaseModel): + username: str = Field(..., min_length=1, max_length=128) + with_goods: bool = True + + +class DouyuCollabAccountLookupOut(BaseModel): + found: bool = False + ok: bool = False + message: str = "" + errors: list[str] = Field(default_factory=list) + account_id: int | None = None + account_username: str = "" + account_uid: str = "" + account_nickname: str = "" + ck_status: str = "" + ck_status_label: str = "" + manual_type: int | None = None + manual_score: int | None = None + expire_time: int | None = None + manual_opened: bool = False + bind_game_account: str = "" + bind_game_role: str = "" + bind_game_channel: str = "" + fish_fin_balance: int | None = None + goods: list[DouyuCollabGoodsOut] = Field(default_factory=list) + goods_count: int = 0 + orders_using_account: list[dict[str, Any]] = Field(default_factory=list) + + +class DouyuCollabOrderCreate(BaseModel): + order_no: str = Field(..., min_length=1, max_length=64) + shop: str = Field("", max_length=64) + owner_id: int | None = None + account_username: str = Field("", max_length=128) + gift_name: str = Field("", max_length=128) + commodity_id: str = Field("", max_length=64) + required_points: int | None = Field(None, ge=0) + home_images: list[str] = Field(default_factory=list) + remark: str = Field("", max_length=512) + draft: bool = False + require_verified: bool = True + + +class DouyuCollabOrderUpdate(BaseModel): + order_no: str | None = Field(None, max_length=64) + shop: str | None = Field(None, max_length=64) + owner_id: int | None = None + account_username: str | None = Field(None, max_length=128) + gift_name: str | None = Field(None, max_length=128) + commodity_id: str | None = Field(None, max_length=64) + required_points: int | None = Field(None, ge=0) + home_images: list[str] | None = None + remark: str | None = Field(None, max_length=512) + expected_version: int | None = None + + +class DouyuCollabStatusRequest(BaseModel): + status: str = Field(..., min_length=1, max_length=16) + reason: str = Field("", max_length=256) + rollback: bool = False + expected_version: int | None = None + + +class DouyuCollabStatusBatchRequest(BaseModel): + ids: list[int] = Field(default_factory=list) + status: str = Field(..., min_length=1, max_length=16) + reason: str = Field("", max_length=256) + rollback: bool = False + + +class DouyuCollabAccountRequest(BaseModel): + account_username: str = Field(..., min_length=1, max_length=128) + expected_version: int | None = None + + +class DouyuCollabImagesRequest(BaseModel): + home_images: list[str] = Field(default_factory=list) + expected_version: int | None = None + + +class DouyuCollabArchiveRequest(BaseModel): + ids: list[int] = Field(default_factory=list) + reason: str = Field("", max_length=256) + + +class DouyuCollabOrderOut(BaseModel): + id: int + order_no: str + shop: str = "" + owner_id: int | None = None + owner_name: str = "" + + account_id: int | None = None + account_username: str = "" + account_uid: str = "" + account_nickname: str = "" + account_exists: bool = False + has_cookie: bool = False + account_ck_status: str = "" + account_status: str = "" + + bind_game_account: str = "" + bind_game_role: str = "" + bind_game_channel: str = "" + bind_snapshot_at: datetime | None = None + + home_images: list[str] = Field(default_factory=list) + home_image_urls: list[str] = Field(default_factory=list) + home_image_thumb_urls: list[str] = Field(default_factory=list) + gift_name: str = "" + commodity_id: str = "" + required_points: int | None = None + remark: str = "" + + status: str = "incomplete" + status_label: str = "" + next_status: str | None = None + missing_fields: list[str] = Field(default_factory=list) + fish_fin_balance: int | None = None + points_balance: int | None = None + points_required_total: int | None = None + points_surplus: int | None = None + points_threshold: int | None = None + points_checked_at: datetime | None = None + points_check_reason: str = "" + points_check_message: str = "" + points_check_detail: dict[str, Any] | None = None + recharged_at: datetime | None = None + scripted_at: datetime | None = None + done_at: datetime | None = None + void_reason: str = "" + + is_archived: bool = False + archived_at: datetime | None = None + archived_by: int | None = None + archived_by_name: str = "" + archive_reason: str = "" + + created_by: int | None = None + created_by_name: str = "" + updated_by: int | None = None + updated_by_name: str = "" + version: int = 0 + created_at: datetime | None = None + updated_at: datetime | None = None + account_cluster: dict[str, Any] | None = None + + last_metric_at: datetime | None = None + last_metric_message: str = "" + last_verdict: str = "" + last_verdict_label: str = "" + last_records_total: int = 0 + last_records_matched: int = 0 + + +class DouyuCollabOrderListOut(BaseModel): + items: list[DouyuCollabOrderOut] + total: int + page: int + page_size: int + + +class DouyuCollabSummaryOut(BaseModel): + total: int + by_status: dict[str, int] + account_missing: int = 0 + ck_invalid: int = 0 + archived_total: int = 0 + + +class DouyuCollabOwnerOut(BaseModel): + id: int + username: str + + +class DouyuCollabMetricOut(BaseModel): + id: int + order_id: int + order_no: str = "" + account_username: str = "" + fish_fin_balance: int | None = None + points_balance: int | None = None + points_required_total: int | None = None + points_surplus: int | None = None + records_total: int = 0 + records_matched: int = 0 + verdict: str = "" + verdict_label: str = "" + message: str = "" + source: str = "" + created_at: datetime | None = None + + +class DouyuCollabTimelineOut(BaseModel): + id: int + action: str + username: str = "" + detail: str = "" + success: bool | None = None + created_at: datetime | None = None diff --git a/web/backend/services/douyu_collab_events.py b/web/backend/services/douyu_collab_events.py new file mode 100644 index 0000000..016d76f --- /dev/null +++ b/web/backend/services/douyu_collab_events.py @@ -0,0 +1,89 @@ +"""斗鱼外部单协作台账的进程内实时事件总线。 + +写接口提交成功后只发布「台账失效」事件,前端收到后按自己的筛选静默重拉, +不在事件里携带整行数据。当前生产单 worker,进程内队列足够;多实例时再换 Redis。 +""" + +from __future__ import annotations + +import asyncio +import itertools +import threading +from datetime import UTC, datetime +from typing import Any + + +class CollabEventHub: + def __init__(self) -> None: + self._lock = threading.Lock() + self._revisions = itertools.count(1) + self._current_revision = 0 + self._loop: asyncio.AbstractEventLoop | None = None + self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set() + + def subscribe(self) -> asyncio.Queue[dict[str, Any]]: + loop = asyncio.get_running_loop() + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100) + with self._lock: + self._loop = loop + self._subscribers.add(queue) + return queue + + def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None: + with self._lock: + self._subscribers.discard(queue) + + def ready_event(self) -> dict[str, Any]: + with self._lock: + revision = self._current_revision + return {"type": "collab.ready", "revision": revision} + + def ping_event(self) -> dict[str, Any]: + with self._lock: + revision = self._current_revision + return {"type": "collab.ping", "revision": revision} + + def publish( + self, + *, + scope: str, + action: str, + actor_id: int | None = None, + ) -> dict[str, Any]: + with self._lock: + revision = next(self._revisions) + self._current_revision = revision + loop = self._loop + subscribers = list(self._subscribers) + + event = { + "type": "collab.invalidate", + "revision": revision, + "scope": scope, + "action": action, + "actor_id": actor_id, + "at": datetime.now(UTC).isoformat(), + } + if loop is None or not subscribers: + return event + for queue in subscribers: + try: + loop.call_soon_threadsafe(self._offer, queue, event) + except RuntimeError: + continue + return event + + @staticmethod + def _offer(queue: asyncio.Queue[dict[str, Any]], event: dict[str, Any]) -> None: + while queue.full(): + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break + try: + queue.put_nowait(event) + except asyncio.QueueFull: + pass + + +collab_events = CollabEventHub() diff --git a/web/backend/services/douyu_collab_service.py b/web/backend/services/douyu_collab_service.py new file mode 100644 index 0000000..ff01bda --- /dev/null +++ b/web/backend/services/douyu_collab_service.py @@ -0,0 +1,1988 @@ +"""斗鱼外部单协作台账服务。 + +核心边界(和虎牙外部 CK 台账的区别): + +1. **一行 = 一单 = 一个内部斗鱼账号**。协作单只存 ``account_id`` 和用户名快照, + **不复制 CK**;所有查询实时读 ``accounts.cookie``,账号换 CK 后自动生效。 +2. 鱼翅和手册积分是两笔资产:先充值鱼翅,再用鱼翅赠送精英令换积分。 + 查询时两笔都要返回,核账只看手册积分。 +3. 同一斗鱼账号允许挂多单;积分核账按账号合并计算所有未作废订单的礼包积分。 +4. 兑换是否成功只看当前账号的 ``exchangeList``。``getRecordList`` 是全站动态, + 不能拿来判断某一单。 +""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient + +from ..models import ( + Account, + AuditLog, + DouyuCollabShop, + DouyuConfig, + DouyuExternalOrder, + DouyuExternalOrderMetric, + DouyuGoodsSnapshot, + User, +) +from .audit_service import record_audit +from .file_service import build_file_url + +# --------------------------------------------------------------------------- +# 状态机 +# --------------------------------------------------------------------------- + +STATUS_INCOMPLETE = "incomplete" +STATUS_TO_RECHARGE = "to_recharge" +STATUS_RECHARGED = "recharged" +STATUS_SCRIPTED = "scripted" +STATUS_DONE = "done" +STATUS_POINTS_ERROR = "points_error" +STATUS_FAILED = "failed" +STATUS_VOID = "void" + +ORDER_STATUSES: tuple[str, ...] = ( + STATUS_INCOMPLETE, + STATUS_TO_RECHARGE, + STATUS_RECHARGED, + STATUS_SCRIPTED, + STATUS_DONE, + STATUS_POINTS_ERROR, + STATUS_FAILED, + STATUS_VOID, +) + +ORDER_STATUS_LABELS = { + STATUS_INCOMPLETE: "待补资料", + STATUS_TO_RECHARGE: "待充值", + STATUS_RECHARGED: "已充值", + STATUS_SCRIPTED: "已上脚本", + STATUS_DONE: "抢购成功", + STATUS_POINTS_ERROR: "积分异常", + STATUS_FAILED: "抢购失败", + STATUS_VOID: "已作废", +} + +STATUS_FLOW: tuple[str, ...] = ( + STATUS_INCOMPLETE, + STATUS_TO_RECHARGE, + STATUS_RECHARGED, + STATUS_SCRIPTED, + STATUS_DONE, +) + +STAGE_INDEX = { + STATUS_INCOMPLETE: 0, + STATUS_TO_RECHARGE: 1, + STATUS_RECHARGED: 2, + STATUS_SCRIPTED: 3, + STATUS_DONE: 4, +} + +STATUS_TIMESTAMP_FIELDS = { + STATUS_RECHARGED: "recharged_at", + STATUS_SCRIPTED: "scripted_at", + STATUS_DONE: "done_at", +} + +ARCHIVE_SCOPES = ("active", "archived", "all") +ARCHIVABLE_STATUSES = (STATUS_DONE,) +# 同一账号核账只统计“还没有完成兑换”的单:done/scripted 不重复占积分。 +POINTS_GROUP_STATUSES: tuple[str, ...] = ( + STATUS_TO_RECHARGE, + STATUS_RECHARGED, + STATUS_POINTS_ERROR, +) + +POINTS_THRESHOLD_DEFAULT = 100 +POINTS_THRESHOLD_MAX = 1_000_000 +MAX_IMAGES = 20 + +VERDICT_SUCCESS = "success" +VERDICT_FAILED = "failed" +VERDICT_UNKNOWN = "unknown" + +CK_VALID = "valid" +CK_INVALID = "invalid" +CK_UNVERIFIED = "unverified" +CK_LABELS = { + CK_VALID: "CK 正常", + CK_INVALID: "CK 异常", + CK_UNVERIFIED: "未检测", +} + + +class CollabError(RuntimeError): + def __init__(self, detail: str, *, status_code: int = 400, code: str = ""): + super().__init__(detail) + self.detail = detail + self.status_code = status_code + self.code = code + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _to_int(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + +def _text(value: Any, *, max_length: int | None = None) -> str: + text = str(value or "").strip() + return text[:max_length] if max_length else text + + +def _json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _first_value(data: Mapping[str, Any], *keys: str) -> Any: + """取第一个存在且非 None 的字段;0 是合法值,不能用 `or` 兜。""" + for key in keys: + if key in data and data.get(key) is not None: + return data.get(key) + return None + + +def normalize_images(value: Any, *, field: str = "home_images") -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise CollabError(f"{field} 必须是图片 key 数组", code="images_invalid") + keys: list[str] = [] + for item in value: + key = _text(item, max_length=512) + if key and key not in keys: + keys.append(key) + if len(keys) > MAX_IMAGES: + raise CollabError(f"最多 {MAX_IMAGES} 张图片", code="images_too_many") + return keys + + +def normalize_threshold(value: Any) -> int: + number = _to_int(value) + if number is None: + return POINTS_THRESHOLD_DEFAULT + if number < 0: + raise CollabError("积分阈值不能小于 0", code="points_threshold_invalid") + return min(number, POINTS_THRESHOLD_MAX) + + +def resolve_config(db: Session) -> DouyuConfig: + config = db.query(DouyuConfig).order_by(DouyuConfig.id.asc()).first() + if config is None: + config = DouyuConfig() + db.add(config) + db.flush() + return config + + +def account_by_username(db: Session, username: str) -> Account | None: + name = _text(username, max_length=128) + if not name: + return None + return ( + db.query(Account) + .filter(Account.username == name) + .order_by(Account.id.asc()) + .first() + ) + + +def account_by_id(db: Session, account_id: int | None) -> Account | None: + if not account_id: + return None + return db.query(Account).filter(Account.id == account_id).first() + + +def users_by_id(db: Session, ids: set[int]) -> dict[int, str]: + clean = {int(item) for item in ids if item} + if not clean: + return {} + return { + int(user_id): str(username) + for user_id, username in db.query(User.id, User.username) + .filter(User.id.in_(clean)) + .all() + } + + +def accounts_by_id(db: Session, ids: set[int]) -> dict[int, Account]: + clean = {int(item) for item in ids if item} + if not clean: + return {} + return { + int(account.id): account + for account in db.query(Account).filter(Account.id.in_(clean)).all() + } + + +def _client_for_account(account: Account) -> DouyuActivityClient: + cookie = str(account.cookie or "").strip() + if not cookie: + raise CollabError("该账号没有 CK", code="account_no_cookie") + return DouyuActivityClient(cookie) + + +def serialize_live_goods(goods: list[dict[str, Any]]) -> list[dict[str, Any]]: + """把 storehome 原始商品映射成前端稳定字段。""" + result: list[dict[str, Any]] = [] + for raw in goods: + if not isinstance(raw, dict): + continue + commodity_id = _text( + _first_value(raw, "commodityId", "commodity_id"), max_length=64 + ) + name = _text(_first_value(raw, "commodityName", "name"), max_length=256) + open_status = _to_int(_first_value(raw, "openStatus", "open_status")) + mark = _text(_first_value(raw, "mark"), max_length=32) + status_value = _first_value(raw, "status") + result.append( + { + "commodity_id": commodity_id, + "name": name, + "score": _to_int(_first_value(raw, "score")), + "status": str(status_value) if status_value is not None else "", + "open_status": open_status, + "start_time": _to_int(_first_value(raw, "startTime", "start_time")), + "end_time": _to_int(_first_value(raw, "endTime", "end_time")), + "mark": mark, + "count": _to_int(_first_value(raw, "count")), + "elite_limited": _to_int(_first_value(raw, "eliteLimited")), + "is_limited": bool(mark) or open_status == 0, + "raw": _json_safe(raw), + } + ) + return result + + +def _probe_with_client( + account: Account, + *, + manual_id: str, + rid: str, + act_alias: str, + client: Any | None = None, + with_goods: bool = True, +) -> dict[str, Any]: + """只读探针:账号 CK → 手册积分 / 绑定角色 / 鱼翅余额 / 商品列表。""" + if not str(account.cookie or "").strip(): + return { + "found": True, + "ok": False, + "ck_status": CK_UNVERIFIED, + "message": "该账号还没有 CK", + "errors": ["该账号还没有 CK"], + } + + active_client = client or _client_for_account(account) + errors: list[str] = [] + elite: dict[str, Any] = {} + bind: dict[str, Any] = {} + gold: dict[str, Any] = {} + goods: list[dict[str, Any]] = [] + + try: + elite = active_client.elite_user_info(manual_id=manual_id, rid=rid) + except Exception as exc: # noqa: BLE001 - 外部接口边界 + errors.append(f"手册状态: {exc}") + + try: + bind = active_client.bind_info(act_alias=act_alias) + except Exception as exc: # noqa: BLE001 + errors.append(f"绑定角色: {exc}") + + try: + gold = active_client.gold_account() + except Exception as exc: # noqa: BLE001 + errors.append(f"鱼翅余额: {exc}") + + if with_goods: + try: + goods = ( + active_client.list_goods(manual_id=manual_id, rid=rid).get("goods") + or [] + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"活动商品: {exc}") + + manual_type = _to_int(elite.get("manual_type")) + manual_score = _to_int(elite.get("manual_score")) + expire_time = _to_int(elite.get("expire_time")) + user_info = elite.get("user_info") or {} + profile = DouyuActivityClient.profile_from_cookie(str(account.cookie or "")) + bind_role = _text(bind.get("role_name"), max_length=128) + area_name = _text(bind.get("area_name"), max_length=64) + plat_name = _text(bind.get("plat_name"), max_length=64) + bind_channel = " · ".join(item for item in (area_name, plat_name) if item) + bind_account = _text(bind.get("nickname"), max_length=128) + ck_status = CK_VALID if (elite or bind) else CK_INVALID + ok = bool(bind_role) and not (elite == {} and bind == {}) + + return { + "found": True, + "ok": ok, + "ck_status": ck_status, + "message": ";".join(errors) if errors else ("" if ok else "未读到绑定角色"), + "errors": errors, + "account_id": int(account.id), + "account_username": str(account.username or ""), + "account_uid": _text(account.uid or profile.get("uid"), max_length=32), + "account_nickname": _text( + user_info.get("nickname") or account.nickname or profile.get("nickname"), + max_length=128, + ), + "manual_type": manual_type, + "manual_score": manual_score, + "expire_time": expire_time, + "manual_opened": bool(manual_type is not None and manual_type >= 1), + "bind_game_account": bind_account, + "bind_game_role": bind_role, + "bind_game_channel": bind_channel, + "fish_fin_balance": _to_int(gold.get("gold")), + "goods": serialize_live_goods(goods), + "goods_count": len(goods), + } + + +def lookup_account( + db: Session, + username: str, + *, + client: Any | None = None, + with_goods: bool = True, +) -> dict[str, Any]: + config = resolve_config(db) + account = account_by_username(db, username) + if account is None: + return { + "found": False, + "ok": False, + "message": "账号不存在,请先到「账号管理」导入并登录", + "account_username": _text(username, max_length=128), + } + probe = _probe_with_client( + account, + manual_id=str(config.manual_id or ""), + rid=str(config.rid or ""), + act_alias=str(config.bind_act_alias or ""), + client=client, + with_goods=with_goods, + ) + orders = ( + db.query(DouyuExternalOrder) + .filter( + DouyuExternalOrder.account_id == account.id, + DouyuExternalOrder.archived_at.is_(None), + DouyuExternalOrder.status != STATUS_VOID, + ) + .order_by(DouyuExternalOrder.id.asc()) + .all() + ) + probe["orders_using_account"] = [ + {"id": int(order.id), "order_no": order.order_no, "status": order.status} + for order in orders + ] + probe["ck_status_label"] = CK_LABELS.get(str(probe.get("ck_status")), "") + return probe + + +def apply_probe_snapshot( + order: DouyuExternalOrder, account: Account, probe: Mapping[str, Any] +) -> None: + order.account_id = int(account.id) + order.account_username = _text(account.username, max_length=128) or _text( + probe.get("account_username"), max_length=128 + ) + order.account_uid = _text(probe.get("account_uid") or account.uid, max_length=32) + order.account_nickname = _text( + probe.get("account_nickname") or account.nickname, max_length=128 + ) + order.bind_game_account = _text(probe.get("bind_game_account"), max_length=128) + order.bind_game_role = _text(probe.get("bind_game_role"), max_length=128) + order.bind_game_channel = _text(probe.get("bind_game_channel"), max_length=128) + order.bind_snapshot_at = _now() + fish = _to_int(probe.get("fish_fin_balance")) + points = _to_int(probe.get("manual_score")) + if fish is not None: + order.fish_fin_balance = fish + account.gold_balance = fish + if points is not None: + order.points_balance = points + account.points = points + ck_status = str(probe.get("ck_status") or "") + if ck_status in (CK_VALID, CK_INVALID): + account.ck_check_status = ck_status + account.ck_checked_at = _now() + account.updated_at = _now() + + +def _assert_version(order: DouyuExternalOrder, expected: int | None) -> None: + if expected is not None and int(expected) != int(order.version or 0): + raise CollabError( + "这行已被别人修改,请刷新后再试", + status_code=409, + code="version_conflict", + ) + + +def _assert_mutable(order: DouyuExternalOrder) -> None: + if order.archived_at is not None: + raise CollabError("已归档的订单不能再编辑", code="order_archived") + if order.status == STATUS_VOID: + raise CollabError("已作废的订单不能再编辑", code="order_void") + + +def _resolve_gift( + db: Session, *, gift_name: str, commodity_id: str, required_points: Any +) -> tuple[str, str, int | None]: + name = _text(gift_name, max_length=128) + cid = _text(commodity_id, max_length=64) + points = _to_int(required_points) + query = db.query(DouyuGoodsSnapshot) + row = None + if cid: + row = query.filter(DouyuGoodsSnapshot.commodity_id == cid).first() + if row is None and name: + row = query.filter(DouyuGoodsSnapshot.name == name).first() + if row is not None: + if not name: + name = _text(row.name, max_length=128) + if not cid: + cid = _text(row.commodity_id, max_length=64) + if points is None: + points = _to_int(row.score) + return name, cid, points + + +def _missing_for_recharge(order: DouyuExternalOrder) -> list[str]: + missing: list[str] = [] + if not order.account_id or not _text(order.account_username): + missing.append("斗鱼账号") + if not order.gift_name: + missing.append("礼包") + if not order.home_images: + missing.append("主页截图") + if not order.bind_game_role: + missing.append("绑定角色(先校验账号)") + return missing + + +def _ready_for_recharge(order: DouyuExternalOrder) -> bool: + return not _missing_for_recharge(order) + + +def _maybe_advance_to_recharge( + db: Session, + current: User, + order: DouyuExternalOrder, + *, + commit: bool = False, +) -> None: + if order.status != STATUS_INCOMPLETE or not _ready_for_recharge(order): + return + old = order.status + order.status = STATUS_TO_RECHARGE + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:status_auto", + target=f"douyu_external_order:{order.id}", + detail={"from": old, "to": order.status, "reason": "资料齐全"}, + ) + if commit: + db.commit() + + +def create_order( + db: Session, + current: User, + *, + order_no: str, + shop: str = "", + owner_id: int | None = None, + account_username: str = "", + gift_name: str = "", + commodity_id: str = "", + required_points: Any = None, + home_images: Any = None, + remark: str = "", + draft: bool = False, + require_verified: bool = True, + client: Any | None = None, + commit: bool = True, +) -> DouyuExternalOrder: + order_text = _text(order_no, max_length=64) + if not order_text: + raise CollabError("订单号不能为空", code="order_no_required") + exists = ( + db.query(DouyuExternalOrder) + .filter(DouyuExternalOrder.order_no == order_text) + .first() + ) + if exists is not None: + raise CollabError("订单号已存在", status_code=409, code="order_no_exists") + + images = normalize_images(home_images) + name, cid, points = _resolve_gift( + db, + gift_name=gift_name, + commodity_id=commodity_id, + required_points=required_points, + ) + order = DouyuExternalOrder( + order_no=order_text, + shop=_text(shop, max_length=64), + owner_id=int(owner_id) if owner_id is not None else int(current.id), + account_username=_text(account_username, max_length=128), + gift_name=name, + commodity_id=cid, + required_points=points, + home_images=images, + remark=_text(remark, max_length=512), + status=STATUS_INCOMPLETE, + points_threshold=POINTS_THRESHOLD_DEFAULT, + created_by=current.id, + updated_by=current.id, + version=0, + ) + + account: Account | None = None + probe: dict[str, Any] | None = None + if order.account_username: + account = account_by_username(db, order.account_username) + if account is None and not draft: + raise CollabError( + "斗鱼账号不存在,请先到账号管理导入/登录", + code="account_not_found", + ) + if account is not None and not draft: + config = resolve_config(db) + probe = _probe_with_client( + account, + manual_id=str(config.manual_id or ""), + rid=str(config.rid or ""), + act_alias=str(config.bind_act_alias or ""), + client=client, + with_goods=True, + ) + apply_probe_snapshot(order, account, probe) + if points is None and name: + live = next( + ( + item + for item in (probe.get("goods") or []) + if str(item.get("commodityName") or "") == name + ), + None, + ) + if live is not None: + points = _to_int(live.get("score")) + order.required_points = points + elif account is not None: + # 草稿不联网探针,但既然用户名已存在,先把内部账号关联上, + # 后续补资料时不用再靠“用户名变化”触发解析。 + order.account_id = int(account.id) + order.account_uid = _text(account.uid, max_length=32) + order.account_nickname = _text(account.nickname, max_length=128) + + if not draft and require_verified: + missing: list[str] = [] + if not order.shop: + missing.append("店铺") + if not order.account_username: + missing.append("斗鱼账号") + if not order.gift_name: + missing.append("礼包") + if not order.home_images: + missing.append("主页截图") + if missing: + raise CollabError( + f"下面这几项必须填:{'、'.join(missing)}", + code="create_field_required", + ) + if account is None: + raise CollabError("斗鱼账号不存在", code="account_not_found") + if probe is None or not probe.get("ok") or not probe.get("bind_game_role"): + message = (probe or {}).get("message") or "未读到绑定角色" + raise CollabError( + f"这个账号当前不能用:{message}", + code="account_not_verified", + ) + + if order.shop: + ensure_shops_registered(db, current, [order.shop]) + + db.add(order) + db.flush() + record_audit( + db, + current, + action="douyu:collab:create", + target=f"douyu_external_order:{order.id}", + detail={ + "order_no": order.order_no, + "shop": order.shop, + "account_username": order.account_username, + "account_id": order.account_id, + "gift_name": order.gift_name, + "required_points": order.required_points, + "bind_game_role": order.bind_game_role, + "home_images": len(order.home_images or []), + "draft": bool(draft), + }, + ) + _maybe_advance_to_recharge(db, current, order) + if commit: + db.commit() + db.refresh(order) + return order + + +def update_order( + db: Session, + current: User, + order: DouyuExternalOrder, + payload: Mapping[str, Any], + *, + expected_version: int | None = None, + client: Any | None = None, + commit: bool = True, +) -> dict[str, Any]: + _assert_mutable(order) + _assert_version(order, expected_version) + changes: dict[str, dict[str, Any]] = {} + editable = { + "order_no", + "shop", + "owner_id", + "account_username", + "gift_name", + "commodity_id", + "required_points", + "remark", + } + for field in editable: + if field not in payload: + continue + old = getattr(order, field) + if field == "owner_id": + new = int(payload[field]) if payload[field] is not None else None + elif field == "required_points": + new = _to_int(payload[field]) + elif field == "order_no": + new = _text(payload[field], max_length=64) + if new and new != order.order_no: + duplicate = ( + db.query(DouyuExternalOrder.id) + .filter( + DouyuExternalOrder.order_no == new, + DouyuExternalOrder.id != order.id, + ) + .first() + ) + if duplicate: + raise CollabError( + "订单号已存在", status_code=409, code="order_no_exists" + ) + else: + new = _text(payload[field], max_length=512 if field == "remark" else 128) + if new != old: + changes[field] = {"old": old, "new": new} + setattr(order, field, new) + if "account_username" in payload and ( + order.account_id is None + or not order.bind_game_role + or "account_username" in changes + ): + account = account_by_username(db, order.account_username) + if account is None: + if order.status == STATUS_INCOMPLETE: + order.account_id = None + order.account_uid = "" + order.account_nickname = "" + else: + raise CollabError("斗鱼账号不存在", code="account_not_found") + else: + config = resolve_config(db) + probe = _probe_with_client( + account, + manual_id=str(config.manual_id or ""), + rid=str(config.rid or ""), + act_alias=str(config.bind_act_alias or ""), + client=client, + with_goods=True, + ) + apply_probe_snapshot(order, account, probe) + changes["account_id"] = {"old": None, "new": int(account.id)} + changes["bind_game_role"] = {"old": "", "new": order.bind_game_role} + if "home_images" in payload: + images = normalize_images(payload.get("home_images")) + if images != (order.home_images or []): + changes["home_images"] = { + "old": len(order.home_images or []), + "new": len(images), + } + order.home_images = images + if order.gift_name and "commodity_id" in payload: + _, cid, points = _resolve_gift( + db, + gift_name=order.gift_name, + commodity_id=str(order.commodity_id or ""), + required_points=order.required_points, + ) + order.commodity_id = cid + if points is not None and order.required_points is None: + order.required_points = points + if changes: + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:update", + target=f"douyu_external_order:{order.id}", + detail=changes, + ) + _maybe_advance_to_recharge(db, current, order) + if commit: + db.commit() + db.refresh(order) + return changes + + +def set_account( + db: Session, + current: User, + order: DouyuExternalOrder, + username: str, + *, + expected_version: int | None = None, + client: Any | None = None, + commit: bool = True, +) -> dict[str, Any]: + _assert_mutable(order) + _assert_version(order, expected_version) + account = account_by_username(db, username) + if account is None: + raise CollabError("斗鱼账号不存在", code="account_not_found") + config = resolve_config(db) + probe = _probe_with_client( + account, + manual_id=str(config.manual_id or ""), + rid=str(config.rid or ""), + act_alias=str(config.bind_act_alias or ""), + client=client, + with_goods=True, + ) + apply_probe_snapshot(order, account, probe) + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:account", + target=f"douyu_external_order:{order.id}", + detail={ + "account_id": account.id, + "account_username": account.username, + "ck_status": probe.get("ck_status"), + "role": probe.get("bind_game_role"), + }, + ) + _maybe_advance_to_recharge(db, current, order) + if commit: + db.commit() + db.refresh(order) + return {"order": order, "probe": probe} + + +def verify_order( + db: Session, + current: User, + order: DouyuExternalOrder, + *, + client: Any | None = None, + commit: bool = True, +) -> dict[str, Any]: + _assert_mutable(order) + account = account_by_id(db, order.account_id) or account_by_username( + db, order.account_username + ) + if account is None: + raise CollabError("订单关联的斗鱼账号不存在", code="account_not_found") + config = resolve_config(db) + probe = _probe_with_client( + account, + manual_id=str(config.manual_id or ""), + rid=str(config.rid or ""), + act_alias=str(config.bind_act_alias or ""), + client=client, + with_goods=True, + ) + apply_probe_snapshot(order, account, probe) + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:verify", + target=f"douyu_external_order:{order.id}", + detail={ + "account_id": account.id, + "ck_status": probe.get("ck_status"), + "role": probe.get("bind_game_role"), + "fish_fin_balance": probe.get("fish_fin_balance"), + "points": probe.get("manual_score"), + "message": probe.get("message"), + }, + ) + _maybe_advance_to_recharge(db, current, order) + if commit: + db.commit() + db.refresh(order) + return {"order": order, "probe": probe} + + +def _status_audit_target(order: DouyuExternalOrder) -> str: + return f"douyu_external_order:{order.id}" + + +def _assert_ready_for_recharge(order: DouyuExternalOrder) -> None: + missing: list[str] = [] + if not order.account_id or not order.account_username: + missing.append("斗鱼账号") + if not order.gift_name: + missing.append("礼包") + if not order.home_images: + missing.append("主页截图") + if not order.bind_game_role: + missing.append("绑定角色(先校验账号)") + if missing: + raise CollabError( + f"进入待充值前还缺:{'、'.join(missing)}", + code="recharge_precondition", + ) + + +def set_status( + db: Session, + current: User, + order: DouyuExternalOrder, + *, + status: str, + reason: str = "", + rollback: bool = False, + expected_version: int | None = None, + commit: bool = True, +) -> DouyuExternalOrder: + _assert_version(order, expected_version) + target = _text(status, max_length=16) + if target not in ORDER_STATUSES: + raise CollabError("非法状态", code="status_invalid") + if order.archived_at is not None: + raise CollabError("已归档的订单不能改状态", code="order_archived") + if order.status == STATUS_VOID: + raise CollabError("已作废的订单不能改状态", code="order_void") + + old = order.status + now = _now() + if target == STATUS_VOID: + order.status = STATUS_VOID + order.void_reason = _text(reason, max_length=256) + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:void", + target=_status_audit_target(order), + detail={"from": old, "reason": order.void_reason}, + ) + if commit: + db.commit() + db.refresh(order) + return order + + if target == STATUS_FAILED: + order.status = STATUS_FAILED + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:status", + target=_status_audit_target(order), + detail={"from": old, "to": target, "reason": _text(reason, max_length=256)}, + ) + if commit: + db.commit() + db.refresh(order) + return order + + if target == STATUS_POINTS_ERROR: + order.status = STATUS_POINTS_ERROR + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:points_error", + target=_status_audit_target(order), + detail={"from": old, "reason": _text(reason, max_length=256)}, + ) + if commit: + db.commit() + db.refresh(order) + return order + + if old in (STATUS_FAILED, STATUS_VOID) and not rollback: + raise CollabError("从失败/作废状态回流程必须显式回退", code="rollback_required") + if target not in STAGE_INDEX: + raise CollabError("该状态不能手工切换", code="status_not_manual") + current_index = STAGE_INDEX.get(old) + target_index = STAGE_INDEX.get(target) + if current_index is None: + current_index = -1 + if target_index is None: + raise CollabError("非法状态", code="status_invalid") + if target_index < current_index and not rollback: + raise CollabError("回退状态必须显式确认", code="rollback_required") + if target_index >= STAGE_INDEX[STATUS_TO_RECHARGE]: + _assert_ready_for_recharge(order) + + if rollback: + for name, field in STATUS_TIMESTAMP_FIELDS.items(): + if STAGE_INDEX.get(name, -1) > target_index: + setattr(order, field, None) + else: + for name, field in STATUS_TIMESTAMP_FIELDS.items(): + if current_index < STAGE_INDEX[name] <= target_index: + setattr(order, field, now) + order.status = target + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:status", + target=_status_audit_target(order), + detail={ + "from": old, + "to": target, + "rollback": bool(rollback), + "reason": _text(reason, max_length=256), + }, + ) + if commit: + db.commit() + db.refresh(order) + return order + + +def _match_gift(records: list[dict[str, Any]], gift_name: str) -> int: + name = _text(gift_name) + if len(name) < 2: + return 0 + matched = 0 + for record in records: + record_name = _text( + record.get("commodityName") or record.get("commodity_name") or "" + ) + if not record_name or len(record_name) < 2: + continue + if name in record_name or record_name in name: + matched += 1 + return matched + + +def group_points_total(db: Session, order: DouyuExternalOrder) -> int: + if not order.account_id: + return int(order.required_points or 0) + rows = ( + db.query(DouyuExternalOrder) + .filter( + DouyuExternalOrder.account_id == order.account_id, + DouyuExternalOrder.status.in_(POINTS_GROUP_STATUSES), + DouyuExternalOrder.archived_at.is_(None), + ) + .all() + ) + return sum(int(item.required_points or 0) for item in rows) + + +def query_activity( + db: Session, + current: User, + order: DouyuExternalOrder, + *, + client: Any | None = None, + source: str = "manual", + commit: bool = True, +) -> dict[str, Any]: + """查询鱼翅、积分和当前账号兑换记录,落一条快照。""" + account = account_by_id(db, order.account_id) or account_by_username( + db, order.account_username + ) + if account is None: + raise CollabError("订单关联的斗鱼账号不存在", code="account_not_found") + config = resolve_config(db) + active_client = client or _client_for_account(account) + errors: list[str] = [] + elite: dict[str, Any] = {} + gold: dict[str, Any] = {} + records: list[dict[str, Any]] = [] + orders: list[dict[str, Any]] = [] + try: + elite = active_client.elite_user_info( + manual_id=str(config.manual_id or ""), rid=str(config.rid or "") + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"积分: {exc}") + try: + gold = active_client.gold_account() + except Exception as exc: # noqa: BLE001 + errors.append(f"鱼翅: {exc}") + try: + records = ( + active_client.exchange_records( + manual_id=str(config.manual_id or ""), limit=100, offset=0 + ).get("records") + or [] + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"兑换记录: {exc}") + try: + orders = ( + active_client.exchange_orders( + manual_id=str(config.manual_id or ""), page=1, page_size=10 + ).get("orders") + or [] + ) + except Exception as exc: # noqa: BLE001 + errors.append(f"锁单: {exc}") + + manual_score = _to_int(elite.get("manual_score")) + fish = _to_int(gold.get("gold")) + if manual_score is not None: + order.points_balance = manual_score + account.points = manual_score + if fish is not None: + order.fish_fin_balance = fish + account.gold_balance = fish + account.updated_at = _now() + required_total = group_points_total(db, order) + surplus = ( + (manual_score - required_total) + if manual_score is not None and required_total > 0 + else None + ) + order.points_required_total = required_total + order.points_surplus = surplus + order.points_checked_at = _now() + threshold = normalize_threshold(order.points_threshold) + order.points_threshold = threshold + + if errors: + verdict = VERDICT_UNKNOWN + message = ";".join(errors) + elif not order.gift_name: + verdict = VERDICT_UNKNOWN + message = "未填写礼包名,无法判定兑换结果" + else: + matched = _match_gift(records, order.gift_name) + verdict = VERDICT_SUCCESS if matched else VERDICT_FAILED + message = ( + f"兑换记录命中「{order.gift_name}」{matched} 条" + if matched + else f"兑换记录里没有「{order.gift_name}」" + ) + + reason = "" + if surplus is not None: + if surplus < 0: + reason = "shortage" + elif surplus > threshold: + reason = "excess" + else: + reason = "ok" + points_message = "" + if required_total > 0 or manual_score is not None: + points_message = ( + f"积分 {manual_score if manual_score is not None else '-'} / " + f"本账号未完成订单合计 {required_total},结余 " + f"{surplus if surplus is not None else '-'}" + ) + order.points_check_reason = reason + order.points_check_message = points_message + order.points_check_detail = { + "fish_fin_balance": fish, + "points_balance": manual_score, + "required_total": required_total, + "surplus": surplus, + "threshold": threshold, + "errors": errors, + "exchange_records": len(records), + "locked_orders": len(orders), + } + + metric = DouyuExternalOrderMetric( + order_id=order.id, + order_no=order.order_no, + account_id=order.account_id, + account_username=order.account_username, + fish_fin_balance=fish, + points_balance=manual_score, + points_required_total=required_total, + points_surplus=surplus, + records_total=len(records), + records_matched=_match_gift(records, order.gift_name), + verdict=verdict, + message=message, + source=_text(source, max_length=16) or "manual", + raw=_json_safe( + { + "elite": elite, + "gold": gold, + "records": records, + "orders": orders, + } + ), + created_by=current.id, + ) + db.add(metric) + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + record_audit( + db, + current, + action="douyu:collab:activity", + target=_status_audit_target(order), + detail={ + "verdict": verdict, + "message": message, + "fish_fin_balance": fish, + "points": manual_score, + "required_total": required_total, + "surplus": surplus, + "records": len(records), + }, + ) + + # 已上脚本的单,兑换记录命中/未命中是业务事实;其它状态只记快照。 + if order.status == STATUS_SCRIPTED and verdict == VERDICT_SUCCESS: + set_status( + db, + current, + order, + status=STATUS_DONE, + reason=message, + expected_version=None, + commit=False, + ) + elif order.status == STATUS_SCRIPTED and verdict == VERDICT_FAILED: + set_status( + db, + current, + order, + status=STATUS_FAILED, + reason=message, + expected_version=None, + commit=False, + ) + elif order.status in (STATUS_RECHARGED, STATUS_SCRIPTED) and reason in ( + "shortage", + "excess", + ): + set_status( + db, + current, + order, + status=STATUS_POINTS_ERROR, + reason=points_message, + expected_version=None, + commit=False, + ) + + if commit: + db.commit() + db.refresh(order) + db.refresh(metric) + return { + "order": order, + "metric": metric, + "verdict": verdict, + "message": message, + "points_reason": reason, + "points_message": points_message, + "fish_fin_balance": fish, + "points_balance": manual_score, + "required_total": required_total, + "surplus": surplus, + "errors": errors, + } + + +def ensure_shops_registered( + db: Session, current: User | None, names: list[str] +) -> None: + seen: set[str] = set() + for raw in names: + name = _text(raw, max_length=64) + if not name or name in seen: + continue + seen.add(name) + exists = ( + db.query(DouyuCollabShop.id).filter(DouyuCollabShop.name == name).first() + ) + if exists: + continue + db.add( + DouyuCollabShop( + name=name, + created_by=current.id if current else None, + updated_by=current.id if current else None, + ) + ) + + +def list_shops(db: Session, *, active_only: bool = False) -> list[DouyuCollabShop]: + query = db.query(DouyuCollabShop) + if active_only: + query = query.filter(DouyuCollabShop.is_active.is_(True)) + return query.order_by( + DouyuCollabShop.sort_order.asc(), DouyuCollabShop.id.asc() + ).all() + + +def get_shop(db: Session, shop_id: int) -> DouyuCollabShop: + shop = db.query(DouyuCollabShop).filter(DouyuCollabShop.id == shop_id).first() + if shop is None: + raise CollabError("店铺不存在", status_code=404, code="shop_not_found") + return shop + + +def create_shop( + db: Session, current: User, *, name: str, remark: str = "" +) -> DouyuCollabShop: + clean = _text(name, max_length=64) + if not clean: + raise CollabError("店铺名不能为空", code="shop_name_required") + exists = db.query(DouyuCollabShop).filter(DouyuCollabShop.name == clean).first() + if exists: + raise CollabError("店铺已存在", status_code=409, code="shop_exists") + shop = DouyuCollabShop( + name=clean, + remark=_text(remark, max_length=255), + created_by=current.id, + updated_by=current.id, + ) + db.add(shop) + db.commit() + db.refresh(shop) + return shop + + +def update_shop( + db: Session, + current: User, + shop: DouyuCollabShop, + payload: Mapping[str, Any], +) -> DouyuCollabShop: + old_name = shop.name + old = { + "name": shop.name, + "sort_order": shop.sort_order, + "is_active": shop.is_active, + "remark": shop.remark, + } + if "name" in payload: + name = _text(payload.get("name"), max_length=64) + if not name: + raise CollabError("店铺名不能为空", code="shop_name_required") + duplicate = ( + db.query(DouyuCollabShop.id) + .filter( + DouyuCollabShop.name == name, + DouyuCollabShop.id != shop.id, + ) + .first() + ) + if duplicate: + raise CollabError("店铺已存在", status_code=409, code="shop_exists") + shop.name = name + if "sort_order" in payload: + shop.sort_order = _to_int(payload.get("sort_order")) or 0 + if "is_active" in payload: + shop.is_active = bool(payload.get("is_active")) + if "remark" in payload: + shop.remark = _text(payload.get("remark"), max_length=255) + shop.updated_by = current.id + if shop.name != old_name: + db.query(DouyuExternalOrder).filter(DouyuExternalOrder.shop == old_name).update( + {DouyuExternalOrder.shop: shop.name}, synchronize_session=False + ) + record_audit( + db, + current, + action="douyu:collab:shop_update", + target=f"douyu_collab_shop:{shop.id}", + detail={ + "old": old, + "new": { + "name": shop.name, + "sort_order": shop.sort_order, + "is_active": shop.is_active, + "remark": shop.remark, + }, + }, + ) + db.commit() + db.refresh(shop) + return shop + + +def delete_shop(db: Session, current: User, shop: DouyuCollabShop) -> dict[str, Any]: + shop_name = shop.name + used = ( + db.query(func.count(DouyuExternalOrder.id)) + .filter(DouyuExternalOrder.shop == shop_name) + .scalar() + or 0 + ) + if used: + raise CollabError( + f"还有 {used} 个协作单使用「{shop_name}」,请先改名或停用", + code="shop_in_use", + ) + record_audit( + db, + current, + action="douyu:collab:shop_delete", + target=f"douyu_collab_shop:{shop.id}", + detail={"name": shop_name}, + ) + db.delete(shop) + db.commit() + return {"success": True, "name": shop_name} + + +def list_gift_names(db: Session) -> list[str]: + rows = ( + db.query(DouyuExternalOrder.gift_name) + .filter(DouyuExternalOrder.gift_name != "") + .distinct() + .order_by(DouyuExternalOrder.gift_name.asc()) + .all() + ) + return [str(name) for (name,) in rows if name] + + +def refresh_activity_goods( + db: Session, + current: User, + *, + account_id: int | None = None, + client: Any | None = None, +) -> dict[str, Any]: + config = resolve_config(db) + account = account_by_id(db, account_id) + if account is None: + account = ( + db.query(Account) + .filter(Account.cookie.isnot(None), Account.cookie != "") + .order_by(Account.id.asc()) + .first() + ) + if account is None: + raise CollabError("没有带 CK 的斗鱼账号,无法刷新活动商品", code="no_account") + active_client = client or _client_for_account(account) + result = active_client.list_goods( + manual_id=str(config.manual_id or ""), rid=str(config.rid or "") + ) + goods = result.get("goods") or [] + now = _now() + for raw in goods: + commodity_id = _text( + raw.get("commodityId") or raw.get("commodity_id"), max_length=64 + ) + if not commodity_id: + continue + row = ( + db.query(DouyuGoodsSnapshot) + .filter(DouyuGoodsSnapshot.commodity_id == commodity_id) + .first() + ) + if row is None: + row = DouyuGoodsSnapshot(commodity_id=commodity_id) + db.add(row) + row.name = _text(raw.get("commodityName") or raw.get("name"), max_length=256) + row.score = _to_int(raw.get("score")) + status_value = _first_value(raw, "status") + row.status = str(status_value) if status_value is not None else "" + row.raw = _json_safe(raw) + row.updated_at = now + record_audit( + db, + current, + action="douyu:collab:goods_refresh", + target=f"douyu_external_goods:{account.id}", + detail={"count": len(goods), "account_id": account.id}, + ) + db.commit() + return {"count": len(goods), "goods": serialized_goods(db)} + + +def serialized_goods(db: Session) -> list[dict[str, Any]]: + rows = db.query(DouyuGoodsSnapshot).order_by(DouyuGoodsSnapshot.id.asc()).all() + result: list[dict[str, Any]] = [] + for row in rows: + raw = row.raw or {} + start = _to_int(_first_value(raw, "startTime", "start_time")) + end = _to_int(_first_value(raw, "endTime", "end_time")) + open_status = _to_int(_first_value(raw, "openStatus", "open_status")) + mark = _text(_first_value(raw, "mark"), max_length=32) + result.append( + { + "commodity_id": row.commodity_id, + "name": row.name, + "score": row.score, + "status": row.status, + "open_status": open_status, + "start_time": start, + "end_time": end, + "mark": mark, + "count": _to_int(_first_value(raw, "count")), + "elite_limited": _to_int(_first_value(raw, "eliteLimited")), + "is_limited": bool(mark) or open_status == 0, + "raw": raw, + } + ) + return result + + +def _metric_views( + db: Session, order_ids: list[int] +) -> dict[int, DouyuExternalOrderMetric]: + if not order_ids: + return {} + rows = ( + db.query(DouyuExternalOrderMetric) + .filter(DouyuExternalOrderMetric.order_id.in_(order_ids)) + .order_by( + DouyuExternalOrderMetric.order_id.asc(), + DouyuExternalOrderMetric.id.desc(), + ) + .all() + ) + result: dict[int, DouyuExternalOrderMetric] = {} + for row in rows: + result.setdefault(int(row.order_id), row) + return result + + +def account_clusters( + db: Session, orders: list[DouyuExternalOrder] +) -> dict[int, dict[str, Any]]: + account_ids = {int(order.account_id) for order in orders if order.account_id} + if not account_ids: + return {} + rows = ( + db.query(DouyuExternalOrder) + .filter( + DouyuExternalOrder.account_id.in_(account_ids), + DouyuExternalOrder.archived_at.is_(None), + DouyuExternalOrder.status != STATUS_VOID, + ) + .order_by(DouyuExternalOrder.id.asc()) + .all() + ) + grouped: dict[int, list[DouyuExternalOrder]] = {} + for row in rows: + if row.account_id: + grouped.setdefault(int(row.account_id), []).append(row) + result: dict[int, dict[str, Any]] = {} + for account_id, items in grouped.items(): + if len(items) <= 1: + continue + result[account_id] = { + "total": len(items), + "orders": [ + { + "id": int(item.id), + "order_no": item.order_no, + "status": item.status, + "status_label": ORDER_STATUS_LABELS.get(item.status, item.status), + "gift_name": item.gift_name, + } + for item in items + ], + } + return result + + +def _image_urls(keys: list[str], variant: str = "") -> list[str]: + return [build_file_url(key, variant) for key in keys] + + +def serialize_order( + order: DouyuExternalOrder, + *, + users: Mapping[int, str] | None = None, + accounts: Mapping[int, Account] | None = None, + cluster: Mapping[str, Any] | None = None, + metric: DouyuExternalOrderMetric | None = None, +) -> dict[str, Any]: + users = users or {} + accounts = accounts or {} + account = accounts.get(int(order.account_id)) if order.account_id else None + images = list(order.home_images or []) + payload = { + "id": int(order.id), + "order_no": order.order_no, + "shop": order.shop or "", + "owner_id": order.owner_id, + "owner_name": users.get(int(order.owner_id), "") if order.owner_id else "", + "account_id": order.account_id, + "account_username": order.account_username or "", + "account_uid": order.account_uid or "", + "account_nickname": order.account_nickname or "", + "account_exists": account is not None, + "has_cookie": bool(account and account.cookie), + "account_ck_status": (account.ck_check_status if account else "") or "", + "account_status": (account.status if account else "") or "", + "bind_game_account": order.bind_game_account or "", + "bind_game_role": order.bind_game_role or "", + "bind_game_channel": order.bind_game_channel or "", + "bind_snapshot_at": order.bind_snapshot_at, + "home_images": images, + "home_image_urls": _image_urls(images), + "home_image_thumb_urls": _image_urls(images, "thumb"), + "gift_name": order.gift_name or "", + "commodity_id": order.commodity_id or "", + "required_points": order.required_points, + "remark": order.remark or "", + "status": order.status or STATUS_INCOMPLETE, + "status_label": ORDER_STATUS_LABELS.get( + order.status or STATUS_INCOMPLETE, order.status or "" + ), + "next_status": ( + STATUS_FLOW[STAGE_INDEX[order.status] + 1] + if order.status in STAGE_INDEX and order.status != STATUS_DONE + else None + ), + "missing_fields": _missing_for_recharge(order), + "fish_fin_balance": order.fish_fin_balance, + "points_balance": order.points_balance, + "points_required_total": order.points_required_total, + "points_surplus": order.points_surplus, + "points_threshold": order.points_threshold, + "points_checked_at": order.points_checked_at, + "points_check_reason": order.points_check_reason or "", + "points_check_message": order.points_check_message or "", + "points_check_detail": order.points_check_detail, + "recharged_at": order.recharged_at, + "scripted_at": order.scripted_at, + "done_at": order.done_at, + "void_reason": order.void_reason or "", + "is_archived": order.archived_at is not None, + "archived_at": order.archived_at, + "archived_by": order.archived_by, + "archived_by_name": ( + users.get(int(order.archived_by), "") if order.archived_by else "" + ), + "archive_reason": order.archive_reason or "", + "created_by": order.created_by, + "created_by_name": users.get(int(order.created_by), "") + if order.created_by + else "", + "updated_by": order.updated_by, + "updated_by_name": users.get(int(order.updated_by), "") + if order.updated_by + else "", + "version": int(order.version or 0), + "created_at": order.created_at, + "updated_at": order.updated_at, + "account_cluster": dict(cluster) if cluster else None, + "last_metric_at": metric.created_at if metric else None, + "last_metric_message": metric.message if metric else "", + "last_verdict": metric.verdict if metric else "", + "last_verdict_label": { + VERDICT_SUCCESS: "兑换成功", + VERDICT_FAILED: "兑换失败", + VERDICT_UNKNOWN: "无法判定", + }.get(metric.verdict, "") + if metric + else "", + "last_records_total": metric.records_total if metric else 0, + "last_records_matched": metric.records_matched if metric else 0, + } + return payload + + +def _base_order_query(db: Session): + return db.query(DouyuExternalOrder) + + +def _apply_order_filters( + db: Session, + query, + *, + include_void: bool = False, + archive_scope: str = "active", + status: str = "", + shop: str = "", + owner_id: int | None = None, + gift_name: str = "", + keyword: str = "", + ck_status: str = "", +): + scope = archive_scope if archive_scope in ARCHIVE_SCOPES else "active" + if scope == "active": + query = query.filter(DouyuExternalOrder.archived_at.is_(None)) + elif scope == "archived": + query = query.filter(DouyuExternalOrder.archived_at.isnot(None)) + if not include_void: + query = query.filter(DouyuExternalOrder.status != STATUS_VOID) + if _text(status): + query = query.filter(DouyuExternalOrder.status == _text(status)) + if _text(shop): + query = query.filter(DouyuExternalOrder.shop == _text(shop)) + if owner_id is not None: + query = query.filter(DouyuExternalOrder.owner_id == int(owner_id)) + if _text(gift_name): + query = query.filter(DouyuExternalOrder.gift_name == _text(gift_name)) + ck_scope = _text(ck_status) + if ck_scope: + query = query.outerjoin(Account, Account.id == DouyuExternalOrder.account_id) + if ck_scope == CK_VALID: + query = query.filter(Account.ck_check_status == CK_VALID) + elif ck_scope == CK_INVALID: + query = query.filter(Account.ck_check_status == CK_INVALID) + elif ck_scope == CK_UNVERIFIED: + query = query.filter( + or_( + DouyuExternalOrder.account_id.is_(None), + Account.ck_check_status.is_(None), + Account.ck_check_status.in_(("", CK_UNVERIFIED)), + ) + ) + search = _text(keyword) + if search: + pattern = f"%{search}%" + query = query.filter( + or_( + DouyuExternalOrder.order_no.ilike(pattern), + DouyuExternalOrder.account_username.ilike(pattern), + DouyuExternalOrder.account_nickname.ilike(pattern), + DouyuExternalOrder.bind_game_role.ilike(pattern), + DouyuExternalOrder.shop.ilike(pattern), + DouyuExternalOrder.remark.ilike(pattern), + ) + ) + return query + + +def list_orders( + db: Session, + *, + page: int = 1, + page_size: int = 20, + include_void: bool = False, + archive_scope: str = "active", + status: str = "", + shop: str = "", + owner_id: int | None = None, + gift_name: str = "", + keyword: str = "", + ck_status: str = "", +) -> dict[str, Any]: + query = _apply_order_filters( + db, + _base_order_query(db), + include_void=include_void, + archive_scope=archive_scope, + status=status, + shop=shop, + owner_id=owner_id, + gift_name=gift_name, + keyword=keyword, + ck_status=ck_status, + ) + total = ( + query.order_by(None).with_entities(func.count(DouyuExternalOrder.id)).scalar() + or 0 + ) + orders = ( + query.order_by(DouyuExternalOrder.id.asc()) + .offset((max(1, page) - 1) * page_size) + .limit(page_size) + .all() + ) + users = users_by_id( + db, + { + int(item) + for order in orders + for item in ( + order.owner_id, + order.created_by, + order.updated_by, + order.archived_by, + ) + if item + }, + ) + accounts = accounts_by_id( + db, {int(order.account_id) for order in orders if order.account_id} + ) + clusters = account_clusters(db, orders) + metrics = _metric_views(db, [int(order.id) for order in orders]) + items = [ + serialize_order( + order, + users=users, + accounts=accounts, + cluster=clusters.get(int(order.account_id)) if order.account_id else None, + metric=metrics.get(int(order.id)), + ) + for order in orders + ] + return {"items": items, "total": int(total), "page": page, "page_size": page_size} + + +def get_order(db: Session, order_id: int) -> DouyuExternalOrder: + order = ( + db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id == order_id).first() + ) + if order is None: + raise CollabError("协作单不存在", status_code=404, code="order_not_found") + return order + + +def serialize_one(db: Session, order: DouyuExternalOrder) -> dict[str, Any]: + users = users_by_id( + db, + { + int(item) + for item in ( + order.owner_id, + order.created_by, + order.updated_by, + order.archived_by, + ) + if item + }, + ) + accounts = accounts_by_id( + db, {int(order.account_id)} if order.account_id else set() + ) + clusters = account_clusters(db, [order]) + metrics = _metric_views(db, [int(order.id)]) + return serialize_order( + order, + users=users, + accounts=accounts, + cluster=clusters.get(int(order.account_id)) if order.account_id else None, + metric=metrics.get(int(order.id)), + ) + + +def summary( + db: Session, *, include_void: bool = False, archive_scope: str = "active" +) -> dict[str, Any]: + query = _apply_order_filters( + db, + _base_order_query(db), + include_void=include_void, + archive_scope=archive_scope, + ) + rows = query.with_entities( + DouyuExternalOrder.status, func.count(DouyuExternalOrder.id) + ).group_by(DouyuExternalOrder.status) + by_status = {str(status): int(count) for status, count in rows.all()} + total = sum(by_status.values()) + ck_invalid = ( + query.outerjoin(Account, Account.id == DouyuExternalOrder.account_id) + .with_entities(func.count(DouyuExternalOrder.id)) + .filter(Account.ck_check_status == CK_INVALID) + .scalar() + or 0 + ) + return { + "total": total, + "by_status": by_status, + "ck_invalid": int(ck_invalid), + "archived_total": ( + db.query(func.count(DouyuExternalOrder.id)) + .filter(DouyuExternalOrder.archived_at.isnot(None)) + .scalar() + or 0 + ), + "account_missing": ( + db.query(func.count(DouyuExternalOrder.id)) + .filter( + DouyuExternalOrder.account_id.is_(None), + DouyuExternalOrder.archived_at.is_(None), + ) + .scalar() + or 0 + ), + } + + +def order_metrics(db: Session, order_id: int) -> list[dict[str, Any]]: + rows = ( + db.query(DouyuExternalOrderMetric) + .filter(DouyuExternalOrderMetric.order_id == order_id) + .order_by(DouyuExternalOrderMetric.id.desc()) + .all() + ) + return [serialize_metric(row) for row in rows] + + +def serialize_metric(row: DouyuExternalOrderMetric) -> dict[str, Any]: + return { + "id": int(row.id), + "order_id": int(row.order_id), + "order_no": row.order_no, + "account_username": row.account_username, + "fish_fin_balance": row.fish_fin_balance, + "points_balance": row.points_balance, + "points_required_total": row.points_required_total, + "points_surplus": row.points_surplus, + "records_total": row.records_total, + "records_matched": row.records_matched, + "verdict": row.verdict, + "verdict_label": { + VERDICT_SUCCESS: "兑换成功", + VERDICT_FAILED: "兑换失败", + VERDICT_UNKNOWN: "无法判定", + }.get(row.verdict, row.verdict), + "message": row.message, + "source": row.source, + "created_at": row.created_at, + } + + +def order_timeline( + db: Session, order_id: int, *, limit: int = 100 +) -> list[dict[str, Any]]: + rows = ( + db.query(AuditLog) + .filter(AuditLog.target == f"douyu_external_order:{order_id}") + .order_by(AuditLog.id.desc()) + .limit(limit) + .all() + ) + return [ + { + "id": int(row.id), + "action": row.action, + "username": row.username or "", + "detail": row.detail or "", + "success": row.success, + "created_at": row.created_at, + } + for row in rows + ] + + +def archive_orders( + db: Session, + current: User, + ids: list[int], + *, + reason: str = "", +) -> dict[str, Any]: + clean_ids = [int(item) for item in ids if int(item) > 0] + if not clean_ids: + return {"archived": 0} + rows = ( + db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id.in_(clean_ids)).all() + ) + archived = 0 + now = _now() + for order in rows: + if order.status not in ARCHIVABLE_STATUSES or order.archived_at is not None: + continue + order.archived_at = now + order.archived_by = current.id + order.archive_reason = _text(reason, max_length=256) + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + archived += 1 + record_audit( + db, + current, + action="douyu:collab:archive", + target=_status_audit_target(order), + detail={"reason": order.archive_reason}, + ) + db.commit() + return {"archived": archived} + + +def unarchive_orders(db: Session, current: User, ids: list[int]) -> dict[str, Any]: + clean_ids = [int(item) for item in ids if int(item) > 0] + if not clean_ids: + return {"unarchived": 0} + rows = ( + db.query(DouyuExternalOrder).filter(DouyuExternalOrder.id.in_(clean_ids)).all() + ) + unarchived = 0 + for order in rows: + if order.archived_at is None: + continue + order.archived_at = None + order.archived_by = None + order.archive_reason = "" + order.updated_by = current.id + order.version = int(order.version or 0) + 1 + unarchived += 1 + record_audit( + db, + current, + action="douyu:collab:unarchive", + target=_status_audit_target(order), + detail={}, + ) + db.commit() + return {"unarchived": unarchived} + + +def delete_order( + db: Session, current: User, order: DouyuExternalOrder +) -> dict[str, Any]: + order_no = order.order_no + record_audit( + db, + current, + action="douyu:collab:delete", + target=f"douyu_external_order:{order.id}", + detail={"order_no": order_no, "account": order.account_username}, + ) + db.delete(order) + db.commit() + return {"success": True, "order_no": order_no} diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 5d61d72..b794225 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -21,6 +21,7 @@ const UsersPage = lazy(() => import('./pages/UsersPage')); const CookiePage = lazy(() => import('./pages/CookiePage')); const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage')); const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage')); +const DouyuCollabPage = lazy(() => import('./pages/DouyuCollabPage')); const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage')); const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage')); const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage')); @@ -94,6 +95,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/douyuCollab.ts b/web/frontend/src/api/douyuCollab.ts new file mode 100644 index 0000000..9753aa9 --- /dev/null +++ b/web/frontend/src/api/douyuCollab.ts @@ -0,0 +1,326 @@ +import api from './client'; + +export type DouyuCollabStatus = + | 'incomplete' + | 'to_recharge' + | 'recharged' + | 'scripted' + | 'done' + | 'points_error' + | 'failed' + | 'void'; + +export interface DouyuCollabGoods { + commodity_id: string; + name: string; + score: number | null; + status: string; + open_status: number | null; + start_time: number | null; + end_time: number | null; + mark: string; + count: number | null; + elite_limited: number | null; + is_limited: boolean; + raw?: Record; +} + +export interface DouyuCollabOrder { + id: number; + order_no: string; + shop: string; + owner_id: number | null; + owner_name: string; + account_id: number | null; + account_username: string; + account_uid: string; + account_nickname: string; + account_exists: boolean; + has_cookie: boolean; + account_ck_status: string; + account_status: string; + bind_game_account: string; + bind_game_role: string; + bind_game_channel: string; + bind_snapshot_at: string | null; + home_images: string[]; + home_image_urls: string[]; + home_image_thumb_urls: string[]; + gift_name: string; + commodity_id: string; + required_points: number | null; + remark: string; + status: DouyuCollabStatus; + status_label: string; + next_status: DouyuCollabStatus | null; + missing_fields: string[]; + fish_fin_balance: number | null; + points_balance: number | null; + points_required_total: number | null; + points_surplus: number | null; + points_threshold: number | null; + points_checked_at: string | null; + points_check_reason: string; + points_check_message: string; + points_check_detail: Record | null; + recharged_at: string | null; + scripted_at: string | null; + done_at: string | null; + void_reason: string; + is_archived: boolean; + archived_at: string | null; + archived_by: number | null; + archived_by_name: string; + archive_reason: string; + created_by: number | null; + created_by_name: string; + updated_by: number | null; + updated_by_name: string; + version: number; + created_at: string | null; + updated_at: string | null; + account_cluster: { + total: number; + orders: Array<{ + id: number; + order_no: string; + status: DouyuCollabStatus; + status_label: string; + gift_name: string; + }>; + } | null; + last_metric_at: string | null; + last_metric_message: string; + last_verdict: string; + last_verdict_label: string; + last_records_total: number; + last_records_matched: number; +} + +export interface DouyuCollabOrderList { + items: DouyuCollabOrder[]; + total: number; + page: number; + page_size: number; +} + +export interface DouyuCollabSummary { + total: number; + by_status: Record; + account_missing: number; + ck_invalid: number; + archived_total: number; +} + +export interface DouyuCollabOwner { + id: number; + username: string; +} + +export interface DouyuCollabShop { + id: number; + name: string; + sort_order: number; + is_active: boolean; + remark: string; +} + +export interface DouyuCollabAccountLookup { + found: boolean; + ok: boolean; + message: string; + errors: string[]; + account_id: number | null; + account_username: string; + account_uid: string; + account_nickname: string; + ck_status: string; + ck_status_label: string; + manual_type: number | null; + manual_score: number | null; + expire_time: number | null; + manual_opened: boolean; + bind_game_account: string; + bind_game_role: string; + bind_game_channel: string; + fish_fin_balance: number | null; + goods: DouyuCollabGoods[]; + goods_count: number; + orders_using_account: Array<{ + id: number; + order_no: string; + status: DouyuCollabStatus; + }>; +} + +export interface DouyuCollabOrderCreate { + order_no: string; + shop?: string; + owner_id?: number | null; + account_username?: string; + gift_name?: string; + commodity_id?: string; + required_points?: number | null; + home_images?: string[]; + remark?: string; + draft?: boolean; + require_verified?: boolean; +} + +export interface DouyuCollabOrderUpdate { + order_no?: string; + shop?: string; + owner_id?: number | null; + account_username?: string; + gift_name?: string; + commodity_id?: string; + required_points?: number | null; + home_images?: string[]; + remark?: string; + expected_version?: number; +} + +export interface DouyuCollabMetric { + id: number; + order_id: number; + order_no: string; + account_username: string; + fish_fin_balance: number | null; + points_balance: number | null; + points_required_total: number | null; + points_surplus: number | null; + records_total: number; + records_matched: number; + verdict: string; + verdict_label: string; + message: string; + source: string; + created_at: string | null; +} + +export interface DouyuCollabTimeline { + id: number; + action: string; + username: string; + detail: string; + success: boolean | null; + created_at: string | null; +} + +export interface DouyuCollabQueryResult { + order: DouyuCollabOrder; + metric: DouyuCollabMetric; + verdict: string; + message: string; + points_reason: string; + fish_fin_balance: number | null; + points_balance: number | null; + required_total: number | null; + surplus: number | null; + errors: string[]; +} + +export const douyuCollabApi = { + listOrders: (params: { + page?: number; + page_size?: number; + include_void?: boolean; + archive_scope?: 'active' | 'archived' | 'all'; + status?: string; + shop?: string; + owner_id?: number; + gift_name?: string; + keyword?: string; + ck_status?: string; + } = {}) => + api.get('/douyu/external-orders', { params }), + summary: (params: { include_void?: boolean; archive_scope?: 'active' | 'archived' | 'all' } = {}) => + api.get('/douyu/external-orders/summary', { params }), + owners: () => api.get('/douyu/external-orders/owners'), + shops: () => api.get('/douyu/external-orders/shops'), + shopRecords: () => api.get('/douyu/collab-shops'), + createShop: (data: { name: string; remark?: string }) => + api.post('/douyu/collab-shops', data), + updateShop: ( + id: number, + data: { name?: string; sort_order?: number; is_active?: boolean; remark?: string }, + ) => api.put(`/douyu/collab-shops/${id}`, data), + deleteShop: (id: number) => + api.delete<{ success: boolean; name: string; id: number }, { success: boolean; name: string; id: number }>( + `/douyu/collab-shops/${id}`, + ), + gifts: () => api.get('/douyu/external-orders/gifts'), + goods: () => api.get('/douyu/external-orders/goods'), + refreshGoods: (accountId?: number) => + api.post<{ count: number; goods: DouyuCollabGoods[] }, { count: number; goods: DouyuCollabGoods[] }>( + '/douyu/external-orders/goods/refresh', + undefined, + { params: accountId ? { account_id: accountId } : undefined }, + ), + lookupAccount: (username: string, withGoods = true) => + api.post( + '/douyu/external-orders/accounts/lookup', + { username, with_goods: withGoods }, + ), + createOrder: (data: DouyuCollabOrderCreate) => + api.post('/douyu/external-orders', data), + getOrder: (id: number) => + api.get(`/douyu/external-orders/${id}`), + updateOrder: (id: number, data: DouyuCollabOrderUpdate) => + api.put(`/douyu/external-orders/${id}`, data), + setAccount: (id: number, account_username: string, expected_version?: number) => + api.post(`/douyu/external-orders/${id}/account`, { + account_username, + expected_version, + }), + verifyOrder: (id: number) => + api.post<{ order: DouyuCollabOrder; probe: DouyuCollabAccountLookup }, { order: DouyuCollabOrder; probe: DouyuCollabAccountLookup }>( + `/douyu/external-orders/${id}/verify`, + ), + batchVerify: (ids: number[]) => + api.post< + { results: Array<{ id: number; success: boolean; ck_status?: string; role?: string; message?: string }>; success: boolean }, + { results: Array<{ id: number; success: boolean; ck_status?: string; role?: string; message?: string }>; success: boolean } + >('/douyu/external-orders/verify/batch', ids), + queryActivity: (id: number) => + api.post( + `/douyu/external-orders/${id}/activity/query`, + ), + setStatus: (id: number, data: { + status: DouyuCollabStatus; + reason?: string; + rollback?: boolean; + expected_version?: number; + }) => api.post(`/douyu/external-orders/${id}/status`, data), + batchStatus: (data: { + ids: number[]; + status: DouyuCollabStatus; + reason?: string; + rollback?: boolean; + }) => api.post<{ results: Array<{ id: number; success: boolean; message?: string }>; success: boolean }, { results: Array<{ id: number; success: boolean; message?: string }>; success: boolean }>( + '/douyu/external-orders/status/batch', + data, + ), + batchActivity: (ids: number[]) => + api.post<{ results: Array>; success: boolean }, { results: Array>; success: boolean }>( + '/douyu/external-orders/activity/query', + ids, + ), + updateImages: (id: number, home_images: string[], expected_version?: number) => + api.post(`/douyu/external-orders/${id}/images`, { + home_images, + expected_version, + }), + archive: (ids: number[], reason = '') => + api.post<{ archived: number }, { archived: number }>('/douyu/external-orders/archive', { ids, reason }), + unarchive: (ids: number[]) => + api.post<{ unarchived: number }, { unarchived: number }>('/douyu/external-orders/unarchive', ids), + timeline: (id: number) => + api.get(`/douyu/external-orders/${id}/timeline`), + metrics: (id: number) => + api.get(`/douyu/external-orders/${id}/metrics`), + deleteOrder: (id: number) => + api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>( + `/douyu/external-orders/${id}`, + ), +}; diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index e3b5102..08f649f 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -8,6 +8,7 @@ export { automationApi } from './automation'; export { cookieApi } from './cookies'; export { dashboardApi } from './dashboard'; export { douyuApi } from './douyu'; +export { douyuCollabApi } from './douyuCollab'; export { fileApi } from './files'; export { huyaApi } from './huya'; export { loginApi } from './login'; diff --git a/web/frontend/src/components/DouyuCollabShopModal.tsx b/web/frontend/src/components/DouyuCollabShopModal.tsx new file mode 100644 index 0000000..72a7f83 --- /dev/null +++ b/web/frontend/src/components/DouyuCollabShopModal.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Empty, + Input, + InputNumber, + Modal, + Popconfirm, + Space, + Switch, + Table, + Tag, + Tooltip, + Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'; +import { douyuCollabApi } from '../api/modules'; +import type { DouyuCollabShop } from '../api/douyuCollab'; +import { message } from '../utils/antdMessage'; +import { getErrorMessage } from '../utils/error'; + +const { Text } = Typography; + +/** + * 斗鱼店铺管理:先维护好店铺,新建协作单时直接点选。 + * + * - 停用后不再出现在新建下拉里,历史订单不受影响; + * - 重命名会自动同步已有订单上的店铺名,避免筛选出现新旧两个名字; + * - 删除只删字典行,仍有订单使用时后端会拒绝(更安全)。 + */ +export default function DouyuCollabShopModal({ + open, + shops, + canEdit, + canManage, + onClose, + onChanged, +}: { + open: boolean; + shops: DouyuCollabShop[]; + canEdit: boolean; + canManage: boolean; + onClose: () => void; + onChanged: () => void; +}) { + const [newName, setNewName] = useState(''); + const [busy, setBusy] = useState(false); + const [renameTarget, setRenameTarget] = useState(null); + const [renameValue, setRenameValue] = useState(''); + + useEffect(() => { + if (!open) { + setNewName(''); + setRenameTarget(null); + } + }, [open]); + + const create = async () => { + const name = newName.trim(); + if (!name) { + message.warning('请先输入店铺名'); + return; + } + setBusy(true); + try { + await douyuCollabApi.createShop({ name }); + message.success(`已添加店铺「${name}」`); + setNewName(''); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const patch = async ( + shop: DouyuCollabShop, + payload: { sort_order?: number; is_active?: boolean; remark?: string }, + ) => { + setBusy(true); + try { + await douyuCollabApi.updateShop(shop.id, payload); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + onChanged(); + } finally { + setBusy(false); + } + }; + + const submitRename = async () => { + if (!renameTarget) return; + const name = renameValue.trim(); + if (!name || name === renameTarget.name) { + setRenameTarget(null); + return; + } + setBusy(true); + try { + await douyuCollabApi.updateShop(renameTarget.id, { name }); + message.success(`已重命名为「${name}」,已有订单里的店铺名也已同步`); + setRenameTarget(null); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const remove = async (shop: DouyuCollabShop) => { + setBusy(true); + try { + await douyuCollabApi.deleteShop(shop.id); + message.success(`已删除店铺「${shop.name}」`); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const columns: TableProps['columns'] = [ + { + title: '店铺名', + dataIndex: 'name', + width: 220, + render: (value: string, row) => ( + + {value} + {!row.is_active && 已停用} + {canEdit && ( + + } + width={820} + destroyOnHidden + > + + + 先在这里定义店铺,新建协作单时可以直接点选。停用只是不再出现在新建选择里,历史订单不受影响; + 重命名会自动同步已有订单,已有订单仍在使用的店铺不能删除。 + + {canEdit && ( + + setNewName(event.target.value)} + onPressEnter={() => void create()} + /> + + + )} + + rowKey="id" + size="small" + columns={columns} + dataSource={shops} + pagination={false} + scroll={{ y: 340 }} + locale={{ + emptyText: , + }} + /> + + + + setRenameTarget(null)} + onOk={() => void submitRename()} + confirmLoading={busy} + okText="保存" + destroyOnHidden + > + + setRenameValue(event.target.value)} + onPressEnter={() => void submitRename()} + /> + 保存后会同时更新已有协作单上的店铺名。 + + + + ); +} diff --git a/web/frontend/src/components/DouyuCollabShopPicker.tsx b/web/frontend/src/components/DouyuCollabShopPicker.tsx new file mode 100644 index 0000000..f3cb77e --- /dev/null +++ b/web/frontend/src/components/DouyuCollabShopPicker.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { Button, Input, Space, Tag, Typography } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import type { DouyuCollabShop } from '../api/douyuCollab'; + +const { Text } = Typography; + +/** + * 斗鱼店铺选择器:一个店铺一个按钮,筛选和新建弹窗共用。 + * + * - 当前选中的按钮显示主题色,一眼看得出选的是谁; + * - `allowClear` 为真时多一个「全部 / 不填」按钮; + * - `allowCustom` 允许直接输入新店铺名,后端会在建单时自动登记; + * - 停用店铺可选择是否展示(筛选时展示,避免看不到历史值)。 + */ +export default function DouyuCollabShopPicker({ + value, + shops, + disabled, + includeInactive = false, + allowCustom = true, + allowClear = true, + clearLabel = '不填', + size = 'small', + onChange, +}: { + value?: string | null; + shops: DouyuCollabShop[]; + disabled?: boolean; + includeInactive?: boolean; + allowCustom?: boolean; + allowClear?: boolean; + clearLabel?: string; + size?: 'small' | 'middle' | 'large'; + onChange?: (value: string) => void; +}) { + const [customOpen, setCustomOpen] = useState(false); + const [customName, setCustomName] = useState(''); + + useEffect(() => { + if (!customOpen) setCustomName(''); + }, [customOpen]); + + const current = value ?? ''; + const visible = shops.filter((shop) => includeInactive || shop.is_active); + const options: { value: string; label: ReactNode }[] = visible.map((shop) => ({ + value: shop.name, + label: ( + + {shop.name} + {!shop.is_active && 停用} + + ), + })); + if (current && !visible.some((shop) => shop.name === current)) { + options.unshift({ value: current, label: current }); + } + + const submitCustom = () => { + const name = customName.trim(); + if (!name) { + setCustomOpen(false); + return; + } + onChange?.(name); + setCustomOpen(false); + }; + + return ( + + + {options.map((option) => ( + + ))} + {allowClear && ( + + )} + {allowCustom && !disabled && !customOpen && ( + + )} + + {customOpen && ( + setCustomName(event.target.value)} + onPressEnter={submitCustom} + onBlur={submitCustom} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setCustomName(''); + setCustomOpen(false); + } + }} + /> + )} + + ); +} diff --git a/web/frontend/src/hooks/useCollabRealtime.ts b/web/frontend/src/hooks/useCollabRealtime.ts index 49e3adb..d4a9306 100644 --- a/web/frontend/src/hooks/useCollabRealtime.ts +++ b/web/frontend/src/hooks/useCollabRealtime.ts @@ -12,9 +12,11 @@ export interface CollabRealtimeEvent { interface UseCollabRealtimeOptions { enabled: boolean; onInvalidate: (event: CollabRealtimeEvent) => void; + /** 默认是虎牙协作台账;斗鱼页面传入自己的 WS 路径。 */ + path?: string; } -const WS_PATH = '/api/huya/external-orders/ws'; +const DEFAULT_WS_PATH = '/api/huya/external-orders/ws'; const HEARTBEAT_TIMEOUT_MS = 60_000; const MAX_RECONNECT_DELAY_MS = 30_000; @@ -32,6 +34,7 @@ function websocketUrl(path: string): string { export function useCollabRealtime({ enabled, onInvalidate, + path = DEFAULT_WS_PATH, }: UseCollabRealtimeOptions) { const [connected, setConnected] = useState(false); const [reconnecting, setReconnecting] = useState(false); @@ -129,7 +132,7 @@ export function useCollabRealtime({ return; } - const nextSocket = new WebSocket(websocketUrl(WS_PATH)); + const nextSocket = new WebSocket(websocketUrl(path)); socket = nextSocket; nextSocket.onopen = () => { @@ -179,7 +182,7 @@ export function useCollabRealtime({ setConnected(false); setReconnecting(false); }; - }, [enabled]); + }, [enabled, path]); return { connected, reconnecting }; } diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 54e5db3..f1dd9c8 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -85,6 +85,11 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: }); } + // 外部单协作:权限独立,客服没有 douyu:task 也要能进共享台账。 + if (can('douyu:collab:view')) { + douyuItems.push({ key: '/douyu/collab', label: '外部单协作', icon: }); + } + // 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口 if (can('huya:task')) { if (user?.role === 'support') { diff --git a/web/frontend/src/pages/DouyuCollabPage.tsx b/web/frontend/src/pages/DouyuCollabPage.tsx new file mode 100644 index 0000000..536ff91 --- /dev/null +++ b/web/frontend/src/pages/DouyuCollabPage.tsx @@ -0,0 +1,1730 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Alert, + App, + Badge, + Button, + Card, + Col, + Descriptions, + Drawer, + Dropdown, + Empty, + Image, + Input, + InputNumber, + Modal, + Row, + Select, + Space, + Statistic, + Switch, + Table, + Tag, + Timeline, + Tooltip, + Typography, + type TableProps, +} from 'antd'; +import { + CheckCircleOutlined, + DeleteOutlined, + EditOutlined, + FormOutlined, + InboxOutlined, + LinkOutlined, + PlusOutlined, + ReloadOutlined, + SafetyCertificateOutlined, + ShopOutlined, + SyncOutlined, + TagOutlined, + UndoOutlined, +} from '@ant-design/icons'; +import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader'; +import DouyuCollabShopModal from '../components/DouyuCollabShopModal'; +import DouyuCollabShopPicker from '../components/DouyuCollabShopPicker'; +import { douyuCollabApi } from '../api/modules'; +import type { + DouyuCollabAccountLookup, + DouyuCollabGoods, + DouyuCollabMetric, + DouyuCollabOrder, + DouyuCollabShop, + DouyuCollabStatus, + DouyuCollabSummary, + DouyuCollabTimeline, +} from '../api/douyuCollab'; +import { usePermissions } from '../hooks/usePermissions'; +import { useCollabRealtime, type CollabRealtimeEvent } from '../hooks/useCollabRealtime'; +import { message } from '../utils/antdMessage'; +import { getErrorMessage } from '../utils/error'; + +const { Text } = Typography; + +function hasTextInputFocus(): boolean { + const active = document.activeElement; + return ( + active instanceof HTMLElement && + (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable) + ); +} + +/** 实时同步暂停开关按浏览器记住:刷新页面后仍保持暂停,直到手动恢复。 */ +const REALTIME_PAUSED_STORAGE_KEY = 'douyu.collab.realtime-paused'; +const REALTIME_WS_PATH = '/api/douyu/external-orders/ws'; + +function readRealtimePaused(): boolean { + try { + return window.localStorage.getItem(REALTIME_PAUSED_STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +function cardStyle(active: boolean) { + return { + borderColor: active ? '#1677ff' : undefined, + cursor: 'pointer', + } as const; +} + +const STATUS_META: Record = { + incomplete: { label: '待补资料', color: 'default' }, + to_recharge: { label: '待充值', color: 'blue' }, + recharged: { label: '已充值', color: 'cyan' }, + scripted: { label: '已上脚本', color: 'purple' }, + done: { label: '抢购成功', color: 'green' }, + points_error: { label: '积分异常', color: 'orange' }, + failed: { label: '抢购失败', color: 'red' }, + void: { label: '已作废', color: 'default' }, +}; + +const STATUS_ORDER: DouyuCollabStatus[] = [ + 'incomplete', + 'to_recharge', + 'recharged', + 'scripted', + 'done', + 'points_error', + 'failed', + 'void', +]; + +const FLOW_ORDER: DouyuCollabStatus[] = [ + 'incomplete', + 'to_recharge', + 'recharged', + 'scripted', + 'done', +]; + +const CK_META: Record = { + unverified: { label: '未检测', color: 'default' }, + valid: { label: 'CK 正常', color: 'green' }, + invalid: { label: 'CK 异常', color: 'red' }, + error: { label: 'CK 异常', color: 'red' }, + '': { label: '未检测', color: 'default' }, +}; + +function formatTime(value?: string | null): string { + if (!value) return '-'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function pointsReasonMeta(reason: string): { label: string; color: string } { + if (reason === 'shortage') return { label: '积分不足', color: 'red' }; + if (reason === 'excess') return { label: '积分超阈值', color: 'orange' }; + if (reason === 'ok') return { label: '积分正常', color: 'green' }; + return { label: '未核账', color: 'default' }; +} + +function goodsOptions(goods: DouyuCollabGoods[], accountSpecific = false) { + return goods + .filter((item) => item.commodity_id && item.name) + .map((item) => { + const time = item.is_limited ? ' · 23返场/限时' : ''; + const accountState = + accountSpecific && item.status === '1' + ? ' · 该账号已兑换' + : item.open_status === 0 + ? ' · 未开始' + : ''; + return { + value: item.commodity_id, + label: `${item.name} · ${item.score ?? '-'} 积分${time}${accountState}`, + goods: item, + disabled: accountSpecific && item.status === '1', + }; + }); +} + +interface OrderFormState { + order_no: string; + shop: string; + owner_id: number | null; + account_username: string; + gift_name: string; + commodity_id: string; + required_points: number | null; + home_images: string[]; + remark: string; +} + +const EMPTY_FORM: OrderFormState = { + order_no: '', + shop: '', + owner_id: null, + account_username: '', + gift_name: '', + commodity_id: '', + required_points: null, + home_images: [], + remark: '', +}; + +export default function DouyuCollabPage() { + const navigate = useNavigate(); + const { modal } = App.useApp(); + const { can, user } = usePermissions(); + const canView = can('douyu:collab:view'); + const canEdit = can('douyu:collab:edit'); + const canManage = can('douyu:collab:manage'); + + const [orders, setOrders] = useState([]); + const [summary, setSummary] = useState({ + total: 0, + by_status: {}, + account_missing: 0, + ck_invalid: 0, + archived_total: 0, + }); + const [owners, setOwners] = useState>([]); + const [shopRecords, setShopRecords] = useState([]); + const [gifts, setGifts] = useState([]); + const [goods, setGoods] = useState([]); + const [loading, setLoading] = useState(false); + const [goodsLoading, setGoodsLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [total, setTotal] = useState(0); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + const [keyword, setKeyword] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [shopFilter, setShopFilter] = useState(''); + const [ownerFilter, setOwnerFilter] = useState(); + const [giftFilter, setGiftFilter] = useState(''); + const [ckFilter, setCkFilter] = useState(''); + const [archiveScope, setArchiveScope] = useState<'active' | 'archived' | 'all'>('active'); + const [includeVoid, setIncludeVoid] = useState(false); + const [shopModalOpen, setShopModalOpen] = useState(false); + const [realtimePending, setRealtimePending] = useState(false); + const [realtimePaused, setRealtimePaused] = useState(readRealtimePaused); + + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [lookup, setLookup] = useState(null); + const lookupSeq = useRef(0); + const [lookupLoading, setLookupLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const [detail, setDetail] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailMetrics, setDetailMetrics] = useState([]); + const [detailTimeline, setDetailTimeline] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + + const [statusTarget, setStatusTarget] = useState(null); + const [batchStatusOpen, setBatchStatusOpen] = useState(false); + const [statusValue, setStatusValue] = useState('to_recharge'); + const [statusReason, setStatusReason] = useState(''); + const [statusBusy, setStatusBusy] = useState(false); + + const loadOrders = useCallback(async (options?: { silent?: boolean }) => { + const silent = options?.silent === true; + if (!silent) setLoading(true); + try { + const data = await douyuCollabApi.listOrders({ + page, + page_size: pageSize, + keyword: keyword.trim() || undefined, + status: statusFilter || undefined, + shop: shopFilter || undefined, + owner_id: ownerFilter, + gift_name: giftFilter || undefined, + ck_status: ckFilter || undefined, + archive_scope: archiveScope, + include_void: includeVoid, + }); + setOrders(data.items); + setTotal(data.total); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + if (!silent) setLoading(false); + } + }, [ + page, + pageSize, + keyword, + statusFilter, + shopFilter, + ownerFilter, + giftFilter, + ckFilter, + archiveScope, + includeVoid, + ]); + + const loadSummary = useCallback(async () => { + try { + setSummary(await douyuCollabApi.summary({ archive_scope: archiveScope, include_void: includeVoid })); + } catch { + // 统计失败不影响列表操作。 + } + }, [archiveScope, includeVoid]); + + const loadMeta = useCallback(async () => { + try { + const [ownerRows, giftRows, goodsRows, records] = await Promise.all([ + douyuCollabApi.owners(), + douyuCollabApi.gifts(), + douyuCollabApi.goods(), + douyuCollabApi.shopRecords(), + ]); + setOwners(ownerRows); + setGifts(giftRows); + setGoods(goodsRows); + setShopRecords(records); + } catch (error) { + message.error(getErrorMessage(error)); + } + }, []); + + useEffect(() => { + void loadOrders(); + }, [loadOrders]); + + useEffect(() => { + void loadSummary(); + }, [loadSummary]); + + useEffect(() => { + void loadMeta(); + }, [loadMeta]); + + const collabModalOpen = Boolean( + createOpen || detailOpen || shopModalOpen || batchStatusOpen || statusTarget, + ); + // 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。 + const realtimeSyncEnabled = canView && !realtimePaused; + + const reloadSilently = useCallback( + (withMeta: boolean) => { + void loadOrders({ silent: true }); + void loadSummary(); + if (withMeta) void loadMeta(); + }, + [loadOrders, loadSummary, loadMeta], + ); + + const toggleRealtimeSync = (nextEnabled: boolean) => { + const nextPaused = !nextEnabled; + setRealtimePaused(nextPaused); + try { + window.localStorage.setItem(REALTIME_PAUSED_STORAGE_KEY, nextPaused ? '1' : '0'); + } catch { + // 隐私模式下 localStorage 不可写也不影响本次会话的开关。 + } + if (nextPaused) { + setRealtimePending(false); + message.info('已暂停实时同步,点「刷新」手动获取最新数据'); + return; + } + message.success('已恢复实时同步'); + reloadSilently(true); + }; + + const handleRealtimeInvalidate = useCallback( + (event: CollabRealtimeEvent) => { + if (!realtimeSyncEnabled) return; + if (collabModalOpen || hasTextInputFocus()) { + setRealtimePending(true); + return; + } + reloadSilently(event.scope !== 'orders'); + }, + [realtimeSyncEnabled, collabModalOpen, reloadSilently], + ); + + const { connected: realtimeConnected, reconnecting: realtimeReconnecting } = useCollabRealtime({ + enabled: realtimeSyncEnabled, + onInvalidate: handleRealtimeInvalidate, + path: REALTIME_WS_PATH, + }); + + useEffect(() => { + if (!realtimePending || !realtimeSyncEnabled || collabModalOpen) return undefined; + let cancelled = false; + const flush = () => { + if (cancelled || collabModalOpen || hasTextInputFocus()) return; + setRealtimePending(false); + reloadSilently(true); + }; + const timer = window.setTimeout(flush, 300); + const handleFocusOut = () => window.setTimeout(flush, 0); + window.addEventListener('focusout', handleFocusOut); + return () => { + cancelled = true; + window.clearTimeout(timer); + window.removeEventListener('focusout', handleFocusOut); + }; + }, [realtimePending, realtimeSyncEnabled, collabModalOpen, reloadSilently]); + + // WebSocket 断开时用低频轮询兜底;连接正常或手动暂停时完全不轮询。 + useEffect(() => { + if (!realtimeSyncEnabled || realtimeConnected) return undefined; + const timer = window.setInterval(() => { + if (document.visibilityState !== 'visible' || collabModalOpen) return; + reloadSilently(false); + }, 30_000); + return () => window.clearInterval(timer); + }, [realtimeSyncEnabled, realtimeConnected, collabModalOpen, reloadSilently]); + + const selectedCount = selectedRowKeys.length; + const selectedIds = useMemo(() => selectedRowKeys.map(Number), [selectedRowKeys]); + const selectedOrders = useMemo( + () => orders.filter((row) => selectedIds.includes(row.id)), + [orders, selectedIds], + ); + const canArchiveSelected = + selectedOrders.length > 0 && + selectedOrders.length === selectedIds.length && + selectedOrders.every((row) => row.status === 'done' && !row.is_archived); + + const refreshAll = () => { + void loadOrders(); + void loadSummary(); + void loadMeta(); + }; + + const lookupUsername = useCallback(async (raw: string, options?: { notify?: boolean }) => { + const username = raw.trim(); + if (!username) { + if (options?.notify !== false) message.warning('请先输入斗鱼用户名'); + return; + } + const seq = ++lookupSeq.current; + setLookupLoading(true); + try { + const result = await douyuCollabApi.lookupAccount(username, true); + if (seq !== lookupSeq.current) return; + setLookup(result); + if (options?.notify === false) return; + if (result.found && result.ok) { + message.success( + `已找到账号:${result.account_nickname || result.account_username},角色 ${result.bind_game_role || '-'}`, + ); + } else { + message.warning(result.message || '账号暂不可用'); + } + } catch (error) { + if (seq === lookupSeq.current) message.error(getErrorMessage(error)); + } finally { + if (seq === lookupSeq.current) setLookupLoading(false); + } + }, []); + + const openCreate = () => { + setEditing(null); + setForm({ ...EMPTY_FORM, owner_id: user?.id ?? null }); + lookupSeq.current += 1; + setLookup(null); + setLookupLoading(false); + setCreateOpen(true); + }; + + const openEdit = (order: DouyuCollabOrder) => { + setEditing(order); + setForm({ + order_no: order.order_no, + shop: order.shop, + owner_id: order.owner_id, + account_username: order.account_username, + gift_name: order.gift_name, + commodity_id: order.commodity_id, + required_points: order.required_points, + home_images: order.home_images, + remark: order.remark, + }); + lookupSeq.current += 1; + setLookup(null); + setLookupLoading(false); + setCreateOpen(true); + }; + + const updateForm = (key: K, value: OrderFormState[K]) => { + if (key === 'account_username') { + // 账号名一变就作废旧查询结果,避免弹窗里显示上一个账号的角色/积分。 + lookupSeq.current += 1; + setLookup(null); + setLookupLoading(false); + } + setForm((previous) => ({ ...previous, [key]: value })); + }; + + const handleLookup = () => { + void lookupUsername(form.account_username); + }; + + // 输入停下约 650ms 后自动查账号,右侧按钮保留给立即重试。 + useEffect(() => { + if (!createOpen) return undefined; + const username = form.account_username.trim(); + if (username.length < 2) return undefined; + const timer = window.setTimeout(() => { + void lookupUsername(username, { notify: false }); + }, 650); + return () => window.clearTimeout(timer); + }, [createOpen, form.account_username, lookupUsername]); + + const handleGiftChange = (commodityId: string) => { + const pool = lookup?.goods?.length ? lookup.goods : goods; + const item = pool.find((row) => row.commodity_id === commodityId); + if (!item) return; + setForm((previous) => ({ + ...previous, + commodity_id: item.commodity_id, + gift_name: item.name, + required_points: item.score, + })); + }; + + const submitCreate = async (draft: boolean) => { + if (!form.order_no.trim()) { + message.warning('订单编号不能为空'); + return; + } + if (!draft && !form.shop.trim()) { + message.warning('请填写店铺'); + return; + } + // 参考虎牙:资料没填全也允许直接点「新建」,自动按待补资料创建。 + const accountVerified = Boolean( + lookup?.found && + lookup.ok && + lookup.account_username && + lookup.account_username.trim() === form.account_username.trim(), + ); + const createAsDraft = + draft || + !accountVerified || + !form.account_username.trim() || + !form.gift_name.trim() || + form.home_images.length === 0; + setSubmitting(true); + try { + if (editing) { + const updated = await douyuCollabApi.updateOrder(editing.id, { + order_no: form.order_no.trim(), + shop: form.shop.trim(), + owner_id: form.owner_id, + account_username: form.account_username.trim(), + gift_name: form.gift_name, + commodity_id: form.commodity_id, + required_points: form.required_points, + home_images: form.home_images, + remark: form.remark, + expected_version: editing.version, + }); + if (updated.status === 'incomplete' && updated.missing_fields?.length) { + message.warning(`资料已保存,但仍缺:${updated.missing_fields.join('、')}`); + } else if (editing.status === 'incomplete') { + message.success('资料已补齐,协作单已自动进入待充值'); + } else { + message.success('协作单已更新'); + } + } else { + const created = await douyuCollabApi.createOrder({ + order_no: form.order_no.trim(), + shop: form.shop.trim(), + owner_id: form.owner_id, + account_username: form.account_username.trim(), + gift_name: form.gift_name, + commodity_id: form.commodity_id, + required_points: form.required_points, + home_images: form.home_images, + remark: form.remark, + draft: createAsDraft, + require_verified: !createAsDraft, + }); + message.success( + created.status === 'incomplete' ? '已创建待补资料协作单' : '协作单已创建', + ); + } + setCreateOpen(false); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setSubmitting(false); + } + }; + + const verifyOrder = async (order: DouyuCollabOrder) => { + try { + message.loading({ content: '正在校验账号…', key: 'dy-collab-verify' }); + await douyuCollabApi.verifyOrder(order.id); + message.success({ content: '账号已校验', key: 'dy-collab-verify' }); + refreshAll(); + } catch (error) { + message.error({ content: getErrorMessage(error), key: 'dy-collab-verify' }); + } + }; + + const queryOrder = async (order: DouyuCollabOrder) => { + try { + message.loading({ content: '正在查询鱼翅、积分与兑换记录…', key: 'dy-collab-query' }); + const result = await douyuCollabApi.queryActivity(order.id); + message.success({ + content: `${result.message}${result.points_reason ? `;${pointsReasonMeta(result.points_reason).label}` : ''}`, + key: 'dy-collab-query', + }); + refreshAll(); + if (detail?.id === order.id) void openDetail(order); + } catch (error) { + message.error({ content: getErrorMessage(error), key: 'dy-collab-query' }); + } + }; + + const openDetail = async (order: DouyuCollabOrder) => { + setDetail(order); + setDetailOpen(true); + setDetailLoading(true); + try { + const [metrics, timeline, latest] = await Promise.all([ + douyuCollabApi.metrics(order.id), + douyuCollabApi.timeline(order.id), + douyuCollabApi.getOrder(order.id), + ]); + setDetailMetrics(metrics); + setDetailTimeline(timeline); + setDetail(latest); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setDetailLoading(false); + } + }; + + const openStatus = (order: DouyuCollabOrder) => { + setStatusTarget(order); + setBatchStatusOpen(false); + setStatusValue(order.next_status || order.status); + setStatusReason(''); + }; + + const openBatchStatus = () => { + if (selectedIds.length === 0) { + message.warning('请先勾选协作单'); + return; + } + setStatusTarget(null); + setBatchStatusOpen(true); + setStatusValue('recharged'); + setStatusReason(''); + }; + + const submitStatus = async () => { + setStatusBusy(true); + try { + if (batchStatusOpen) { + const result = await douyuCollabApi.batchStatus({ + ids: selectedIds, + status: statusValue, + reason: statusReason, + }); + message.success(`批量改状态完成:${result.results.filter((item) => item.success).length}/${result.results.length}`); + setBatchStatusOpen(false); + setSelectedRowKeys([]); + } else if (statusTarget) { + const currentIndex = FLOW_ORDER.indexOf(statusTarget.status); + const targetIndex = FLOW_ORDER.indexOf(statusValue); + await douyuCollabApi.setStatus(statusTarget.id, { + status: statusValue, + reason: statusReason, + rollback: targetIndex >= 0 && currentIndex >= 0 && targetIndex < currentIndex, + expected_version: statusTarget.version, + }); + message.success('状态已更新'); + setStatusTarget(null); + } + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setStatusBusy(false); + } + }; + + const batchQuery = async () => { + if (selectedIds.length === 0) { + message.warning('请先勾选协作单'); + return; + } + setLoading(true); + try { + const result = await douyuCollabApi.batchActivity(selectedIds); + const ok = result.results.filter((item) => item.success).length; + message.success(`查询完成:${ok}/${result.results.length}`); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const batchVerify = async () => { + if (selectedIds.length === 0) { + message.warning('请先勾选协作单'); + return; + } + setLoading(true); + try { + const result = await douyuCollabApi.batchVerify(selectedIds); + const ok = result.results.filter((item) => item.success).length; + const invalid = result.results.filter((item) => item.ck_status === 'invalid').length; + message.success( + `校验完成:${ok}/${result.results.length}${invalid ? `,其中 CK 异常 ${invalid} 单` : ''}`, + ); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setLoading(false); + } + }; + + const archiveOrders = async (order: DouyuCollabOrder) => { + try { + const result = await douyuCollabApi.archive([order.id], ''); + message.success(`已归档 ${result.archived} 单`); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const unarchiveOrders = async (order: DouyuCollabOrder) => { + try { + const result = await douyuCollabApi.unarchive([order.id]); + message.success(`已取消归档 ${result.unarchived} 单`); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const batchArchive = () => { + if (!canArchiveSelected) { + message.warning('只有勾选的抢购成功订单才能批量归档'); + return; + } + modal.confirm({ + title: `归档选中的 ${selectedIds.length} 单?`, + content: '归档后这些订单在「查看归档」里只读展示,需要继续操作时可取消归档。', + onOk: async () => { + try { + const result = await douyuCollabApi.archive(selectedIds, ''); + message.success(`已归档 ${result.archived} 单`); + setSelectedRowKeys([]); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }, + }); + }; + + const batchUnarchive = () => { + if (selectedIds.length === 0) { + message.warning('请先勾选已归档订单'); + return; + } + modal.confirm({ + title: `取消归档选中的 ${selectedIds.length} 单?`, + content: '取消归档后会回到主台账,可以继续编辑或改状态。', + onOk: async () => { + try { + const result = await douyuCollabApi.unarchive(selectedIds); + message.success(`已取消归档 ${result.unarchived} 单`); + setSelectedRowKeys([]); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }, + }); + }; + + const deleteOrder = (order: DouyuCollabOrder) => { + modal.confirm({ + title: `删除协作单 ${order.order_no}?`, + content: '只删除这一行台账,不会删除内部斗鱼账号,也不会清理已上传图片。', + okButtonProps: { danger: true }, + onOk: async () => { + try { + await douyuCollabApi.deleteOrder(order.id); + message.success('已删除'); + setSelectedRowKeys((keys) => keys.filter((key) => Number(key) !== order.id)); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }, + }); + }; + + const refreshGoods = async () => { + setGoodsLoading(true); + try { + const result = await douyuCollabApi.refreshGoods(lookup?.account_id || undefined); + setGoods(result.goods); + message.success(`已刷新 ${result.count} 个活动商品`); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setGoodsLoading(false); + } + }; + + const statusCards = [ + { key: '', label: '全部', value: summary.total }, + ...STATUS_ORDER.map((status) => ({ + key: status, + label: STATUS_META[status].label, + value: summary.by_status[status] || 0, + })), + ]; + + const columns: TableProps['columns'] = [ + { + title: '序号', + width: 72, + fixed: 'left', + align: 'center', + render: (_, row) => {row.id}, + }, + { + title: '订单编号', + dataIndex: 'order_no', + width: 170, + fixed: 'left', + render: (value: string, row) => ( + + + {value} + + {row.status === 'void' && 已作废:{row.void_reason || '-'}} + + ), + }, + { + title: '店铺', + dataIndex: 'shop', + width: 120, + render: (value: string) => value || 未填, + }, + { + title: '负责人', + dataIndex: 'owner_id', + width: 120, + render: (value: number | null, row) => ( + { + setOwnerFilter(value); + setPage(1); + }} + options={owners.map((item) => ({ value: item.id, label: item.username }))} + /> + { + setCkFilter(value || ''); + setPage(1); + }} + options={[ + { value: 'valid', label: CK_META.valid.label }, + { value: 'invalid', label: CK_META.invalid.label }, + { value: 'unverified', label: CK_META.unverified.label }, + ]} + /> + updateForm('order_no', event.target.value)} + /> + + + +
+ 负责人 + updateForm('account_username', event.target.value)} + /> + + + {lookup && ( + 0 + ? `这个账号已经挂在:${lookup.orders_using_account.map((item) => item.order_no).join('、')};允许多单,照常提交` + : lookup.message || undefined + } + /> + )} +
+ + + +
+ 礼包 + setStatusValue(value)} + options={STATUS_ORDER.map((item) => ({ + value: item, + label: STATUS_META[item].label, + }))} + /> + {statusValue === 'void' && ( + + )} + setStatusReason(event.target.value)} + /> + + + + setDetailOpen(false)} + width={760} + extra={ + detail && canEdit ? ( + + {detail.account_id && ( + + )} + + + + + ) : undefined + } + > + {detail ? ( + + + {detail.order_no} + {detail.shop || '-'} + {detail.owner_name || '-'} + + {detail.status_label} + + {detail.account_username || '-'} + {detail.account_nickname || '-'} + {detail.bind_game_role || '-'} + {detail.bind_game_channel || '-'} + + {detail.gift_name || '-'}({detail.required_points ?? '-'} 积分) + + + 鱼翅 {detail.fish_fin_balance ?? '-'} / 积分 {detail.points_balance ?? '-'} + + + 需 {detail.points_required_total ?? '-'},结余 {detail.points_surplus ?? '-'} + + + {detail.points_check_message || '-'} + + + {detail.remark || '-'} + + + +
+ 主页截图 +
+ {detail.home_image_urls.length ? ( + + + {detail.home_image_thumb_urls.map((url, index) => ( + {`主页截图${index + ))} + + + ) : ( + 未上传 + )} +
+
+ +
+ 查询时间线 + {detailLoading ? ( + 加载中… + ) : detailMetrics.length ? ( + ({ + color: metric.verdict === 'success' ? 'green' : metric.verdict === 'failed' ? 'red' : 'gray', + children: ( + + + {metric.verdict_label || '查询'} · {formatTime(metric.created_at)} + + {metric.message} + + 鱼翅 {metric.fish_fin_balance ?? '-'} / 积分 {metric.points_balance ?? '-'} / 记录 {metric.records_matched}/{metric.records_total} + + + ), + }))} + /> + ) : ( + + )} +
+ +
+ 操作记录 + ({ + children: ( + + + {item.username || '-'} · {item.action} + + + {formatTime(item.created_at)} + + {item.detail && ( + + {item.detail} + + )} + + ), + }))} + /> +
+
+ ) : null} +
+ + ); +}