Files
svfs/src/svfs_client/bus.py
T
chakcy e0d5bad7b2 feat: 消息模块(MQTT 推送 + 统一总线),并补退出登录/顺序编排/设置分页签
一、消息模块:服务端主动推送 + 插件定向分发
* 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。
2026-09-17 01:06:02 +08:00

193 lines
8.1 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.
#!/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")