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
+151 -23
View File
@@ -329,6 +329,7 @@ wheel 里三个包(sqlite_vfs / svfs_studio / svfs_client)一起分发——
```bash
pip install "sqlite-vfs[client]" # 只装插件客户端(含 PySide6)
pip install "sqlite-vfs[mqtt]" # 再加消息模块的 MQTT 推送通道(paho-mqtt)
pip install sqlite-vfs # 只要核心库 + CLI(零第三方依赖)
```
@@ -358,11 +359,13 @@ pip install sqlite-vfs # 只要核心库 + CLI(零第三方依赖
PLUGIN_API_VERSION` 的插件(桥能力只增不破,升级客户端不会废掉旧插件;
要求更高版本的插件会得到「请升级客户端」提示)。取值参考:v2 = net 与
host.info 的 baseUrl;v3 = host.info 的 authUser、宿主给同源 net 请求自动
附加登录 token;v4 = dialog 权限与 `ui.confirm`/`ui.message` 原生对话框。
附加登录 token;v4 = dialog 权限与 `ui.confirm`/`ui.message` 原生对话框;
v5 = messages 权限与服务端推送(`svfs.messages.subscribe`)。
没用新能力就写 2(甚至 1)即可,兼容面更大;
* `entry`:SPA 入口。路径 404 且末段没有扩展名时回退到它(每个插件都是完整前端);
* `permissions`:声明这个插件要用哪些桥能力(storage / notify / title / net /
dialog),安装时向用户展示;
dialog / **messages**),安装时向用户展示。messages = 接收服务端推给本插件的
消息(见下文「消息模块」);
* `net_hosts`(可选,**net 目标白名单**):声明后,该插件经桥发的请求只能指向
列表里的主机,其它一律被宿主拒绝并给出明确错误。支持精确主机名与 `*.` 后缀
匹配(`"*.corp.example"` 含 `corp.example` 本身)。不写 = 不限制(老插件行为
@@ -409,28 +412,52 @@ pip install sqlite-vfs # 只要核心库 + CLI(零第三方依赖
误报「离线」),横幅给出「重新登录…」一键入口,用户中心与侧栏头像同步显示
失效状态。提示只发一次,重新登录成功后自动恢复;
* `host.info` 新增 `authUser` 字段(当前登录用户名,未登录为空串);
* 退出登录(用户中心或服务器卡片):清掉本地凭据 → 关闭主窗口 → 回到登录
界面(重新登录成功用新会话开回主窗口;关掉登录窗即退出客户端)——强制登录
下不存在"能用的未登录状态"。退出时同时断开消息推送并清掉该账号收到的消息
(下一个登录的人不该看到上一个人的消息);
* 登录成功后接上消息模块:**先同步**(HTTP 补齐离线期间的消息),再连 MQTT
接收实时推送;两者都进统一总线按 id 去重;
* 「插件管理 → 服务器」卡片显示登录状态,可「重新登录…」或「退出登录」。
### 界面:侧栏 / 设置 / 用户中心
* 启动后**直接显示插件内容**(可配置):优先用设置里配置的启动插件,
未配置则按默认规则(上次打开的 > 按 id 排序第一个启用的插件),都没有
未配置则按默认规则(上次打开的 > 显示顺序里第一个启用的插件),都没有
才停在插件管理页;
* **插件顺序可编排**:插件管理页每张卡片右下角有 ↑/↓,上移/下移一位即改
显示顺序(侧栏、图标栏、管理页三者一致);顺序存在 `settings.json` 的
`plugin_order` 里,只影响显示——不动包、不动注册表、不请求服务器。没排到
的插件(新装的)按名称跟在后面,"恢复默认顺序"一键清掉编排(那之后默认
按名称排)。启动直显的默认规则也读这个顺序,把常用插件排到最前面就默认
打开它;
* 侧栏顶部「«」把侧栏收成**图标栏**(宽度动画过渡,不再是瞬跳也不再让插件
不可达——图标带悬停提示,点击即打开),再点「»」展开;选择会记住,
下次启动保持;
* 侧栏搜索框**模糊匹配**插件名与 id(包含匹配优先,其次首字母缩写:
「csd」能搜到 `com.svfs.demo`、中文名直接搜汉字);无结果时给出提示,
清空即恢复全部;
* 侧栏底部「设置」:**插件管理入口**(安装本地 .svfs、从服务器安装、
升级/回滚/卸载)、**启动时打开**的插件、修改服务器地址(与服务器卡片
同步生效)、**数据目录**(插件路径,写指针文件,重启生效)、**对象缓存
路径**(留空 = 系统默认,安装时即时生效)与缓存清理、**网络**(插件 net
请求的超时与重试次数、代理、额外 CA 证书——内网自签 HTTPS 与公司代理都
在这里配,保存即生效,CA 路径写错会当场报错而不是等请求失败)、
**登录凭据**存储方式、**诊断日志落盘**开关;
* 侧栏底部「设置」——设置项按类型分成五个页签(每页内容超出时可滚动,不会被
压扁),每页里保存按钮只作用于自己那一组:
* **插件**:插件管理入口(安装本地 .svfs、从服务器安装、升级/回滚/卸载)、
桥调试台(一键装调试插件)、启动时打开的插件;
* **服务器**:服务器地址(与服务器卡片同步生效)、登录凭据的存储方式、
消息推送(MQTT)的 broker/组织/前缀/账号(保存即重连);
* **存储**:数据目录(插件路径,写指针文件,重启生效)、对象缓存路径
(留空 = 系统默认,安装时即时生效)与缓存清理;
* **网络**:插件 net 请求的超时与重试次数、代理、额外 CA 证书(内网自签
HTTPS 与公司代理都在这里配,保存即生效,CA 路径写错会当场报错);
* **诊断**:诊断日志落盘开关、导出诊断报告;
* 侧栏底部的 **✉ 消息入口**(带未读数字)进「消息中心」:服务端推来的主机
消息按时间倒序列出(级别、标题、时间、来源、广播标记),顶部是 MQTT 通道
状态灯与「立即同步 / 全部已读 / 清空」——进页面即算已读,徽标不会一直挂着;
* 侧栏底部的圆形头像(登录用户首字母)点击进「用户中心」:当前登录
状态、服务器地址、本次登录时间,以及退出登录 / 重新登录。
状态、服务器地址、本次登录时间,以及退出登录 / 重新登录;
* **退出登录 = 关掉主窗口并回到登录界面**:客户端是强制登录的,"未登录的
主窗口"本就不该存在,所以退出后不会留一个没有凭据的界面继续用。已打开的
插件页面会一并收掉(插件的键值存储按插件隔离、不区分用户,页面状态同理,
换账号重进时重新加载);重新登录成功则用新会话把主窗口开回来,用户关掉
登录窗则退出客户端。退出登录时也会尽力通知服务器作废 token;
* 插件页面底色固定为主题底色;WebEngine 在启动阶段预热(Profile 提前
创建 + 隐藏预热视图 + 共享 OpenGL 上下文)——首次打开插件不再闪白屏
或整窗闪烁。
@@ -494,12 +521,87 @@ await window.svfs.ui.setTitle('时钟 · 12:30'); // 需要 "title" 权限
// 需要 "dialog" 权限(v4+):宿主**原生对话框**,Promise 返回用户是否点了"确定"
const ok = await window.svfs.ui.confirm({ title: '删除', text: '确定删除这条记录?' });
await window.svfs.ui.message({ kind: 'warn', title: '警告', text: '余额不足' });
// 需要 "messages" 权限(v5+):订阅服务端推给**本插件**的消息
const off = (await window.svfsReady).messages.subscribe(function (message) {
// message.text 是原文;能解析成 JSON 时另有 message.json(仪表盘数据这类)
render(message.json || message.text);
});
// off() 取消订阅
</script>
```
`host.info` 不需要权限——它只包含插件自己的元数据、宿主版本号与配置的服务器
地址,不含用户数据。
### 消息模块:服务端主动推送(MQTT)
服务端要能随时把消息推给某个用户(或某个插件),走的是**统一消息总线**:
所有通道的消息先进总线,由总线按主题分类、去重、再分发。两条通道:
| 通道 | 时机 | 说明 |
|---|---|---|
| MQTT 推送 | 实时 | 服务端主动推;broker 地址由登录响应下发(也可在设置里手配) |
| HTTP 补齐 | **登录成功后先做一次**,也可点「立即同步」 | `GET /api/messages?since=…` 把离线期间攒下的消息补回来 |
两条路的消息都喂进同一个总线、按 id 去重,所以"推送"与"补齐"重叠、QoS 1
重投都不会重复显示。
**主题方案**(组织 `org` 是租户位,从第一版就占好——多租户上线不用改格式,
broker 侧按 `<prefix>/<org>/#` 下发 ACL 即可隔离):
```text
<prefix>/<org>/user/<user>/message 主机消息 → 消息中心
<prefix>/<org>/user/<user>/plugin/<plugin_id> 插件消息 → 只给该插件
<prefix>/<org>/broadcast/message 组织内广播 → 消息中心
<prefix>/<org>/broadcast/plugin/<plugin_id> 组织内广播 → 只给该插件
```
* 登录后客户端只订阅两条(最小权限,不是整个组织):`<prefix>/<org>/user/<user>/#`
与 `<prefix>/<org>/broadcast/#`;
* 用户名/插件 id 占一个主题层级,经**可逆**的百分号编码(`topic_segment`),
所以 "a b" 与 "a_b" 不会串线;服务端用同一个函数(`svfs_client.topics`)
拼主题,两端规则一致;
* 即使 broker 的 ACL 配错、把别人的消息推过来,总线还会按当前组织/用户再挡
一道(`forbidden` 计数 + 诊断日志)。
**两类消息泾渭分明**(分类只看主题,不看载荷):
* **主机消息**进消息中心(侧栏底部的 ✉,带未读徽标):载荷是消息封皮
`{id, title, body, level, time, source}`,纯字符串也当正文;
* **插件消息**不进消息中心:服务端发什么插件就收到什么(仪表盘数据、行情、
位号刷新这类业务载荷,补历史没有意义),宿主只负责按插件 id 投递。
插件页面是懒加载的,所以**消息可能比页面先到**:总线按插件缓存最近 50 条
(超了丢最旧的并计数),页面打开后一次性补投;页面侧还会再排一次队
(宿主在 DocumentCreation 阶段就立好投递入口),插件订阅时补发——所以
插件写 `subscribe` 时不用担心"消息是不是已经来过了"。
```js
// 插件侧(清单里声明 "messages" 权限才会存在)
const svfs = await window.svfsReady;
svfs.messages.subscribe((message) => {
console.log(message.text, message.json); // 原文 + 尽量解析出的 JSON
});
```
**配置与降级**:
* broker 地址优先用登录响应下发的 `mqtt: {host, port, tls, prefix, org}`
(部署信息不该让每个用户手填)。**也可以在「设置 → 消息推送(MQTT)」里直接
配**:broker(接受 `host` / `host:1883` / `mqtts://host:8883`,URL 形式里的
scheme 与端口优先)、端口、TLS、主题前缀、组织标识、broker 账号——保存即重连,
不用等下次登录;那一节还会显示**生效**的配置与来源(手配 / 服务端下发);
* broker 密码不走设置界面:缺省用登录 token(已加密存储、不落明文);需要单独的
broker 密码时由服务端在登录响应里下发(或写 `settings.json` 的
`mqtt_password`)。落盘的键:`mqtt_host` / `mqtt_port` / `mqtt_tls` /
`mqtt_prefix` / `mqtt_username` / `mqtt_password` / `org`;
* MQTT 需要可选依赖:`pip install "sqlite-vfs[mqtt]"`(`paho-mqtt`)。没装、
没配 broker、连不上,都只是"没有实时推送"——界面在消息中心显示状态
(未启用 / 连接中 / 已连接 / 连接失败),补齐照常工作;
* 断线重连交给 paho(指数退避 1→30 秒),重连成功后自动重新订阅;
* 连接状态、每个通道的收发与丢弃计数都进诊断日志(含 `forbidden`/`dropped`)。
### 发布 → 安装的完整链路
发布端用现成的 `svfs publish`,客户端用 manifest 安装(对象逐个 sha256 校验):
@@ -548,6 +650,19 @@ API 供插件经 `net` 调用;可选 `SVFS_UPLOAD_TOKEN` 环境变量保护上
| `GET /api/me` | 带 Bearer token → `{"ok","username","login_time"}`,用于启动时静默校验 |
| `POST /api/logout` | 作废当前 token |
消息相关(见上文「消息模块」):
| 接口 | 说明 |
|---|---|
| `GET /api/messages?since=` | 客户端登录后/「立即同步」时补齐消息(只返回该登录用户的) |
| `POST /api/push` | 服务端主动推:`kind=message` 进消息中心 + 待收列表;`kind=plugin` 只走插件主题(`plugin` 字段指定目标插件,`data` 原样发给插件) |
| `POST /api/broadcast` | 组织广播(主题里带 `broadcast`) |
配了 broker(`SVFS_MQTT_HOST`,可选 `SVFS_MQTT_PORT` / `SVFS_MQTT_TLS` /
`SVFS_ORG`)时,推送会真的发布到 broker,且 broker 地址随登录响应下发,
客户端自动连上;没配就只写待收列表(响应里如实说明 `mqtt=skipped`),
客户端仍能靠登录后的同步看到消息——这条降级路径让演示不依赖 broker。
另外提供可选的 `/client/manifest.json`(客户端自更新检查):设置环境变量
`SVFS_CLIENT_VERSION=9.9.9 SVFS_CLIENT_URL=https://…` 即可看到「客户端有新版本」
提示,不设置时返回 404(客户端静默跳过)。生产上这个文件直接静态托管即可,
@@ -595,20 +710,32 @@ curl -H "Authorization: Bearer $token" http://127.0.0.1:8000/api/ping
### 实现分层(与 svfs-studio 同一套约定)
`manifest` / `paths` / `registry` / `installer` / `sample` / `auth` /
`credentials` / `diagnostics` / `netutil` / `search` 是**纯标准库**
(不 import Qt,可在无 PySide6 的环境单独测试);`scheme` / `bridge` /
`webview` / `worker` / `login`(启动动画与登录窗)/ `shell` / `app` 属于 Qt 层。
`credentials` / `diagnostics` / `netutil` / `search` / `topics` / `messages` /
`bus` / `mqtt` 是**纯标准库/无 Qt 依赖**(不 import Qt,可在无 PySide6 的环境
单独测试;`mqtt` 的 paho 也是可选依赖,缺了只报 `missing` 状态);
`scheme` / `bridge` / `webview` / `worker` / `mqtt_service` / `messages_page` /
`login`(启动动画与登录窗)/ `shell` / `app` 属于 Qt 层。
界面复用 svfs-studio 的主题与基础部件,视觉与 Studio 一致。
测试(逻辑层纯标准库;界面层 offscreen,含一次真实的 WebEngine 冒烟——加载
示例插件并验证桥注入与 storage 往返,环境起不来 WebEngine 时自动跳过):
示例插件并验证桥注入、storage 往返与消息订阅;环境起不来 WebEngine 时自动跳过)。
**入口是 `tests/run_tests.py` 而不是直接 `python -m unittest`**:QtWebEngine 在
offscreen 下的解释器退出路径会崩(测试全绿、退出码却是 139,CI 会判失败),
runner 拿到 unittest 的结果后用 `os._exit` 退出,绕开那段清理;测试真正崩在
中途时退出码照样非 0:
```bash
python -m unittest discover -s tests -p "test_client_logic.py" -v
python -m unittest discover -s tests -p "test_client_auth.py" -v # 登录认证/网络策略(纯层)
python -m unittest discover -s tests -p "test_client_credentials.py" -v # 凭据存储(纯层)
python -m unittest discover -s tests -p "test_client_login_ui.py" -v # 登录窗/启动动画/桥注入
python -m unittest discover -s tests -p "test_client_ui.py" -v
uv run python tests/run_tests.py # 全部(716 条)
uv run python tests/run_tests.py -v # 逐条打印
# 只跑某一层(同样建议走 runner,参数透传给 unittest discover)
uv run python tests/run_tests.py -p "test_client_logic.py" # 清单/路径/注册表/安装器/顺序编排(纯层)
uv run python tests/run_tests.py -p "test_client_auth.py" # 登录认证/网络策略(纯层)
uv run python tests/run_tests.py -p "test_client_credentials.py" # 凭据存储(纯层)
uv run python tests/run_tests.py -p "test_client_messages.py" # 消息主题/封皮/总线(纯层)
uv run python tests/run_tests.py -p "test_client_mqtt.py" # MQTT 通道(含自建 broker 端到端)
uv run python tests/run_tests.py -p "test_client_login_ui.py" # 登录窗/启动动画/桥注入
uv run python tests/run_tests.py -p "test_client_ui.py" # 主窗口/协议层/WebEngine 冒烟
```
@@ -892,12 +1019,13 @@ sqlite_vfs/
# 安装开发依赖
uv sync --dev
# 运行测试(测试是纯标准库 unittest,没有引入 pytest)
uv run python -m unittest discover -s tests
# 运行测试(测试是纯标准库 unittest,没有引入 pytest;入口见 tests/run_tests.py
# 的说明——QtWebEngine 的解释器退出路径会崩,直接 discover 会拿到非零退出码)
uv run python tests/run_tests.py
# 只跑界面测试(offscreen,不需要显示器;未装含 GUI 的 extra 时会整体跳过)
uv sync --extra gui
uv run python -m unittest discover -s tests -p "test_studio_ui.py" -v
uv run python tests/run_tests.py -p "test_studio_ui.py" -v
# 构建包
uv build