price_updates.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. """WebSocket:区域价格清单更新推送(客户端实时刷新)"""
  2. from __future__ import annotations
  3. import asyncio
  4. import logging
  5. from dataclasses import dataclass
  6. from typing import Dict, Set, Optional, Any
  7. import anyio
  8. from fastapi import WebSocket, WebSocketDisconnect
  9. from app.core.database import SessionLocal
  10. from app.core.auth import get_current_user_by_token
  11. logger = logging.getLogger(__name__)
  12. @dataclass(frozen=True)
  13. class PriceListUpdatedEvent:
  14. regionId: str
  15. priceListId: str
  16. priceVersion: int
  17. publishedTime: Optional[str] = None
  18. def to_message(self) -> dict:
  19. return {
  20. "event": "price_list_updated",
  21. "data": {
  22. "regionId": self.regionId,
  23. "priceListId": self.priceListId,
  24. "priceVersion": self.priceVersion,
  25. "publishedTime": self.publishedTime,
  26. },
  27. }
  28. class RegionWSManager:
  29. """按 region_id 管理 websocket 连接并广播。"""
  30. def __init__(self) -> None:
  31. self._lock = asyncio.Lock()
  32. self._rooms: Dict[str, Set[WebSocket]] = {}
  33. async def connect(self, websocket: WebSocket, *, token: str) -> dict:
  34. """鉴权并接入房间。返回 current_user dict。"""
  35. # 验证 token(包含 Redis 会话校验 + DB 用户校验)
  36. db = SessionLocal()
  37. try:
  38. current_user = await get_current_user_by_token(token, db)
  39. finally:
  40. db.close()
  41. region_id = (current_user.get("region_id") or "").strip()
  42. if not region_id:
  43. logger.warning("[ws-mini] reject: empty region_id user=%s", current_user.get("id"))
  44. await websocket.close(code=1008)
  45. raise RuntimeError("user has no region_id")
  46. await websocket.accept()
  47. async with self._lock:
  48. self._rooms.setdefault(region_id, set()).add(websocket)
  49. room_size = len(self._rooms.get(region_id, set()))
  50. logger.warning(
  51. "[ws-mini] connected user=%s role=%s region=%s room_size=%s",
  52. current_user.get("id"),
  53. current_user.get("role"),
  54. region_id,
  55. room_size,
  56. )
  57. return current_user
  58. async def disconnect(self, websocket: WebSocket, *, region_id: str) -> None:
  59. async with self._lock:
  60. s = self._rooms.get(region_id)
  61. if not s:
  62. return
  63. if websocket in s:
  64. s.remove(websocket)
  65. room_size = len(s)
  66. if not s:
  67. self._rooms.pop(region_id, None)
  68. logger.warning("[ws-mini] disconnected region=%s room_size=%s", region_id, room_size if "room_size" in locals() else 0)
  69. async def broadcast_region(self, region_id: str, message: dict) -> int:
  70. """向 region 房间广播消息。返回尝试发送的连接数。"""
  71. async with self._lock:
  72. targets = list(self._rooms.get(region_id, set()))
  73. if not targets:
  74. logger.warning("[ws-mini] broadcast skip: no targets region=%s event=%s", region_id, message.get("event"))
  75. return 0
  76. logger.warning(
  77. "[ws-mini] broadcasting region=%s event=%s target_count=%s",
  78. region_id,
  79. message.get("event"),
  80. len(targets),
  81. )
  82. dead: list[WebSocket] = []
  83. for ws in targets:
  84. try:
  85. await ws.send_json(message)
  86. except Exception:
  87. dead.append(ws)
  88. if dead:
  89. async with self._lock:
  90. room = self._rooms.get(region_id)
  91. if room:
  92. for ws in dead:
  93. room.discard(ws)
  94. if not room:
  95. self._rooms.pop(region_id, None)
  96. return len(targets)
  97. manager = RegionWSManager()
  98. async def ws_mini_price_updates(websocket: WebSocket) -> None:
  99. """小程序端 WebSocket 入口:/ws/mini"""
  100. token = (websocket.query_params.get("token") or "").strip()
  101. if not token:
  102. # 兼容 Authorization: Bearer <token>
  103. auth = (websocket.headers.get("authorization") or "").strip()
  104. if auth.lower().startswith("bearer "):
  105. token = auth[7:].strip()
  106. if not token:
  107. logger.warning("[ws-mini] reject: missing token ip=%s", websocket.client.host if websocket.client else "")
  108. await websocket.close(code=1008)
  109. return
  110. try:
  111. current_user = await manager.connect(websocket, token=token)
  112. except Exception:
  113. logger.exception("[ws-mini] connect failed")
  114. # connect 内部会 close;这里兜底
  115. try:
  116. await websocket.close(code=1008)
  117. except Exception:
  118. pass
  119. return
  120. region_id = (current_user.get("region_id") or "").strip()
  121. try:
  122. # 当前实现:仅服务器推送;客户端无需主动发消息。
  123. while True:
  124. await websocket.receive_text()
  125. except WebSocketDisconnect:
  126. logger.warning("[ws-mini] client disconnected user=%s region=%s", current_user.get("id"), region_id)
  127. pass
  128. except Exception:
  129. logger.exception("[ws-mini] receive loop error user=%s region=%s", current_user.get("id"), region_id)
  130. pass
  131. finally:
  132. await manager.disconnect(websocket, region_id=region_id)
  133. def notify_price_list_updated(event: PriceListUpdatedEvent) -> None:
  134. """线程/同步上下文安全:广播价格更新事件。"""
  135. async def _run():
  136. sent = await manager.broadcast_region(event.regionId, event.to_message())
  137. logger.warning(
  138. "[ws-mini] notify price update region=%s price_list_id=%s version=%s sent=%s",
  139. event.regionId,
  140. event.priceListId,
  141. event.priceVersion,
  142. sent,
  143. )
  144. try:
  145. loop = asyncio.get_running_loop()
  146. loop.create_task(_run())
  147. except RuntimeError:
  148. # sync endpoint 常在 worker thread:使用 anyio 跳回事件循环
  149. anyio.from_thread.run(_run)
  150. def register_ws_routes(app: Any) -> None:
  151. """在 FastAPI app 上注册 websocket 路由。"""
  152. app.websocket("/ws/mini")(ws_mini_price_updates)