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:
2026-09-17 01:06:02 +08:00
parent ae9fc8dbe4
commit e0d5bad7b2
29 changed files with 4105 additions and 105 deletions
+183
View File
@@ -34,6 +34,14 @@ if HAVE_FASTAPI:
from svfs_client import paths as client_paths
from svfs_client import sample as client_sample
try:
# 自建最小 broker(见 test_client_mqtt)——端到端用例要一个真 broker,
# 但测试不应该依赖装 mosquitto
from test_client_mqtt import _MiniBroker, wait_until
HAVE_BROKER = True
except Exception:
HAVE_BROKER = False
_load_seq = [0]
def _load_example(plugins_dir):
@@ -104,6 +112,181 @@ if HAVE_FASTAPI:
self.assertEqual(response.status_code, 401)
self.assertIn("不正确", response.json()["detail"])
def test_message_push_and_sync(self):
"""服务端主动推消息:进该用户的待收列表,客户端登录后能补齐。"""
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "message", "title": "备份完成", "body": "3 个包",
"level": "warn"})
self.assertEqual(response.status_code, 200, response.text)
body = response.json()
# 主题按方案拼(组织位在第一段之后,多租户就靠它隔离)
self.assertEqual(body["topic"], "svfs/default/user/admin/message")
self.assertEqual(body["message"]["title"], "备份完成")
self.assertTrue(body["message"]["id"])
self.assertTrue(body["stored"])
# 没配 broker:如实说明是跳过了,不是假装发成功
self.assertIn("skipped", body["mqtt"])
# 补齐:默认全给;给了 since 只给更新的
response = self.client.get("/api/messages", headers=headers)
self.assertEqual(len(response.json()["messages"]), 1)
newest = response.json()["messages"][0]["time"]
response = self.client.get(f"/api/messages?since={newest}",
headers=headers)
self.assertEqual(response.json()["messages"], [])
# 发给别人的消息进别人的待收列表(自己的列表不受影响)
response = self.client.post("/api/push", headers=headers,
json={"title": "给别人的", "user": "bob"})
self.assertEqual(response.json()["topic"],
"svfs/default/user/bob/message")
self.assertEqual(
len(self.client.get("/api/messages", headers=headers)
.json()["messages"]), 1)
def test_plugin_push_goes_to_plugin_topic_and_not_backlog(self):
"""插件消息走插件主题、**不进**待收列表(仪表盘数据补历史没意义)。"""
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "plugin", "plugin": "com.svfs.demo", "data": {"cpu": 0.42}})
self.assertEqual(response.status_code, 200, response.text)
body = response.json()
self.assertEqual(body["topic"],
"svfs/default/user/admin/plugin/com.svfs.demo")
self.assertFalse(body["stored"])
self.assertEqual(
self.client.get("/api/messages", headers=headers).json()["messages"],
[])
# 少了 plugin 字段:400,别猜
response = self.client.post("/api/push", headers=headers,
json={"kind": "plugin"})
self.assertEqual(response.status_code, 400)
def test_broadcast_and_org_announcement(self):
"""广播走组织主题;登录响应下发组织标识(多租户预留位)。"""
body = self._login().json()
self.assertEqual(body["org"], "default")
self.assertNotIn("mqtt", body) # 没配 broker 就不下发
headers = {"Authorization": f"Bearer {body['token']}"}
response = self.client.post("/api/broadcast", headers=headers,
json={"title": "全员通知", "body": "维护"})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["topic"],
"svfs/default/broadcast/message")
def test_org_and_broker_announcement_follows_env(self):
"""服务端配了组织/broker:登录与 /api/me 都下发(客户端据此订阅)。"""
keys = ("SVFS_ORG", "SVFS_MQTT_HOST", "SVFS_MQTT_PORT", "SVFS_MQTT_TLS")
old = {key: os.environ.get(key) for key in keys}
def restore():
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(restore)
os.environ.update({"SVFS_ORG": "acme", "SVFS_MQTT_HOST": "broker.acme",
"SVFS_MQTT_PORT": "8883", "SVFS_MQTT_TLS": "true"})
body = self._login().json()
self.assertEqual(body["org"], "acme")
self.assertEqual(body["mqtt"], {"host": "broker.acme", "tls": True,
"port": 8883, "prefix": "svfs",
"org": "acme"})
headers = {"Authorization": f"Bearer {body['token']}"}
self.assertEqual(
self.client.get("/api/me", headers=headers).json()["org"], "acme")
# 主题里的组织段跟着变(多租户隔离就靠它)
response = self.client.post("/api/push", headers=headers,
json={"title": "x"})
self.assertEqual(response.json()["topic"],
"svfs/acme/user/admin/message")
def test_push_requires_auth(self):
self.assertEqual(
self.client.post("/api/push", json={"title": "x"}).status_code, 401)
self.assertEqual(self.client.get("/api/messages").status_code, 401)
@unittest.skipUnless(HAVE_BROKER, "需要 paho-mqtt 与自建 broker 助手")
def test_server_push_reaches_client_over_mqtt(self):
"""端到端:``/api/push`` → broker → 客户端 MQTT 通道 → 消息中心。
这条链路把三方接在一起验证:**broker 地址由登录响应下发**(客户端
不用手配)、服务端按主题方案发布、客户端用同一套主题方案订阅并让
消息落进总线。
"""
from svfs_client import settings as client_settings
from svfs_client.bus import MessageBus
from svfs_client.mqtt import MqttTransport
broker = _MiniBroker()
self.addCleanup(broker.close)
keys = ("SVFS_MQTT_HOST", "SVFS_MQTT_PORT", "SVFS_ORG")
old = {key: os.environ.get(key) for key in keys}
def restore():
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(restore)
os.environ.update({"SVFS_MQTT_HOST": "127.0.0.1",
"SVFS_MQTT_PORT": str(broker.port),
"SVFS_ORG": "acme"})
session = self._login().json()
self.assertEqual(session["mqtt"]["host"], "127.0.0.1")
self.assertEqual(session["org"], "acme")
# 客户端侧:与服务端同一套主题方案 + 同一个解析器
base = client_paths.ensure_layout(self.path("data"))
config = client_settings.mqtt_config(base, session)
self.assertTrue(config["enabled"])
self.assertEqual(config["org"], "acme")
bus = MessageBus(org=config["org"], user=session["username"])
transport = MqttTransport(config, on_message=bus.deliver)
self.addCleanup(transport.stop)
transport.start()
self.assertTrue(wait_until(lambda: transport.is_connected()),
"客户端没有连上 broker")
# 服务端推一条 → 经 broker 到客户端
headers = {"Authorization": f"Bearer {session['token']}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "message", "title": "备份完成", "body": "3 个包"})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(wait_until(lambda: len(bus.log) == 1),
"推送没有到达客户端,消息中心是空的")
message = bus.log.items()[0]
self.assertEqual(message["title"], "备份完成")
self.assertEqual(message["scope"], "user")
# 插件消息走另一条通道:不进消息中心,只进该插件的队列
response = self.client.post("/api/push", headers=headers, json={
"kind": "plugin", "plugin": "com.svfs.demo", "data": {"cpu": 0.5}})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(
wait_until(lambda: bus.pending("com.svfs.demo") == 1),
"插件消息没有到达插件队列")
self.assertEqual(len(bus.log), 1)
# 组织广播:进消息中心并带 broadcast 标记
response = self.client.post("/api/broadcast", headers=headers,
json={"title": "全员通知"})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(wait_until(lambda: len(bus.log) == 2))
self.assertEqual(bus.log.items()[1]["scope"], "broadcast")
def test_me_and_logout(self):
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}