feat(huya): 协作台账增加 WebSocket 实时同步
- 后端新增进程内事件总线与 /api/huya/external-orders/ws\n- 所有写接口成功后发布轻量失效事件\n- 前端自动重连、静默刷新、断线兜底轮询
This commit is contained in:
@@ -17,12 +17,22 @@ CK 验证是**只读探针**(``POST .../verify`` 单条、``POST .../ck/verify
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
import asyncio
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Query,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import get_db
|
||||
from ...deps import require_permission
|
||||
from ...deps import authenticate_websocket, require_permission
|
||||
from ...models import AuditLog, HuyaExternalOrder, User
|
||||
from ...permissions import get_user_permissions
|
||||
from ...schemas import (
|
||||
HuyaCollabCkPreviewOut,
|
||||
HuyaCollabCkPreviewRequest,
|
||||
@@ -53,6 +63,7 @@ from ...schemas import (
|
||||
HuyaExternalOrderVerifyRequest,
|
||||
)
|
||||
from ...services import huya_collab_service as collab
|
||||
from ...services.huya_collab_events import collab_events
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -75,6 +86,11 @@ def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def _publish_change(current: User, *, scope: str, action: str) -> None:
|
||||
"""写操作提交成功后发布失效事件。只带轻量信息,不推整行数据。"""
|
||||
collab_events.publish(scope=scope, action=action, actor_id=int(current.id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 字面量路径必须声明在 /{order_id} 之前,否则 "summary" 会被当成 id
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -154,6 +170,7 @@ def create_collab_shop(
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="shops", action="shop.create")
|
||||
return collab.serialize_shop(shop)
|
||||
|
||||
|
||||
@@ -175,6 +192,7 @@ def update_collab_shop(
|
||||
collab.update_shop(db, current, shop, payload, rename_orders=rename_orders)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="all", action="shop.update")
|
||||
return collab.serialize_shop(shop)
|
||||
|
||||
|
||||
@@ -190,6 +208,7 @@ def delete_collab_shop(
|
||||
snapshot = collab.delete_shop(db, current, shop)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="shops", action="shop.delete")
|
||||
return {"success": True, "name": snapshot["name"]}
|
||||
|
||||
|
||||
@@ -268,6 +287,7 @@ def create_external_order(
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="order.create")
|
||||
return _serialize_one(db, order)
|
||||
|
||||
|
||||
@@ -284,7 +304,10 @@ def import_external_orders(
|
||||
.filter(User.is_active.is_(True))
|
||||
.all()
|
||||
}
|
||||
return collab.import_orders(db, current, req.text, owner_by_name=owners)
|
||||
result = collab.import_orders(db, current, req.text, owner_by_name=owners)
|
||||
if result.get("created") or result.get("updated"):
|
||||
_publish_change(current, scope="orders", action="order.import")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/external-orders/ck/preview", response_model=HuyaCollabCkPreviewOut)
|
||||
@@ -340,6 +363,7 @@ def batch_update_external_order_status(
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
result["target_label"] = collab.ORDER_STATUS_LABELS.get(req.status, req.status)
|
||||
_publish_change(current, scope="orders", action="orders.status_batch")
|
||||
return result
|
||||
|
||||
|
||||
@@ -367,6 +391,7 @@ def batch_query_external_order_activity(
|
||||
)
|
||||
auto = item.get("auto_status") or ""
|
||||
item["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto)
|
||||
_publish_change(current, scope="orders", action="orders.activity_query")
|
||||
return result
|
||||
|
||||
|
||||
@@ -383,9 +408,11 @@ def verify_external_orders_ck(
|
||||
"""
|
||||
try:
|
||||
orders = collab.list_orders_by_ids(db, req.ids)
|
||||
return collab.verify_ck_batch(db, current, orders)
|
||||
result = collab.verify_ck_batch(db, current, orders)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="orders.ck_verify")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/external-orders/ck/export", response_model=HuyaExternalOrderCkExportOut)
|
||||
@@ -407,6 +434,30 @@ def export_external_orders_ck(
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.websocket("/external-orders/ws")
|
||||
async def ws_external_orders(websocket: WebSocket):
|
||||
"""协作台账实时失效通知:客户端收到后按自己的筛选静默重拉。"""
|
||||
user = authenticate_websocket(websocket)
|
||||
if not user or "huya: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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 单行操作
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -472,6 +523,7 @@ def update_external_order(
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="order.update")
|
||||
return _serialize_one(db, order)
|
||||
|
||||
|
||||
@@ -496,6 +548,7 @@ def update_external_order_status(
|
||||
)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="order.status")
|
||||
return _serialize_one(db, order)
|
||||
|
||||
|
||||
@@ -523,6 +576,7 @@ def query_external_order_activity(
|
||||
)
|
||||
auto = result.get("auto_status") or ""
|
||||
result["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto)
|
||||
_publish_change(current, scope="orders", action="order.activity_query")
|
||||
return result
|
||||
|
||||
|
||||
@@ -561,6 +615,7 @@ def update_external_order_ck(
|
||||
collab.set_ck(db, current, order, req.ck_text)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="order.ck")
|
||||
return _serialize_one(db, order)
|
||||
|
||||
|
||||
@@ -584,6 +639,7 @@ def update_external_order_ck_status(
|
||||
collab.set_manual_ck_status(db, current, order, req.status, message=req.message)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="order.ck_status")
|
||||
return _serialize_one(db, order)
|
||||
|
||||
|
||||
@@ -603,6 +659,7 @@ def verify_external_order_ck(
|
||||
collab.verify_ck(db, current, order)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="order.ck_verify")
|
||||
return _serialize_one(db, order)
|
||||
|
||||
|
||||
@@ -620,6 +677,7 @@ def update_external_order_images(
|
||||
collab.set_images(db, current, order, req.slot, req.images)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="order.images")
|
||||
return _serialize_one(db, order)
|
||||
|
||||
|
||||
@@ -635,4 +693,5 @@ def delete_external_order(
|
||||
snapshot = collab.delete_order(db, current, order)
|
||||
except collab.CollabError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_publish_change(current, scope="orders", action="order.delete")
|
||||
return {"success": True, "order_no": snapshot["order_no"]}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""虎牙外部单协作台账的进程内实时事件总线。
|
||||
|
||||
写接口提交成功后只发布「台账失效」事件,前端收到后按自己的筛选条件静默重拉,
|
||||
不在事件里携带整行数据。当前生产是单 uvicorn worker,进程内队列足够;
|
||||
如果以后改成多 worker / 多实例,这里再换成 Redis Pub/Sub。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CollabEventHub:
|
||||
"""维护 revision 与当前 WebSocket 订阅队列。"""
|
||||
|
||||
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()
|
||||
|
||||
@property
|
||||
def revision(self) -> int:
|
||||
with self._lock:
|
||||
return self._current_revision
|
||||
|
||||
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
|
||||
"""注册一个 WebSocket 订阅者;必须在事件循环线程里调用。"""
|
||||
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,
|
||||
orders: list[dict[str, 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,
|
||||
"orders": orders or [],
|
||||
"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()
|
||||
Reference in New Issue
Block a user