633 lines
21 KiB
Python
633 lines
21 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))
|
|
|
|
|
|
@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 {
|
|
"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}
|