"""斗鱼外部单协作接口。 页面只做参数校验、权限和异常翻译;业务规则见 ``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, DouyuCollabGiftCreate, DouyuCollabGiftOut, DouyuCollabGiftUpdate, 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 _gift_out(gift) -> dict: return { "id": int(gift.id), "name": gift.name, "points": int(gift.points or 0), "sort_order": int(gift.sort_order or 0), "is_active": bool(gift.is_active), "remark": gift.remark or "", "created_by": gift.created_by, "updated_by": gift.updated_by, "created_at": gift.created_at, "updated_at": gift.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} @router.get("/collab-gifts", response_model=list[DouyuCollabGiftOut]) def list_collab_gifts( db: Session = Depends(get_db), _current: User = Depends(require_permission("douyu:collab:view")), ): return [_gift_out(gift) for gift in collab.list_gifts(db)] @router.post("/collab-gifts", response_model=DouyuCollabGiftOut) def create_collab_gift( req: DouyuCollabGiftCreate, db: Session = Depends(get_db), current: User = Depends(require_permission("douyu:collab:manage")), ): try: gift = collab.create_gift( db, current, name=req.name, points=req.points, remark=req.remark, ) except collab.CollabError as exc: raise _http_error(exc) from exc _publish_change(current, scope="gifts", action="gift.create") return _gift_out(gift) @router.put("/collab-gifts/{gift_id}", response_model=DouyuCollabGiftOut) def update_collab_gift( gift_id: int, req: DouyuCollabGiftUpdate, db: Session = Depends(get_db), current: User = Depends(require_permission("douyu:collab:manage")), ): try: gift = collab.get_gift(db, gift_id) collab.update_gift(db, current, gift, req.model_dump(exclude_unset=True)) except collab.CollabError as exc: raise _http_error(exc) from exc _publish_change(current, scope="gifts", action="gift.update") return _gift_out(gift) @router.delete("/collab-gifts/{gift_id}") def delete_collab_gift( gift_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("douyu:collab:manage")), ): try: result = collab.delete_gift(db, current, collab.get_gift(db, gift_id)) except collab.CollabError as exc: raise _http_error(exc) from exc _publish_change(current, scope="gifts", action="gift.delete") return {**result, "id": gift_id}