feat: 斗鱼外部单协作台账与店铺实时同步

This commit is contained in:
yml2213
2026-10-20 20:11:35 +08:00
parent c778ae9a09
commit 71574908ff
18 changed files with 6187 additions and 4 deletions
+395
View File
@@ -0,0 +1,395 @@
"""斗鱼外部单协作:账号导入、状态机、鱼翅/积分与兑换记录查询。"""
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, User
from web.backend.schemas import DouyuCollabAccountLookupOut, DouyuCollabOrderOut
from web.backend.services import douyu_collab_service as collab
class FakeClient:
def __init__(self, *, points=5000, fish=1000, records=None):
self.points = points
self.fish = fish
self.records = records if records is not None else []
def elite_user_info(self, **_):
return {
"manual_type": 1,
"manual_score": self.points,
"expire_time": 0,
"user_info": {"nickname": "斗鱼昵称"},
}
def bind_info(self, **_):
return {
"role_name": "角色A",
"area_name": "微信",
"plat_name": "苹果(iOS)",
"nickname": "微信昵称",
}
def gold_account(self):
return {"gold": self.fish}
def list_goods(self, **_):
return {
"goods": [
{
"commodityId": "gift-a",
"commodityName": "礼包A",
"score": 100,
"status": 0,
"openStatus": 1,
"mark": "",
"count": 10,
}
]
}
def exchange_records(self, **_):
return {"records": self.records}
def exchange_orders(self, **_):
return {"orders": []}
class FakeNoBindClient(FakeClient):
def bind_info(self, **_):
raise RuntimeError("未绑定角色")
class TestDouyuCollabOrders:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(
username="douyu-collab-admin",
password_hash="hash",
role="super_admin",
is_active=True,
)
self.session.add(self.admin)
self.session.commit()
self.account = Account(
username="douyu-account-a",
password="password",
email="a@example.com",
email_password="mail-password",
cookie="acf_uid=100; acf_nickname=test",
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def test_lookup_account_resolves_internal_ck_and_role(self):
result = collab.lookup_account(
self.session, "douyu-account-a", client=FakeClient()
)
assert result["found"] is True
assert result["ok"] is True
assert result["account_id"] == self.account.id
assert result["bind_game_role"] == "角色A"
assert result["manual_score"] == 5000
assert result["fish_fin_balance"] == 1000
assert result["goods"][0]["commodity_id"] == "gift-a"
DouyuCollabAccountLookupOut.model_validate(result)
def test_create_strict_order_uses_internal_account_and_advances(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-001",
shop="店铺A",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-1.png"],
client=FakeClient(),
)
assert order.account_id == self.account.id
assert order.bind_game_role == "角色A"
assert order.points_balance == 5000
assert order.fish_fin_balance == 1000
assert order.status == collab.STATUS_TO_RECHARGE
DouyuCollabOrderOut.model_validate(collab.serialize_one(self.session, order))
def test_same_account_allows_multiple_orders(self):
collab.create_order(
self.session,
self.admin,
order_no="DY-002",
shop="店铺A",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-1.png"],
client=FakeClient(),
)
second = collab.create_order(
self.session,
self.admin,
order_no="DY-003",
shop="店铺A",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-2.png"],
client=FakeClient(),
)
assert second.account_id == self.account.id
assert collab.group_points_total(self.session, second) == 200
def test_status_flow_and_exchange_record_marks_done(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-004",
shop="店铺A",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-3.png"],
client=FakeClient(),
)
collab.set_status(
self.session,
self.admin,
order,
status=collab.STATUS_RECHARGED,
reason="鱼翅已充值",
)
collab.set_status(
self.session,
self.admin,
order,
status=collab.STATUS_SCRIPTED,
reason="脚本已上",
)
result = collab.query_activity(
self.session,
self.admin,
order,
client=FakeClient(records=[{"commodityName": "礼包A"}]),
)
assert result["verdict"] == collab.VERDICT_SUCCESS
assert result["order"].status == collab.STATUS_DONE
assert result["order"].done_at is not None
def test_points_shortage_enters_points_error(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-005",
shop="店铺A",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-4.png"],
client=FakeClient(),
)
collab.set_status(
self.session,
self.admin,
order,
status=collab.STATUS_RECHARGED,
reason="鱼翅已充值",
)
order.points_threshold = 10
self.session.commit()
result = collab.query_activity(
self.session,
self.admin,
order,
client=FakeClient(points=50, records=[]),
)
assert result["points_reason"] == "shortage"
assert result["order"].status == collab.STATUS_POINTS_ERROR
def test_draft_can_be_created_before_account_resolution(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-006",
shop="",
account_username="not-exists-yet",
draft=True,
)
assert order.status == collab.STATUS_INCOMPLETE
assert order.account_id is None
assert order.account_username == "not-exists-yet"
def test_draft_existing_username_links_account_and_can_be_completed(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-009",
shop="店铺A",
account_username="douyu-account-a",
draft=True,
)
assert order.account_id == self.account.id
collab.update_order(
self.session,
self.admin,
order,
{
"account_username": "douyu-account-a",
"gift_name": "礼包A",
"commodity_id": "gift-a",
"required_points": 100,
"home_images": ["collab/home-7.png"],
},
client=FakeClient(),
)
self.session.refresh(order)
assert order.bind_game_role == "角色A"
assert order.status == collab.STATUS_TO_RECHARGE
assert collab.serialize_one(self.session, order)["missing_fields"] == []
def test_missing_bind_role_keeps_incomplete_and_explains_why(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-011",
shop="店铺A",
account_username="douyu-account-a",
draft=True,
)
collab.update_order(
self.session,
self.admin,
order,
{
"account_username": "douyu-account-a",
"gift_name": "礼包A",
"commodity_id": "gift-a",
"required_points": 100,
"home_images": ["collab/home-missing-role.png"],
},
client=FakeNoBindClient(),
)
self.session.refresh(order)
assert order.status == collab.STATUS_INCOMPLETE
payload = collab.serialize_one(self.session, order)
assert "绑定角色(先校验账号)" in payload["missing_fields"]
DouyuCollabOrderOut.model_validate(payload)
def test_done_order_is_not_counted_again_in_group_points(self):
first = collab.create_order(
self.session,
self.admin,
order_no="DY-007",
shop="店铺A",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-5.png"],
client=FakeClient(),
)
second = collab.create_order(
self.session,
self.admin,
order_no="DY-008",
shop="店铺A",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-6.png"],
client=FakeClient(),
)
assert collab.group_points_total(self.session, second) == 200
collab.set_status(
self.session,
self.admin,
first,
status=collab.STATUS_DONE,
reason="已兑换",
)
assert collab.group_points_total(self.session, second) == 100
def test_goods_normalization_keeps_zero_values(self):
goods = collab.serialize_live_goods(
[
{
"commodityId": "gift-23",
"commodityName": "23返场礼包",
"score": 8000,
"status": 0,
"openStatus": 0,
"startTime": "1792749600",
"endTime": "1793375999",
"mark": "限时",
"count": 2000,
}
]
)
assert goods[0]["status"] == "0"
assert goods[0]["open_status"] == 0
assert goods[0]["start_time"] == 1792749600
assert goods[0]["is_limited"] is True
def test_shop_rename_cascades_and_delete_rejects_in_use(self):
shop = collab.create_shop(self.session, self.admin, name="店铺B", remark="测试")
order = collab.create_order(
self.session,
self.admin,
order_no="DY-009",
shop="店铺B",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-7.png"],
client=FakeClient(),
)
collab.update_shop(
self.session,
self.admin,
shop,
{"name": "店铺B-改名", "is_active": False},
)
self.session.refresh(order)
assert order.shop == "店铺B-改名"
assert shop.is_active is False
with pytest.raises(collab.CollabError):
collab.delete_shop(self.session, self.admin, shop)
collab.delete_order(self.session, self.admin, order)
assert collab.delete_shop(self.session, self.admin, shop)["success"] is True
def test_ck_status_filter_and_summary_count_invalid(self):
order = collab.create_order(
self.session,
self.admin,
order_no="DY-010",
shop="店铺C",
account_username="douyu-account-a",
gift_name="礼包A",
required_points=100,
home_images=["collab/home-8.png"],
client=FakeClient(),
)
self.account.ck_check_status = collab.CK_INVALID
self.session.commit()
invalid = collab.list_orders(self.session, ck_status=collab.CK_INVALID)
assert [item["id"] for item in invalid["items"]] == [order.id]
assert collab.list_orders(self.session, ck_status=collab.CK_VALID)["total"] == 0
assert collab.summary(self.session)["ck_invalid"] == 1
def test_douyu_collab_routes_registered(self):
from web.backend.main import app
paths = app.openapi()["paths"]
assert "/api/douyu/external-orders" in paths
assert "/api/douyu/external-orders/{order_id}/status" in paths
assert "/api/douyu/external-orders/status/batch" in paths
assert "/api/douyu/external-orders/verify/batch" in paths
assert "/api/douyu/external-orders/accounts/lookup" in paths
assert "/api/douyu/collab-shops/{shop_id}" in paths
assert "put" in paths["/api/douyu/collab-shops/{shop_id}"]
assert "delete" in paths["/api/douyu/collab-shops/{shop_id}"]
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261019_0061"
HEAD_REVISION = "20261020_0062"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
+2
View File
@@ -24,6 +24,7 @@ from .routers import (
cookies,
dashboard,
douyu,
douyu_collab,
files,
huya,
login,
@@ -153,6 +154,7 @@ app.include_router(proxy.router)
app.include_router(cookies.router)
app.include_router(huya.router)
app.include_router(douyu.router)
app.include_router(douyu_collab.router)
app.include_router(yyb.router)
app.include_router(audit.router)
app.include_router(automation.router)
@@ -0,0 +1,245 @@
"""斗鱼外部单协作台账:订单、店铺字典、查询快照。
一行 = 一单 = 一个内部斗鱼账号。**不复制 CK**:协作单只存 account_id 与用户名快照,
所有查询实时读 ``accounts.cookie``,账号换 CK 后协作单自动使用最新凭据。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261020_0062"
down_revision: str | None = "20261019_0061"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
ORDERS = "douyu_external_orders"
METRICS = "douyu_external_order_metrics"
SHOPS = "douyu_collab_shops"
USERS = "users"
ACCOUNTS = "accounts"
def _tables(bind) -> set[str]:
return set(sa.inspect(bind).get_table_names())
def upgrade() -> None:
bind = op.get_bind()
tables = _tables(bind)
if SHOPS not in tables:
op.create_table(
SHOPS,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column(
"remark", sa.String(length=255), nullable=False, server_default=""
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["created_by"], [f"{USERS}.id"], name=f"fk_{SHOPS}_created_by"
),
sa.ForeignKeyConstraint(
["updated_by"], [f"{USERS}.id"], name=f"fk_{SHOPS}_updated_by"
),
)
op.create_index(f"ix_{SHOPS}_name", SHOPS, ["name"], unique=True)
op.create_index(f"ix_{SHOPS}_is_active", SHOPS, ["is_active"])
if ORDERS not in tables:
op.create_table(
ORDERS,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("order_no", sa.String(length=64), nullable=False),
sa.Column("shop", sa.String(length=64), nullable=False, server_default=""),
sa.Column("owner_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column(
"account_username",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column(
"account_uid", sa.String(length=32), nullable=False, server_default=""
),
sa.Column(
"account_nickname",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column(
"bind_game_account",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column(
"bind_game_role",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column(
"bind_game_channel",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column("bind_snapshot_at", sa.DateTime(), nullable=True),
sa.Column("home_images", sa.JSON(), nullable=True),
sa.Column(
"gift_name", sa.String(length=128), nullable=False, server_default=""
),
sa.Column(
"commodity_id", sa.String(length=64), nullable=False, server_default=""
),
sa.Column("required_points", sa.Integer(), nullable=True),
sa.Column(
"remark", sa.String(length=512), nullable=False, server_default=""
),
sa.Column(
"status",
sa.String(length=16),
nullable=False,
server_default="incomplete",
),
sa.Column("recharged_at", sa.DateTime(), nullable=True),
sa.Column("scripted_at", sa.DateTime(), nullable=True),
sa.Column("done_at", sa.DateTime(), nullable=True),
sa.Column(
"void_reason", sa.String(length=256), nullable=False, server_default=""
),
sa.Column("fish_fin_balance", sa.Integer(), nullable=True),
sa.Column("points_balance", sa.Integer(), nullable=True),
sa.Column("points_required_total", sa.Integer(), nullable=True),
sa.Column("points_surplus", sa.Integer(), nullable=True),
sa.Column("points_threshold", sa.Integer(), nullable=True),
sa.Column("points_checked_at", sa.DateTime(), nullable=True),
sa.Column(
"points_check_reason",
sa.String(length=16),
nullable=False,
server_default="",
),
sa.Column(
"points_check_message",
sa.String(length=512),
nullable=False,
server_default="",
),
sa.Column("points_check_detail", sa.JSON(), nullable=True),
sa.Column("archived_at", sa.DateTime(), nullable=True),
sa.Column("archived_by", sa.Integer(), nullable=True),
sa.Column(
"archive_reason",
sa.String(length=256),
nullable=False,
server_default="",
),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["owner_id"], [f"{USERS}.id"], name=f"fk_{ORDERS}_owner_id"
),
sa.ForeignKeyConstraint(
["account_id"],
[f"{ACCOUNTS}.id"],
name=f"fk_{ORDERS}_account_id",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["archived_by"], [f"{USERS}.id"], name=f"fk_{ORDERS}_archived_by"
),
sa.ForeignKeyConstraint(
["created_by"], [f"{USERS}.id"], name=f"fk_{ORDERS}_created_by"
),
sa.ForeignKeyConstraint(
["updated_by"], [f"{USERS}.id"], name=f"fk_{ORDERS}_updated_by"
),
)
op.create_index(f"ix_{ORDERS}_order_no", ORDERS, ["order_no"], unique=True)
op.create_index(f"ix_{ORDERS}_shop", ORDERS, ["shop"])
op.create_index(f"ix_{ORDERS}_owner_id", ORDERS, ["owner_id"])
op.create_index(f"ix_{ORDERS}_account_id", ORDERS, ["account_id"])
op.create_index(f"ix_{ORDERS}_account_username", ORDERS, ["account_username"])
op.create_index(f"ix_{ORDERS}_status", ORDERS, ["status"])
op.create_index(f"ix_{ORDERS}_created_by", ORDERS, ["created_by"])
op.create_index(f"ix_{ORDERS}_archived_at", ORDERS, ["archived_at"])
op.create_index(f"ix_{ORDERS}_updated_at", ORDERS, ["updated_at"])
if METRICS not in tables:
op.create_table(
METRICS,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("order_id", sa.Integer(), nullable=False),
sa.Column(
"order_no", sa.String(length=64), nullable=False, server_default=""
),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column(
"account_username",
sa.String(length=128),
nullable=False,
server_default="",
),
sa.Column("fish_fin_balance", sa.Integer(), nullable=True),
sa.Column("points_balance", sa.Integer(), nullable=True),
sa.Column("points_required_total", sa.Integer(), nullable=True),
sa.Column("points_surplus", sa.Integer(), nullable=True),
sa.Column(
"records_total", sa.Integer(), nullable=False, server_default="0"
),
sa.Column(
"records_matched", sa.Integer(), nullable=False, server_default="0"
),
sa.Column(
"verdict",
sa.String(length=16),
nullable=False,
server_default="unknown",
),
sa.Column(
"message", sa.String(length=512), nullable=False, server_default=""
),
sa.Column(
"source", sa.String(length=16), nullable=False, server_default="manual"
),
sa.Column("raw", sa.JSON(), nullable=True),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["order_id"],
[f"{ORDERS}.id"],
name=f"fk_{METRICS}_order_id",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by"], [f"{USERS}.id"], name=f"fk_{METRICS}_created_by"
),
)
op.create_index(f"ix_{METRICS}_order_id", METRICS, ["order_id"])
op.create_index(f"ix_{METRICS}_account_id", METRICS, ["account_id"])
op.create_index(f"ix_{METRICS}_verdict", METRICS, ["verdict"])
op.create_index(f"ix_{METRICS}_created_at", METRICS, ["created_at"])
def downgrade() -> None:
bind = op.get_bind()
tables = _tables(bind)
for table in (METRICS, ORDERS, SHOPS):
if table in tables:
op.drop_table(table)
+166
View File
@@ -514,6 +514,172 @@ class DouyuEsportsGoodsSnapshot(Base):
)
class DouyuCollabShop(Base):
"""斗鱼外部单协作的店铺字典(新建单时点选,历史文本自动登记)。"""
__tablename__ = "douyu_collab_shops"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="1", index=True
)
remark: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
updated_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class DouyuExternalOrder(Base):
"""斗鱼外部单协作台账(一行 = 一单 = 一个内部斗鱼账号)。
和虎牙外部 CK 台账的关键区别:**不复制 CK**。协作单只存 ``account_id``
(可空,账号删除后置空)与用户名/UID 快照,任何查询都实时读
``accounts.cookie``。账号 CK 更新后,协作单自动使用最新凭据。
"""
__tablename__ = "douyu_external_orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_no: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
shop: Mapped[str] = mapped_column(
String(64), default="", server_default="", index=True
)
owner_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
# ---- 内部斗鱼账号(CK 不落本表)----
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True
)
account_username: Mapped[str] = mapped_column(
String(128), default="", server_default="", index=True
)
account_uid: Mapped[str] = mapped_column(String(32), default="", server_default="")
account_nickname: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
# ---- 绑定角色只读快照(来自 memberthird/tencent/bindInfo)----
bind_game_account: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
bind_game_role: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
bind_game_channel: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
bind_snapshot_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
# ---- 订单资料 ----
home_images: Mapped[list | None] = mapped_column(JSON, nullable=True)
gift_name: Mapped[str] = mapped_column(String(128), default="", server_default="")
commodity_id: Mapped[str] = mapped_column(String(64), default="", server_default="")
required_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
remark: Mapped[str] = mapped_column(String(512), default="", server_default="")
# ---- 业务进度 ----
status: Mapped[str] = mapped_column(
String(16), default="incomplete", server_default="incomplete", index=True
)
recharged_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
scripted_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
done_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
void_reason: Mapped[str] = mapped_column(String(256), default="", server_default="")
# ---- 鱼翅 / 积分 / 核账快照 ----
fish_fin_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_threshold: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_checked_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
points_check_reason: Mapped[str] = mapped_column(
String(16), default="", server_default=""
)
points_check_message: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
points_check_detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# ---- 归档 ----
archived_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True, index=True
)
archived_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
archive_reason: Mapped[str] = mapped_column(
String(256), default="", server_default=""
)
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
updated_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
version: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow, index=True
)
class DouyuExternalOrderMetric(Base):
"""斗鱼协作单的鱼翅/积分/兑换记录快照(只追加,不存 CK)。"""
__tablename__ = "douyu_external_order_metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_id: Mapped[int] = mapped_column(
ForeignKey("douyu_external_orders.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), default="", server_default="")
account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
account_username: Mapped[str] = mapped_column(
String(128), default="", server_default=""
)
fish_fin_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_required_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
points_surplus: Mapped[int | None] = mapped_column(Integer, nullable=True)
records_total: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
records_matched: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
verdict: Mapped[str] = mapped_column(
String(16), default="unknown", server_default="unknown", index=True
)
message: Mapped[str] = mapped_column(String(512), default="", server_default="")
source: Mapped[str] = mapped_column(
String(16), default="manual", server_default="manual"
)
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, index=True
)
class HuyaAccount(Base):
"""虎牙账号"""
+9
View File
@@ -31,6 +31,10 @@ PERMISSIONS = {
# 斗鱼活动
"douyu:task": "斗鱼任务管理",
"douyu:config": "斗鱼配置管理",
# 斗鱼外部单协作(订单挂内部账号,不复制 CK)
"douyu:collab:view": "查看斗鱼外部单协作",
"douyu:collab:edit": "编辑斗鱼外部单协作",
"douyu:collab:manage": "删除斗鱼外部单协作",
"yyb:session": "应用宝扫码登录与角色查询",
"yyb:recharge": "应用宝充值与付款",
"yyb:manage": "接管并操作他人的应用宝任务",
@@ -87,6 +91,9 @@ ROLE_PERMISSIONS = {
"cookie:export",
"douyu:task",
"douyu:config",
"douyu:collab:view",
"douyu:collab:edit",
"douyu:collab:manage",
"yyb:session",
"yyb:recharge",
"yyb:manage",
@@ -120,6 +127,8 @@ ROLE_PERMISSIONS = {
"account:tag",
"account:remark",
"douyu:task",
"douyu:collab:view",
"douyu:collab:edit",
"yyb:session",
"yyb:history",
"huya:view_assigned",
+573
View File
@@ -0,0 +1,573 @@
"""斗鱼外部单协作接口。
页面只做参数校验、权限和异常翻译;业务规则见
``web/backend/services/douyu_collab_service.py``。
"""
from __future__ import annotations
import asyncio
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
WebSocket,
WebSocketDisconnect,
)
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import authenticate_websocket, require_permission
from ..models import User
from ..permissions import get_user_permissions
from ..schemas import (
DouyuCollabAccountLookupOut,
DouyuCollabAccountLookupRequest,
DouyuCollabAccountRequest,
DouyuCollabArchiveRequest,
DouyuCollabGoodsOut,
DouyuCollabImagesRequest,
DouyuCollabMetricOut,
DouyuCollabOrderCreate,
DouyuCollabOrderListOut,
DouyuCollabOrderOut,
DouyuCollabOrderUpdate,
DouyuCollabOwnerOut,
DouyuCollabShopCreate,
DouyuCollabShopOut,
DouyuCollabShopUpdate,
DouyuCollabStatusBatchRequest,
DouyuCollabStatusRequest,
DouyuCollabSummaryOut,
DouyuCollabTimelineOut,
)
from ..services import douyu_collab_service as collab
from ..services.douyu_collab_events import collab_events
router = APIRouter(prefix="/api/douyu", tags=["斗鱼外部单协作"])
def _http_error(exc: collab.CollabError) -> HTTPException:
return HTTPException(status_code=exc.status_code, detail=exc.detail)
def _one(db: Session, order) -> dict:
return collab.serialize_one(db, order)
def _shop_out(shop) -> dict:
return {
"id": int(shop.id),
"name": shop.name,
"sort_order": int(shop.sort_order or 0),
"is_active": bool(shop.is_active),
"remark": shop.remark or "",
"created_by": shop.created_by,
"updated_by": shop.updated_by,
"created_at": shop.created_at,
"updated_at": shop.updated_at,
}
def _publish_change(current: User, *, scope: str, action: str) -> None:
collab_events.publish(scope=scope, action=action, actor_id=int(current.id))
@router.websocket("/external-orders/ws")
async def ws_external_orders(websocket: WebSocket):
"""协作台账实时失效通知:前端收到后按自己的筛选静默重拉。"""
user = authenticate_websocket(websocket)
if not user or "douyu:collab:view" not in get_user_permissions(user):
await websocket.close(code=1008, reason="未授权")
return
await websocket.accept()
queue = collab_events.subscribe()
try:
await websocket.send_json(collab_events.ready_event())
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=25)
await websocket.send_json(event)
except TimeoutError:
await websocket.send_json(collab_events.ping_event())
except (WebSocketDisconnect, RuntimeError):
pass
finally:
collab_events.unsubscribe(queue)
@router.get("/external-orders/summary", response_model=DouyuCollabSummaryOut)
def external_orders_summary(
include_void: bool = Query(False),
archive_scope: str = Query("active"),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
return collab.summary(db, include_void=include_void, archive_scope=archive_scope)
@router.get("/external-orders/owners", response_model=list[DouyuCollabOwnerOut])
def external_orders_owners(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
rows = (
db.query(User.id, User.username)
.filter(User.is_active.is_(True))
.order_by(User.username)
.all()
)
return [
{"id": int(user_id), "username": str(username)} for user_id, username in rows
]
@router.get("/external-orders/shops", response_model=list[str])
def external_orders_shops(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
return [shop.name for shop in collab.list_shops(db, active_only=False)]
@router.get("/external-orders/gifts", response_model=list[str])
def external_orders_gifts(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
return collab.list_gift_names(db)
@router.get("/external-orders/goods", response_model=list[DouyuCollabGoodsOut])
def external_orders_goods(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
return collab.serialized_goods(db)
@router.post("/external-orders/goods/refresh")
def external_orders_goods_refresh(
account_id: int | None = Query(None),
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
result = collab.refresh_activity_goods(db, current, account_id=account_id)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="shops", action="goods.refresh")
return result
@router.post(
"/external-orders/accounts/lookup",
response_model=DouyuCollabAccountLookupOut,
)
def external_orders_account_lookup(
req: DouyuCollabAccountLookupRequest,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:edit")),
):
return collab.lookup_account(db, req.username, with_goods=req.with_goods)
@router.get("/external-orders", response_model=DouyuCollabOrderListOut)
def list_external_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
include_void: bool = Query(False),
archive_scope: str = Query("active"),
status: str = Query(""),
shop: str = Query(""),
owner_id: int | None = Query(None),
gift_name: str = Query(""),
keyword: str = Query(""),
ck_status: str = Query(""),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
return collab.list_orders(
db,
page=page,
page_size=page_size,
include_void=include_void,
archive_scope=archive_scope,
status=status,
shop=shop,
owner_id=owner_id,
gift_name=gift_name,
keyword=keyword,
ck_status=ck_status,
)
@router.post("/external-orders", response_model=DouyuCollabOrderOut)
def create_external_order(
req: DouyuCollabOrderCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
order = collab.create_order(
db,
current,
order_no=req.order_no,
shop=req.shop,
owner_id=req.owner_id,
account_username=req.account_username,
gift_name=req.gift_name,
commodity_id=req.commodity_id,
required_points=req.required_points,
home_images=req.home_images,
remark=req.remark,
draft=req.draft,
require_verified=req.require_verified,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.create")
return _one(db, order)
@router.get("/external-orders/{order_id}", response_model=DouyuCollabOrderOut)
def get_external_order(
order_id: int,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
try:
return _one(db, collab.get_order(db, order_id))
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.put("/external-orders/{order_id}", response_model=DouyuCollabOrderOut)
def update_external_order(
order_id: int,
req: DouyuCollabOrderUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
collab.update_order(
db,
current,
order,
req.model_dump(exclude_unset=True),
expected_version=req.expected_version,
)
_publish_change(current, scope="orders", action="order.update")
return _one(db, order)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post("/external-orders/{order_id}/account", response_model=DouyuCollabOrderOut)
def set_external_order_account(
order_id: int,
req: DouyuCollabAccountRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
collab.set_account(
db,
current,
order,
req.account_username,
expected_version=req.expected_version,
)
_publish_change(current, scope="orders", action="order.account")
return _one(db, order)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post("/external-orders/{order_id}/verify")
def verify_external_order(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
result = collab.verify_order(db, current, order)
_publish_change(current, scope="orders", action="order.verify")
return {"order": _one(db, result["order"]), "probe": result["probe"]}
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post("/external-orders/verify/batch")
def verify_external_orders_batch(
ids: list[int],
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
results: list[dict] = []
for order_id in ids:
try:
order = collab.get_order(db, order_id)
result = collab.verify_order(db, current, order)
probe = result["probe"]
results.append(
{
"id": order_id,
"success": True,
"ck_status": probe.get("ck_status"),
"role": probe.get("bind_game_role"),
}
)
except Exception as exc: # noqa: BLE001 - 批量逐行隔离
db.rollback()
results.append({"id": order_id, "success": False, "message": str(exc)})
if any(item["success"] for item in results):
_publish_change(current, scope="orders", action="order.verify_batch")
return {"results": results, "success": all(item["success"] for item in results)}
@router.post("/external-orders/{order_id}/activity/query")
def query_external_order_activity(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
result = collab.query_activity(db, current, order, source="manual")
_publish_change(current, scope="orders", action="order.activity")
return {
"order": _one(db, result["order"]),
"metric": collab.serialize_metric(result["metric"]),
"verdict": result["verdict"],
"message": result["message"],
"points_reason": result["points_reason"],
"fish_fin_balance": result["fish_fin_balance"],
"points_balance": result["points_balance"],
"required_total": result["required_total"],
"surplus": result["surplus"],
"errors": result["errors"],
}
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post("/external-orders/{order_id}/status", response_model=DouyuCollabOrderOut)
def set_external_order_status(
order_id: int,
req: DouyuCollabStatusRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
collab.set_status(
db,
current,
order,
status=req.status,
reason=req.reason,
rollback=req.rollback,
expected_version=req.expected_version,
)
_publish_change(current, scope="orders", action="order.status")
return _one(db, order)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post("/external-orders/status/batch")
def set_external_order_status_batch(
req: DouyuCollabStatusBatchRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
results: list[dict] = []
for order_id in req.ids:
try:
order = collab.get_order(db, order_id)
collab.set_status(
db,
current,
order,
status=req.status,
reason=req.reason,
rollback=req.rollback,
)
results.append({"id": order_id, "success": True})
except Exception as exc: # noqa: BLE001 - 批量逐行隔离
db.rollback()
results.append({"id": order_id, "success": False, "message": str(exc)})
if any(item["success"] for item in results):
_publish_change(current, scope="orders", action="order.status_batch")
return {"results": results, "success": all(item["success"] for item in results)}
@router.post("/external-orders/activity/query")
def query_external_orders_activity(
ids: list[int],
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
results: list[dict] = []
for order_id in ids:
try:
order = collab.get_order(db, order_id)
result = collab.query_activity(db, current, order, source="batch")
results.append(
{
"id": order_id,
"success": True,
"verdict": result["verdict"],
"message": result["message"],
}
)
except Exception as exc: # noqa: BLE001
db.rollback()
results.append({"id": order_id, "success": False, "message": str(exc)})
if any(item["success"] for item in results):
_publish_change(current, scope="orders", action="order.activity_batch")
return {"results": results, "success": all(item["success"] for item in results)}
@router.post("/external-orders/{order_id}/images", response_model=DouyuCollabOrderOut)
def update_external_order_images(
order_id: int,
req: DouyuCollabImagesRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
order = collab.get_order(db, order_id)
collab.update_order(
db,
current,
order,
{"home_images": req.home_images},
expected_version=req.expected_version,
)
_publish_change(current, scope="orders", action="order.images")
return _one(db, order)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post("/external-orders/archive")
def archive_external_orders(
req: DouyuCollabArchiveRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
result = collab.archive_orders(db, current, req.ids, reason=req.reason)
if result.get("archived"):
_publish_change(current, scope="orders", action="order.archive")
return result
@router.post("/external-orders/unarchive")
def unarchive_external_orders(
ids: list[int],
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
result = collab.unarchive_orders(db, current, ids)
if result.get("unarchived"):
_publish_change(current, scope="orders", action="order.unarchive")
return result
@router.get(
"/external-orders/{order_id}/timeline",
response_model=list[DouyuCollabTimelineOut],
)
def external_order_timeline(
order_id: int,
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
return collab.order_timeline(db, order_id, limit=limit)
@router.get(
"/external-orders/{order_id}/metrics",
response_model=list[DouyuCollabMetricOut],
)
def external_order_metrics(
order_id: int,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
return collab.order_metrics(db, order_id)
@router.delete("/external-orders/{order_id}")
def delete_external_order(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:manage")),
):
try:
result = collab.delete_order(db, current, collab.get_order(db, order_id))
_publish_change(current, scope="orders", action="order.delete")
return result
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.get("/collab-shops", response_model=list[DouyuCollabShopOut])
def list_collab_shops(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
rows = collab.list_shops(db)
return [_shop_out(shop) for shop in rows]
@router.post("/collab-shops", response_model=DouyuCollabShopOut)
def create_collab_shop(
req: DouyuCollabShopCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
shop = collab.create_shop(db, current, name=req.name, remark=req.remark)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="shops", action="shop.create")
return _shop_out(shop)
@router.put("/collab-shops/{shop_id}", response_model=DouyuCollabShopOut)
def update_collab_shop(
shop_id: int,
req: DouyuCollabShopUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:edit")),
):
try:
shop = collab.get_shop(db, shop_id)
collab.update_shop(db, current, shop, req.model_dump(exclude_unset=True))
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="shops", action="shop.update")
return _shop_out(shop)
@router.delete("/collab-shops/{shop_id}")
def delete_collab_shop(
shop_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:manage")),
):
try:
result = collab.delete_shop(db, current, collab.get_shop(db, shop_id))
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="shops", action="shop.delete")
return {**result, "id": shop_id}
+244
View File
@@ -1669,3 +1669,247 @@ class HuyaExternalOrderAuditOut(BaseModel):
detail: str = ""
success: bool | None = None
created_at: datetime | None = None
# ==================== 斗鱼外部单协作 ====================
class DouyuCollabShopCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
remark: str = Field("", max_length=255)
class DouyuCollabShopUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=64)
sort_order: int | None = None
is_active: bool | None = None
remark: str | None = Field(None, max_length=255)
class DouyuCollabShopOut(BaseModel):
id: int
name: str
sort_order: int = 0
is_active: bool = True
remark: str = ""
created_by: int | None = None
updated_by: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class DouyuCollabGoodsOut(BaseModel):
commodity_id: str = ""
name: str = ""
score: int | None = None
status: str = ""
open_status: int | None = None
start_time: int | None = None
end_time: int | None = None
mark: str = ""
count: int | None = None
elite_limited: int | None = None
is_limited: bool = False
raw: dict[str, Any] | None = None
class DouyuCollabAccountLookupRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=128)
with_goods: bool = True
class DouyuCollabAccountLookupOut(BaseModel):
found: bool = False
ok: bool = False
message: str = ""
errors: list[str] = Field(default_factory=list)
account_id: int | None = None
account_username: str = ""
account_uid: str = ""
account_nickname: str = ""
ck_status: str = ""
ck_status_label: str = ""
manual_type: int | None = None
manual_score: int | None = None
expire_time: int | None = None
manual_opened: bool = False
bind_game_account: str = ""
bind_game_role: str = ""
bind_game_channel: str = ""
fish_fin_balance: int | None = None
goods: list[DouyuCollabGoodsOut] = Field(default_factory=list)
goods_count: int = 0
orders_using_account: list[dict[str, Any]] = Field(default_factory=list)
class DouyuCollabOrderCreate(BaseModel):
order_no: str = Field(..., min_length=1, max_length=64)
shop: str = Field("", max_length=64)
owner_id: int | None = None
account_username: str = Field("", max_length=128)
gift_name: str = Field("", max_length=128)
commodity_id: str = Field("", max_length=64)
required_points: int | None = Field(None, ge=0)
home_images: list[str] = Field(default_factory=list)
remark: str = Field("", max_length=512)
draft: bool = False
require_verified: bool = True
class DouyuCollabOrderUpdate(BaseModel):
order_no: str | None = Field(None, max_length=64)
shop: str | None = Field(None, max_length=64)
owner_id: int | None = None
account_username: str | None = Field(None, max_length=128)
gift_name: str | None = Field(None, max_length=128)
commodity_id: str | None = Field(None, max_length=64)
required_points: int | None = Field(None, ge=0)
home_images: list[str] | None = None
remark: str | None = Field(None, max_length=512)
expected_version: int | None = None
class DouyuCollabStatusRequest(BaseModel):
status: str = Field(..., min_length=1, max_length=16)
reason: str = Field("", max_length=256)
rollback: bool = False
expected_version: int | None = None
class DouyuCollabStatusBatchRequest(BaseModel):
ids: list[int] = Field(default_factory=list)
status: str = Field(..., min_length=1, max_length=16)
reason: str = Field("", max_length=256)
rollback: bool = False
class DouyuCollabAccountRequest(BaseModel):
account_username: str = Field(..., min_length=1, max_length=128)
expected_version: int | None = None
class DouyuCollabImagesRequest(BaseModel):
home_images: list[str] = Field(default_factory=list)
expected_version: int | None = None
class DouyuCollabArchiveRequest(BaseModel):
ids: list[int] = Field(default_factory=list)
reason: str = Field("", max_length=256)
class DouyuCollabOrderOut(BaseModel):
id: int
order_no: str
shop: str = ""
owner_id: int | None = None
owner_name: str = ""
account_id: int | None = None
account_username: str = ""
account_uid: str = ""
account_nickname: str = ""
account_exists: bool = False
has_cookie: bool = False
account_ck_status: str = ""
account_status: str = ""
bind_game_account: str = ""
bind_game_role: str = ""
bind_game_channel: str = ""
bind_snapshot_at: datetime | None = None
home_images: list[str] = Field(default_factory=list)
home_image_urls: list[str] = Field(default_factory=list)
home_image_thumb_urls: list[str] = Field(default_factory=list)
gift_name: str = ""
commodity_id: str = ""
required_points: int | None = None
remark: str = ""
status: str = "incomplete"
status_label: str = ""
next_status: str | None = None
missing_fields: list[str] = Field(default_factory=list)
fish_fin_balance: int | None = None
points_balance: int | None = None
points_required_total: int | None = None
points_surplus: int | None = None
points_threshold: int | None = None
points_checked_at: datetime | None = None
points_check_reason: str = ""
points_check_message: str = ""
points_check_detail: dict[str, Any] | None = None
recharged_at: datetime | None = None
scripted_at: datetime | None = None
done_at: datetime | None = None
void_reason: str = ""
is_archived: bool = False
archived_at: datetime | None = None
archived_by: int | None = None
archived_by_name: str = ""
archive_reason: str = ""
created_by: int | None = None
created_by_name: str = ""
updated_by: int | None = None
updated_by_name: str = ""
version: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
account_cluster: dict[str, Any] | None = None
last_metric_at: datetime | None = None
last_metric_message: str = ""
last_verdict: str = ""
last_verdict_label: str = ""
last_records_total: int = 0
last_records_matched: int = 0
class DouyuCollabOrderListOut(BaseModel):
items: list[DouyuCollabOrderOut]
total: int
page: int
page_size: int
class DouyuCollabSummaryOut(BaseModel):
total: int
by_status: dict[str, int]
account_missing: int = 0
ck_invalid: int = 0
archived_total: int = 0
class DouyuCollabOwnerOut(BaseModel):
id: int
username: str
class DouyuCollabMetricOut(BaseModel):
id: int
order_id: int
order_no: str = ""
account_username: str = ""
fish_fin_balance: int | None = None
points_balance: int | None = None
points_required_total: int | None = None
points_surplus: int | None = None
records_total: int = 0
records_matched: int = 0
verdict: str = ""
verdict_label: str = ""
message: str = ""
source: str = ""
created_at: datetime | None = None
class DouyuCollabTimelineOut(BaseModel):
id: int
action: str
username: str = ""
detail: str = ""
success: bool | None = None
created_at: datetime | None = None
@@ -0,0 +1,89 @@
"""斗鱼外部单协作台账的进程内实时事件总线。
写接口提交成功后只发布「台账失效」事件,前端收到后按自己的筛选静默重拉,
不在事件里携带整行数据。当前生产单 worker,进程内队列足够;多实例时再换 Redis。
"""
from __future__ import annotations
import asyncio
import itertools
import threading
from datetime import UTC, datetime
from typing import Any
class CollabEventHub:
def __init__(self) -> None:
self._lock = threading.Lock()
self._revisions = itertools.count(1)
self._current_revision = 0
self._loop: asyncio.AbstractEventLoop | None = None
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
loop = asyncio.get_running_loop()
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
with self._lock:
self._loop = loop
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
with self._lock:
self._subscribers.discard(queue)
def ready_event(self) -> dict[str, Any]:
with self._lock:
revision = self._current_revision
return {"type": "collab.ready", "revision": revision}
def ping_event(self) -> dict[str, Any]:
with self._lock:
revision = self._current_revision
return {"type": "collab.ping", "revision": revision}
def publish(
self,
*,
scope: str,
action: str,
actor_id: int | None = None,
) -> dict[str, Any]:
with self._lock:
revision = next(self._revisions)
self._current_revision = revision
loop = self._loop
subscribers = list(self._subscribers)
event = {
"type": "collab.invalidate",
"revision": revision,
"scope": scope,
"action": action,
"actor_id": actor_id,
"at": datetime.now(UTC).isoformat(),
}
if loop is None or not subscribers:
return event
for queue in subscribers:
try:
loop.call_soon_threadsafe(self._offer, queue, event)
except RuntimeError:
continue
return event
@staticmethod
def _offer(queue: asyncio.Queue[dict[str, Any]], event: dict[str, Any]) -> None:
while queue.full():
try:
queue.get_nowait()
except asyncio.QueueEmpty:
break
try:
queue.put_nowait(event)
except asyncio.QueueFull:
pass
collab_events = CollabEventHub()
File diff suppressed because it is too large Load Diff
+2
View File
@@ -21,6 +21,7 @@ const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage'));
const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage'));
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
const DouyuCollabPage = lazy(() => import('./pages/DouyuCollabPage'));
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
@@ -94,6 +95,7 @@ function AppContent() {
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage key="elite" handbook="elite" />)} />
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} />
<Route path="douyu/peace" element={lazyRoute(<DouyuTasksPage key="peace" handbook="peace" />)} />
<Route path="douyu/collab" element={lazyRoute(<DouyuCollabPage />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/device-bindings" element={lazyRoute(<HuyaDeviceBindingsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
+326
View File
@@ -0,0 +1,326 @@
import api from './client';
export type DouyuCollabStatus =
| 'incomplete'
| 'to_recharge'
| 'recharged'
| 'scripted'
| 'done'
| 'points_error'
| 'failed'
| 'void';
export interface DouyuCollabGoods {
commodity_id: string;
name: string;
score: number | null;
status: string;
open_status: number | null;
start_time: number | null;
end_time: number | null;
mark: string;
count: number | null;
elite_limited: number | null;
is_limited: boolean;
raw?: Record<string, unknown>;
}
export interface DouyuCollabOrder {
id: number;
order_no: string;
shop: string;
owner_id: number | null;
owner_name: string;
account_id: number | null;
account_username: string;
account_uid: string;
account_nickname: string;
account_exists: boolean;
has_cookie: boolean;
account_ck_status: string;
account_status: string;
bind_game_account: string;
bind_game_role: string;
bind_game_channel: string;
bind_snapshot_at: string | null;
home_images: string[];
home_image_urls: string[];
home_image_thumb_urls: string[];
gift_name: string;
commodity_id: string;
required_points: number | null;
remark: string;
status: DouyuCollabStatus;
status_label: string;
next_status: DouyuCollabStatus | null;
missing_fields: string[];
fish_fin_balance: number | null;
points_balance: number | null;
points_required_total: number | null;
points_surplus: number | null;
points_threshold: number | null;
points_checked_at: string | null;
points_check_reason: string;
points_check_message: string;
points_check_detail: Record<string, unknown> | null;
recharged_at: string | null;
scripted_at: string | null;
done_at: string | null;
void_reason: string;
is_archived: boolean;
archived_at: string | null;
archived_by: number | null;
archived_by_name: string;
archive_reason: string;
created_by: number | null;
created_by_name: string;
updated_by: number | null;
updated_by_name: string;
version: number;
created_at: string | null;
updated_at: string | null;
account_cluster: {
total: number;
orders: Array<{
id: number;
order_no: string;
status: DouyuCollabStatus;
status_label: string;
gift_name: string;
}>;
} | null;
last_metric_at: string | null;
last_metric_message: string;
last_verdict: string;
last_verdict_label: string;
last_records_total: number;
last_records_matched: number;
}
export interface DouyuCollabOrderList {
items: DouyuCollabOrder[];
total: number;
page: number;
page_size: number;
}
export interface DouyuCollabSummary {
total: number;
by_status: Record<string, number>;
account_missing: number;
ck_invalid: number;
archived_total: number;
}
export interface DouyuCollabOwner {
id: number;
username: string;
}
export interface DouyuCollabShop {
id: number;
name: string;
sort_order: number;
is_active: boolean;
remark: string;
}
export interface DouyuCollabAccountLookup {
found: boolean;
ok: boolean;
message: string;
errors: string[];
account_id: number | null;
account_username: string;
account_uid: string;
account_nickname: string;
ck_status: string;
ck_status_label: string;
manual_type: number | null;
manual_score: number | null;
expire_time: number | null;
manual_opened: boolean;
bind_game_account: string;
bind_game_role: string;
bind_game_channel: string;
fish_fin_balance: number | null;
goods: DouyuCollabGoods[];
goods_count: number;
orders_using_account: Array<{
id: number;
order_no: string;
status: DouyuCollabStatus;
}>;
}
export interface DouyuCollabOrderCreate {
order_no: string;
shop?: string;
owner_id?: number | null;
account_username?: string;
gift_name?: string;
commodity_id?: string;
required_points?: number | null;
home_images?: string[];
remark?: string;
draft?: boolean;
require_verified?: boolean;
}
export interface DouyuCollabOrderUpdate {
order_no?: string;
shop?: string;
owner_id?: number | null;
account_username?: string;
gift_name?: string;
commodity_id?: string;
required_points?: number | null;
home_images?: string[];
remark?: string;
expected_version?: number;
}
export interface DouyuCollabMetric {
id: number;
order_id: number;
order_no: string;
account_username: string;
fish_fin_balance: number | null;
points_balance: number | null;
points_required_total: number | null;
points_surplus: number | null;
records_total: number;
records_matched: number;
verdict: string;
verdict_label: string;
message: string;
source: string;
created_at: string | null;
}
export interface DouyuCollabTimeline {
id: number;
action: string;
username: string;
detail: string;
success: boolean | null;
created_at: string | null;
}
export interface DouyuCollabQueryResult {
order: DouyuCollabOrder;
metric: DouyuCollabMetric;
verdict: string;
message: string;
points_reason: string;
fish_fin_balance: number | null;
points_balance: number | null;
required_total: number | null;
surplus: number | null;
errors: string[];
}
export const douyuCollabApi = {
listOrders: (params: {
page?: number;
page_size?: number;
include_void?: boolean;
archive_scope?: 'active' | 'archived' | 'all';
status?: string;
shop?: string;
owner_id?: number;
gift_name?: string;
keyword?: string;
ck_status?: string;
} = {}) =>
api.get<DouyuCollabOrderList, DouyuCollabOrderList>('/douyu/external-orders', { params }),
summary: (params: { include_void?: boolean; archive_scope?: 'active' | 'archived' | 'all' } = {}) =>
api.get<DouyuCollabSummary, DouyuCollabSummary>('/douyu/external-orders/summary', { params }),
owners: () => api.get<DouyuCollabOwner[], DouyuCollabOwner[]>('/douyu/external-orders/owners'),
shops: () => api.get<string[], string[]>('/douyu/external-orders/shops'),
shopRecords: () => api.get<DouyuCollabShop[], DouyuCollabShop[]>('/douyu/collab-shops'),
createShop: (data: { name: string; remark?: string }) =>
api.post<DouyuCollabShop, DouyuCollabShop>('/douyu/collab-shops', data),
updateShop: (
id: number,
data: { name?: string; sort_order?: number; is_active?: boolean; remark?: string },
) => api.put<DouyuCollabShop, DouyuCollabShop>(`/douyu/collab-shops/${id}`, data),
deleteShop: (id: number) =>
api.delete<{ success: boolean; name: string; id: number }, { success: boolean; name: string; id: number }>(
`/douyu/collab-shops/${id}`,
),
gifts: () => api.get<string[], string[]>('/douyu/external-orders/gifts'),
goods: () => api.get<DouyuCollabGoods[], DouyuCollabGoods[]>('/douyu/external-orders/goods'),
refreshGoods: (accountId?: number) =>
api.post<{ count: number; goods: DouyuCollabGoods[] }, { count: number; goods: DouyuCollabGoods[] }>(
'/douyu/external-orders/goods/refresh',
undefined,
{ params: accountId ? { account_id: accountId } : undefined },
),
lookupAccount: (username: string, withGoods = true) =>
api.post<DouyuCollabAccountLookup, DouyuCollabAccountLookup>(
'/douyu/external-orders/accounts/lookup',
{ username, with_goods: withGoods },
),
createOrder: (data: DouyuCollabOrderCreate) =>
api.post<DouyuCollabOrder, DouyuCollabOrder>('/douyu/external-orders', data),
getOrder: (id: number) =>
api.get<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}`),
updateOrder: (id: number, data: DouyuCollabOrderUpdate) =>
api.put<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}`, data),
setAccount: (id: number, account_username: string, expected_version?: number) =>
api.post<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}/account`, {
account_username,
expected_version,
}),
verifyOrder: (id: number) =>
api.post<{ order: DouyuCollabOrder; probe: DouyuCollabAccountLookup }, { order: DouyuCollabOrder; probe: DouyuCollabAccountLookup }>(
`/douyu/external-orders/${id}/verify`,
),
batchVerify: (ids: number[]) =>
api.post<
{ results: Array<{ id: number; success: boolean; ck_status?: string; role?: string; message?: string }>; success: boolean },
{ results: Array<{ id: number; success: boolean; ck_status?: string; role?: string; message?: string }>; success: boolean }
>('/douyu/external-orders/verify/batch', ids),
queryActivity: (id: number) =>
api.post<DouyuCollabQueryResult, DouyuCollabQueryResult>(
`/douyu/external-orders/${id}/activity/query`,
),
setStatus: (id: number, data: {
status: DouyuCollabStatus;
reason?: string;
rollback?: boolean;
expected_version?: number;
}) => api.post<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}/status`, data),
batchStatus: (data: {
ids: number[];
status: DouyuCollabStatus;
reason?: string;
rollback?: boolean;
}) => api.post<{ results: Array<{ id: number; success: boolean; message?: string }>; success: boolean }, { results: Array<{ id: number; success: boolean; message?: string }>; success: boolean }>(
'/douyu/external-orders/status/batch',
data,
),
batchActivity: (ids: number[]) =>
api.post<{ results: Array<Record<string, unknown>>; success: boolean }, { results: Array<Record<string, unknown>>; success: boolean }>(
'/douyu/external-orders/activity/query',
ids,
),
updateImages: (id: number, home_images: string[], expected_version?: number) =>
api.post<DouyuCollabOrder, DouyuCollabOrder>(`/douyu/external-orders/${id}/images`, {
home_images,
expected_version,
}),
archive: (ids: number[], reason = '') =>
api.post<{ archived: number }, { archived: number }>('/douyu/external-orders/archive', { ids, reason }),
unarchive: (ids: number[]) =>
api.post<{ unarchived: number }, { unarchived: number }>('/douyu/external-orders/unarchive', ids),
timeline: (id: number) =>
api.get<DouyuCollabTimeline[], DouyuCollabTimeline[]>(`/douyu/external-orders/${id}/timeline`),
metrics: (id: number) =>
api.get<DouyuCollabMetric[], DouyuCollabMetric[]>(`/douyu/external-orders/${id}/metrics`),
deleteOrder: (id: number) =>
api.delete<{ success: boolean; order_no: string }, { success: boolean; order_no: string }>(
`/douyu/external-orders/${id}`,
),
};
+1
View File
@@ -8,6 +8,7 @@ export { automationApi } from './automation';
export { cookieApi } from './cookies';
export { dashboardApi } from './dashboard';
export { douyuApi } from './douyu';
export { douyuCollabApi } from './douyuCollab';
export { fileApi } from './files';
export { huyaApi } from './huya';
export { loginApi } from './login';
@@ -0,0 +1,286 @@
import { useEffect, useState } from 'react';
import {
Button,
Empty,
Input,
InputNumber,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import { douyuCollabApi } from '../api/modules';
import type { DouyuCollabShop } from '../api/douyuCollab';
import { message } from '../utils/antdMessage';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
/**
* 斗鱼店铺管理:先维护好店铺,新建协作单时直接点选。
*
* - 停用后不再出现在新建下拉里,历史订单不受影响;
* - 重命名会自动同步已有订单上的店铺名,避免筛选出现新旧两个名字;
* - 删除只删字典行,仍有订单使用时后端会拒绝(更安全)。
*/
export default function DouyuCollabShopModal({
open,
shops,
canEdit,
canManage,
onClose,
onChanged,
}: {
open: boolean;
shops: DouyuCollabShop[];
canEdit: boolean;
canManage: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const [renameTarget, setRenameTarget] = useState<DouyuCollabShop | null>(null);
const [renameValue, setRenameValue] = useState('');
useEffect(() => {
if (!open) {
setNewName('');
setRenameTarget(null);
}
}, [open]);
const create = async () => {
const name = newName.trim();
if (!name) {
message.warning('请先输入店铺名');
return;
}
setBusy(true);
try {
await douyuCollabApi.createShop({ name });
message.success(`已添加店铺「${name}」`);
setNewName('');
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const patch = async (
shop: DouyuCollabShop,
payload: { sort_order?: number; is_active?: boolean; remark?: string },
) => {
setBusy(true);
try {
await douyuCollabApi.updateShop(shop.id, payload);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
onChanged();
} finally {
setBusy(false);
}
};
const submitRename = async () => {
if (!renameTarget) return;
const name = renameValue.trim();
if (!name || name === renameTarget.name) {
setRenameTarget(null);
return;
}
setBusy(true);
try {
await douyuCollabApi.updateShop(renameTarget.id, { name });
message.success(`已重命名为「${name}」,已有订单里的店铺名也已同步`);
setRenameTarget(null);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const remove = async (shop: DouyuCollabShop) => {
setBusy(true);
try {
await douyuCollabApi.deleteShop(shop.id);
message.success(`已删除店铺「${shop.name}」`);
onChanged();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const columns: TableProps<DouyuCollabShop>['columns'] = [
{
title: '店铺名',
dataIndex: 'name',
width: 220,
render: (value: string, row) => (
<Space size={4}>
<Text strong>{value}</Text>
{!row.is_active && <Tag>已停用</Tag>}
{canEdit && (
<Tooltip title="重命名(已有订单同步更新)">
<Button
size="small"
type="text"
icon={<EditOutlined />}
disabled={busy}
onClick={() => {
setRenameTarget(row);
setRenameValue(row.name);
}}
/>
</Tooltip>
)}
</Space>
),
},
{
title: '排序',
dataIndex: 'sort_order',
width: 100,
render: (value: number, row) => (
<InputNumber
size="small"
value={value}
disabled={!canEdit || busy}
onChange={(next) => {
if (next === null || next === value) return;
void patch(row, { sort_order: Number(next) });
}}
/>
),
},
{
title: '新建时可选',
dataIndex: 'is_active',
width: 120,
align: 'center',
render: (value: boolean, row) => (
<Switch
size="small"
checked={value}
disabled={!canEdit || busy}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(checked) => void patch(row, { is_active: checked })}
/>
),
},
{
title: '备注',
dataIndex: 'remark',
render: (value: string, row) => (
<Input
size="small"
defaultValue={value}
disabled={!canEdit || busy}
placeholder="备注(失焦保存)"
onBlur={(event) => {
const next = event.target.value.trim();
if (next !== value) void patch(row, { remark: next });
}}
/>
),
},
{
title: '操作',
width: 80,
align: 'center',
render: (_, row) =>
canManage ? (
<Popconfirm
title={`删除店铺「${row.name}」?`}
description="仍有协作单使用该店铺时会被拒绝,避免误删历史归属"
onConfirm={() => void remove(row)}
disabled={busy}
>
<Button size="small" danger type="text" icon={<DeleteOutlined />} disabled={busy} />
</Popconfirm>
) : (
<Text type="secondary">-</Text>
),
},
];
return (
<>
<Modal
title="店铺管理"
open={open}
onCancel={onClose}
footer={<Button onClick={onClose}>关闭</Button>}
width={820}
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Text type="secondary">
先在这里定义店铺,新建协作单时可以直接点选。停用只是不再出现在新建选择里,历史订单不受影响;
重命名会自动同步已有订单,已有订单仍在使用的店铺不能删除。
</Text>
{canEdit && (
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入店铺名,回车添加"
value={newName}
maxLength={64}
onChange={(event) => setNewName(event.target.value)}
onPressEnter={() => void create()}
/>
<Button type="primary" icon={<PlusOutlined />} loading={busy} onClick={() => void create()}>
添加
</Button>
</Space.Compact>
)}
<Table<DouyuCollabShop>
rowKey="id"
size="small"
columns={columns}
dataSource={shops}
pagination={false}
scroll={{ y: 340 }}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有店铺,先加一个" />,
}}
/>
</Space>
</Modal>
<Modal
title={renameTarget ? `重命名「${renameTarget.name}」` : '重命名店铺'}
open={!!renameTarget}
onCancel={() => setRenameTarget(null)}
onOk={() => void submitRename()}
confirmLoading={busy}
okText="保存"
destroyOnHidden
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
value={renameValue}
maxLength={64}
autoFocus
onChange={(event) => setRenameValue(event.target.value)}
onPressEnter={() => void submitRename()}
/>
<Text type="secondary">保存后会同时更新已有协作单上的店铺名。</Text>
</Space>
</Modal>
</>
);
}
@@ -0,0 +1,119 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Button, Input, Space, Tag, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { DouyuCollabShop } from '../api/douyuCollab';
const { Text } = Typography;
/**
* 斗鱼店铺选择器:一个店铺一个按钮,筛选和新建弹窗共用。
*
* - 当前选中的按钮显示主题色,一眼看得出选的是谁;
* - `allowClear` 为真时多一个「全部 / 不填」按钮;
* - `allowCustom` 允许直接输入新店铺名,后端会在建单时自动登记;
* - 停用店铺可选择是否展示(筛选时展示,避免看不到历史值)。
*/
export default function DouyuCollabShopPicker({
value,
shops,
disabled,
includeInactive = false,
allowCustom = true,
allowClear = true,
clearLabel = '不填',
size = 'small',
onChange,
}: {
value?: string | null;
shops: DouyuCollabShop[];
disabled?: boolean;
includeInactive?: boolean;
allowCustom?: boolean;
allowClear?: boolean;
clearLabel?: string;
size?: 'small' | 'middle' | 'large';
onChange?: (value: string) => void;
}) {
const [customOpen, setCustomOpen] = useState(false);
const [customName, setCustomName] = useState('');
useEffect(() => {
if (!customOpen) setCustomName('');
}, [customOpen]);
const current = value ?? '';
const visible = shops.filter((shop) => includeInactive || shop.is_active);
const options: { value: string; label: ReactNode }[] = visible.map((shop) => ({
value: shop.name,
label: (
<Space size={4}>
{shop.name}
{!shop.is_active && <Tag style={{ margin: 0 }}>停用</Tag>}
</Space>
),
}));
if (current && !visible.some((shop) => shop.name === current)) {
options.unshift({ value: current, label: current });
}
const submitCustom = () => {
const name = customName.trim();
if (!name) {
setCustomOpen(false);
return;
}
onChange?.(name);
setCustomOpen(false);
};
return (
<Space direction="vertical" size={8}>
<Space wrap size={size === 'large' ? 12 : 8}>
{options.map((option) => (
<Button
key={option.value}
size={size}
type={option.value === current ? 'primary' : 'default'}
disabled={disabled}
onClick={() => onChange?.(option.value)}
>
{option.label}
</Button>
))}
{allowClear && (
<Button
size={size}
type={current === '' ? 'primary' : 'default'}
disabled={disabled}
onClick={() => onChange?.('')}
>
<Text type={current === '' ? undefined : 'secondary'}>{clearLabel}</Text>
</Button>
)}
{allowCustom && !disabled && !customOpen && (
<Button size={size} type="link" icon={<PlusOutlined />} onClick={() => setCustomOpen(true)}>
新店
</Button>
)}
</Space>
{customOpen && (
<Input
size={size}
autoFocus
maxLength={64}
placeholder="输入新店铺名,回车确认"
value={customName}
onChange={(event) => setCustomName(event.target.value)}
onPressEnter={submitCustom}
onBlur={submitCustom}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setCustomName('');
setCustomOpen(false);
}
}}
/>
)}
</Space>
);
}
+6 -3
View File
@@ -12,9 +12,11 @@ export interface CollabRealtimeEvent {
interface UseCollabRealtimeOptions {
enabled: boolean;
onInvalidate: (event: CollabRealtimeEvent) => void;
/** 默认是虎牙协作台账;斗鱼页面传入自己的 WS 路径。 */
path?: string;
}
const WS_PATH = '/api/huya/external-orders/ws';
const DEFAULT_WS_PATH = '/api/huya/external-orders/ws';
const HEARTBEAT_TIMEOUT_MS = 60_000;
const MAX_RECONNECT_DELAY_MS = 30_000;
@@ -32,6 +34,7 @@ function websocketUrl(path: string): string {
export function useCollabRealtime({
enabled,
onInvalidate,
path = DEFAULT_WS_PATH,
}: UseCollabRealtimeOptions) {
const [connected, setConnected] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
@@ -129,7 +132,7 @@ export function useCollabRealtime({
return;
}
const nextSocket = new WebSocket(websocketUrl(WS_PATH));
const nextSocket = new WebSocket(websocketUrl(path));
socket = nextSocket;
nextSocket.onopen = () => {
@@ -179,7 +182,7 @@ export function useCollabRealtime({
setConnected(false);
setReconnecting(false);
};
}, [enabled]);
}, [enabled, path]);
return { connected, reconnecting };
}
+5
View File
@@ -85,6 +85,11 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
douyuItems.push({ key: '/douyu/peace', label: '和平小店', icon: <ShopOutlined /> });
}
// 外部单协作:权限独立,客服没有 douyu:task 也要能进共享台账。
if (can('douyu:collab:view')) {
douyuItems.push({ key: '/douyu/collab', label: '外部单协作', icon: <FileExcelOutlined /> });
}
// 虎牙:客服只保留浏览器兑换;管理员/运营显示完整入口
if (can('huya:task')) {
if (user?.role === 'support') {
File diff suppressed because it is too large Load Diff