feat: 斗鱼外部单协作台账与店铺实时同步
This commit is contained in:
@@ -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}"]
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
"""虎牙账号"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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 />)} />
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user