193 lines
8.1 KiB
Python
193 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""统一消息总线(纯标准库):所有通道的消息都先进这里,再按需分发。
|
|||
|
|
|
|||
|
|
通道有两条,汇到总线上之后再分流:
|
|||
|
|
|
|||
|
|
* **MQTT 推送**(服务端主动推,实时);
|
|||
|
|
* **HTTP 补齐**(登录后先同步、以及推送漏掉的)——见 worker 的 ``sync_messages``。
|
|||
|
|
|
|||
|
|
总线只做四件事:
|
|||
|
|
|
|||
|
|
1. **分类**:按**主题**判定"主机消息(进消息中心)"还是"定向插件消息",
|
|||
|
|
不看载荷内容——仪表盘数据之类的业务载荷永远不会混进给人看的消息列表,
|
|||
|
|
主机消息也不会被投给插件;
|
|||
|
|
2. **归属校验**:解析出的组织/用户必须与当前登录身份一致(broker 配错、
|
|||
|
|
ACL 没生效时把别人的消息推过来,这里挡掉并记日志);
|
|||
|
|
3. **去重**:主机消息进 :class:`MessageLog`(按 id 去重);插件消息按
|
|||
|
|
"插件 + 去重键"记最近若干条——MQTT 的 QoS 1 可能重投,HTTP 补齐与推送
|
|||
|
|
也会重叠,两条路叠在一起才最需要去重;
|
|||
|
|
4. **分发**:主机消息落在日志里等界面来取;插件消息进**该插件的队列**,
|
|||
|
|
页面打开后再取走(插件页面是懒加载的,消息可能比页面先到)。
|
|||
|
|
|
|||
|
|
坏消息(畸形载荷、不认识的主题、越权归属)只记诊断日志并计数,不抛——
|
|||
|
|
消息通道不能因为一条坏消息就断掉。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from collections import Counter, deque
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
from . import diagnostics, messages as messages_mod, topics
|
|||
|
|
|
|||
|
|
__all__ = ["MessageBus"]
|
|||
|
|
|
|||
|
|
#: 每个插件缓存的上限(页面没打开时先攒着;超了丢最旧的)
|
|||
|
|
PLUGIN_BUFFER = 50
|
|||
|
|
#: 插件消息去重记忆的上限(按插件)
|
|||
|
|
PLUGIN_SEEN = 200
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MessageBus:
|
|||
|
|
"""消息总线。线程安全由调用方保证(客户端里统一在主线程使用)。"""
|
|||
|
|
|
|||
|
|
def __init__(self, org="", user="", prefix=topics.DEFAULT_PREFIX,
|
|||
|
|
log=None, plugin_buffer=PLUGIN_BUFFER):
|
|||
|
|
self.prefix = prefix or topics.DEFAULT_PREFIX
|
|||
|
|
#: 当前身份(空 = 不校验,测试与未登录场景)。同时用于过滤"别人的消息"
|
|||
|
|
self.org = str(org or "")
|
|||
|
|
self.user = str(user or "")
|
|||
|
|
self.log = log if log is not None else messages_mod.MessageLog()
|
|||
|
|
self.plugin_buffer = max(1, int(plugin_buffer))
|
|||
|
|
self._queues = {} # 插件 id → deque(未投递的消息)
|
|||
|
|
self._seen = {} # 插件 id → deque(去重键)
|
|||
|
|
self._counters = Counter()
|
|||
|
|
|
|||
|
|
# ---- 身份 -------------------------------------------------------
|
|||
|
|
|
|||
|
|
def identify(self, org="", user=""):
|
|||
|
|
"""登录/切组织后更新身份;传空 = 退出登录(此后一律不收消息)。
|
|||
|
|
|
|||
|
|
换身份时一并清掉上一个身份攒下的插件队列——消息是按人推的,留给下一个
|
|||
|
|
人就是把两个账号的数据混在一起。
|
|||
|
|
"""
|
|||
|
|
self.org = str(org or "")
|
|||
|
|
self.user = str(user or "")
|
|||
|
|
self._queues.clear()
|
|||
|
|
self._seen.clear()
|
|||
|
|
|
|||
|
|
# ---- 入口 -------------------------------------------------------
|
|||
|
|
|
|||
|
|
def deliver(self, topic, payload, received_time=""):
|
|||
|
|
"""收一条消息(来自任何通道),返回处理结果 dict(测试与日志都读它)。
|
|||
|
|
|
|||
|
|
结果里的 ``action`` 取值:``host``(进消息中心)/ ``plugin``(投给
|
|||
|
|
插件)/ ``duplicate`` / ``unknown``(主题不认识)/ ``forbidden``
|
|||
|
|
(不是发给这个组织/用户的)/ ``empty``(解析不出正文)。
|
|||
|
|
"""
|
|||
|
|
self._counters["received"] += 1
|
|||
|
|
info = topics.parse(topic, self.prefix)
|
|||
|
|
if info is None:
|
|||
|
|
self._counters["unknown"] += 1
|
|||
|
|
diagnostics.log(f"不认识的消息主题: {topic}", "bus")
|
|||
|
|
return {"action": "unknown", "topic": topic}
|
|||
|
|
|
|||
|
|
if not self._allowed(info):
|
|||
|
|
self._counters["forbidden"] += 1
|
|||
|
|
diagnostics.log(
|
|||
|
|
f"丢弃不属于当前身份的消息(org={info['org']} "
|
|||
|
|
f"user={info['user'] or '-'}): {topic}", "bus")
|
|||
|
|
return {"action": "forbidden", "topic": topic, **info}
|
|||
|
|
|
|||
|
|
if info["kind"] == "plugin":
|
|||
|
|
return self._deliver_plugin(info, payload)
|
|||
|
|
return self._deliver_host(info, payload, received_time)
|
|||
|
|
|
|||
|
|
def _allowed(self, info):
|
|||
|
|
"""组织/用户是否与当前身份相符。
|
|||
|
|
|
|||
|
|
没有身份(未登录 / 已退出登录)时**一律不收**:消息模块只在登录后
|
|||
|
|
工作,退出登录后残留的推送不该再落进消息中心。
|
|||
|
|
"""
|
|||
|
|
if not self.org:
|
|||
|
|
return False
|
|||
|
|
if info["org"] != self.org:
|
|||
|
|
return False
|
|||
|
|
if info["scope"] == "user" and self.user and info["user"] != self.user:
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def _deliver_host(self, info, payload, received_time):
|
|||
|
|
message = messages_mod.parse_message(payload, received_time=received_time)
|
|||
|
|
if message is None:
|
|||
|
|
self._counters["empty"] += 1
|
|||
|
|
diagnostics.log(f"消息没有可展示的内容: {info}", "bus")
|
|||
|
|
return {"action": "empty", "topic": info, **info}
|
|||
|
|
message["scope"] = info["scope"] # user / broadcast(界面上做个标记)
|
|||
|
|
stored = self.log.add(message)
|
|||
|
|
if stored is None:
|
|||
|
|
self._counters["duplicate"] += 1
|
|||
|
|
return {"action": "duplicate", "message": message, **info}
|
|||
|
|
self._counters["host"] += 1
|
|||
|
|
return {"action": "host", "message": stored, **info}
|
|||
|
|
|
|||
|
|
def _deliver_plugin(self, info, payload):
|
|||
|
|
plugin_id = info["plugin"]
|
|||
|
|
body = _decode(payload)
|
|||
|
|
key = _plugin_key(info, body)
|
|||
|
|
seen = self._seen.setdefault(plugin_id, deque(maxlen=PLUGIN_SEEN))
|
|||
|
|
if key in seen:
|
|||
|
|
self._counters["duplicate"] += 1
|
|||
|
|
return {"action": "duplicate", "plugin": plugin_id, **info}
|
|||
|
|
seen.append(key)
|
|||
|
|
|
|||
|
|
queue = self._queues.setdefault(plugin_id, deque(maxlen=self.plugin_buffer))
|
|||
|
|
if len(queue) == queue.maxlen:
|
|||
|
|
self._counters["dropped"] += 1
|
|||
|
|
diagnostics.log(
|
|||
|
|
f"插件 {plugin_id} 的消息积压到 {self.plugin_buffer} 条,丢最旧的"
|
|||
|
|
"(页面还没打开或没来取)", "bus")
|
|||
|
|
queue.append({"payload": body, "time": _now(), "scope": info["scope"]})
|
|||
|
|
self._counters["plugin"] += 1
|
|||
|
|
return {"action": "plugin", "plugin": plugin_id, "payload": body, **info}
|
|||
|
|
|
|||
|
|
# ---- 插件侧取用 -------------------------------------------------
|
|||
|
|
|
|||
|
|
def drain(self, plugin_id):
|
|||
|
|
"""取走某个插件攒下的消息(取完即清);没有则返回空列表。"""
|
|||
|
|
queue = self._queues.pop(plugin_id, None)
|
|||
|
|
return list(queue) if queue else []
|
|||
|
|
|
|||
|
|
def pending(self, plugin_id):
|
|||
|
|
"""看一眼某个插件攒了多少条(不取走)。"""
|
|||
|
|
return len(self._queues.get(plugin_id, ()))
|
|||
|
|
|
|||
|
|
def buffered_plugins(self):
|
|||
|
|
return sorted(pid for pid, queue in self._queues.items() if queue)
|
|||
|
|
|
|||
|
|
# ---- 观测 -------------------------------------------------------
|
|||
|
|
|
|||
|
|
def stats(self):
|
|||
|
|
"""计数快照(诊断报告/状态栏用)。"""
|
|||
|
|
return dict(self._counters)
|
|||
|
|
|
|||
|
|
def reset(self):
|
|||
|
|
"""清空消息与计数(退出登录/切换账号时用)。"""
|
|||
|
|
self.log.clear()
|
|||
|
|
self._queues.clear()
|
|||
|
|
self._seen.clear()
|
|||
|
|
self._counters.clear()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _decode(payload):
|
|||
|
|
if isinstance(payload, (bytes, bytearray)):
|
|||
|
|
try:
|
|||
|
|
return bytes(payload).decode("utf-8")
|
|||
|
|
except UnicodeDecodeError:
|
|||
|
|
return bytes(payload).decode("utf-8", "replace")
|
|||
|
|
if isinstance(payload, str):
|
|||
|
|
return payload
|
|||
|
|
return "" if payload is None else str(payload)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _plugin_key(info, body):
|
|||
|
|
"""插件消息的去重键:同一插件 + 同一正文 + 同一秒内视为重复。
|
|||
|
|
|
|||
|
|
插件载荷是插件自己的协议,宿主不解析语义,所以只能按"内容 + 时间窗"
|
|||
|
|
粗去重——目的只是挡住 QoS 1 重投与推送/补齐的重叠,不动真业务。
|
|||
|
|
"""
|
|||
|
|
scope = info.get("scope") or ""
|
|||
|
|
return f"{scope}|{body}|{_now()[:19]}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _now():
|
|||
|
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|