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
+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