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

160 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)