一、消息模块:服务端主动推送 + 插件定向分发
* 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。
417 lines
19 KiB
Python
417 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""examples/plugin_server.py 的最小验证(需要 fastapi + httpx,缺则整模块跳过)。
|
||
|
||
覆盖两条链路:
|
||
|
||
* API 本身:登录换 token、/api/ping(需 Bearer token)、上传 .svfs →
|
||
服务端 publish → 清单/对象可被 GET;
|
||
* **完整闭环**:经 HTTP 上传后,起一个真实 HTTP 服务吐插件目录,
|
||
用客户端的 ``installer.install_from_url`` 从服务端装回本地。
|
||
"""
|
||
|
||
import importlib.util
|
||
import json
|
||
import os
|
||
import threading
|
||
import unittest
|
||
from functools import partial
|
||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
import helpers
|
||
from helpers import VFSTestCase, quiet
|
||
|
||
try:
|
||
from fastapi.testclient import TestClient
|
||
|
||
HAVE_FASTAPI = True
|
||
except ImportError:
|
||
HAVE_FASTAPI = False
|
||
|
||
EXAMPLE_PATH = os.path.join(helpers.REPO_ROOT, "examples", "plugin_server.py")
|
||
|
||
if HAVE_FASTAPI:
|
||
from svfs_client import installer, registry
|
||
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):
|
||
"""以指定插件目录加载示例模块(StaticFiles 在 import 时建 mount)。"""
|
||
_load_seq[0] += 1
|
||
os.environ["SVFS_PLUGINS_DIR"] = plugins_dir
|
||
spec = importlib.util.spec_from_file_location(
|
||
f"plugin_server_example_{_load_seq[0]}", EXAMPLE_PATH
|
||
)
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
if HAVE_FASTAPI:
|
||
|
||
class PluginServerTest(VFSTestCase):
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.plugins_dir = self.path("plugins-root")
|
||
self.mod = _load_example(self.plugins_dir)
|
||
self.client = TestClient(self.mod.app)
|
||
|
||
def _build_sample(self, name="sample.svfs"):
|
||
package = self.path(name)
|
||
with quiet():
|
||
client_sample.build(package)
|
||
return package
|
||
|
||
def _upload(self, plugin_id, pkg_path, token=None):
|
||
with open(pkg_path, "rb") as handle:
|
||
headers = {"X-SVFS-Token": token} if token is not None else {}
|
||
return self.client.post(
|
||
f"/plugins/{plugin_id}",
|
||
files={"file": ("plugin.svfs", handle.read())},
|
||
headers=headers,
|
||
)
|
||
|
||
def _login(self, username="admin", password="admin123"):
|
||
return self.client.post(
|
||
"/api/login", json={"username": username, "password": password}
|
||
)
|
||
|
||
def test_index_open_ping_requires_auth(self):
|
||
# /api/* 需要登录;服务索引保持开放
|
||
response = self.client.get("/api/ping")
|
||
self.assertEqual(response.status_code, 401)
|
||
|
||
response = self.client.get("/")
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response.json()["plugins"], [])
|
||
|
||
def test_login_then_ping(self):
|
||
response = self._login()
|
||
self.assertEqual(response.status_code, 200, response.text)
|
||
body = response.json()
|
||
self.assertEqual(body["username"], "admin")
|
||
self.assertTrue(body["token"])
|
||
|
||
headers = {"Authorization": f"Bearer {body['token']}"}
|
||
response = self.client.get("/api/ping", headers=headers)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertTrue(response.json()["pong"])
|
||
self.assertEqual(response.json()["user"], "admin")
|
||
|
||
def test_login_rejects_bad_credentials(self):
|
||
response = self._login(password="wrong")
|
||
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}"}
|
||
|
||
response = self.client.get("/api/me", headers=headers)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response.json()["username"], "admin")
|
||
|
||
response = self.client.post("/api/logout", headers=headers)
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
# token 已作废
|
||
response = self.client.get("/api/me", headers=headers)
|
||
self.assertEqual(response.status_code, 401)
|
||
|
||
def test_custom_users_env(self):
|
||
os.environ["SVFS_USERS"] = "alice:pw1, bob: pw2"
|
||
try:
|
||
response = self._login() # 默认演示账号被关闭
|
||
self.assertEqual(response.status_code, 401)
|
||
response = self._login("alice", "pw1")
|
||
self.assertEqual(response.status_code, 200)
|
||
response = self._login("bob", "pw2") # 值两侧空白被容忍
|
||
self.assertEqual(response.status_code, 200)
|
||
finally:
|
||
del os.environ["SVFS_USERS"]
|
||
|
||
def test_upload_then_serve(self):
|
||
package = self._build_sample()
|
||
response = self._upload(client_sample.PLUGIN_ID, package)
|
||
self.assertEqual(response.status_code, 200, response.text)
|
||
body = response.json()
|
||
self.assertTrue(body["ok"])
|
||
self.assertEqual(body["id"], client_sample.PLUGIN_ID)
|
||
self.assertEqual(body["version"], "1.0.0")
|
||
|
||
# 服务列表与 manifest 可取
|
||
self.assertEqual(self.client.get("/").json()["plugins"],
|
||
[client_sample.PLUGIN_ID])
|
||
response = self.client.get(f"/{client_sample.PLUGIN_ID}/manifest.json")
|
||
self.assertEqual(response.status_code, 200)
|
||
manifest = response.json()
|
||
self.assertEqual(manifest["format"], "svfs-objects")
|
||
|
||
# 任意一个 blob 也能按 hash 取到
|
||
blob_hash = next(
|
||
entry["hash"] for entry in manifest["entries"].values()
|
||
if "hash" in entry
|
||
)
|
||
response = self.client.get(
|
||
f"/{client_sample.PLUGIN_ID}/blobs/{blob_hash[:2]}/{blob_hash}"
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertTrue(len(response.content) > 0)
|
||
|
||
def test_upload_rejects_mismatched_id(self):
|
||
package = self._build_sample()
|
||
response = self._upload("com.other.app", package)
|
||
self.assertEqual(response.status_code, 400)
|
||
self.assertIn("不一致", response.json()["detail"])
|
||
|
||
def test_upload_rejects_corrupt_package(self):
|
||
junk = self.path("junk.svfs")
|
||
with open(junk, "wb") as handle:
|
||
handle.write(b"not a sqlite file")
|
||
response = self._upload("com.other.app", junk)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_upload_token(self):
|
||
package = self._build_sample()
|
||
os.environ["SVFS_UPLOAD_TOKEN"] = "secret"
|
||
try:
|
||
response = self._upload(client_sample.PLUGIN_ID, package)
|
||
self.assertEqual(response.status_code, 403)
|
||
response = self._upload(client_sample.PLUGIN_ID, package,
|
||
token="wrong")
|
||
self.assertEqual(response.status_code, 403)
|
||
response = self._upload(client_sample.PLUGIN_ID, package,
|
||
token="secret")
|
||
self.assertEqual(response.status_code, 200)
|
||
finally:
|
||
del os.environ["SVFS_UPLOAD_TOKEN"]
|
||
|
||
class PluginServerEndToEndTest(VFSTestCase):
|
||
"""上传 → 服务端 publish → 真实 HTTP 服务 → 客户端 install_from_url。"""
|
||
|
||
def test_full_circle(self):
|
||
plugins_dir = self.path("plugins-root")
|
||
mod = _load_example(plugins_dir)
|
||
client = TestClient(mod.app)
|
||
|
||
package = self.path("sample.svfs")
|
||
with quiet():
|
||
client_sample.build(package)
|
||
with open(package, "rb") as handle:
|
||
response = client.post(
|
||
f"/plugins/{client_sample.PLUGIN_ID}",
|
||
files={"file": ("plugin.svfs", handle.read())},
|
||
)
|
||
self.assertEqual(response.status_code, 200, response.text)
|
||
|
||
# 用真实 HTTP 服务吐出插件目录,走客户端完整的远程安装链路
|
||
server = ThreadingHTTPServer(
|
||
("127.0.0.1", 0), partial(SimpleHTTPRequestHandler,
|
||
directory=plugins_dir)
|
||
)
|
||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||
thread.start()
|
||
self.addCleanup(server.shutdown)
|
||
base = f"http://127.0.0.1:{server.server_address[1]}"
|
||
|
||
data_base = client_paths.ensure_layout(self.path("data"))
|
||
with quiet():
|
||
entry = installer.install_from_url(
|
||
f"{base}/{client_sample.PLUGIN_ID}/manifest.json",
|
||
base=data_base,
|
||
)
|
||
self.assertEqual(entry["version"], "1.0.0")
|
||
self.assertEqual(entry["source"]["type"], "url")
|
||
self.assertEqual(
|
||
registry.load(data_base)["plugins"][client_sample.PLUGIN_ID]["version"],
|
||
"1.0.0",
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|