From 79cfd4a6340112b17609c56bedcbcad43b99e4f2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 8 Nov 2026 11:41:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E5=A2=9E=E5=8A=A0=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E5=8D=95=E5=8D=8F=E4=BD=9C=E5=8F=B0=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_migrations.py | 2 +- tests/test_weibo_collab.py | 168 ++ web/backend/main.py | 2 + .../20261108_0071_weibo_external_orders.py | 251 +++ web/backend/models.py | 193 ++ web/backend/permissions.py | 8 + web/backend/routers/weibo_collab.py | 640 ++++++ web/backend/schemas.py | 265 +++ web/backend/services/weibo_collab_events.py | 82 + web/backend/services/weibo_collab_service.py | 1857 +++++++++++++++++ web/backend/services/weibo_service.py | 9 +- web/frontend/src/App.tsx | 2 + web/frontend/src/api/modules.ts | 24 + web/frontend/src/api/weiboCollab.ts | 408 ++++ web/frontend/src/layouts/MainLayout.tsx | 3 + web/frontend/src/pages/WeiboCollabPage.tsx | 1307 ++++++++++++ 16 files changed, 5217 insertions(+), 4 deletions(-) create mode 100644 tests/test_weibo_collab.py create mode 100644 web/backend/migrations/versions/20261108_0071_weibo_external_orders.py create mode 100644 web/backend/routers/weibo_collab.py create mode 100644 web/backend/services/weibo_collab_events.py create mode 100644 web/backend/services/weibo_collab_service.py create mode 100644 web/frontend/src/api/weiboCollab.ts create mode 100644 web/frontend/src/pages/WeiboCollabPage.tsx diff --git a/tests/test_migrations.py b/tests/test_migrations.py index aead9ac..dd122bc 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 = "20261108_0070" +HEAD_REVISION = "20261108_0071" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/tests/test_weibo_collab.py b/tests/test_weibo_collab.py new file mode 100644 index 0000000..c1b8c53 --- /dev/null +++ b/tests/test_weibo_collab.py @@ -0,0 +1,168 @@ +"""微博外部单协作核心流程测试。""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from web.backend.models import User, WeiboAccount, WeiboExternalOrderMetric +from web.backend.services import weibo_collab_service as collab + + +def make_account(db: Session, uid: str = "4014000001") -> WeiboAccount: + account = WeiboAccount( + uid=uid, + nickname="测试微博", + cookie="SCF=a; SUB=b", + tag="", + remark="", + activity="bowen_tjg", + is_enabled=True, + ck_check_status="valid", + is_bound=True, + game_role_name="角色A", + can_rebind=False, + rebind_remain_days=7, + weidou_balance=0, + ) + db.add(account) + db.commit() + db.refresh(account) + return account + + +def make_user(db: Session) -> User: + user = User(username="weibo-collab", password_hash="hash", role="super_admin") + db.add(user) + db.commit() + db.refresh(user) + return user + + +def test_create_orders_groups_and_balance_check(monkeypatch, db): + user = make_user(db) + account = make_account(db) + + class FakeClient: + def __init__(self, *_args, **_kwargs): + pass + + def list_mall(self, *_args, **_kwargs): + return {"weidou_balance": 400, "list": []} + + monkeypatch.setattr(collab, "WeiboClient", FakeClient) + + first = collab.create_order( + db, + user, + order_no="WB-001", + shop="店铺A", + account_id=account.id, + goods_name="商品A", + required_weidou=100, + home_images=["proof/a.png"], + require_verified=False, + ) + second = collab.create_order( + db, + user, + order_no="WB-002", + shop="店铺A", + account_id=account.id, + goods_name="商品B", + required_weidou=200, + require_verified=False, + ) + + assert first.status == collab.STATUS_TO_RECHARGE + assert second.status == collab.STATUS_TO_RECHARGE + assert first.game_role_name == "角色A" + + result = collab.check_balance_groups( + db, + user, + [first], + threshold=100, + activity="bowen_tjg", + ) + + assert result["updated"] == 2 + assert result["groups"][0]["balance"] == 400 + assert result["groups"][0]["required_total"] == 300 + assert result["groups"][0]["surplus"] == 100 + db.refresh(first) + db.refresh(second) + assert first.status == collab.STATUS_RECHARGED + assert second.status == collab.STATUS_RECHARGED + assert first.recharged_at is not None + assert db.query(WeiboExternalOrderMetric).count() == 2 + + +def test_exchange_record_unique_match_and_archive(monkeypatch, db): + user = make_user(db) + account = make_account(db) + first = collab.create_order( + db, + user, + order_no="WB-101", + shop="店铺A", + account_id=account.id, + goods_name="商品A", + required_weidou=100, + require_verified=False, + ) + second = collab.create_order( + db, + user, + order_no="WB-102", + shop="店铺A", + account_id=account.id, + goods_name="商品B", + required_weidou=200, + require_verified=False, + ) + + class FakeClient: + def __init__(self, *_args, **_kwargs): + pass + + def list_mall(self, *_args, **_kwargs): + return {"weidou_balance": 500, "list": []} + + def list_exchange_records(self, *_args, **_kwargs): + return { + "list": [ + { + "goods_name": "商品A", + "game_account": "角色A", + "cost_weidou": 100, + "exchange_time": "2099-01-01 00:00:00", + "reward_status": 2, + } + ] + } + + monkeypatch.setattr(collab, "WeiboClient", FakeClient) + collab.check_balance_groups(db, user, [first], threshold=100) + collab.set_status(db, user, first, collab.STATUS_EXCHANGE_PENDING) + collab.set_status(db, user, second, collab.STATUS_EXCHANGE_PENDING) + + result = collab.check_exchange_records(db, user, [first, second]) + + assert result["success"] == 1 + assert result["unknown"] == 1 + db.refresh(first) + db.refresh(second) + assert first.status == collab.STATUS_DONE + assert first.exchange_verdict == collab.VERDICT_SUCCESS + assert second.status == collab.STATUS_EXCHANGE_PENDING + assert second.exchange_verdict == collab.VERDICT_UNKNOWN + + archived = collab.archive_orders(db, user, [first.id], reason="客户验收") + assert archived["archived"] == 1 + db.refresh(first) + assert first.archived_at is not None + assert first.archive_reason == "客户验收" + + timeline = collab.order_timeline(db, first.id) + assert timeline + assert any(item["action"] == "weibo:collab:archive" for item in timeline) diff --git a/web/backend/main.py b/web/backend/main.py index 7ada324..f023ce8 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -34,6 +34,7 @@ from .routers import ( proxy, users, weibo, + weibo_collab, yyb, ) from .schemas import AppInfo @@ -167,6 +168,7 @@ app.include_router(proxy.router) app.include_router(cookies.router) app.include_router(huya.router) app.include_router(weibo.router) +app.include_router(weibo_collab.router) app.include_router(douyu.router) app.include_router(douyu_collab.router) app.include_router(yyb.router) diff --git a/web/backend/migrations/versions/20261108_0071_weibo_external_orders.py b/web/backend/migrations/versions/20261108_0071_weibo_external_orders.py new file mode 100644 index 0000000..95b0494 --- /dev/null +++ b/web/backend/migrations/versions/20261108_0071_weibo_external_orders.py @@ -0,0 +1,251 @@ +"""增加微博外部单协作台账。""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261108_0071" +down_revision: str | None = "20261108_0070" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _create_shops() -> None: + op.create_table( + "weibo_collab_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=sa.text("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"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by"], ["users.id"]), + ) + op.create_index( + "ix_weibo_collab_shops_name", "weibo_collab_shops", ["name"], unique=True + ) + op.create_index( + "ix_weibo_collab_shops_is_active", "weibo_collab_shops", ["is_active"] + ) + + +def _create_goods() -> None: + op.create_table( + "weibo_collab_goods", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("price", sa.Integer(), nullable=False, server_default="0"), + sa.Column("goods_id", sa.Integer(), nullable=True), + sa.Column( + "goods_image", sa.String(length=512), nullable=False, server_default="" + ), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "is_active", sa.Boolean(), nullable=False, server_default=sa.text("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"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by"], ["users.id"]), + ) + op.create_index( + "ix_weibo_collab_goods_name", "weibo_collab_goods", ["name"], unique=True + ) + op.create_index( + "ix_weibo_collab_goods_is_active", "weibo_collab_goods", ["is_active"] + ) + + +def _create_orders() -> None: + op.create_table( + "weibo_external_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_uid", sa.String(length=32), nullable=False, server_default="" + ), + sa.Column( + "account_nickname", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column( + "game_role_name", sa.String(length=128), nullable=False, server_default="" + ), + sa.Column("bind_snapshot_at", sa.DateTime(), nullable=True), + sa.Column("goods_id", sa.Integer(), nullable=True), + sa.Column( + "goods_name", sa.String(length=128), nullable=False, server_default="" + ), + sa.Column( + "goods_image", sa.String(length=512), nullable=False, server_default="" + ), + sa.Column("required_weidou", sa.Integer(), nullable=True), + sa.Column("home_images", sa.JSON(), nullable=True), + sa.Column("remark", sa.String(length=512), nullable=False, server_default=""), + sa.Column( + "status", + sa.String(length=20), + nullable=False, + server_default="incomplete", + ), + sa.Column("recharged_at", sa.DateTime(), nullable=True), + sa.Column("exchange_started_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("weidou_balance", sa.Integer(), nullable=True), + sa.Column("weidou_required_total", sa.Integer(), nullable=True), + sa.Column("weidou_surplus", sa.Integer(), nullable=True), + sa.Column("weidou_threshold", sa.Integer(), nullable=True), + sa.Column("weidou_checked_at", sa.DateTime(), nullable=True), + sa.Column( + "weidou_check_reason", + sa.String(length=16), + nullable=False, + server_default="", + ), + sa.Column( + "weidou_check_message", + sa.String(length=512), + nullable=False, + server_default="", + ), + sa.Column("weidou_check_detail", sa.JSON(), nullable=True), + sa.Column("exchange_checked_at", sa.DateTime(), nullable=True), + sa.Column( + "exchange_record_key", + sa.String(length=128), + nullable=False, + server_default="", + ), + sa.Column("exchange_record_snapshot", sa.JSON(), nullable=True), + sa.Column( + "exchange_verdict", + sa.String(length=16), + nullable=False, + server_default="", + ), + sa.Column( + "exchange_message", + sa.String(length=512), + nullable=False, + server_default="", + ), + 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( + ["account_id"], ["weibo_accounts.id"], ondelete="SET NULL" + ), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"]), + sa.ForeignKeyConstraint(["archived_by"], ["users.id"]), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + sa.ForeignKeyConstraint(["updated_by"], ["users.id"]), + ) + for column in ( + "order_no", + "shop", + "owner_id", + "account_id", + "account_uid", + "status", + "exchange_verdict", + "archived_at", + "created_by", + "updated_at", + ): + op.create_index( + f"ix_weibo_external_orders_{column}", + "weibo_external_orders", + [column], + unique=column == "order_no", + ) + + +def _create_metrics() -> None: + op.create_table( + "weibo_external_order_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_uid", sa.String(length=32), nullable=False, server_default="" + ), + sa.Column("weidou_balance", sa.Integer(), nullable=True), + sa.Column("weidou_required_total", sa.Integer(), nullable=True), + sa.Column("weidou_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=24), 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"], ["weibo_external_orders.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + ) + for column in ("order_id", "account_id", "verdict", "created_at"): + op.create_index( + f"ix_weibo_external_order_metrics_{column}", + "weibo_external_order_metrics", + [column], + ) + + +def upgrade() -> None: + bind = op.get_bind() + existing = set(sa.inspect(bind).get_table_names()) + if "weibo_collab_shops" not in existing: + _create_shops() + if "weibo_collab_goods" not in existing: + _create_goods() + if "weibo_external_orders" not in existing: + _create_orders() + if "weibo_external_order_metrics" not in existing: + _create_metrics() + + +def downgrade() -> None: + bind = op.get_bind() + existing = set(sa.inspect(bind).get_table_names()) + if "weibo_external_order_metrics" in existing: + op.drop_table("weibo_external_order_metrics") + if "weibo_external_orders" in existing: + op.drop_table("weibo_external_orders") + if "weibo_collab_goods" in existing: + op.drop_table("weibo_collab_goods") + if "weibo_collab_shops" in existing: + op.drop_table("weibo_collab_shops") diff --git a/web/backend/models.py b/web/backend/models.py index d67cc8a..c0c2162 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -961,6 +961,199 @@ class WeiboAccount(Base): ) +class WeiboCollabShop(Base): + """微博外部单协作的店铺字典。""" + + __tablename__ = "weibo_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 WeiboCollabGoods(Base): + """微博外部单协作的商品字典,微豆价格仅作下单快照兜底。""" + + __tablename__ = "weibo_collab_goods" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column( + String(128), unique=True, nullable=False, index=True + ) + price: Mapped[int] = mapped_column( + Integer, default=0, server_default="0", nullable=False + ) + goods_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + goods_image: Mapped[str] = mapped_column(String(512), default="", server_default="") + 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 WeiboExternalOrder(Base): + """微博外部单协作台账:一行 = 一单,引用现有微博账号池。""" + + __tablename__ = "weibo_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 + ) + + account_id: Mapped[int | None] = mapped_column( + ForeignKey("weibo_accounts.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + account_uid: Mapped[str] = mapped_column( + String(32), default="", server_default="", index=True + ) + account_nickname: Mapped[str] = mapped_column( + String(128), default="", server_default="" + ) + game_role_name: Mapped[str] = mapped_column( + String(128), default="", server_default="" + ) + bind_snapshot_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) + + goods_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + goods_name: Mapped[str] = mapped_column(String(128), default="", server_default="") + goods_image: Mapped[str] = mapped_column(String(512), default="", server_default="") + required_weidou: Mapped[int | None] = mapped_column(Integer, nullable=True) + home_images: Mapped[list | None] = mapped_column(JSON, nullable=True) + remark: Mapped[str] = mapped_column(String(512), default="", server_default="") + + status: Mapped[str] = mapped_column( + String(20), default="incomplete", server_default="incomplete", index=True + ) + recharged_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) + exchange_started_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="") + + weidou_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) + weidou_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + weidou_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True) + weidou_threshold: Mapped[int | None] = mapped_column(Integer, nullable=True) + weidou_checked_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) + weidou_check_reason: Mapped[str] = mapped_column( + String(16), default="", server_default="" + ) + weidou_check_message: Mapped[str] = mapped_column( + String(512), default="", server_default="" + ) + weidou_check_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + exchange_checked_at: Mapped[datetime | None] = mapped_column( + UTCDateTime, nullable=True + ) + exchange_record_key: Mapped[str] = mapped_column( + String(128), default="", server_default="" + ) + exchange_record_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True) + exchange_verdict: Mapped[str] = mapped_column( + String(16), default="", server_default="", index=True + ) + exchange_message: Mapped[str] = mapped_column( + String(512), default="", server_default="" + ) + + 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 WeiboExternalOrderMetric(Base): + """微博协作单的核账与兑换记录快照,只追加,不保存 CK。""" + + __tablename__ = "weibo_external_order_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + order_id: Mapped[int] = mapped_column( + ForeignKey("weibo_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_uid: Mapped[str] = mapped_column(String(32), default="", server_default="") + weidou_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) + weidou_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + weidou_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(24), 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 HuyaRegisterBatch(Base): """虎牙自动注册批次(持久化元数据)""" diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 1ee7d41..d256d31 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -49,6 +49,9 @@ PERMISSIONS = { "weibo:recharge": "创建微博助威充值订单", "weibo:exchange": "执行微博微豆兑换", "weibo:delete": "删除微博账号", + "weibo:collab:view": "查看微博外部单协作", + "weibo:collab:edit": "编辑微博外部单协作", + "weibo:collab:manage": "删除微博外部单协作及字典", # 虎牙 "huya:account": "虎牙账号管理(兼容旧权限)", "huya:view_all": "查看所有虎牙账号", @@ -116,6 +119,9 @@ ROLE_PERMISSIONS = { "weibo:recharge", "weibo:exchange", "weibo:delete", + "weibo:collab:view", + "weibo:collab:edit", + "weibo:collab:manage", "huya:account", "huya:view_all", "huya:import", @@ -131,6 +137,8 @@ ROLE_PERMISSIONS = { # 协作台账是共享表:运营既能改也能删。 "huya:collab:view", "huya:collab:edit", + "weibo:collab:view", + "weibo:collab:edit", "huya:collab:manage", "huya:collab:ck_export", "proxy:manage", diff --git a/web/backend/routers/weibo_collab.py b/web/backend/routers/weibo_collab.py new file mode 100644 index 0000000..73d3690 --- /dev/null +++ b/web/backend/routers/weibo_collab.py @@ -0,0 +1,640 @@ +"""微博外部单协作接口。""" + +from __future__ import annotations + +import asyncio +import os + +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, WeiboAccount, WeiboCollabGoods, WeiboCollabShop +from ..permissions import get_user_permissions +from ..schemas import ( + WeiboCollabAccountLookupOut, + WeiboCollabAccountLookupRequest, + WeiboCollabAccountOptionOut, + WeiboCollabAccountRequest, + WeiboCollabArchiveRequest, + WeiboCollabBalanceCheckRequest, + WeiboCollabExchangeCheckRequest, + WeiboCollabGoodsCreate, + WeiboCollabGoodsOut, + WeiboCollabGoodsUpdate, + WeiboCollabMetricOut, + WeiboCollabOrderCreate, + WeiboCollabOrderListOut, + WeiboCollabOrderOut, + WeiboCollabOrderUpdate, + WeiboCollabOwnerOut, + WeiboCollabShopCreate, + WeiboCollabShopOut, + WeiboCollabShopUpdate, + WeiboCollabStatusBatchRequest, + WeiboCollabStatusRequest, + WeiboCollabSummaryOut, + WeiboCollabTimelineOut, +) +from ..services import weibo_collab_service as collab +from ..services.weibo_collab_events import collab_events + +router = APIRouter(prefix="/api/weibo", tags=["微博外部单协作"]) +WEIBO_DEFAULT_ACTIVITY = os.getenv("WEIBO_DEFAULT_ACTIVITY", "").strip() + + +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 _publish_change(current: User, *, scope: str, action: str) -> None: + collab_events.publish(scope=scope, action=action, actor_id=int(current.id)) + + +def _shop_out(shop: WeiboCollabShop) -> dict: + return { + "id": shop.id, + "name": shop.name, + "sort_order": shop.sort_order, + "is_active": shop.is_active, + "remark": shop.remark, + "created_by": shop.created_by, + "updated_by": shop.updated_by, + "created_at": shop.created_at, + "updated_at": shop.updated_at, + } + + +def _goods_out(goods: WeiboCollabGoods) -> dict: + return { + "id": goods.id, + "name": goods.name, + "price": goods.price, + "goods_id": goods.goods_id, + "goods_image": goods.goods_image, + "sort_order": goods.sort_order, + "is_active": goods.is_active, + "remark": goods.remark, + "created_by": goods.created_by, + "updated_by": goods.updated_by, + "created_at": goods.created_at, + "updated_at": goods.updated_at, + } + + +@router.websocket("/external-orders/ws") +async def ws_external_orders(websocket: WebSocket): + user = authenticate_websocket(websocket) + if not user or "weibo: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=WeiboCollabSummaryOut) +def external_orders_summary( + include_void: bool = Query(False), + archive_scope: str = Query("active"), + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo:collab:view")), +): + return collab.summary(db, include_void=include_void, archive_scope=archive_scope) + + +@router.get("/external-orders/owners", response_model=list[WeiboCollabOwnerOut]) +def external_orders_owners( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo: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("/collab-shops", response_model=list[WeiboCollabShopOut]) +def list_collab_shops( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo:collab:view")), +): + return [_shop_out(shop) for shop in collab.list_shops(db, active_only=False)] + + +@router.post("/collab-shops", response_model=WeiboCollabShopOut) +def create_collab_shop( + req: WeiboCollabShopCreate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo: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=WeiboCollabShopOut) +def update_collab_shop( + shop_id: int, + req: WeiboCollabShopUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + shop = db.query(WeiboCollabShop).filter(WeiboCollabShop.id == shop_id).first() + if shop is None: + raise HTTPException(status_code=404, detail="店铺不存在") + try: + collab.update_shop(db, current, shop, req.model_dump(exclude_unset=True)) + except collab.CollabError as exc: + raise _http_error(exc) from exc + db.refresh(shop) + _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("weibo:collab:manage")), +): + shop = db.query(WeiboCollabShop).filter(WeiboCollabShop.id == shop_id).first() + if shop is None: + raise HTTPException(status_code=404, detail="店铺不存在") + try: + collab.delete_shop(db, shop) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="shops", action="shop.delete") + return {"success": True} + + +@router.get("/collab-goods", response_model=list[WeiboCollabGoodsOut]) +def list_collab_goods( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo:collab:view")), +): + return [_goods_out(goods) for goods in collab.list_goods(db, active_only=False)] + + +@router.post("/collab-goods", response_model=WeiboCollabGoodsOut) +def create_collab_goods( + req: WeiboCollabGoodsCreate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + goods = collab.create_goods( + db, + current, + name=req.name, + price=req.price, + goods_id=req.goods_id, + goods_image=req.goods_image, + remark=req.remark, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="gifts", action="goods.create") + return _goods_out(goods) + + +@router.put("/collab-goods/{goods_id}", response_model=WeiboCollabGoodsOut) +def update_collab_goods( + goods_id: int, + req: WeiboCollabGoodsUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + goods = db.query(WeiboCollabGoods).filter(WeiboCollabGoods.id == goods_id).first() + if goods is None: + raise HTTPException(status_code=404, detail="商品字典不存在") + try: + collab.update_goods(db, current, goods, req.model_dump(exclude_unset=True)) + except collab.CollabError as exc: + raise _http_error(exc) from exc + db.refresh(goods) + _publish_change(current, scope="gifts", action="goods.update") + return _goods_out(goods) + + +@router.delete("/collab-goods/{goods_id}") +def delete_collab_goods( + goods_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:manage")), +): + goods = db.query(WeiboCollabGoods).filter(WeiboCollabGoods.id == goods_id).first() + if goods is None: + raise HTTPException(status_code=404, detail="商品字典不存在") + try: + collab.delete_goods(db, goods) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="gifts", action="goods.delete") + return {"success": True} + + +@router.post( + "/external-orders/accounts/lookup", response_model=WeiboCollabAccountLookupOut +) +def lookup_external_order_account( + req: WeiboCollabAccountLookupRequest, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + return collab.lookup_account( + db, + req.account_id, + with_goods=req.with_goods, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + + +@router.get( + "/external-orders/accounts/options", + response_model=list[WeiboCollabAccountOptionOut], +) +def external_order_account_options( + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo:collab:view")), +): + rows = ( + db.query(WeiboAccount) + .filter(WeiboAccount.is_enabled.is_(True)) + .order_by(WeiboAccount.id.desc()) + .all() + ) + return [ + { + "id": row.id, + "uid": row.uid, + "nickname": row.nickname, + "game_role_name": row.game_role_name, + "weidou_balance": row.weidou_balance, + "ck_check_status": row.ck_check_status, + } + for row in rows + ] + + +@router.get("/external-orders", response_model=WeiboCollabOrderListOut) +def list_external_orders( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), + archive_scope: str = Query("active"), + status: str = Query(""), + shop: str = Query(""), + owner_id: int | None = Query(None), + goods_name: str = Query(""), + keyword: str = Query(""), + include_void: bool = Query(False), + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo:collab:view")), +): + return collab.list_orders( + db, + page=page, + page_size=page_size, + archive_scope=archive_scope, + status=status, + shop=shop, + owner_id=owner_id, + goods_name=goods_name, + keyword=keyword, + include_void=include_void, + ) + + +@router.post("/external-orders", response_model=WeiboCollabOrderOut) +def create_external_order( + req: WeiboCollabOrderCreate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + order = collab.create_order( + db, + current, + order_no=req.order_no, + shop=req.shop, + owner_id=req.owner_id, + account_id=req.account_id, + goods_id=req.goods_id, + goods_name=req.goods_name, + goods_image=req.goods_image, + required_weidou=req.required_weidou, + home_images=req.home_images, + remark=req.remark, + draft=req.draft, + require_verified=req.require_verified, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.create") + return _one(db, order) + + +@router.post("/external-orders/verify/batch") +def verify_external_orders_batch( + ids: list[int], + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + rows = collab.list_orders_by_ids(db, ids) + results: list[dict] = [] + for order in rows: + try: + with db.begin_nested(): + collab.verify_order_account( + db, + current, + order, + activity=WEIBO_DEFAULT_ACTIVITY, + commit=False, + ) + results.append( + { + "id": order.id, + "order_no": order.order_no, + "success": True, + "message": "", + } + ) + except Exception as exc: # noqa: BLE001 + results.append( + { + "id": order.id, + "order_no": order.order_no, + "success": False, + "message": str(exc), + } + ) + db.commit() + _publish_change(current, scope="orders", action="order.verify_batch") + return {"results": results, "success": all(item["success"] for item in results)} + + +@router.post("/external-orders/balances/check") +def check_external_orders_balance( + req: WeiboCollabBalanceCheckRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + result = collab.check_balance_groups( + db, + current, + collab.list_orders_by_ids(db, req.ids), + threshold=req.threshold, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.balance_check") + return result + + +@router.post("/external-orders/exchange-records/check") +def check_external_orders_exchange_records( + req: WeiboCollabExchangeCheckRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + result = collab.check_exchange_records( + db, + current, + collab.list_orders_by_ids(db, req.ids), + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.exchange_check") + return result + + +@router.post("/external-orders/status/batch") +def set_external_orders_status_batch( + req: WeiboCollabStatusBatchRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + result = collab.set_status_batch( + db, + current, + collab.list_orders_by_ids(db, req.ids), + req.status, + reason=req.reason, + rollback=req.rollback, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.status_batch") + return result + + +@router.post("/external-orders/archive") +def archive_external_orders( + req: WeiboCollabArchiveRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + result = collab.archive_orders(db, current, req.ids, reason=req.reason) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _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("weibo:collab:edit")), +): + try: + result = collab.unarchive_orders(db, current, ids) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.unarchive") + return result + + +@router.get("/external-orders/{order_id}", response_model=WeiboCollabOrderOut) +def get_external_order( + order_id: int, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo: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=WeiboCollabOrderOut) +def update_external_order( + order_id: int, + req: WeiboCollabOrderUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo: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, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.update") + return _one(db, order) + + +@router.post("/external-orders/{order_id}/account", response_model=WeiboCollabOrderOut) +def set_external_order_account( + order_id: int, + req: WeiboCollabAccountRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + collab.set_order_account( + db, + current, + order, + req.account_id, + expected_version=req.expected_version, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.account") + return _one(db, order) + + +@router.post("/external-orders/{order_id}/verify", response_model=WeiboCollabOrderOut) +def verify_external_order( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + collab.verify_order_account( + db, + current, + order, + activity=WEIBO_DEFAULT_ACTIVITY, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.verify") + return _one(db, order) + + +@router.post("/external-orders/{order_id}/status", response_model=WeiboCollabOrderOut) +def set_external_order_status( + order_id: int, + req: WeiboCollabStatusRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:edit")), +): + try: + order = collab.get_order(db, order_id) + collab._assert_version(order, req.expected_version) + collab.set_status( + db, + current, + order, + req.status, + reason=req.reason, + rollback=req.rollback, + ) + except collab.CollabError as exc: + raise _http_error(exc) from exc + _publish_change(current, scope="orders", action="order.status") + return _one(db, order) + + +@router.get( + "/external-orders/{order_id}/metrics", + response_model=list[WeiboCollabMetricOut], +) +def external_order_metrics( + order_id: int, + db: Session = Depends(get_db), + _current: User = Depends(require_permission("weibo:collab:view")), +): + return collab.order_metrics(db, order_id) + + +@router.get( + "/external-orders/{order_id}/timeline", + response_model=list[WeiboCollabTimelineOut], +) +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("weibo:collab:view")), +): + return collab.order_timeline(db, order_id, limit=limit) + + +@router.delete("/external-orders/{order_id}") +def delete_external_order( + order_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("weibo:collab:manage")), +): + try: + order = collab.get_order(db, order_id) + except collab.CollabError as exc: + raise _http_error(exc) from exc + db.delete(order) + db.commit() + _publish_change(current, scope="orders", action="order.delete") + return {"success": True} diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 1d65be2..c43924a 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -629,6 +629,271 @@ class WeiboPaymentStatusOut(BaseModel): sub_status: str = "" +# ==================== 微博外部单协作 ==================== +class WeiboCollabShopCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=64) + remark: str = Field("", max_length=255) + + +class WeiboCollabShopUpdate(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 WeiboCollabShopOut(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 WeiboCollabGoodsCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=128) + price: int = Field(..., ge=0) + goods_id: int | None = Field(None, gt=0) + goods_image: str = Field("", max_length=512) + remark: str = Field("", max_length=255) + + +class WeiboCollabGoodsUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=128) + price: int | None = Field(None, ge=0) + goods_id: int | None = Field(None, gt=0) + goods_image: str | None = Field(None, max_length=512) + sort_order: int | None = None + is_active: bool | None = None + remark: str | None = Field(None, max_length=255) + + +class WeiboCollabGoodsOut(BaseModel): + id: int + name: str + price: int = 0 + goods_id: int | None = None + goods_image: 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 WeiboCollabLiveGoodsOut(BaseModel): + id: int + goods_name: str = "" + goods_image: str = "" + price: int = 0 + remain_count: int | None = None + is_sold_out: bool = False + + +class WeiboCollabAccountLookupRequest(BaseModel): + account_id: int = Field(..., gt=0) + with_goods: bool = True + + +class WeiboCollabAccountOptionOut(BaseModel): + id: int + uid: str = "" + nickname: str = "" + game_role_name: str = "" + weidou_balance: int | None = None + ck_check_status: str = "" + + +class WeiboCollabAccountLookupOut(BaseModel): + ok: bool = False + message: str = "" + account_id: int + uid: str = "" + nickname: str = "" + is_enabled: bool = True + ck_check_status: str = "" + ck_check_message: str = "" + is_bound: bool | None = None + game_role_name: str = "" + weidou_balance: int | None = None + goods: list[WeiboCollabLiveGoodsOut] = Field(default_factory=list) + orders_using_account: list[dict[str, Any]] = Field(default_factory=list) + + +class WeiboCollabOrderCreate(BaseModel): + order_no: str = Field(..., min_length=1, max_length=64) + shop: str = Field("", max_length=64) + owner_id: int | None = None + account_id: int | None = None + goods_id: int | None = Field(None, gt=0) + goods_name: str = Field("", max_length=128) + goods_image: str = Field("", max_length=512) + required_weidou: 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 WeiboCollabOrderUpdate(BaseModel): + order_no: str | None = Field(None, max_length=64) + shop: str | None = Field(None, max_length=64) + owner_id: int | None = None + goods_id: int | None = Field(None, gt=0) + goods_name: str | None = Field(None, max_length=128) + goods_image: str | None = Field(None, max_length=512) + required_weidou: 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 WeiboCollabAccountRequest(BaseModel): + account_id: int = Field(..., gt=0) + expected_version: int | None = None + + +class WeiboCollabStatusRequest(BaseModel): + status: str = Field(..., min_length=1, max_length=20) + reason: str = Field("", max_length=256) + rollback: bool = False + expected_version: int | None = None + + +class WeiboCollabStatusBatchRequest(BaseModel): + ids: list[int] = Field(default_factory=list) + status: str = Field(..., min_length=1, max_length=20) + reason: str = Field("", max_length=256) + rollback: bool = False + + +class WeiboCollabBalanceCheckRequest(BaseModel): + ids: list[int] = Field(default_factory=list) + threshold: int = Field(100, ge=0, le=1_000_000) + + +class WeiboCollabExchangeCheckRequest(BaseModel): + ids: list[int] = Field(default_factory=list) + + +class WeiboCollabArchiveRequest(BaseModel): + ids: list[int] = Field(default_factory=list) + reason: str = Field("", max_length=256) + + +class WeiboCollabOrderOut(BaseModel): + id: int + order_no: str + shop: str = "" + owner_id: int | None = None + owner_name: str = "" + account_id: int | None = None + account_uid: str = "" + account_nickname: str = "" + account_exists: bool = False + account_enabled: bool = False + account_ck_status: str = "" + account_ck_message: str = "" + account_is_bound: bool | None = None + game_role_name: str = "" + bind_snapshot_at: datetime | None = None + goods_id: int | None = None + goods_name: str = "" + goods_image: str = "" + required_weidou: int | None = None + home_images: list[str] = Field(default_factory=list) + remark: str = "" + status: str = "incomplete" + status_label: str = "" + next_status: str | None = None + missing_fields: list[str] = Field(default_factory=list) + weidou_balance: int | None = None + weidou_required_total: int | None = None + weidou_surplus: int | None = None + weidou_threshold: int | None = None + weidou_checked_at: datetime | None = None + weidou_check_reason: str = "" + weidou_check_message: str = "" + weidou_check_detail: dict[str, Any] | None = None + exchange_started_at: datetime | None = None + exchange_checked_at: datetime | None = None + exchange_record_key: str = "" + exchange_record_snapshot: dict[str, Any] | None = None + exchange_verdict: str = "" + exchange_message: str = "" + recharged_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 = "" + + +class WeiboCollabOrderListOut(BaseModel): + items: list[WeiboCollabOrderOut] + total: int + page: int + page_size: int + + +class WeiboCollabSummaryOut(BaseModel): + total: int + by_status: dict[str, int] + account_missing: int = 0 + account_disabled: int = 0 + ck_invalid: int = 0 + archived_total: int = 0 + + +class WeiboCollabOwnerOut(BaseModel): + id: int + username: str + + +class WeiboCollabMetricOut(BaseModel): + id: int + order_id: int + order_no: str = "" + account_uid: str = "" + weidou_balance: int | None = None + weidou_required_total: int | None = None + weidou_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 WeiboCollabTimelineOut(BaseModel): + id: int + action: str + username: str = "" + detail: str = "" + success: bool | None = None + created_at: datetime | None = None + + # ---- 虎牙 ---- class HuyaCookieImport(BaseModel): """批量导入虎牙 Cookie。支持纯 CK 或 账号----密码----CK。""" diff --git a/web/backend/services/weibo_collab_events.py b/web/backend/services/weibo_collab_events.py new file mode 100644 index 0000000..89884ae --- /dev/null +++ b/web/backend/services/weibo_collab_events.py @@ -0,0 +1,82 @@ +"""微博外部单协作的进程内实时失效通知。""" + +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: + return {"type": "collab.ready", "revision": self._current_revision} + + def ping_event(self) -> dict[str, Any]: + with self._lock: + return {"type": "collab.ping", "revision": self._current_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: + 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/weibo_collab_service.py b/web/backend/services/weibo_collab_service.py new file mode 100644 index 0000000..a714f85 --- /dev/null +++ b/web/backend/services/weibo_collab_service.py @@ -0,0 +1,1857 @@ +"""微博外部单协作台账服务。 + +一行 = 一单,引用现有 ``weibo_accounts`` 中的外部 CK 账号,不复制凭据。 +账号可以同时服务多单;余额核账按账号分组。 +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from typing import Any +from zoneinfo import ZoneInfo + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from core.weibo import WeiboClient, WeiboError + +from ..models import ( + AuditLog, + User, + WeiboAccount, + WeiboCollabGoods, + WeiboCollabShop, + WeiboExternalOrder, + WeiboExternalOrderMetric, +) +from .audit_service import record_audit +from .weibo_service import query_weibo_mall, refresh_weibo_account + +STATUS_INCOMPLETE = "incomplete" +STATUS_TO_RECHARGE = "to_recharge" +STATUS_RECHARGED = "recharged" +STATUS_EXCHANGE_PENDING = "exchange_pending" +STATUS_DONE = "done" +STATUS_WEIDOU_ERROR = "weidou_error" +STATUS_FAILED = "failed" +STATUS_VOID = "void" + +ORDER_STATUSES = ( + STATUS_INCOMPLETE, + STATUS_TO_RECHARGE, + STATUS_RECHARGED, + STATUS_EXCHANGE_PENDING, + STATUS_DONE, + STATUS_WEIDOU_ERROR, + STATUS_FAILED, + STATUS_VOID, +) + +ORDER_STATUS_LABELS = { + STATUS_INCOMPLETE: "待补资料", + STATUS_TO_RECHARGE: "待充值", + STATUS_RECHARGED: "已充值", + STATUS_EXCHANGE_PENDING: "兑换待确认", + STATUS_DONE: "兑换成功", + STATUS_WEIDOU_ERROR: "微豆异常", + STATUS_FAILED: "兑换失败", + STATUS_VOID: "已作废", +} + +STATUS_FLOW = ( + STATUS_INCOMPLETE, + STATUS_TO_RECHARGE, + STATUS_RECHARGED, + STATUS_EXCHANGE_PENDING, + STATUS_DONE, +) +STAGE_INDEX = {status: index for index, status in enumerate(STATUS_FLOW)} +NEXT_STATUS = { + STATUS_INCOMPLETE: STATUS_TO_RECHARGE, + STATUS_TO_RECHARGE: STATUS_RECHARGED, + STATUS_RECHARGED: STATUS_EXCHANGE_PENDING, + STATUS_EXCHANGE_PENDING: STATUS_DONE, +} + +BALANCE_GROUP_STATUSES = ( + STATUS_TO_RECHARGE, + STATUS_RECHARGED, + STATUS_WEIDOU_ERROR, +) + +VERDICT_SUCCESS = "success" +VERDICT_FAILED = "failed" +VERDICT_UNKNOWN = "unknown" +VERDICT_LABELS = { + VERDICT_SUCCESS: "兑换成功", + VERDICT_FAILED: "兑换失败", + VERDICT_UNKNOWN: "无法判定", +} + +ARCHIVE_SCOPES = ("active", "archived", "all") +ARCHIVABLE_STATUSES = (STATUS_DONE,) +MAX_IMAGES = 20 +CHECK_BATCH_MAX = 50 +ARCHIVE_BATCH_MAX = 5000 +DEFAULT_WEIDOU_THRESHOLD = 100 + + +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 = 512) -> str: + return str(value or "").strip()[:max_length] + + +def _json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item 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 normalize_images(value: Any) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise CollabError("图片必须是 key 数组", code="images_invalid") + result: list[str] = [] + for item in value: + key = _text(item, max_length=512) + if key and key not in result: + result.append(key) + if len(result) > MAX_IMAGES: + raise CollabError(f"最多 {MAX_IMAGES} 张图片", code="images_too_many") + return result + + +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, WeiboAccount]: + clean = {int(item) for item in ids if item} + if not clean: + return {} + return { + int(account.id): account + for account in db.query(WeiboAccount).filter(WeiboAccount.id.in_(clean)).all() + } + + +def _touch(order: WeiboExternalOrder, current: User | None) -> None: + order.updated_by = current.id if current else None + order.version = int(order.version or 0) + 1 + + +def _audit_target(order: WeiboExternalOrder) -> str: + return f"weibo_external_order:{order.id}" + + +def _audit( + db: Session, + current: User | None, + order: WeiboExternalOrder, + action: str, + detail: Mapping[str, Any], +) -> None: + record_audit( + db, + current, + action=action, + target=_audit_target(order), + detail=detail, + ) + + +def get_order(db: Session, order_id: int) -> WeiboExternalOrder: + order = ( + db.query(WeiboExternalOrder).filter(WeiboExternalOrder.id == order_id).first() + ) + if order is None: + raise CollabError("协作单不存在", status_code=404, code="order_not_found") + return order + + +def list_orders_by_ids(db: Session, ids: Sequence[int]) -> list[WeiboExternalOrder]: + clean = [int(item) for item in ids if int(item) > 0] + if not clean: + return [] + return ( + db.query(WeiboExternalOrder) + .filter(WeiboExternalOrder.id.in_(clean)) + .order_by(WeiboExternalOrder.id.asc()) + .all() + ) + + +def _missing_for_recharge(order: WeiboExternalOrder) -> list[str]: + missing: list[str] = [] + if not order.shop: + missing.append("店铺") + if not order.account_id: + missing.append("微博账号") + if not order.game_role_name: + missing.append("绑定角色(先校验账号)") + if not order.goods_name: + missing.append("商品") + if not order.required_weidou: + missing.append("所需微豆") + return missing + + +def _maybe_advance_to_recharge( + db: Session, + current: User, + order: WeiboExternalOrder, +) -> None: + if order.status != STATUS_INCOMPLETE or _missing_for_recharge(order): + return + old = order.status + order.status = STATUS_TO_RECHARGE + _touch(order, current) + _audit( + db, + current, + order, + "weibo:collab:status_auto", + {"from": old, "to": order.status, "reason": "资料齐全"}, + ) + + +def _account_for_order(db: Session, order: WeiboExternalOrder) -> WeiboAccount: + account = ( + db.query(WeiboAccount).filter(WeiboAccount.id == order.account_id).first() + if order.account_id + else None + ) + if account is None: + raise CollabError("订单关联的微博账号不存在", code="account_not_found") + if not account.is_enabled: + raise CollabError("订单关联的微博账号已停用", code="account_disabled") + return account + + +def _enabled_account(db: Session, account_id: int) -> WeiboAccount: + account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first() + if account is None: + raise CollabError("微博账号不存在", code="account_not_found") + if not account.is_enabled: + raise CollabError("微博账号已停用", code="account_disabled") + return account + + +def _apply_account_snapshot( + order: WeiboExternalOrder, + account: WeiboAccount, + *, + strict_role: bool, +) -> None: + role = _text(account.game_role_name, max_length=128) + if strict_role and order.game_role_name and role != order.game_role_name: + raise CollabError( + f"账号绑定角色已从「{order.game_role_name}」变为「{role or '未绑定'}」,请人工确认后更新订单", + code="role_changed", + ) + order.account_id = account.id + order.account_uid = _text(account.uid, max_length=32) + order.account_nickname = _text(account.nickname, max_length=128) + order.game_role_name = role + order.bind_snapshot_at = _now() + + +def verify_order_account( + db: Session, + current: User, + order: WeiboExternalOrder, + *, + activity: str = "", + commit: bool = True, +) -> WeiboAccount: + if order.archived_at is not None: + raise CollabError("已归档订单不能校验账号", code="order_archived") + account = _account_for_order(db, order) + snapshot = refresh_weibo_account(db, account, activity=activity, commit=False) + _apply_account_snapshot(order, account, strict_role=True) + if not snapshot.is_bound: + raise CollabError("微博账号尚未绑定游戏角色", code="role_unbound") + _touch(order, current) + _audit( + db, + current, + order, + "weibo:collab:verify", + { + "account_id": account.id, + "uid": account.uid, + "ck_status": account.ck_check_status, + "role": account.game_role_name, + "balance": account.weidou_balance, + }, + ) + _maybe_advance_to_recharge(db, current, order) + if commit: + db.commit() + db.refresh(order) + return account + + +def _resolve_goods( + db: Session, + *, + goods_id: int | None, + goods_name: str, + goods_image: str, + required_weidou: int | None, +) -> tuple[int | None, str, str, int | None]: + name = _text(goods_name, max_length=128) + image = _text(goods_image, max_length=512) + price = _to_int(required_weidou) + catalog = None + if name: + catalog = ( + db.query(WeiboCollabGoods).filter(WeiboCollabGoods.name == name).first() + ) + elif goods_id: + catalog = ( + db.query(WeiboCollabGoods) + .filter(WeiboCollabGoods.goods_id == goods_id) + .first() + ) + if catalog is not None: + name = name or catalog.name + image = image or catalog.goods_image + goods_id = goods_id or catalog.goods_id + if price is None: + price = int(catalog.price or 0) + return goods_id, name, image, price + + +def create_order( + db: Session, + current: User, + *, + order_no: str, + shop: str = "", + owner_id: int | None = None, + account_id: int | None = None, + goods_id: int | None = None, + goods_name: str = "", + goods_image: str = "", + required_weidou: int | None = None, + home_images: Any = None, + remark: str = "", + draft: bool = False, + require_verified: bool = True, + activity: str = "", +) -> WeiboExternalOrder: + order_text = _text(order_no, max_length=64) + if not order_text: + raise CollabError("订单号不能为空", code="order_no_required") + if ( + db.query(WeiboExternalOrder) + .filter(WeiboExternalOrder.order_no == order_text) + .first() + ): + raise CollabError("订单号已存在", status_code=409, code="order_no_exists") + + goods_id, goods_name, goods_image, required_weidou = _resolve_goods( + db, + goods_id=goods_id, + goods_name=goods_name, + goods_image=goods_image, + required_weidou=required_weidou, + ) + order = WeiboExternalOrder( + 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), + goods_id=goods_id, + goods_name=goods_name, + goods_image=goods_image, + required_weidou=required_weidou, + home_images=normalize_images(home_images), + remark=_text(remark, max_length=512), + status=STATUS_INCOMPLETE, + weidou_threshold=DEFAULT_WEIDOU_THRESHOLD, + created_by=current.id, + updated_by=current.id, + version=0, + ) + db.add(order) + db.flush() + if account_id: + account = _enabled_account(db, account_id) + _apply_account_snapshot(order, account, strict_role=False) + if order.shop: + ensure_shops_registered(db, current, [order.shop]) + _audit( + db, + current, + order, + "weibo:collab:create", + { + "order_no": order.order_no, + "shop": order.shop, + "account_id": order.account_id, + "uid": order.account_uid, + "goods_name": order.goods_name, + "required_weidou": order.required_weidou, + "draft": draft, + }, + ) + if not draft and require_verified: + missing = _missing_for_recharge(order) + if missing: + raise CollabError( + f"下面这几项必须填:{'、'.join(missing)}", + code="create_field_required", + ) + verify_order_account(db, current, order, activity=activity, commit=False) + _maybe_advance_to_recharge(db, current, order) + db.commit() + db.refresh(order) + return order + + +def update_order( + db: Session, + current: User, + order: WeiboExternalOrder, + payload: Mapping[str, Any], + *, + expected_version: int | None = None, +) -> dict[str, Any]: + _assert_mutable(order) + _assert_version(order, expected_version) + changes: dict[str, dict[str, Any]] = {} + editable = { + "order_no", + "shop", + "owner_id", + "goods_id", + "goods_name", + "goods_image", + "required_weidou", + "remark", + } + for field in editable: + if field not in payload: + continue + old = getattr(order, field) + value = payload[field] + if field == "owner_id": + new = int(value) if value is not None else None + elif field in ("goods_id", "required_weidou"): + new = _to_int(value) + elif field == "remark" or field == "goods_image": + new = _text(value, max_length=512) + elif field == "goods_name": + new = _text(value, max_length=128) + elif field == "order_no": + new = _text(value, max_length=64) + else: + new = _text(value, max_length=64) + if new != old: + changes[field] = {"old": old, "new": new} + setattr(order, field, new) + if "home_images" in payload: + images = normalize_images(payload.get("home_images")) + if images != list(order.home_images or []): + changes["home_images"] = { + "old": len(order.home_images or []), + "new": len(images), + } + order.home_images = images + if order.order_no != _text(order.order_no, max_length=64): + raise CollabError("订单号格式不正确", code="order_no_invalid") + duplicate = ( + db.query(WeiboExternalOrder.id) + .filter( + WeiboExternalOrder.order_no == order.order_no, + WeiboExternalOrder.id != order.id, + ) + .first() + ) + if duplicate: + raise CollabError("订单号已存在", status_code=409, code="order_no_exists") + if changes: + if "shop" in changes and order.shop: + ensure_shops_registered(db, current, [order.shop]) + _touch(order, current) + _audit(db, current, order, "weibo:collab:update", changes) + _maybe_advance_to_recharge(db, current, order) + db.commit() + db.refresh(order) + return changes + + +def set_order_account( + db: Session, + current: User, + order: WeiboExternalOrder, + account_id: int, + *, + expected_version: int | None = None, + activity: str = "", +) -> WeiboAccount: + _assert_mutable(order) + _assert_version(order, expected_version) + account = _enabled_account(db, account_id) + order.account_id = account.id + order.game_role_name = "" + verify_order_account(db, current, order, activity=activity, commit=False) + db.commit() + db.refresh(order) + return account + + +def _assert_version(order: WeiboExternalOrder, expected_version: int | None) -> None: + if expected_version is None: + return + if int(expected_version) != int(order.version or 0): + raise CollabError( + "订单已被其他人修改,请刷新后重试", + status_code=409, + code="version_conflict", + ) + + +def _assert_mutable(order: WeiboExternalOrder) -> None: + if order.archived_at is not None: + raise CollabError("已归档订单只读,请先取消归档", code="order_archived") + + +def set_status( + db: Session, + current: User, + order: WeiboExternalOrder, + target: str, + *, + reason: str = "", + rollback: bool = False, + allow_check: bool = False, + commit: bool = True, +) -> dict[str, Any]: + _assert_mutable(order) + if target not in ORDER_STATUSES: + raise CollabError("非法状态", code="status_invalid") + if target in (STATUS_RECHARGED, STATUS_WEIDOU_ERROR) and not allow_check: + raise CollabError( + "「已充值 / 微豆异常」必须通过余额核账写入", + code="balance_check_required", + ) + old = order.status + if old == target: + return {"from": old, "to": target} + if old == STATUS_VOID: + raise CollabError("已作废订单不能直接改回流程内", code="order_void") + if target == STATUS_VOID: + clean_reason = _text(reason, max_length=256) + if not clean_reason: + raise CollabError("作废必须填写原因", code="void_reason_required") + order.status = target + order.void_reason = clean_reason + _touch(order, current) + _audit( + db, + current, + order, + "weibo:collab:void", + {"from": old, "reason": clean_reason}, + ) + if commit: + db.commit() + db.refresh(order) + return {"from": old, "to": target} + if ( + target in STAGE_INDEX + and old in STAGE_INDEX + and STAGE_INDEX[target] < STAGE_INDEX[old] + and not rollback + ): + raise CollabError("回退状态必须显式勾选「回退」", code="status_not_forward") + if target == STATUS_TO_RECHARGE: + missing = _missing_for_recharge(order) + if missing: + raise CollabError( + f"进入待充值前还缺:{'、'.join(missing)}", + code="recharge_precondition", + ) + now = _now() + if target == STATUS_TO_RECHARGE: + order.recharged_at = None + order.exchange_started_at = None + order.done_at = None + elif target in (STATUS_RECHARGED, STATUS_WEIDOU_ERROR): + if order.recharged_at is None: + order.recharged_at = now + if rollback: + order.exchange_started_at = None + order.done_at = None + elif target == STATUS_EXCHANGE_PENDING and not order.recharged_at: + raise CollabError("请先完成余额核账", code="recharge_check_required") + elif target == STATUS_EXCHANGE_PENDING: + if order.exchange_started_at is None: + order.exchange_started_at = now + order.done_at = None + elif target == STATUS_DONE: + if order.exchange_started_at is None: + order.exchange_started_at = now + order.done_at = now + elif target == STATUS_FAILED: + order.done_at = None + order.status = target + if target != STATUS_VOID: + order.void_reason = "" + _touch(order, current) + _audit( + db, + current, + order, + "weibo:collab:status", + {"from": old, "to": target, "rollback": rollback, "reason": reason}, + ) + if commit: + db.commit() + db.refresh(order) + return {"from": old, "to": target} + + +def set_status_batch( + db: Session, + current: User, + orders: Sequence[WeiboExternalOrder], + target: str, + *, + reason: str = "", + rollback: bool = False, +) -> dict[str, Any]: + rows = list(orders) + if not rows: + raise CollabError("请先勾选订单", code="status_batch_empty") + if len(rows) > CHECK_BATCH_MAX: + raise CollabError(f"一次最多操作 {CHECK_BATCH_MAX} 条", code="batch_too_large") + results: list[dict[str, Any]] = [] + updated = 0 + for order in rows: + try: + with db.begin_nested(): + set_status( + db, + current, + order, + target, + reason=reason, + rollback=rollback, + allow_check=False, + commit=False, + ) + updated += 1 + results.append( + { + "id": order.id, + "order_no": order.order_no, + "ok": True, + "message": "", + } + ) + except CollabError as exc: + results.append( + { + "id": order.id, + "order_no": order.order_no, + "ok": False, + "message": exc.detail, + } + ) + db.commit() + return { + "total": len(rows), + "updated": updated, + "failed": len(rows) - updated, + "target": target, + "target_label": ORDER_STATUS_LABELS.get(target, target), + "results": results, + } + + +def lookup_account( + db: Session, + account_id: int, + *, + with_goods: bool, + activity: str = "", +) -> dict[str, Any]: + account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first() + if account is None: + raise CollabError("微博账号不存在", code="account_not_found") + goods: list[dict[str, Any]] = [] + ok = account.is_enabled + message = "" if ok else "账号已停用" + if ok and with_goods: + try: + data = query_weibo_mall(db, account, activity=activity) + for item in data.get("list") or []: + if not isinstance(item, dict): + continue + goods_id = _to_int(item.get("id")) + if not goods_id: + continue + goods.append( + { + "id": goods_id, + "goods_name": _text(item.get("goods_name"), max_length=128), + "goods_image": _text(item.get("goods_image"), max_length=512), + "price": _to_int(item.get("price")) or 0, + "remain_count": _to_int(item.get("remain_count")), + "is_sold_out": bool(item.get("is_sold_out")), + } + ) + except (WeiboError, ValueError) as exc: + ok = False + message = str(exc) + orders_using = ( + db.query(WeiboExternalOrder) + .filter( + WeiboExternalOrder.account_id == account.id, + WeiboExternalOrder.archived_at.is_(None), + WeiboExternalOrder.status != STATUS_VOID, + ) + .order_by(WeiboExternalOrder.id.asc()) + .all() + ) + return { + "ok": ok, + "message": message, + "account_id": account.id, + "uid": account.uid, + "nickname": account.nickname, + "is_enabled": account.is_enabled, + "ck_check_status": account.ck_check_status, + "ck_check_message": account.ck_check_message, + "is_bound": account.is_bound, + "game_role_name": account.game_role_name, + "weidou_balance": account.weidou_balance, + "goods": goods, + "orders_using_account": [ + { + "id": item.id, + "order_no": item.order_no, + "status": item.status, + "status_label": ORDER_STATUS_LABELS.get(item.status, item.status), + } + for item in orders_using + ], + } + + +def _metric( + db: Session, + current: User, + order: WeiboExternalOrder, + *, + balance: int | None, + required_total: int | None, + surplus: int | None, + records_total: int, + records_matched: int, + verdict: str, + message: str, + source: str, + raw: Mapping[str, Any], +) -> WeiboExternalOrderMetric: + metric = WeiboExternalOrderMetric( + order_id=order.id, + order_no=order.order_no, + account_id=order.account_id, + account_uid=order.account_uid, + weidou_balance=balance, + weidou_required_total=required_total, + weidou_surplus=surplus, + records_total=records_total, + records_matched=records_matched, + verdict=verdict, + message=message[:512], + source=source, + raw=_json_safe(dict(raw)), + created_by=current.id, + ) + db.add(metric) + return metric + + +def check_balance_groups( + db: Session, + current: User, + orders: Sequence[WeiboExternalOrder], + *, + threshold: int, + activity: str = "", +) -> dict[str, Any]: + rows = list(orders) + if not rows: + raise CollabError("请先勾选订单", code="balance_check_empty") + if len(rows) > CHECK_BATCH_MAX: + raise CollabError(f"一次最多核账 {CHECK_BATCH_MAX} 条", code="batch_too_large") + threshold = max(0, min(int(threshold), 1_000_000)) + grouped: dict[int, list[WeiboExternalOrder]] = {} + failures: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + for order in rows: + if order.archived_at is not None: + skipped.append( + {"id": order.id, "order_no": order.order_no, "message": "已归档"} + ) + continue + if not order.account_id: + failures.append( + {"id": order.id, "order_no": order.order_no, "message": "未关联账号"} + ) + continue + if order.status not in BALANCE_GROUP_STATUSES: + skipped.append( + { + "id": order.id, + "order_no": order.order_no, + "message": f"状态「{ORDER_STATUS_LABELS.get(order.status, order.status)}」不参与核账", + } + ) + continue + grouped.setdefault(int(order.account_id), []).append(order) + + selected_account_ids = set(grouped) + grouped = {} + for account_id in selected_account_ids: + grouped[account_id] = ( + db.query(WeiboExternalOrder) + .filter( + WeiboExternalOrder.account_id == account_id, + WeiboExternalOrder.status.in_(BALANCE_GROUP_STATUSES), + WeiboExternalOrder.archived_at.is_(None), + ) + .order_by(WeiboExternalOrder.id.asc()) + .all() + ) + + groups: list[dict[str, Any]] = [] + updated = 0 + for account_id, group_orders in grouped.items(): + account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first() + if account is None: + for order in group_orders: + failures.append( + { + "id": order.id, + "order_no": order.order_no, + "message": "账号不存在", + } + ) + continue + if not account.is_enabled: + for order in group_orders: + failures.append( + { + "id": order.id, + "order_no": order.order_no, + "message": "账号已停用", + } + ) + continue + try: + data = WeiboClient(account.cookie or "").list_mall(activity) + except (WeiboError, ValueError) as exc: + for order in group_orders: + failures.append( + {"id": order.id, "order_no": order.order_no, "message": str(exc)} + ) + continue + + balance = _to_int(data.get("weidou_balance")) + required_total = sum(int(order.required_weidou or 0) for order in group_orders) + surplus = balance - required_total if balance is not None else None + if surplus is None: + reason = "error" + message = "未拿到微豆余额" + target = None + elif surplus < 0: + reason = "shortage" + message = ( + f"微豆不足:余额 {balance},订单合计 {required_total},还差 {-surplus}" + ) + target = STATUS_WEIDOU_ERROR + elif surplus > threshold: + reason = "excess" + message = ( + f"微豆超出阈值:余额 {balance},订单合计 {required_total}," + f"多出 {surplus}(阈值 {threshold})" + ) + target = STATUS_WEIDOU_ERROR + else: + reason = "ok" + message = ( + f"核账通过:余额 {balance},订单合计 {required_total},结余 {surplus}" + ) + target = STATUS_RECHARGED + + if balance is not None: + account.weidou_balance = balance + now = _now() + for order in group_orders: + order.weidou_balance = balance + order.weidou_required_total = required_total + order.weidou_surplus = surplus + order.weidou_threshold = threshold + order.weidou_checked_at = now + order.weidou_check_reason = reason + order.weidou_check_message = message[:512] + order.weidou_check_detail = { + "account_id": account.id, + "uid": account.uid, + "balance": balance, + "required_total": required_total, + "surplus": surplus, + "threshold": threshold, + "order_ids": [item.id for item in group_orders], + } + if target is not None: + set_status( + db, + current, + order, + target, + reason=message, + allow_check=True, + commit=False, + ) + order.recharged_at = now + updated += 1 + _metric( + db, + current, + order, + balance=balance, + required_total=required_total, + surplus=surplus, + records_total=0, + records_matched=0, + verdict=VERDICT_UNKNOWN if target is None else "", + message=message, + source="balance_check", + raw={"reason": reason, "order_ids": [item.id for item in group_orders]}, + ) + _audit( + db, + current, + order, + "weibo:collab:balance_check", + { + "account_id": account.id, + "balance": balance, + "required_total": required_total, + "surplus": surplus, + "threshold": threshold, + "reason": reason, + }, + ) + groups.append( + { + "account_id": account.id, + "uid": account.uid, + "status": target or "", + "status_label": ORDER_STATUS_LABELS.get(target or "", ""), + "balance": balance, + "required_total": required_total, + "surplus": surplus, + "threshold": threshold, + "reason": reason, + "message": message, + "order_ids": [item.id for item in group_orders], + } + ) + db.commit() + return { + "total": len(rows), + "updated": updated, + "threshold": threshold, + "groups": groups, + "failures": failures, + "skipped": skipped, + } + + +def _record_key(record: Mapping[str, Any], index: int) -> str: + raw = json.dumps( + {"index": index, "record": _json_safe(dict(record))}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(raw.encode()).hexdigest() + + +def _record_matches(order: WeiboExternalOrder, record: Mapping[str, Any]) -> bool: + if _text(record.get("game_account")).strip() != _text(order.game_role_name).strip(): + return False + if _text(record.get("goods_name")).strip() != _text(order.goods_name).strip(): + return False + if _to_int(record.get("cost_weidou")) != _to_int(order.required_weidou): + return False + if order.exchange_started_at is not None: + started = order.exchange_started_at.astimezone( + ZoneInfo("Asia/Shanghai") + ).strftime("%Y-%m-%d %H:%M:%S") + if _text(record.get("exchange_time")) < started: + return False + return True + + +def check_exchange_records( + db: Session, + current: User, + orders: Sequence[WeiboExternalOrder], + *, + activity: str = "", +) -> dict[str, Any]: + rows = list(orders) + if not rows: + raise CollabError("请先勾选订单", code="exchange_check_empty") + if len(rows) > CHECK_BATCH_MAX: + raise CollabError(f"一次最多查询 {CHECK_BATCH_MAX} 条", code="batch_too_large") + grouped: dict[int, list[WeiboExternalOrder]] = {} + failures: list[dict[str, Any]] = [] + for order in rows: + if order.archived_at is not None or not order.account_id: + failures.append( + { + "id": order.id, + "order_no": order.order_no, + "message": "订单已归档或未关联账号", + } + ) + continue + grouped.setdefault(int(order.account_id), []).append(order) + + results: list[dict[str, Any]] = [] + success = failed = unknown = 0 + for account_id, group_orders in grouped.items(): + account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first() + if account is None or not account.is_enabled: + for order in group_orders: + failures.append( + { + "id": order.id, + "order_no": order.order_no, + "message": "账号不存在或已停用", + } + ) + continue + try: + data = WeiboClient(account.cookie or "").list_exchange_records( + activity, + page=1, + page_size=100, + ) + records = [ + item for item in (data.get("list") or []) if isinstance(item, dict) + ] + except (WeiboError, ValueError) as exc: + for order in group_orders: + failures.append( + {"id": order.id, "order_no": order.order_no, "message": str(exc)} + ) + continue + + used_keys = { + str(item.exchange_record_key or "") + for item in db.query(WeiboExternalOrder) + .filter( + WeiboExternalOrder.account_id == account_id, + WeiboExternalOrder.exchange_record_key != "", + WeiboExternalOrder.id.notin_([order.id for order in group_orders]), + ) + .all() + if item.exchange_record_key + } + indexed = [ + (_record_key(record, index), record) for index, record in enumerate(records) + ] + for order in group_orders: + candidates = [ + (key, record) + for key, record in indexed + if key not in used_keys and _record_matches(order, record) + ] + now = _now() + order.exchange_checked_at = now + if len(candidates) == 1: + key, record = candidates[0] + used_keys.add(key) + order.exchange_record_key = key[:128] + order.exchange_record_snapshot = _json_safe(record) + order.exchange_verdict = VERDICT_SUCCESS + order.exchange_message = "兑换记录唯一命中" + if order.status == STATUS_EXCHANGE_PENDING: + set_status( + db, + current, + order, + STATUS_DONE, + reason="兑换记录唯一命中", + allow_check=True, + commit=False, + ) + verdict = VERDICT_SUCCESS + message = "兑换记录唯一命中" + success += 1 + elif len(candidates) > 1: + order.exchange_verdict = VERDICT_UNKNOWN + order.exchange_message = ( + f"找到 {len(candidates)} 条候选记录,需人工确认" + ) + verdict = VERDICT_UNKNOWN + message = order.exchange_message + unknown += 1 + else: + order.exchange_verdict = VERDICT_UNKNOWN + order.exchange_message = "未找到匹配记录,需人工核对后再判定失败" + verdict = VERDICT_UNKNOWN + message = order.exchange_message + unknown += 1 + metric = _metric( + db, + current, + order, + balance=account.weidou_balance, + required_total=order.required_weidou, + surplus=None, + records_total=len(records), + records_matched=len(candidates), + verdict=verdict, + message=message, + source="exchange_records", + raw={ + "candidates": [record for _, record in candidates[:5]], + "role": order.game_role_name, + "goods_name": order.goods_name, + }, + ) + db.flush() + _audit( + db, + current, + order, + "weibo:collab:exchange_check", + { + "verdict": verdict, + "message": message, + "records_total": len(records), + "candidates": len(candidates), + }, + ) + results.append( + { + "id": order.id, + "order_no": order.order_no, + "ok": True, + "verdict": verdict, + "verdict_label": VERDICT_LABELS.get(verdict, verdict), + "message": message, + "records_total": len(records), + "records_matched": len(candidates), + "status": order.status, + "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), + "metric_id": metric.id, + } + ) + db.commit() + return { + "total": len(rows), + "success": success, + "failed": failed, + "unknown": unknown, + "results": results, + "failures": failures, + } + + +def archive_orders( + db: Session, + current: User, + ids: Sequence[int], + *, + reason: str = "", +) -> dict[str, Any]: + rows = list_orders_by_ids(db, ids) + if not rows: + raise CollabError("请先勾选订单", code="archive_empty") + if len(rows) > ARCHIVE_BATCH_MAX: + raise CollabError( + f"一次最多归档 {ARCHIVE_BATCH_MAX} 条", code="batch_too_large" + ) + results: list[dict[str, Any]] = [] + archived = 0 + for order in rows: + if order.archived_at is not None: + results.append( + { + "id": order.id, + "order_no": order.order_no, + "ok": False, + "message": "已归档", + } + ) + continue + if order.status not in ARCHIVABLE_STATUSES: + results.append( + { + "id": order.id, + "order_no": order.order_no, + "ok": False, + "message": "只有兑换成功的订单可以归档", + } + ) + continue + order.archived_at = _now() + order.archived_by = current.id + order.archive_reason = _text(reason, max_length=256) + _touch(order, current) + _audit( + db, + current, + order, + "weibo:collab:archive", + {"reason": order.archive_reason}, + ) + archived += 1 + results.append( + {"id": order.id, "order_no": order.order_no, "ok": True, "message": ""} + ) + db.commit() + return { + "total": len(rows), + "archived": archived, + "skipped": len(rows) - archived, + "results": results, + } + + +def unarchive_orders( + db: Session, + current: User, + ids: Sequence[int], +) -> dict[str, Any]: + rows = list_orders_by_ids(db, ids) + if not rows: + raise CollabError("请先勾选订单", code="archive_empty") + results: list[dict[str, Any]] = [] + unarchived = 0 + for order in rows: + if order.archived_at is None: + results.append( + { + "id": order.id, + "order_no": order.order_no, + "ok": False, + "message": "未归档", + } + ) + continue + previous = order.archive_reason + order.archived_at = None + order.archived_by = None + order.archive_reason = "" + _touch(order, current) + _audit( + db, + current, + order, + "weibo:collab:unarchive", + {"previous_reason": previous}, + ) + unarchived += 1 + results.append( + {"id": order.id, "order_no": order.order_no, "ok": True, "message": ""} + ) + db.commit() + return { + "total": len(rows), + "unarchived": unarchived, + "skipped": len(rows) - unarchived, + "results": results, + } + + +def _account_clusters( + db: Session, orders: Sequence[WeiboExternalOrder] +) -> dict[int, dict[str, Any]]: + account_ids = {int(order.account_id) for order in orders if order.account_id} + if not account_ids: + return {} + siblings = ( + db.query(WeiboExternalOrder) + .filter( + WeiboExternalOrder.account_id.in_(account_ids), + WeiboExternalOrder.archived_at.is_(None), + WeiboExternalOrder.status != STATUS_VOID, + ) + .order_by(WeiboExternalOrder.id.asc()) + .all() + ) + grouped: dict[int, list[WeiboExternalOrder]] = {} + for row in siblings: + if row.account_id: + grouped.setdefault(int(row.account_id), []).append(row) + result: dict[int, dict[str, Any]] = {} + for order in orders: + rows = grouped.get(int(order.account_id or 0), []) + if len(rows) <= 1: + continue + result[int(order.id)] = { + "total": len(rows), + "orders": [ + { + "id": item.id, + "order_no": item.order_no, + "status": item.status, + "status_label": ORDER_STATUS_LABELS.get(item.status, item.status), + "goods_name": item.goods_name, + } + for item in rows + if item.id != order.id + ], + } + return result + + +def _latest_metrics( + db: Session, order_ids: Sequence[int] +) -> dict[int, WeiboExternalOrderMetric]: + if not order_ids: + return {} + rows = ( + db.query(WeiboExternalOrderMetric) + .filter(WeiboExternalOrderMetric.order_id.in_(order_ids)) + .order_by(WeiboExternalOrderMetric.id.desc()) + .all() + ) + result: dict[int, WeiboExternalOrderMetric] = {} + for row in rows: + result.setdefault(int(row.order_id), row) + return result + + +def serialize_order( + order: WeiboExternalOrder, + *, + users: Mapping[int, str] | None = None, + accounts: Mapping[int, WeiboAccount] | None = None, + cluster: Mapping[str, Any] | None = None, + last_metric: WeiboExternalOrderMetric | None = None, +) -> dict[str, Any]: + names = users or {} + account_map = accounts or {} + account = account_map.get(int(order.account_id)) if order.account_id else None + return { + "id": order.id, + "order_no": order.order_no, + "shop": order.shop or "", + "owner_id": order.owner_id, + "owner_name": names.get(int(order.owner_id), "") if order.owner_id else "", + "account_id": order.account_id, + "account_uid": order.account_uid or "", + "account_nickname": order.account_nickname or "", + "account_exists": account is not None, + "account_enabled": bool(account and account.is_enabled), + "account_ck_status": account.ck_check_status if account else "", + "account_ck_message": account.ck_check_message if account else "", + "account_is_bound": account.is_bound if account else None, + "game_role_name": order.game_role_name or "", + "bind_snapshot_at": order.bind_snapshot_at, + "goods_id": order.goods_id, + "goods_name": order.goods_name or "", + "goods_image": order.goods_image or "", + "required_weidou": order.required_weidou, + "home_images": list(order.home_images or []), + "remark": order.remark or "", + "status": order.status, + "status_label": ORDER_STATUS_LABELS.get(order.status, order.status), + "next_status": NEXT_STATUS.get(order.status), + "missing_fields": _missing_for_recharge(order), + "weidou_balance": order.weidou_balance, + "weidou_required_total": order.weidou_required_total, + "weidou_surplus": order.weidou_surplus, + "weidou_threshold": order.weidou_threshold, + "weidou_checked_at": order.weidou_checked_at, + "weidou_check_reason": order.weidou_check_reason or "", + "weidou_check_message": order.weidou_check_message or "", + "weidou_check_detail": order.weidou_check_detail, + "exchange_started_at": order.exchange_started_at, + "exchange_checked_at": order.exchange_checked_at, + "exchange_record_key": order.exchange_record_key or "", + "exchange_record_snapshot": order.exchange_record_snapshot, + "exchange_verdict": order.exchange_verdict or "", + "exchange_message": order.exchange_message or "", + "recharged_at": order.recharged_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": names.get(int(order.archived_by), "") + if order.archived_by + else "", + "archive_reason": order.archive_reason or "", + "created_by": order.created_by, + "created_by_name": names.get(int(order.created_by), "") + if order.created_by + else "", + "updated_by": order.updated_by, + "updated_by_name": names.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": last_metric.created_at if last_metric else None, + "last_metric_message": last_metric.message if last_metric else "", + } + + +def _base_query(db: Session): + return db.query(WeiboExternalOrder) + + +def _apply_filters( + query, + *, + archive_scope: str = "active", + status: str = "", + shop: str = "", + owner_id: int | None = None, + goods_name: str = "", + keyword: str = "", + include_void: bool = False, +): + scope = archive_scope if archive_scope in ARCHIVE_SCOPES else "active" + if scope == "active": + query = query.filter(WeiboExternalOrder.archived_at.is_(None)) + elif scope == "archived": + query = query.filter(WeiboExternalOrder.archived_at.isnot(None)) + if not include_void: + query = query.filter(WeiboExternalOrder.status != STATUS_VOID) + if _text(status): + query = query.filter(WeiboExternalOrder.status == status) + if _text(shop): + query = query.filter(WeiboExternalOrder.shop == shop) + if owner_id is not None: + query = query.filter(WeiboExternalOrder.owner_id == int(owner_id)) + if _text(goods_name): + query = query.filter(WeiboExternalOrder.goods_name == goods_name) + search = _text(keyword) + if search: + pattern = f"%{search}%" + query = query.filter( + or_( + WeiboExternalOrder.order_no.ilike(pattern), + WeiboExternalOrder.account_uid.ilike(pattern), + WeiboExternalOrder.account_nickname.ilike(pattern), + WeiboExternalOrder.game_role_name.ilike(pattern), + WeiboExternalOrder.shop.ilike(pattern), + WeiboExternalOrder.goods_name.ilike(pattern), + WeiboExternalOrder.remark.ilike(pattern), + ) + ) + return query + + +def list_orders( + db: Session, + *, + page: int, + page_size: int, + archive_scope: str = "active", + status: str = "", + shop: str = "", + owner_id: int | None = None, + goods_name: str = "", + keyword: str = "", + include_void: bool = False, +) -> dict[str, Any]: + query = _apply_filters( + _base_query(db), + archive_scope=archive_scope, + status=status, + shop=shop, + owner_id=owner_id, + goods_name=goods_name, + keyword=keyword, + include_void=include_void, + ) + total = query.count() + rows = ( + query.order_by(WeiboExternalOrder.id.desc()) + .offset((max(1, page) - 1) * page_size) + .limit(page_size) + .all() + ) + users = _users_by_id( + db, + { + int(item) + for order in rows + 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 rows if order.account_id} + ) + clusters = _account_clusters(db, rows) + metrics = _latest_metrics(db, [order.id for order in rows]) + return { + "items": [ + serialize_order( + order, + users=users, + accounts=accounts, + cluster=clusters.get(order.id), + last_metric=metrics.get(order.id), + ) + for order in rows + ], + "total": total, + "page": page, + "page_size": page_size, + } + + +def serialize_one(db: Session, order: WeiboExternalOrder) -> 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() + ) + cluster = _account_clusters(db, [order]).get(order.id) + metric = _latest_metrics(db, [order.id]).get(order.id) + return serialize_order( + order, + users=users, + accounts=accounts, + cluster=cluster, + last_metric=metric, + ) + + +def summary( + db: Session, *, include_void: bool = False, archive_scope: str = "active" +) -> dict[str, Any]: + query = _apply_filters( + _base_query(db), + archive_scope=archive_scope, + include_void=include_void, + ) + by_status = { + str(status): int(count) + for status, count in query.with_entities( + WeiboExternalOrder.status, func.count(WeiboExternalOrder.id) + ) + .group_by(WeiboExternalOrder.status) + .all() + } + account_disabled = ( + query.join(WeiboAccount, WeiboAccount.id == WeiboExternalOrder.account_id) + .filter(WeiboAccount.is_enabled.is_(False)) + .with_entities(func.count(WeiboExternalOrder.id)) + .scalar() + or 0 + ) + ck_invalid = ( + query.join(WeiboAccount, WeiboAccount.id == WeiboExternalOrder.account_id) + .filter(WeiboAccount.ck_check_status.in_(("invalid", "error"))) + .with_entities(func.count(WeiboExternalOrder.id)) + .scalar() + or 0 + ) + return { + "total": sum(by_status.values()), + "by_status": by_status, + "account_missing": ( + db.query(func.count(WeiboExternalOrder.id)) + .filter( + WeiboExternalOrder.account_id.is_(None), + WeiboExternalOrder.archived_at.is_(None), + ) + .scalar() + or 0 + ), + "account_disabled": int(account_disabled), + "ck_invalid": int(ck_invalid), + "archived_total": ( + db.query(func.count(WeiboExternalOrder.id)) + .filter(WeiboExternalOrder.archived_at.isnot(None)) + .scalar() + or 0 + ), + } + + +def serialize_metric(metric: WeiboExternalOrderMetric) -> dict[str, Any]: + return { + "id": metric.id, + "order_id": metric.order_id, + "order_no": metric.order_no, + "account_uid": metric.account_uid or "", + "weidou_balance": metric.weidou_balance, + "weidou_required_total": metric.weidou_required_total, + "weidou_surplus": metric.weidou_surplus, + "records_total": metric.records_total, + "records_matched": metric.records_matched, + "verdict": metric.verdict or "", + "verdict_label": VERDICT_LABELS.get(metric.verdict, metric.verdict or ""), + "message": metric.message or "", + "source": metric.source or "", + "created_at": metric.created_at, + } + + +def order_metrics(db: Session, order_id: int) -> list[dict[str, Any]]: + rows = ( + db.query(WeiboExternalOrderMetric) + .filter(WeiboExternalOrderMetric.order_id == order_id) + .order_by(WeiboExternalOrderMetric.id.desc()) + .all() + ) + return [serialize_metric(row) for row in rows] + + +def order_timeline( + db: Session, order_id: int, *, limit: int = 100 +) -> list[dict[str, Any]]: + rows = ( + db.query(AuditLog) + .filter(AuditLog.target == f"weibo_external_order:{order_id}") + .order_by(AuditLog.id.desc()) + .limit(limit) + .all() + ) + return [ + { + "id": row.id, + "action": row.action, + "username": row.username, + "detail": row.detail, + "success": row.success, + "created_at": row.created_at, + } + for row in rows + ] + + +def list_shops(db: Session, *, active_only: bool = True) -> list[WeiboCollabShop]: + query = db.query(WeiboCollabShop) + if active_only: + query = query.filter(WeiboCollabShop.is_active.is_(True)) + return query.order_by(WeiboCollabShop.sort_order, WeiboCollabShop.id).all() + + +def ensure_shops_registered(db: Session, current: User, names: Sequence[str]) -> None: + existing = {item.name for item in db.query(WeiboCollabShop.name).all() if item.name} + for raw in names: + name = _text(raw, max_length=64) + if name and name not in existing: + db.add( + WeiboCollabShop(name=name, created_by=current.id, updated_by=current.id) + ) + existing.add(name) + + +def create_shop( + db: Session, current: User, *, name: str, remark: str = "" +) -> WeiboCollabShop: + clean = _text(name, max_length=64) + if not clean: + raise CollabError("店铺名不能为空", code="shop_name_required") + if db.query(WeiboCollabShop).filter(WeiboCollabShop.name == clean).first(): + raise CollabError("店铺已存在", status_code=409, code="shop_exists") + shop = WeiboCollabShop( + 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: WeiboCollabShop, payload: Mapping[str, Any] +) -> None: + old_name = shop.name + for field in ("name", "sort_order", "is_active", "remark"): + if field not in payload: + continue + value = payload[field] + if field == "sort_order": + value = _to_int(value) or 0 + elif field == "is_active": + value = bool(value) + elif field == "remark": + value = _text(value, max_length=255) + else: + value = _text(value, max_length=64) + setattr(shop, field, value) + if shop.name != old_name: + db.query(WeiboExternalOrder).filter(WeiboExternalOrder.shop == old_name).update( + {"shop": shop.name} + ) + shop.updated_by = current.id + db.commit() + + +def delete_shop(db: Session, shop: WeiboCollabShop) -> None: + if ( + db.query(WeiboExternalOrder) + .filter(WeiboExternalOrder.shop == shop.name) + .first() + ): + raise CollabError("仍有订单使用该店铺,请改为停用", code="shop_in_use") + db.delete(shop) + db.commit() + + +def list_goods(db: Session, *, active_only: bool = True) -> list[WeiboCollabGoods]: + query = db.query(WeiboCollabGoods) + if active_only: + query = query.filter(WeiboCollabGoods.is_active.is_(True)) + return query.order_by(WeiboCollabGoods.sort_order, WeiboCollabGoods.id).all() + + +def create_goods( + db: Session, + current: User, + *, + name: str, + price: int, + goods_id: int | None = None, + goods_image: str = "", + remark: str = "", +) -> WeiboCollabGoods: + clean = _text(name, max_length=128) + if not clean: + raise CollabError("商品名不能为空", code="goods_name_required") + if db.query(WeiboCollabGoods).filter(WeiboCollabGoods.name == clean).first(): + raise CollabError("商品已存在", status_code=409, code="goods_exists") + goods = WeiboCollabGoods( + name=clean, + price=max(0, int(price)), + goods_id=goods_id, + goods_image=_text(goods_image, max_length=512), + remark=_text(remark, max_length=255), + created_by=current.id, + updated_by=current.id, + ) + db.add(goods) + db.commit() + db.refresh(goods) + return goods + + +def update_goods( + db: Session, + current: User, + goods: WeiboCollabGoods, + payload: Mapping[str, Any], +) -> None: + old_name = goods.name + for field in ( + "name", + "price", + "goods_id", + "goods_image", + "sort_order", + "is_active", + "remark", + ): + if field not in payload: + continue + value = payload[field] + if field in ("price", "goods_id", "sort_order"): + value = _to_int(value) + elif field == "is_active": + value = bool(value) + elif field == "goods_image": + value = _text(value, max_length=512) + elif field == "remark": + value = _text(value, max_length=255) + else: + value = _text(value, max_length=128) + setattr(goods, field, value) + if goods.name != old_name: + db.query(WeiboExternalOrder).filter( + WeiboExternalOrder.goods_name == old_name + ).update({"goods_name": goods.name}) + goods.updated_by = current.id + db.commit() + + +def delete_goods(db: Session, goods: WeiboCollabGoods) -> None: + if ( + db.query(WeiboExternalOrder) + .filter(WeiboExternalOrder.goods_name == goods.name) + .first() + ): + raise CollabError("仍有订单使用该商品,请改为停用", code="goods_in_use") + db.delete(goods) + db.commit() diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 0f89a5a..38f341f 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -115,6 +115,7 @@ def refresh_weibo_account( account: WeiboAccount, *, activity: str = "", + commit: bool = True, ) -> WeiboHomeSnapshot: """刷新一个微博账号;失败时保留旧资料并记录本次检查状态。""" @@ -126,12 +127,14 @@ def refresh_weibo_account( account.ck_check_status = _error_status(exc) account.ck_check_message = str(exc) account.ck_checked_at = _now() - db.commit() + if commit: + db.commit() raise _apply_snapshot(account, snapshot, account.cookie or "") - db.commit() - db.refresh(account) + if commit: + db.commit() + db.refresh(account) return snapshot diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 4d307f1..326e57c 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -32,6 +32,7 @@ const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage')); const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage')); const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage')); const WeiboAccountsPage = lazy(() => import('./pages/WeiboAccountsPage')); +const WeiboCollabPage = lazy(() => import('./pages/WeiboCollabPage')); const YybRechargePage = lazy(() => import('./pages/YybRechargePage')); const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage')); @@ -107,6 +108,7 @@ function AppContent() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index a31f75a..27a9d7a 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -15,4 +15,28 @@ export { loginApi } from './login'; export { proxyApi } from './proxy'; export { userApi } from './users'; export { weiboApi } from './weibo'; +export { weiboCollabApi } from './weiboCollab'; +export type { + WeiboCollabAccountLookup, + WeiboCollabAccountOption, + WeiboCollabArchiveResult, + WeiboCollabArchiveScope, + WeiboCollabBalanceCheckResult, + WeiboCollabExchangeCheckResult, + WeiboCollabGoods, + WeiboCollabLiveGoods, + WeiboCollabMetric, + WeiboCollabOrder, + WeiboCollabOrderCreate, + WeiboCollabOrderList, + WeiboCollabOrderUpdate, + WeiboCollabOwner, + WeiboCollabShop, + WeiboCollabStatus, + WeiboCollabSummary, + WeiboCollabTimeline, + WeiboCollabUnarchiveResult, + WeiboCollabVerdict, + WeiboCollabVerifyBatchResult, +} from './weiboCollab'; export { yybApi } from './yyb'; diff --git a/web/frontend/src/api/weiboCollab.ts b/web/frontend/src/api/weiboCollab.ts new file mode 100644 index 0000000..ad80571 --- /dev/null +++ b/web/frontend/src/api/weiboCollab.ts @@ -0,0 +1,408 @@ +import api from './client'; + +export type WeiboCollabStatus = + | 'incomplete' + | 'to_recharge' + | 'recharged' + | 'exchange_pending' + | 'done' + | 'weidou_error' + | 'failed' + | 'void'; + +export type WeiboCollabArchiveScope = 'active' | 'archived' | 'all'; +export type WeiboCollabVerdict = 'success' | 'failed' | 'unknown' | ''; + +export interface WeiboCollabShop { + id: number; + name: string; + sort_order: number; + is_active: boolean; + remark: string; + created_by: number | null; + updated_by: number | null; + created_at: string | null; + updated_at: string | null; +} + +export interface WeiboCollabGoods { + id: number; + name: string; + price: number; + goods_id: number | null; + goods_image: string; + sort_order: number; + is_active: boolean; + remark: string; + created_by: number | null; + updated_by: number | null; + created_at: string | null; + updated_at: string | null; +} + +export interface WeiboCollabLiveGoods { + id: number; + goods_name: string; + goods_image: string; + price: number; + remain_count: number | null; + is_sold_out: boolean; +} + +export interface WeiboCollabAccountLookup { + ok: boolean; + message: string; + account_id: number; + uid: string; + nickname: string; + is_enabled: boolean; + ck_check_status: string; + ck_check_message: string; + is_bound: boolean | null; + game_role_name: string; + weidou_balance: number | null; + goods: WeiboCollabLiveGoods[]; + orders_using_account: Array<{ + id: number; + order_no: string; + status: WeiboCollabStatus; + status_label: string; + }>; +} + +export interface WeiboCollabAccountOption { + id: number; + uid: string; + nickname: string; + game_role_name: string; + weidou_balance: number | null; + ck_check_status: string; +} + +export interface WeiboCollabOrder { + id: number; + order_no: string; + shop: string; + owner_id: number | null; + owner_name: string; + account_id: number | null; + account_uid: string; + account_nickname: string; + account_exists: boolean; + account_enabled: boolean; + account_ck_status: string; + account_ck_message: string; + account_is_bound: boolean | null; + game_role_name: string; + bind_snapshot_at: string | null; + goods_id: number | null; + goods_name: string; + goods_image: string; + required_weidou: number | null; + home_images: string[]; + remark: string; + status: WeiboCollabStatus; + status_label: string; + next_status: WeiboCollabStatus | null; + missing_fields: string[]; + weidou_balance: number | null; + weidou_required_total: number | null; + weidou_surplus: number | null; + weidou_threshold: number | null; + weidou_checked_at: string | null; + weidou_check_reason: string; + weidou_check_message: string; + weidou_check_detail: Record | null; + exchange_started_at: string | null; + exchange_checked_at: string | null; + exchange_record_key: string; + exchange_record_snapshot: Record | null; + exchange_verdict: WeiboCollabVerdict; + exchange_message: string; + recharged_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: WeiboCollabStatus; + status_label: string; + goods_name: string; + }>; + } | null; + last_metric_at: string | null; + last_metric_message: string; +} + +export interface WeiboCollabOrderList { + items: WeiboCollabOrder[]; + total: number; + page: number; + page_size: number; +} + +export interface WeiboCollabSummary { + total: number; + by_status: Record; + account_missing: number; + account_disabled: number; + ck_invalid: number; + archived_total: number; +} + +export interface WeiboCollabOwner { + id: number; + username: string; +} + +export interface WeiboCollabMetric { + id: number; + order_id: number; + order_no: string; + account_uid: string; + weidou_balance: number | null; + weidou_required_total: number | null; + weidou_surplus: number | null; + records_total: number; + records_matched: number; + verdict: WeiboCollabVerdict; + verdict_label: string; + message: string; + source: string; + created_at: string | null; +} + +export interface WeiboCollabTimeline { + id: number; + action: string; + username: string; + detail: string; + success: boolean | null; + created_at: string | null; +} + +export interface WeiboCollabOrderCreate { + order_no: string; + shop?: string; + owner_id?: number | null; + account_id?: number | null; + goods_id?: number | null; + goods_name?: string; + goods_image?: string; + required_weidou?: number | null; + home_images?: string[]; + remark?: string; + draft?: boolean; + require_verified?: boolean; +} + +export interface WeiboCollabOrderUpdate { + order_no?: string; + shop?: string; + owner_id?: number | null; + goods_id?: number | null; + goods_name?: string; + goods_image?: string; + required_weidou?: number | null; + home_images?: string[]; + remark?: string; + expected_version?: number; +} + +export interface WeiboCollabBalanceCheckResult { + total: number; + updated: number; + threshold: number; + groups: Array<{ + account_id: number; + uid: string; + status: string; + status_label: string; + balance: number | null; + required_total: number; + surplus: number | null; + threshold: number; + reason: string; + message: string; + order_ids: number[]; + }>; + failures: Array<{ id: number; order_no: string; message: string }>; + skipped: Array<{ id: number; order_no: string; message: string }>; +} + +export interface WeiboCollabExchangeCheckResult { + total: number; + success: number; + failed: number; + unknown: number; + results: Array<{ + id: number; + order_no: string; + ok: boolean; + verdict: WeiboCollabVerdict; + verdict_label: string; + message: string; + records_total: number; + records_matched: number; + status: WeiboCollabStatus; + status_label: string; + metric_id: number; + }>; + failures: Array<{ id: number; order_no: string; message: string }>; +} + +export interface WeiboCollabVerifyBatchResult { + success: boolean; + results: Array<{ + id: number; + order_no: string; + success: boolean; + message: string; + }>; +} + +export interface WeiboCollabArchiveResult { + total: number; + archived: number; + skipped: number; + results: Array<{ id: number; order_no: string; ok: boolean; message: string }>; +} + +export interface WeiboCollabUnarchiveResult { + total: number; + unarchived: number; + skipped: number; + results: Array<{ id: number; order_no: string; ok: boolean; message: string }>; +} + +export const weiboCollabApi = { + accounts: () => + api.get( + '/weibo/external-orders/accounts/options', + ), + summary: (params: { include_void?: boolean; archive_scope?: WeiboCollabArchiveScope }) => + api.get('/weibo/external-orders/summary', { + params, + }), + owners: () => + api.get('/weibo/external-orders/owners'), + listOrders: (params: Record) => + api.get('/weibo/external-orders', { + params, + }), + getOrder: (id: number) => + api.get(`/weibo/external-orders/${id}`), + createOrder: (data: WeiboCollabOrderCreate) => + api.post('/weibo/external-orders', data, { + timeout: 120000, + }), + updateOrder: (id: number, data: WeiboCollabOrderUpdate) => + api.put(`/weibo/external-orders/${id}`, data), + setAccount: (id: number, accountId: number, expectedVersion?: number) => + api.post( + `/weibo/external-orders/${id}/account`, + { account_id: accountId, expected_version: expectedVersion }, + { timeout: 120000 }, + ), + verifyOrder: (id: number) => + api.post( + `/weibo/external-orders/${id}/verify`, + undefined, + { timeout: 120000 }, + ), + updateStatus: ( + id: number, + data: { + status: WeiboCollabStatus; + reason?: string; + rollback?: boolean; + expected_version?: number; + }, + ) => + api.post( + `/weibo/external-orders/${id}/status`, + data, + ), + batchStatus: (data: { + ids: number[]; + status: WeiboCollabStatus; + reason?: string; + rollback?: boolean; + }) => api.post('/weibo/external-orders/status/batch', data), + verifyBatch: (ids: number[]) => + api.post( + '/weibo/external-orders/verify/batch', + ids, + { timeout: 120000 }, + ), + checkBalance: (ids: number[], threshold: number) => + api.post( + '/weibo/external-orders/balances/check', + { ids, threshold }, + { timeout: 120000 }, + ), + checkExchangeRecords: (ids: number[]) => + api.post( + '/weibo/external-orders/exchange-records/check', + { ids }, + { timeout: 120000 }, + ), + archive: (ids: number[], reason = '') => + api.post( + '/weibo/external-orders/archive', + { ids, reason }, + ), + unarchive: (ids: number[]) => + api.post( + '/weibo/external-orders/unarchive', + ids, + ), + deleteOrder: (id: number) => api.delete(`/weibo/external-orders/${id}`), + metrics: (id: number) => + api.get( + `/weibo/external-orders/${id}/metrics`, + ), + timeline: (id: number) => + api.get( + `/weibo/external-orders/${id}/timeline`, + ), + lookupAccount: (accountId: number, withGoods = true) => + api.post( + '/weibo/external-orders/accounts/lookup', + { account_id: accountId, with_goods: withGoods }, + { timeout: 120000 }, + ), + listShops: () => + api.get('/weibo/collab-shops'), + createShop: (data: { name: string; remark?: string }) => + api.post('/weibo/collab-shops', data), + updateShop: (id: number, data: Partial>) => + api.put(`/weibo/collab-shops/${id}`, data), + deleteShop: (id: number) => api.delete(`/weibo/collab-shops/${id}`), + listGoods: () => + api.get('/weibo/collab-goods'), + createGoods: (data: { + name: string; + price: number; + goods_id?: number | null; + goods_image?: string; + remark?: string; + }) => api.post('/weibo/collab-goods', data), + updateGoods: (id: number, data: Partial>) => + api.put(`/weibo/collab-goods/${id}`, data), + deleteGoods: (id: number) => api.delete(`/weibo/collab-goods/${id}`), +}; diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 98b9104..626ced4 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -129,6 +129,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { if (can('weibo:view')) { weiboItems.push({ key: '/weibo/accounts', label: '账号与绑定', icon: }); } + if (can('weibo:collab:view')) { + weiboItems.push({ key: '/weibo/collab', label: '外部单协作', icon: }); + } if (douyuItems.length > 0) { menuItems.push({ key: 'group-douyu', type: 'group', label: '斗鱼', children: douyuItems }); diff --git a/web/frontend/src/pages/WeiboCollabPage.tsx b/web/frontend/src/pages/WeiboCollabPage.tsx new file mode 100644 index 0000000..f04ae75 --- /dev/null +++ b/web/frontend/src/pages/WeiboCollabPage.tsx @@ -0,0 +1,1307 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, + AutoComplete, + Button, + Checkbox, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Statistic, + Table, + Tag, + Tooltip, + Typography, + theme, +} from 'antd'; +import type { TableProps } from 'antd'; +import { + DeleteOutlined, + EditOutlined, + FileExcelOutlined, + InboxOutlined, + PlusOutlined, + ReloadOutlined, + SafetyCertificateOutlined, + SearchOutlined, + ShopOutlined, + SyncOutlined, + UndoOutlined, +} from '@ant-design/icons'; +import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader'; +import { + weiboCollabApi, + type WeiboCollabAccountLookup, + type WeiboCollabAccountOption, + type WeiboCollabBalanceCheckResult, + type WeiboCollabExchangeCheckResult, + type WeiboCollabGoods, + type WeiboCollabMetric, + type WeiboCollabOrder, + type WeiboCollabOrderCreate, + type WeiboCollabOrderUpdate, + type WeiboCollabShop, + type WeiboCollabStatus, + type WeiboCollabSummary, + type WeiboCollabTimeline, +} from '../api/modules'; +import { useCollabRealtime } from '../hooks/useCollabRealtime'; +import { usePermissions } from '../hooks/usePermissions'; +import { getErrorMessage } from '../utils/error'; +import { message } from '../utils/antdMessage'; +import { formatTime } from '../utils/time'; + +const { Text, Title } = Typography; +const { TextArea } = Input; + +const STATUS_META: Record = { + incomplete: { label: '待补资料', color: 'default' }, + to_recharge: { label: '待充值', color: 'blue' }, + recharged: { label: '已充值', color: 'cyan' }, + exchange_pending: { label: '兑换待确认', color: 'purple' }, + done: { label: '兑换成功', color: 'success' }, + weidou_error: { label: '微豆异常', color: 'orange' }, + failed: { label: '兑换失败', color: 'error' }, + void: { label: '已作废', color: 'default' }, +}; + +const STATUS_ORDER: WeiboCollabStatus[] = [ + 'incomplete', + 'to_recharge', + 'recharged', + 'exchange_pending', + 'done', + 'weidou_error', + 'failed', + 'void', +]; + +const MANUAL_STATUS_OPTIONS = STATUS_ORDER.filter( + (item) => item !== 'recharged' && item !== 'weidou_error', +).map((value) => ({ value, label: STATUS_META[value].label })); + +const VERDICT_META: Record = { + success: { label: '唯一命中', color: 'success' }, + failed: { label: '未命中', color: 'error' }, + unknown: { label: '待确认', color: 'warning' }, + '': { label: '未判定', color: 'default' }, +}; + +function balanceMeta(reason: string) { + if (reason === 'ok') return { label: '余额正常', color: 'success' }; + if (reason === 'shortage') return { label: '微豆不足', color: 'error' }; + if (reason === 'excess') return { label: '超出阈值', color: 'warning' }; + return { label: '未核账', color: 'default' }; +} + +interface OrderFormValues { + order_no: string; + shop?: string; + owner_id?: number | null; + account_id?: number | null; + goods_name?: string; + goods_image?: string; + goods_id?: number | null; + required_weidou?: number | null; + home_images?: string[]; + remark?: string; + draft?: boolean; +} + +export default function WeiboCollabPage() { + const { token } = theme.useToken(); + const { can } = usePermissions(); + const canEdit = can('weibo:collab:edit'); + const canManage = can('weibo:collab:manage'); + + const [orders, setOrders] = useState([]); + const [summary, setSummary] = useState(null); + const [owners, setOwners] = useState>([]); + const [shops, setShops] = useState([]); + const [goodsCatalog, setGoodsCatalog] = useState([]); + const [accounts, setAccounts] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [loading, setLoading] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const [keyword, setKeyword] = useState(''); + const [status, setStatus] = useState(''); + const [shop, setShop] = useState(''); + const [ownerId, setOwnerId] = useState(null); + const [goodsName, setGoodsName] = useState(''); + const [includeVoid, setIncludeVoid] = useState(false); + const [archiveScope, setArchiveScope] = useState<'active' | 'archived' | 'all'>('active'); + const [threshold, setThreshold] = useState(100); + + const [form] = Form.useForm(); + const [editOpen, setEditOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const [lookup, setLookup] = useState(null); + const [lookupLoading, setLookupLoading] = useState(false); + + const [detailOpen, setDetailOpen] = useState(false); + const [detail, setDetail] = useState(null); + const [detailMetrics, setDetailMetrics] = useState([]); + const [detailTimeline, setDetailTimeline] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + + const [statusOpen, setStatusOpen] = useState(false); + const [statusTarget, setStatusTarget] = useState('to_recharge'); + const [statusReason, setStatusReason] = useState(''); + const [statusRollback, setStatusRollback] = useState(false); + const [statusIds, setStatusIds] = useState([]); + const [statusSaving, setStatusSaving] = useState(false); + + const [shopOpen, setShopOpen] = useState(false); + const [goodsOpen, setGoodsOpen] = useState(false); + const [shopForm] = Form.useForm<{ name: string; remark?: string }>(); + const [goodsForm] = Form.useForm<{ + name: string; + price: number; + goods_id?: number | null; + goods_image?: string; + remark?: string; + }>(); + + const loadBase = useCallback(async () => { + try { + const [summaryData, ownerRows, shopRows, goodsRows, accountData] = + await Promise.all([ + weiboCollabApi.summary({ include_void: includeVoid, archive_scope: archiveScope }), + weiboCollabApi.owners(), + weiboCollabApi.listShops(), + weiboCollabApi.listGoods(), + weiboCollabApi.accounts(), + ]); + setSummary(summaryData); + setOwners(ownerRows); + setShops(shopRows); + setGoodsCatalog(goodsRows); + setAccounts(accountData); + } catch (error) { + message.error(getErrorMessage(error)); + } + }, [archiveScope, includeVoid]); + + const loadOrders = useCallback(async () => { + setLoading(true); + try { + const data = await weiboCollabApi.listOrders({ + page, + page_size: pageSize, + archive_scope: archiveScope, + status: status || undefined, + shop: shop || undefined, + owner_id: ownerId ?? undefined, + goods_name: goodsName || undefined, + keyword: keyword.trim() || undefined, + include_void: includeVoid, + }); + setOrders(data.items); + setTotal(data.total); + setSelectedIds((ids) => ids.filter((id) => data.items.some((row) => row.id === id))); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setLoading(false); + } + }, [ + archiveScope, + goodsName, + includeVoid, + keyword, + ownerId, + page, + pageSize, + shop, + status, + ]); + + const refreshAll = useCallback(() => { + void loadBase(); + void loadOrders(); + }, [loadBase, loadOrders]); + + useEffect(() => { + void loadBase(); + }, [loadBase]); + + useEffect(() => { + void loadOrders(); + }, [loadOrders]); + + useCollabRealtime({ + enabled: true, + path: '/api/weibo/external-orders/ws', + onInvalidate: () => refreshAll(), + }); + + const accountOptions = accounts.map((item) => ({ + value: item.id, + label: `${item.nickname || item.uid} · UID ${item.uid}${item.game_role_name ? ` · ${item.game_role_name}` : ''}`, + })); + + const liveGoodsOptions = (lookup?.goods || []).map((item) => ({ + value: item.goods_name, + label: `${item.goods_name} · ${item.price} 微豆${item.is_sold_out ? ' · 售罄' : ''}`, + goods: { + name: item.goods_name, + price: item.price, + goods_id: item.id, + goods_image: item.goods_image, + }, + })); + const catalogGoodsOptions = goodsCatalog + .filter((item) => item.is_active) + .map((item) => ({ + value: item.name, + label: `${item.name} · ${item.price} 微豆(字典)`, + goods: { + name: item.name, + price: item.price, + goods_id: item.goods_id, + goods_image: item.goods_image, + }, + })); + const goodsOptions: Array<{ + value: string; + label: string; + goods: { + name: string; + price: number; + goods_id: number | null; + goods_image: string; + }; + }> = [...liveGoodsOptions]; + for (const option of catalogGoodsOptions) { + if (!goodsOptions.some((item) => item.value === option.value)) goodsOptions.push(option); + } + + const lookupAccount = async (accountId?: number | null) => { + setLookup(null); + if (!accountId) return; + setLookupLoading(true); + try { + const result = await weiboCollabApi.lookupAccount(accountId, true); + setLookup(result); + if (!result.ok) message.warning(result.message || '账号当前不可用'); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setLookupLoading(false); + } + }; + + const openCreate = () => { + setEditing(null); + setLookup(null); + form.resetFields(); + form.setFieldsValue({ + owner_id: owners[0]?.id, + required_weidou: 0, + home_images: [], + draft: false, + }); + setEditOpen(true); + }; + + const openEdit = (order: WeiboCollabOrder) => { + setEditing(order); + form.setFieldsValue({ + order_no: order.order_no, + shop: order.shop, + owner_id: order.owner_id, + account_id: order.account_id, + goods_id: order.goods_id, + goods_name: order.goods_name, + goods_image: order.goods_image, + required_weidou: order.required_weidou, + home_images: order.home_images, + remark: order.remark, + draft: false, + }); + void lookupAccount(order.account_id); + setEditOpen(true); + }; + + const handleGoodsChange = (value: string) => { + const selected = goodsOptions.find((item) => item.value === value); + if (!selected) return; + form.setFieldsValue({ + goods_name: selected.goods.name, + required_weidou: selected.goods.price, + goods_id: selected.goods.goods_id, + goods_image: selected.goods.goods_image, + }); + }; + + const saveOrder = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + if (editing) { + const update: WeiboCollabOrderUpdate = { + order_no: values.order_no, + shop: values.shop || '', + owner_id: values.owner_id ?? null, + goods_id: values.goods_id ?? null, + goods_name: values.goods_name || '', + goods_image: values.goods_image || '', + required_weidou: values.required_weidou ?? null, + home_images: values.home_images || [], + remark: values.remark || '', + expected_version: editing.version, + }; + await weiboCollabApi.updateOrder(editing.id, update); + if (editing.account_id !== values.account_id) { + if (values.account_id) { + await weiboCollabApi.setAccount(editing.id, values.account_id); + } else { + message.warning('订单暂不支持取消关联账号'); + } + } + } else { + const create: WeiboCollabOrderCreate = { + order_no: values.order_no, + shop: values.shop || '', + owner_id: values.owner_id ?? null, + account_id: values.account_id ?? null, + goods_id: values.goods_id ?? null, + goods_name: values.goods_name || '', + goods_image: values.goods_image || '', + required_weidou: values.required_weidou ?? null, + home_images: values.home_images || [], + remark: values.remark || '', + draft: Boolean(values.draft), + require_verified: !values.draft, + }; + await weiboCollabApi.createOrder(create); + } + setEditOpen(false); + message.success(editing ? '订单已更新' : values.draft ? '草稿已创建' : '协作单已创建'); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setSaving(false); + } + }; + + const openDetail = async (order: WeiboCollabOrder) => { + setDetail(order); + setDetailOpen(true); + setDetailLoading(true); + try { + const [fresh, metrics, timeline] = await Promise.all([ + weiboCollabApi.getOrder(order.id), + weiboCollabApi.metrics(order.id), + weiboCollabApi.timeline(order.id), + ]); + setDetail(fresh); + setDetailMetrics(metrics); + setDetailTimeline(timeline); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setDetailLoading(false); + } + }; + + const verifyOrders = async (ids: number[]) => { + if (!ids.length) return; + try { + const result = await weiboCollabApi.verifyBatch(ids); + if (result.success) message.success(`已校验 ${ids.length} 单`); + else message.warning(`校验完成,${result.results.filter((item) => !item.success).length} 单失败`); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const runBalanceCheck = async (ids: number[]) => { + if (!ids.length) return; + try { + const result: WeiboCollabBalanceCheckResult = await weiboCollabApi.checkBalance( + ids, + threshold, + ); + const failed = result.failures.length; + message[failed ? 'warning' : 'success']( + `余额核账完成:更新 ${result.updated} 单${failed ? `,失败 ${failed} 单` : ''}`, + ); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const runExchangeCheck = async (ids: number[]) => { + if (!ids.length) return; + try { + const result: WeiboCollabExchangeCheckResult = + await weiboCollabApi.checkExchangeRecords(ids); + message.success( + `兑换记录查询完成:命中 ${result.success},待确认 ${result.unknown},失败 ${result.failed}`, + ); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const openStatus = (ids: number[], initial?: WeiboCollabStatus) => { + if (!ids.length) return; + setStatusIds(ids); + setStatusTarget(initial || 'to_recharge'); + setStatusReason(''); + setStatusRollback(false); + setStatusOpen(true); + }; + + const saveStatus = async () => { + setStatusSaving(true); + try { + if (statusIds.length === 1) { + const row = orders.find((item) => item.id === statusIds[0]); + await weiboCollabApi.updateStatus(statusIds[0], { + status: statusTarget, + reason: statusReason, + rollback: statusRollback, + expected_version: row?.version, + }); + } else { + await weiboCollabApi.batchStatus({ + ids: statusIds, + status: statusTarget, + reason: statusReason, + rollback: statusRollback, + }); + } + setStatusOpen(false); + message.success(`已更新为「${STATUS_META[statusTarget].label}」`); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setStatusSaving(false); + } + }; + + const archiveOrders = async (ids: number[]) => { + if (!ids.length) return; + try { + const result = await weiboCollabApi.archive(ids); + message[result.archived ? 'success' : 'warning']( + `已归档 ${result.archived} 单${result.skipped ? `,跳过 ${result.skipped} 单` : ''}`, + ); + setSelectedIds([]); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const unarchiveOrders = async (ids: number[]) => { + if (!ids.length) return; + try { + const result = await weiboCollabApi.unarchive(ids); + message.success(`已取消归档 ${result.unarchived} 单`); + setSelectedIds([]); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const deleteOrder = async (id: number) => { + try { + await weiboCollabApi.deleteOrder(id); + message.success('协作单已删除'); + refreshAll(); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const createShop = async () => { + const values = await shopForm.validateFields(); + try { + await weiboCollabApi.createShop(values); + shopForm.resetFields(); + await loadBase(); + message.success('店铺已新增'); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const createGoods = async () => { + const values = await goodsForm.validateFields(); + try { + await weiboCollabApi.createGoods(values); + goodsForm.resetFields(); + await loadBase(); + message.success('商品已新增'); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + const statusCards = useMemo( + () => [ + { key: '', label: '全部', value: summary?.total || 0 }, + ...STATUS_ORDER.map((item) => ({ + key: item, + label: STATUS_META[item].label, + value: summary?.by_status[item] || 0, + })), + ], + [summary], + ); + + const columns: TableProps['columns'] = [ + { + title: '订单编号', + dataIndex: 'order_no', + width: 170, + fixed: 'left', + render: (value: string, row) => ( + + + {value} + + {row.is_archived && 已归档} + + ), + }, + { + title: '店铺 / 负责人', + width: 150, + render: (_, row) => ( + + {row.shop || '-'} + + {row.owner_name || '-'} + + + ), + }, + { + title: '微博账号 / 角色', + width: 210, + render: (_, row) => ( + + {row.account_nickname || row.account_uid || '未关联'} + + UID {row.account_uid || '-'} + + + {row.game_role_name || '未读到角色'} + + + {row.account_is_bound === false && 未绑定} + {!row.account_enabled && row.account_exists && 账号停用} + {row.account_cluster && 同账号 {row.account_cluster.total} 单} + + + ), + }, + { + title: '商品 / 微豆', + width: 190, + render: (_, row) => ( + + {row.goods_name || '未选商品'} + + 需要 {row.required_weidou?.toLocaleString() ?? '-'} 微豆 + + + {VERDICT_META[row.exchange_verdict]?.label} + + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 130, + render: (value: WeiboCollabStatus, row) => ( + + {row.status_label || value} + {row.exchange_verdict === 'unknown' && 待确认} + + ), + }, + { + title: '最近核账', + width: 200, + render: (_, row) => { + const meta = balanceMeta(row.weidou_check_reason); + return ( + + + + 余额 {row.weidou_balance?.toLocaleString() ?? '-'} / 合计{' '} + {row.weidou_required_total?.toLocaleString() ?? '-'} + + + 结余 {row.weidou_surplus?.toLocaleString() ?? '-'} + + {meta.label} + + + ); + }, + }, + { + title: '最近改动', + width: 150, + render: (_, row) => ( + + {row.updated_by_name || '-'} + + {formatTime(row.updated_at)} + + + ), + }, + { + title: '操作', + width: 300, + fixed: 'right', + render: (_, row) => ( + + + {canEdit && !row.is_archived && ( + <> + + + + + )} + {canManage && ( + void deleteOrder(row.id)} + > + + {canEdit && ( + + )} + + + +
+ {statusCards.map((item) => ( + + ))} +
+ +
+ + { + setOwnerId(value ?? null); + setPage(1); + }} + allowClear + placeholder="负责人" + style={{ width: 130 }} + options={owners.map((item) => ({ value: item.id, label: item.username }))} + /> + { + setStatus(value); + setPage(1); + }} + style={{ width: 130 }} + options={[ + { value: '', label: '全部状态' }, + ...STATUS_ORDER.map((item) => ({ value: item, label: STATUS_META[item].label })), + ]} + /> + + + + ({ value: item.name }))} + placeholder="可输入新店铺" + filterOption + /> + + + void lookupAccount(value)} + /> + + + + + + + + + + + {lookup && ( + 0 + ? `同账号已有 ${lookup.orders_using_account.length} 单:${lookup.orders_using_account + .map((item) => item.order_no) + .join('、')}` + : undefined + } + /> + )} + + + + +