160 lines
6.4 KiB
Python
160 lines
6.4 KiB
Python
# -*- 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)
|