Files
server-core/app/services/notification_service.py
T

160 lines
6.4 KiB
Python
Raw Normal View History

# -*- 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)