# -*- coding: utf-8 -*- """实时通知统一服务:事件 → 解析接收人 → 落库(权威) + MQTT(实时) + SSE(兜底)。 设计要点: - 幂等:同用户同 event_code 同 ref_id 不重复落库(MQTT/SSE 重推安全)。 - 静默降级:MQTT 断连 / 通知异常不阻塞业务(try/except 包裹)。 - 角色展开:roles=[...] → 查 users 表展开为具体 user_id。 - 广播:不逐用户落库,走 announcements 表 + MQTT broadcast + SSE。 """ from __future__ import annotations import json import logging from ..infrastructure.repositories import Database from ..park.config import settings log = logging.getLogger("notify") # 通知负载最小字段(客户端按此渲染 toast/角标,详情走 HTTP) _NOTIFY_KEYS = ("id", "type", "event", "title", "content", "level", "link", "ref_type", "ref_id", "ts", "unread_total", "user_id", "broadcast") def _mqtt_publish(topic: str, payload: dict) -> None: """发布到 EMQX;未连接/失败静默丢弃(SSE 与落库兜底)。""" try: from ..park.mqtt import hub hub.publish(topic, payload, qos=1) except Exception: # noqa: BLE001 log.debug("MQTT notify publish skipped: %s", topic) def _sse_emit(payload: dict) -> None: """广播到 SSE 兼容通道;客户端按 user_id 过滤。""" try: from ..park.event_bus import bus bus.emit(payload) except Exception: # noqa: BLE001 log.debug("SSE notify emit skipped") class NotificationService: def __init__(self, db: Database): self.db = db async def notify( self, *, users: list[str] | None = None, roles: list[str] | None = None, broadcast: bool = False, type: str = "system", event_code: str = "", title: str = "", content: str = "", level: str = "info", link: str = "", ref_type: str = "", ref_id: str = "", category: str = "", ) -> None: """统一通知入口。users / roles / broadcast 三选一(可组合 users+roles)。""" if not category: category = type targets: list[str] = list(users or []) if roles: try: targets += await self._users_by_roles(roles) except Exception: # noqa: BLE001 log.debug("resolve roles failed: %s", roles) # 去重 targets = list(dict.fromkeys(t for t in targets if t)) if broadcast: await self._broadcast(type=type, event_code=event_code, title=title, content=content, level=level, link=link, category=category) return for uid in targets: try: if await self.db.notifications.exists_dup(uid, event_code, ref_id): continue rec = await self.db.notifications.create( uid, type, title, content, category=category, event_code=event_code, level=level, link=link, ref_type=ref_type, ref_id=ref_id) payload = self._payload(rec, uid, event_code, title, content, level, link, ref_type, ref_id) _mqtt_publish(f"{settings.TOPIC_NOTIFY_USER}/{uid}", payload) _sse_emit(payload) except Exception: # noqa: BLE001 log.debug("notify to %s failed", uid, exc_info=True) async def _broadcast(self, **kw) -> None: try: await self.db.announcements.create( kw.get("title", ""), kw.get("content", ""), category=kw.get("category", "system"), level=kw.get("level", "info"), link=kw.get("link", "")) except Exception: # noqa: BLE001 log.debug("announcement persist failed") payload = { "id": "", "type": kw.get("type", "system"), "event": kw.get("event_code", "sys.announce"), "title": kw.get("title", ""), "content": kw.get("content", ""), "level": kw.get("level", "info"), "link": kw.get("link", ""), "ref_type": "", "ref_id": "", "ts": _now_iso(), "unread_total": 0, "user_id": "*", "broadcast": True, } _mqtt_publish(settings.TOPIC_NOTIFY_BROADCAST, payload) _sse_emit(payload) async def _users_by_roles(self, roles: list[str]) -> list[str]: from sqlalchemy import select from ..infrastructure.models import User rows = (await self.db.session.scalars( select(User.id).where(User.role.in_(roles)) )).all() return [str(r) for r in rows] @staticmethod def _payload(rec: dict, uid: str, event_code: str, title: str, content: str, level: str, link: str, ref_type: str, ref_id: str) -> dict: return { "id": rec.get("id", ""), "type": event_code.split(".")[0] if event_code else "system", "event": event_code, "title": title, "content": content, "level": level, "link": link, "ref_type": ref_type, "ref_id": ref_id, "ts": _now_iso(), "user_id": uid, "broadcast": False, } def _now_iso() -> str: from ..infrastructure.repositories import utcnow_iso return utcnow_iso() # ── 便捷函数(供各业务路由直接调用,统一三通道)───────────────────────── async def notify(db: Database, user_id: str, type: str, title: str, content: str, *, event_code: str = "", level: str = "info", link: str = "", ref_type: str = "", ref_id: str = "", category: str = "") -> None: """单用户通知(兼容旧 _notify 调用形态)。""" await NotificationService(db).notify( users=[user_id], type=type, event_code=event_code, title=title, content=content, level=level, link=link, ref_type=ref_type, ref_id=ref_id, category=category) async def notify_roles(db: Database, roles: list[str], type: str, title: str, content: str, *, event_code: str = "", level: str = "info", link: str = "", ref_type: str = "", ref_id: str = "", category: str = "") -> None: """角色组通知(运营/园区待办)。""" await NotificationService(db).notify( roles=roles, type=type, event_code=event_code, title=title, content=content, level=level, link=link, ref_type=ref_type, ref_id=ref_id, category=category)