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。
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""消息模块纯标准库层的测试:主题方案 / 消息封皮与日志 / 统一总线。
|
||||
|
||||
不 import Qt、不连 broker——这一层必须能在最小环境里跑通(分层约定见
|
||||
src/svfs_client/__init__.py)。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from helpers import VFSTestCase
|
||||
|
||||
from svfs_client import bus as bus_mod
|
||||
from svfs_client import messages as messages_mod
|
||||
from svfs_client import topics
|
||||
|
||||
assert sys.modules.get("helpers") is not None
|
||||
|
||||
|
||||
class TopicTest(unittest.TestCase):
|
||||
"""主题方案:组织位、转义可逆、通配订阅。"""
|
||||
|
||||
ORG = "acme"
|
||||
USER = "alice"
|
||||
|
||||
def test_topics_carry_org_and_user(self):
|
||||
self.assertEqual(
|
||||
topics.user_message_topic("svfs", self.ORG, self.USER),
|
||||
"svfs/acme/user/alice/message")
|
||||
self.assertEqual(
|
||||
topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.clock"),
|
||||
"svfs/acme/user/alice/plugin/com.example.clock")
|
||||
self.assertEqual(
|
||||
topics.broadcast_message_topic("svfs", self.ORG),
|
||||
"svfs/acme/broadcast/message")
|
||||
self.assertEqual(
|
||||
topics.broadcast_plugin_topic("svfs", self.ORG, "com.example.clock"),
|
||||
"svfs/acme/broadcast/plugin/com.example.clock")
|
||||
|
||||
def test_defaults_fill_in_prefix_and_org(self):
|
||||
self.assertEqual(topics.user_message_topic("", "", "alice"),
|
||||
"svfs/default/user/alice/message")
|
||||
# 前后多余的斜杠不该造出空层级
|
||||
self.assertEqual(topics.broadcast_message_topic("/svfs/", "acme"),
|
||||
"svfs/acme/broadcast/message")
|
||||
|
||||
def test_parse_roundtrip(self):
|
||||
topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.x")
|
||||
info = topics.parse(topic, "svfs")
|
||||
self.assertEqual(info["org"], self.ORG)
|
||||
self.assertEqual(info["scope"], "user")
|
||||
self.assertEqual(info["user"], self.USER)
|
||||
self.assertEqual(info["kind"], "plugin")
|
||||
self.assertEqual(info["plugin"], "com.example.x")
|
||||
|
||||
info = topics.parse(topics.broadcast_message_topic("svfs", self.ORG), "svfs")
|
||||
self.assertEqual((info["scope"], info["kind"], info["user"], info["plugin"]),
|
||||
("broadcast", "message", "", ""))
|
||||
|
||||
def test_parse_rejects_other_schemes(self):
|
||||
for topic in ("", "svfs", "svfs/acme", "svfs/acme/user",
|
||||
"other/acme/user/alice/message", # 前缀不对
|
||||
"svfs/acme/user/alice/unknown", # 类别不认识
|
||||
"svfs/acme/user/alice/plugin", # 少了插件 id
|
||||
"svfs/acme/broadcast", # 少了类别
|
||||
"svfs/acme/broadcast/plugin/"): # 空插件 id
|
||||
self.assertIsNone(topics.parse(topic, "svfs"), topic)
|
||||
|
||||
def test_unsafe_segments_are_escaped_and_invertible(self):
|
||||
# 空格、中文、斜杠都不能破坏层级;解析回来必须还是原值
|
||||
for user in ("a b", "张三", "a/b", "a+b", "a#b", "a@corp.example"):
|
||||
topic = topics.user_message_topic("svfs", self.ORG, user)
|
||||
self.assertEqual(len(topic.split("/")), 5, topic)
|
||||
self.assertEqual(topics.parse(topic, "svfs")["user"], user)
|
||||
|
||||
def test_escaping_never_collides(self):
|
||||
# 转义必须是可逆的:不能把两个不同用户映射到同一个主题
|
||||
self.assertNotEqual(topics.topic_segment("a b"),
|
||||
topics.topic_segment("a_b"))
|
||||
self.assertNotEqual(topics.topic_segment("a/b"),
|
||||
topics.topic_segment("ab"))
|
||||
self.assertEqual(topics.topic_segment("safe.name-1@x"), "safe.name-1@x")
|
||||
|
||||
def test_empty_segment_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
topics.topic_segment("")
|
||||
with self.assertRaises(ValueError):
|
||||
topics.user_message_topic("svfs", self.ORG, "")
|
||||
|
||||
def test_subscriptions_are_least_privilege(self):
|
||||
subs = topics.subscriptions("svfs", self.ORG, self.USER)
|
||||
self.assertEqual(subs, ["svfs/acme/user/alice/#", "svfs/acme/broadcast/#"])
|
||||
# 不订阅整个组织的 #(多租户下这正是 ACL 要挡的范围)
|
||||
self.assertNotIn("svfs/acme/#", subs)
|
||||
|
||||
|
||||
class MessageLogTest(VFSTestCase):
|
||||
"""消息封皮解析与消息中心列表。"""
|
||||
|
||||
def test_parse_json_envelope(self):
|
||||
message = messages_mod.parse_message(
|
||||
'{"id": "m1", "title": "备份完成", "body": "3 个包",'
|
||||
' "level": "warn", "time": "2026-09-16T10:00:00", "source": "backup"}')
|
||||
self.assertEqual(message["id"], "m1")
|
||||
self.assertEqual(message["title"], "备份完成")
|
||||
self.assertEqual(message["body"], "3 个包")
|
||||
self.assertEqual(message["level"], "warn")
|
||||
self.assertEqual(message["time"], "2026-09-16T10:00:00")
|
||||
self.assertEqual(message["source"], "backup")
|
||||
self.assertTrue(message["unread"])
|
||||
|
||||
def test_parse_plain_and_scalar_payloads(self):
|
||||
self.assertEqual(messages_mod.parse_message(" 开机完成 ")["body"], "开机完成")
|
||||
self.assertEqual(messages_mod.parse_message('"一句话"')["body"], "一句话")
|
||||
self.assertEqual(messages_mod.parse_message("[1,2]")["body"], "[1, 2]")
|
||||
self.assertEqual(messages_mod.parse_message(b"\xe4\xb8\xad\xe6\x96\x87")["body"],
|
||||
"中文")
|
||||
|
||||
def test_parse_rejects_empty_and_contentless(self):
|
||||
for payload in (None, "", " ", "{}", '{"title": ""}', b""):
|
||||
self.assertIsNone(messages_mod.parse_message(payload), repr(payload))
|
||||
|
||||
def test_parse_normalizes_level_and_time(self):
|
||||
message = messages_mod.parse_message('{"body": "x", "level": "FATAL"}',
|
||||
received_time="2026-09-16T12:00:00")
|
||||
self.assertEqual(message["level"], "info") # 新级别也当 info,消息不能丢
|
||||
self.assertEqual(message["time"], "2026-09-16T12:00:00") # 缺失时用收到时间
|
||||
self.assertTrue(messages_mod.parse_message('{"body": "x"}')["time"])
|
||||
|
||||
def test_parse_caps_length(self):
|
||||
message = messages_mod.parse_message(
|
||||
'{"title": "%s", "body": "%s"}' % ("t" * 500, "b" * 9000))
|
||||
self.assertLessEqual(len(message["title"]), 200)
|
||||
self.assertLessEqual(len(message["body"]), messages_mod.MAX_TEXT)
|
||||
|
||||
def test_log_dedupes_by_id(self):
|
||||
log = messages_mod.MessageLog()
|
||||
self.assertIsNotNone(log.add(messages_mod.parse_message('{"id": "m1", "body": "a"}')))
|
||||
# 同 id 重投(QoS 1 重发 / 补齐与推送重叠)→ 丢掉
|
||||
self.assertIsNone(log.add(messages_mod.parse_message('{"id": "m1", "body": "a"}')))
|
||||
self.assertEqual(len(log), 1)
|
||||
|
||||
def test_log_dedupes_id_less_messages_by_content_and_second(self):
|
||||
"""服务端没给 id 时按"时间(秒)+ 标题 + 正文"去重——这正是没有 id 的
|
||||
消息同时经推送与 HTTP 补齐两条路到达的场景,显示一条才对。"""
|
||||
log = messages_mod.MessageLog()
|
||||
payload = '{"body": "同一条", "time": "2026-09-16T10:00:00"}'
|
||||
self.assertIsNotNone(log.add(messages_mod.parse_message(payload)))
|
||||
self.assertIsNone(log.add(messages_mod.parse_message(payload)))
|
||||
self.assertEqual(len(log), 1)
|
||||
# 时间不同(真·第二条)或内容不同都算新消息
|
||||
self.assertIsNotNone(log.add(messages_mod.parse_message(
|
||||
'{"body": "同一条", "time": "2026-09-16T10:00:01"}')))
|
||||
self.assertIsNotNone(log.add(messages_mod.parse_message(
|
||||
'{"body": "另一条", "time": "2026-09-16T10:00:01"}')))
|
||||
self.assertEqual(len(log), 3)
|
||||
|
||||
def test_log_unread_and_mark_read(self):
|
||||
log = messages_mod.MessageLog()
|
||||
for index in range(3):
|
||||
log.add(messages_mod.parse_message(f'{{"id": "m{index}", "body": "x"}}'))
|
||||
self.assertEqual(log.unread(), 3)
|
||||
self.assertEqual(log.mark_all_read(), 3)
|
||||
self.assertEqual(log.unread(), 0)
|
||||
self.assertEqual(log.mark_all_read(), 0)
|
||||
log.add(messages_mod.parse_message('{"id": "m9", "body": "y"}'))
|
||||
self.assertTrue(log.mark_read("m9"))
|
||||
self.assertFalse(log.mark_read("m9")) # 已经读过
|
||||
self.assertFalse(log.mark_read("nope"))
|
||||
|
||||
def test_log_caps_and_forgets_dropped_keys(self):
|
||||
log = messages_mod.MessageLog(limit=3)
|
||||
for index in range(5):
|
||||
log.add(messages_mod.parse_message(f'{{"id": "m{index}", "body": "x"}}'))
|
||||
self.assertEqual([m["id"] for m in log.items()], ["m2", "m3", "m4"])
|
||||
# 被挤掉的 id 不再占去重表:同样的 id 再来是"新消息"(而不是永久沉默)
|
||||
self.assertIsNotNone(
|
||||
log.add(messages_mod.parse_message('{"id": "m0", "body": "x"}')))
|
||||
|
||||
def test_log_clear(self):
|
||||
log = messages_mod.MessageLog()
|
||||
log.add(messages_mod.parse_message('{"id": "m1", "body": "x"}'))
|
||||
log.clear()
|
||||
self.assertEqual(len(log), 0)
|
||||
self.assertEqual(log.unread(), 0)
|
||||
self.assertIsNotNone(
|
||||
log.add(messages_mod.parse_message('{"id": "m1", "body": "x"}')))
|
||||
|
||||
|
||||
class MessageBusTest(VFSTestCase):
|
||||
"""总线的分类、归属校验与分发。"""
|
||||
|
||||
ORG = "acme"
|
||||
USER = "alice"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.bus = bus_mod.MessageBus(org=self.ORG, user=self.USER)
|
||||
|
||||
def _deliver(self, topic, payload, received_time=""):
|
||||
return self.bus.deliver(topic, payload, received_time=received_time)
|
||||
|
||||
# ---- 分类 -------------------------------------------------------
|
||||
|
||||
def test_host_message_goes_to_message_log(self):
|
||||
topic = topics.user_message_topic("svfs", self.ORG, self.USER)
|
||||
result = self._deliver(topic, '{"id": "m1", "title": "备份完成"}')
|
||||
self.assertEqual(result["action"], "host")
|
||||
self.assertEqual(len(self.bus.log), 1)
|
||||
self.assertEqual(self.bus.log.items()[0]["title"], "备份完成")
|
||||
self.assertEqual(self.bus.log.items()[0]["scope"], "user")
|
||||
|
||||
def test_plugin_message_never_reaches_message_log(self):
|
||||
topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash")
|
||||
result = self._deliver(topic, '{"cpu": 0.42}')
|
||||
self.assertEqual(result["action"], "plugin")
|
||||
self.assertEqual(result["plugin"], "com.example.dash")
|
||||
self.assertEqual(len(self.bus.log), 0, "插件消息不能进消息中心")
|
||||
# 插件取走原文(宿主不解析插件协议)
|
||||
drained = self.bus.drain("com.example.dash")
|
||||
self.assertEqual(len(drained), 1)
|
||||
self.assertEqual(drained[0]["payload"], '{"cpu": 0.42}')
|
||||
self.assertEqual(self.bus.drain("com.example.dash"), [])
|
||||
|
||||
def test_broadcast_plugin_message(self):
|
||||
topic = topics.broadcast_plugin_topic("svfs", self.ORG, "com.example.dash")
|
||||
result = self._deliver(topic, "tick")
|
||||
self.assertEqual(result["action"], "plugin")
|
||||
self.assertEqual(result["scope"], "broadcast")
|
||||
self.assertEqual(self.bus.pending("com.example.dash"), 1)
|
||||
|
||||
def test_broadcast_host_message_is_marked(self):
|
||||
topic = topics.broadcast_message_topic("svfs", self.ORG)
|
||||
self._deliver(topic, '{"body": "全员通知"}')
|
||||
self.assertEqual(self.bus.log.items()[0]["scope"], "broadcast")
|
||||
|
||||
def test_unknown_topic_and_empty_payload(self):
|
||||
self.assertEqual(self._deliver("svfs/acme/user/alice/nope", "x")["action"],
|
||||
"unknown")
|
||||
topic = topics.user_message_topic("svfs", self.ORG, self.USER)
|
||||
self.assertEqual(self._deliver(topic, " ")["action"], "empty")
|
||||
self.assertEqual(len(self.bus.log), 0)
|
||||
|
||||
# ---- 归属校验 ---------------------------------------------------
|
||||
|
||||
def test_other_org_or_user_is_rejected(self):
|
||||
other_org = topics.user_message_topic("svfs", "other", self.USER)
|
||||
self.assertEqual(self._deliver(other_org, '{"body": "x"}')["action"],
|
||||
"forbidden")
|
||||
other_user = topics.user_message_topic("svfs", self.ORG, "bob")
|
||||
self.assertEqual(self._deliver(other_user, '{"body": "x"}')["action"],
|
||||
"forbidden")
|
||||
forbidden_plugin = topics.user_plugin_topic("svfs", self.ORG, "bob",
|
||||
"com.example.dash")
|
||||
self.assertEqual(self._deliver(forbidden_plugin, "x")["action"], "forbidden")
|
||||
self.assertEqual(len(self.bus.log), 0)
|
||||
self.assertEqual(self.bus.pending("com.example.dash"), 0)
|
||||
self.assertEqual(self.bus.stats()["forbidden"], 3)
|
||||
|
||||
def test_identify_switches_tenant_and_clears_buffers(self):
|
||||
self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"),
|
||||
"x")
|
||||
self.assertEqual(self.bus.pending("com.a"), 1)
|
||||
self.bus.identify(org="other", user="bob")
|
||||
self.assertEqual(self.bus.pending("com.a"), 0, "换身份后不该留着旧队列")
|
||||
self.assertEqual(
|
||||
self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER),
|
||||
'{"body": "x"}')["action"], "forbidden")
|
||||
|
||||
def test_bus_rejects_everything_before_identify(self):
|
||||
"""没有身份(未登录/已退出登录)时一条都不收。
|
||||
|
||||
消息模块只在登录后工作;退出登录后残留的推送不该落进消息中心。
|
||||
"""
|
||||
bus = bus_mod.MessageBus()
|
||||
result = bus.deliver(topics.broadcast_message_topic("svfs", "acme"),
|
||||
'{"body": "x"}')
|
||||
self.assertEqual(result["action"], "forbidden")
|
||||
self.assertEqual(len(bus.log), 0)
|
||||
bus.identify(org="acme")
|
||||
self.assertEqual(
|
||||
bus.deliver(topics.broadcast_message_topic("svfs", "acme"),
|
||||
'{"body": "x"}')["action"], "host")
|
||||
|
||||
# ---- 去重与兜底 -------------------------------------------------
|
||||
|
||||
def test_plugin_duplicates_are_dropped(self):
|
||||
topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash")
|
||||
self.assertEqual(self._deliver(topic, "same")["action"], "plugin")
|
||||
self.assertEqual(self._deliver(topic, "same")["action"], "duplicate")
|
||||
self.assertEqual(self.bus.pending("com.example.dash"), 1)
|
||||
# 内容不同就是新消息
|
||||
self.assertEqual(self._deliver(topic, "other")["action"], "plugin")
|
||||
|
||||
def test_plugin_buffer_drops_oldest_and_counts(self):
|
||||
bus = bus_mod.MessageBus(org=self.ORG, user=self.USER, plugin_buffer=3)
|
||||
topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash")
|
||||
for index in range(5):
|
||||
bus.deliver(topic, f"payload-{index}")
|
||||
drained = bus.drain("com.example.dash")
|
||||
self.assertEqual([item["payload"] for item in drained],
|
||||
["payload-2", "payload-3", "payload-4"])
|
||||
self.assertEqual(bus.stats()["dropped"], 2)
|
||||
|
||||
def test_reset_clears_everything(self):
|
||||
self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER),
|
||||
'{"id": "m1", "body": "x"}')
|
||||
self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"), "x")
|
||||
self.bus.reset()
|
||||
self.assertEqual(len(self.bus.log), 0)
|
||||
self.assertEqual(self.bus.pending("com.a"), 0)
|
||||
self.assertEqual(self.bus.stats(), {})
|
||||
|
||||
def test_stats_track_each_channel(self):
|
||||
self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER),
|
||||
'{"id": "m1", "body": "x"}')
|
||||
self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER),
|
||||
'{"id": "m1", "body": "x"}') # 重投
|
||||
self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"),
|
||||
"x")
|
||||
stats = self.bus.stats()
|
||||
self.assertEqual(stats["received"], 3)
|
||||
self.assertEqual(stats["host"], 1)
|
||||
self.assertEqual(stats["plugin"], 1)
|
||||
self.assertEqual(stats["duplicate"], 1)
|
||||
self.assertEqual(self.bus.buffered_plugins(), ["com.a"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user