一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
.../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
/api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
响应下发(/api/login 与 /api/me 都带),客户端不用手填。
二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。
三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。
四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
静默吞掉):带身份快照,不匹配即丢弃。
五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
(手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。
测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
(装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
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")
|