Files
live-hub-py/web/backend/routers/douyu_collab.py
T

696 lines
23 KiB
Python

"""斗鱼外部单协作接口。
页面只做参数校验、权限和异常翻译;业务规则见
``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, get_current_user, require_permission
from ..models import User
from ..permissions import get_user_permissions
from ..schemas import (
DouyuCollabAccountLookupOut,
DouyuCollabAccountLookupRequest,
DouyuCollabAccountRequest,
DouyuCollabArchiveRequest,
DouyuCollabCkExportOut,
DouyuCollabCkExportRequest,
DouyuCollabGoodsOut,
DouyuCollabImagesRequest,
DouyuCollabMetricOut,
DouyuCollabOrderCreate,
DouyuCollabOrderListOut,
DouyuCollabOrderOut,
DouyuCollabOrderUpdate,
DouyuCollabOwnerOut,
DouyuCollabShopCreate,
DouyuCollabShopOut,
DouyuCollabShopUpdate,
DouyuCollabStatusBatchRequest,
DouyuCollabStatusRequest,
DouyuCollabSummaryOut,
DouyuCollabTimelineOut,
DouyuCollabUsernameExportOut,
DouyuCollabUsernameExportRequest,
)
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))
def _query_result_out(db: Session, result: dict) -> dict:
return {
"order": _one(db, result["order"]),
"metric": collab.serialize_metric(result["metric"]),
"scope": result["scope"],
"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"],
}
@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/ck/export", response_model=DouyuCollabCkExportOut)
def export_external_orders_ck(
req: DouyuCollabCkExportRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("douyu:collab:ck_export")),
):
"""勾选多行导出关联内部账号的 CK 明文(前端写剪贴板)。
斗鱼协作单不存 CK,导出的始终是关联账号的实时 ``cookie``。逐行写审计,
但审计里只记单号/账号/CK 状态,不把 CK 本体写进审计。
"""
try:
return collab.export_cks(db, current, req.ids)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post(
"/external-orders/{order_id}/ck/copy",
response_model=DouyuCollabCkExportOut,
)
def copy_external_order_ck(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""复制单行 CK 明文(仅超级管理员;与虎牙协作台账口径一致)。"""
if current.role != "super_admin":
raise HTTPException(status_code=403, detail="仅超级管理员可复制 CK")
try:
order = collab.get_order(db, order_id)
return collab.export_cks(db, current, [order.id])
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post(
"/external-orders/usernames/export",
response_model=DouyuCollabUsernameExportOut,
)
def export_external_order_usernames(
req: DouyuCollabUsernameExportRequest,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("douyu:collab:view")),
):
"""勾选多行导出关联的斗鱼用户名(前端写剪贴板)。
用户名不是凭据;能看列表的人本来就能看到,因此只需要查看权限。
"""
try:
return collab.export_usernames(db, req.ids)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@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 _query_result_out(db, result)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post("/external-orders/{order_id}/balances/query")
def query_external_order_balances(
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="balance_manual",
include_records=False,
)
_publish_change(current, scope="orders", action="order.balances")
return _query_result_out(db, result)
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/balances/query")
def query_external_orders_balances(
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="balance_batch",
include_records=False,
)
results.append(
{
"id": order_id,
"success": True,
"message": result["message"],
"fish_fin_balance": result["fish_fin_balance"],
"points_balance": result["points_balance"],
"points_reason": result["points_reason"],
}
)
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.balances_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}