Files
svfs/docs/mqtt-guide.md
T
chakcy 9dc2dde59f
tests / ubuntu-latest / py3.13 (push) Has been cancelled
tests / windows-latest / py3.13 (push) Has been cancelled
tests / ubuntu-latest / py3.13 (pull_request) Has been cancelled
tests / windows-latest / py3.13 (pull_request) Has been cancelled
modify: 消息模块
2026-09-17 11:23:34 +08:00

704 lines
34 KiB
Markdown
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.
# 消息模块说明:broker 部署 / 客户端配置 / 服务端开发
svfs 的消息模块让**服务端能主动把消息推给某个用户或某个插件**:给用户看的进客户端
「消息中心」,给插件的(仪表盘数据、行情、位号刷新这类业务载荷)只投给那个插件。
整套东西只需要三样:一个 MQTT broker、客户端里配好连接、服务端把消息发出去。
本文按这三块展开——
| 你要做的事 | 看哪一节 |
|---|---|
| 把 broker 跑起来(Docker) | 二、Docker 部署 broker |
| 让客户端连上、看到消息 | 三、前端配置(客户端) |
| 在自己的服务端里发消息 | 四、服务端开发 |
可直接运行的部署资产在 `deploy/mqtt/`:`docker compose up -d` 起一个 mosquitto,
配置里预留了认证、ACL、TLS 三段(默认全关,注释形式)。
> 关于本文的验证程度:主题方案、封套解析、去重、配置优先级这些都对着源码逐条核过
> (各节标注了出处),仓库里的测试也覆盖了这些路径;但 `deploy/mqtt/` 的
> compose / mosquitto 配置是按 mosquitto 2.x 的官方语义写的,**开发机上没有 Docker,
> 没有实际起过容器**。第一次落地前先在内网按第二节跑一遍,有问题再回来对。
---
## 一、模块速览
### 两条通道
| 通道 | 时机 | 说明 |
|---|---|---|
| MQTT 推送 | 实时 | 服务端主动推;broker 地址由登录响应下发(也可在设置里手配) |
| HTTP 补齐 | **登录成功后先做一次**,也可点「立即同步」 | `GET /api/messages?since=…` 把离线期间攒下的消息补回来 |
登录后的顺序是**先补齐、再交给 MQTT**(`shell.py` 的 `_start_messaging`):MQTT 只能保证
"从现在起"的消息,离线那批只能靠补齐拿回来。两条路的消息都喂进同一个总线、按 id 去重,
所以推送与补齐重叠不会重复显示。
### 消息怎么走
```text
服务端 ──publish──> MQTT broker ──> MqttTransport(paho 网络线程)
│ on_message(回调还在网络线程)
▼
MqttService(Qt 排队信号 → 主线程)
│
┌──────────────────┘
▼
MessageBus:按主题分类 → 归属校验 → 去重 → 分发
│
┌───────────┴────────────┐
▼ ▼
消息中心(✉ 未读徽标) 插件队列(页面打开后投递)
```
### 实现分层
| 模块 | 层 | 职责 |
|---|---|---|
| `topics.py` | 纯标准库 | 主题拼装 / 解析 / 层级转义 |
| `messages.py` | 纯标准库 | 消息封套解析 + 消息日志(环形缓冲、未读、去重) |
| `bus.py` | 纯标准库 | 总线:分类、归属校验、去重、分发 |
| `mqtt.py` | 纯标准库(paho 可选) | 连接、重连、订阅、收包 |
| `mqtt_service.py` | Qt | 把网络线程回调转成主线程信号 |
| `messages_page.py` | Qt | 消息中心界面 |
| `shell.py` | Qt | 生命周期编排(登录后先补齐再连) |
纯层不 import Qt,可以在没有 PySide6 的环境里单独测;`mqtt.py` 里的 paho 是可选依赖,
缺了只报 `missing` 状态,不影响其它功能。
### 四条语义(先记住,后面都用得上)
* **只有登录后才有消息**:没有身份时总线一律不收(`bus._allowed`),退出登录即断开并清空;
* **分类只看主题,不看载荷内容**:所以服务端不会"一不小心把仪表盘数据推进用户的消息列表";
* **主机消息有封套、进消息中心;插件消息原样投给插件**,两类泾渭分明;
* **实时通道是 QoS 0**(丢了不补,靠登录后补齐兜底),去重按消息 id。
客户端侧依赖:
```bash
pip install "sqlite-vfs[mqtt]" # paho-mqtt,实时推送通道需要
```
没装不会坏事:状态显示「未安装 paho-mqtt,实时推送不可用」,补齐照常把消息带回来。
开发环境 `uv sync` 已经带了 paho(dev 依赖里有);打包客户端时,构建环境里装了它就会一并打进去。
---
## 二、Docker 部署 broker(mosquitto)
### 五分钟起步
```bash
cd deploy/mqtt
docker compose up -d
docker compose logs -f mqtt # 看到 "Opening ipv4 listen socket on port 1883" 就是好了
```
目录长这样:
```text
deploy/mqtt/
├── docker-compose.yml # eclipse-mosquitto:2,映射 1883(8883 注释着)
├── .gitignore # 密码文件与证书不进仓库
└── config/ # 整份挂到容器的 /mosquitto/config
├── mosquitto.conf # 默认匿名;认证/ACL/TLS 三段注释着
└── acl # 最小权限 ACL 示例(要用才在 conf 里启用)
```
`config/` 是整目录挂载(不是单文件):`mosquitto_passwd` 要往这里写密码文件,
只挂单个文件的话写进去的东西落在容器层,重启就没了。数据与日志用具名卷
(`mqtt-data` / `mqtt-log`),免得撞上 Linux 上的目录属主问题(容器里的
mosquitto 是 uid 1883)。
### 先确认 broker 真的在收发
镜像里自带 `mosquitto_sub` / `mosquitto_pub`,不用另装客户端:
```bash
# 终端 1:订阅所有 svfs 主题(-v 顺带打出主题)
docker compose exec mqtt mosquitto_sub -h localhost -t 'svfs/#' -v
# 终端 2:模拟服务端推给 alice 的一条主机消息
docker compose exec mqtt mosquitto_pub -h localhost \
-t svfs/default/user/alice/message \
-m '{"id":"t1","title":"你好","body":"来自 mosquitto_pub","level":"info"}'
```
终端 1 应当原样打印出主题与载荷。这一对通了,说明 broker 与主题方案没问题,
剩下的就是服务端和客户端的事。
### 配置逐行看
| 配置 | 作用 | 注意 |
|---|---|---|
| `listener 1883` | 开监听口 | mosquitto 2.x 不写 listener 时只接受 loopback 连接(容器里等于没人连得上) |
| `allow_anonymous true` | 放行无凭据连接 | 定义了 listener 之后它**默认是 false**,必须显式写——"端口通、连不上"多半是这里 |
| `persistence true` | 落盘会话/保留消息 | 本模块 QoS 0 + clean session,其实用不上;留着是免得以后调 QoS 时回头改 |
| `log_dest stdout` | 日志进 `docker compose logs` | 另一条 `log_dest file` 是留档,用 `docker compose exec mqtt cat /mosquitto/log/mosquitto.log` 看 |
**客户端带用户名密码连匿名 broker 会被拒吗?** 不会。客户端总是带上用户名
(默认是登录名)与密码(默认是登录 token),而 `allow_anonymous true` 且没有
`password_file` 时,broker 没有可校验的东西,直接放行。所以内网匿名是最省事的组合:
客户端零配置,broker 地址由登录响应下发。
### 档 A:内网匿名(默认,先用它跑通)
适合内网、开发、演示。风险是**任何能连到 1883 的人都能订阅所有主题**,包括别人的消息。
真要收紧就把它绑到内网地址、只对服务端所在网段放行,或者直接进档 B。
### 档 B:认证 + 最小权限 ACL(生产该用)
`config/mosquitto.conf` 的「认证 + ACL」段里写了三步(造密码文件、准备客户端密码、
解开三行重启)。这里补三处最容易踩的:
**1)客户端的密码默认是登录 token,不是你在 passwd 里设的那个密码。**
取值顺序是 `mqtt_password` 设置 → 服务端下发的 `mqtt.password` → **登录 token**
(`settings.py` 的 `mqtt_config`)。所以开了 `password_file` 之后二选一:
* 服务端在登录响应的 `mqtt` 段里多下发一个 `password`(客户端零配置,推荐;
示例服务端只下发 host/port/tls/prefix/org,需要就自己加上这两个键);
* 或在客户端 `settings.json` 里写 `mqtt_password`(明文落盘,见第三节的安全提示)。
**2)服务端自己也连不上了。**
示例服务端的 `_mqtt_publish` 不带 broker 账号,`allow_anonymous false` 之后每条推送
都会失败(`/api/push` 会如实返回 `error: …`,不会假装成功)。给它加账号:
```python
# examples/plugin_server.py 的 _mqtt_publish 里,mqtt.Client(...) 之后、connect 之前
user = os.environ.get("SVFS_MQTT_USER", "").strip()
if user:
client.username_pw_set(user, os.environ.get("SVFS_MQTT_PASS") or None)
```
然后带 `SVFS_MQTT_USER=svfs-server SVFS_MQTT_PASS=…` 启动,并在 ACL 里给它写权限
(`config/acl` 的 `svfs-server` 段就是现成的)。更正规的做法是服务端用一条常驻连接
发布,见第四节的发布代码。
**3)ACL 里的用户名是 broker 用户名,组织段要写死。**
ACL 的 `<用户名>` 默认就等于 svfs 登录名(`mqtt_username` 留空时);组织段(`acl` 里的
`default`)要换成你的 `SVFS_ORG` 并保持固定——多租户隔离靠的就是这一段,写成 `+`
等于没隔离。用 `pattern` 时 `%u` 必须独占一个主题层级(`svfs/x/user/%u/#` 可以,
`svfs/x/user/%u-2/#` 不行)。
### 档 C:TLS(8883)
解开 `config/mosquitto.conf` 的 TLS 段、把证书放进 `config/certs/`、打开 compose 里的
8883 端口映射。证书自签即可:
```bash
cd deploy/mqtt/config && mkdir -p certs && cd certs
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 \
-subj "/CN=svfs-dev-ca"
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr \
-subj "/CN=mqtt.example"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 3650 -extfile <(printf "subjectAltName=DNS:mqtt.example,IP:10.0.0.5")
```
**关键约束:客户端没有 CA 配置项。** 它只做「系统信任库 + `CERT_REQUIRED`」校验
(`mqtt.py` 的 `_configure`:`tls_set(cert_reqs=ssl.CERT_REQUIRED)`),
设置里的 `ca_bundle` 只管 HTTP(`netutil`),没有接到 MQTT 上。所以自签证书要么把
CA 装进**每台客户端**的系统信任库(Windows:导入「受信任的根证书颁发机构」;
macOS:钥匙串;Linux:`/usr/local/share/ca-certificates` + `update-ca-certificates`),
要么内网先用明文 1883 + 网络层隔离。**证书里的 CN/SAN 必须和客户端填的 host 一致**,
用 IP 连就写 IP。
自测(在客户端机器上跑,验证信任链):
```bash
openssl s_client -connect 10.0.0.5:8883 -CAfile ca.crt </dev/null | head -5
```
服务端侧 `SVFS_MQTT_TLS=1` 时走的是 `client.tls_set()`,同样是系统信任库。
### 排障对照表
| 现象 | 多半是 | 处理 |
|---|---|---|
| 状态灯一直「连接中…」 | 端口/防火墙/DNS,或 host 写错 | `docker compose logs -f mqtt` 里没有连接记录,就是包根本没到 broker |
| 「连接失败:broker 拒绝连接(code=4/5)」 | 认证没过 | code=4 是用户名/密码不对(最常见:客户端用的还是登录 token,而 passwd 里是另一个密码),code=5 是没有权限(ACL 里没给这条主题的读);见档 B |
| 连上了但一条都收不到 | 组织或前缀对不上(最常见),或发布主题与订阅主题不一致 | 对一下设置里显示的那行「生效的配置」(含 org / prefix)与发布时用的 prefix/org |
| 收到别人的消息 | broker 的 ACL 没生效 | 总线会挡住并记 `forbidden`(消息不显示),但要在 broker 侧修 |
| 消息偶尔丢/重复 | QoS 0 + 推送与补齐重叠 | 丢的靠「立即同步」补;重复由 id 去重(不填 id 时按"时间+标题+正文",同一秒内相同内容会被当成重投) |
| Linux 上容器起不来,日志报权限 | 宿主的 `config/` 属主不是 uid 1883 | `sudo chown -R 1883:1883 config`(数据/日志已用具名卷规避) |
| 诊断日志里 `unknown topic` | 服务端主题拼错了 | 用 `svfs_client.topics` 拼,别手写(见第四节) |
| 诊断日志里 `消息没有可展示的内容` | 载荷是空对象/空串 | 主机消息至少要有 `title` 或 `body` |
---
## 三、前端配置(客户端)
### broker 地址从哪来
| 优先级 | 来源 | 什么时候生效 |
|---|---|---|
| 1 | 设置里**显式改过**的值 | 本地调试、服务端没下发 |
| 2 | 登录响应下发的 `mqtt: {host, port, tls, prefix, org[, username, password]}` | 正常部署(推荐,客户端零配置) |
| 3 | 内置默认 | 兜底(端口 1883、prefix `svfs`、org `default`) |
**为什么强调"显式改过"**:`mqtt_port` / `mqtt_tls` 自带默认值(1883 / 关),如果按
"有值就优先"来判,服务端下发的 8883 与 TLS 会被默认值永久压掉——这是实现时踩过的坑
(`settings.py` 的 `mqtt_config` 注释里记着),所以判据是"与默认值不同才算你改过"。
反过来说:想恢复"跟随服务端下发",就把这些字段改回默认值(broker 留空、端口 1883、TLS 取消勾选)。
### 设置界面 ↔ settings.json
「设置 → 服务器 → 消息推送(MQTT)」:
| 设置项 | `settings.json` 键 | 默认 | 说明 |
|---|---|---|---|
| broker | `mqtt_host` | 空 | 接受 `host`、`host:1884`、`mqtts://host:8883`;URL 形式里的 scheme 与端口优先 |
| 组织 | `org` | 空 | 留空 = 用服务端下发的,再不行 `default` |
| TLS | `mqtt_tls` | false | 勾上就是 mqtts |
| 端口 | `mqtt_port` | 1883 | TLS 时缺省是 8883 |
| 主题前缀 | `mqtt_prefix` | `svfs` | 两端必须一致 |
| broker 账号 | `mqtt_username` | 空 | 留空 = 用登录用户名 |
| (无界面) | `mqtt_password` | 空 | 留空 = 用登录 token;手写是明文,见下 |
| 「保存并重连」 | — | — | 改完立即重连,不用等下次登录 |
那一节底部会显示**生效**的配置与来源(`mqtt://host:1883(org=acme,prefix=svfs)`
加上「· 来自服务端下发」或「· 已保存并重连」)。手改文件也可以,路径是
```text
Windows: %LOCALAPPDATA%\svfs-client\settings.json
其它: ~/.svfs-client/settings.json # 数据目录被「设置 → 存储」挪过就用挪后的
```
写是原子的(临时文件 + `os.replace`),手改不怕写坏;但改完要重启客户端(除了上面那几个
界面上的字段,它们是保存即生效)。
### 凭据怎么存
| 用什么 | 取值顺序 | 存在哪 |
|---|---|---|
| broker 用户名 | `mqtt_username` → 登录用户名 | 明文(`settings.json` / 登录响应) |
| broker 密码 | `mqtt_password` → 服务端下发的 `mqtt.password` → **登录 token** | 前两者是明文;登录 token 在 `credentials.dat`(Windows DPAPI / keyring 加密) |
| HTTP 补齐用的 token | 登录拿到的 | 同上,加密 |
安全提示:**别把长期的 broker 密码手写进 `settings.json`**(明文落盘)。要独立密码就让
服务端在登录响应里下发——那是每次登录现拿的;或者干脆让 broker 校验登录 token(见 4.8)。
### 连接状态六态
| 状态 | 界面上显示(状态:明细) | 灯色 | 含义 |
|---|---|---|---|
| `disabled` | 未启用:未配置 broker | 灰 | 没配 broker,服务端也没下发 |
| `missing` | 未安装 paho-mqtt,实时推送不可用:需要可选依赖… | 黄 | 缺可选依赖,补齐照常工作 |
| `connecting` | 连接中…:127.0.0.1:1883,掉线时是「连接中…:连接断开(code=0),正在重连…」 | 黄 | 正在连或退避重连(1→30 秒);重连成功后自动重新订阅 |
| `connected` | 已连接:127.0.0.1:1883(已收 3 条) | 绿 | 已订阅两条主题;括号里的条数在"连上了却收不到"时最有用 |
| `error` | 连接失败:broker 拒绝连接(code=5) | 红 | broker 拒连(认证/ACL 问题) |
| `stopped` | 已断开:已主动断开 | 灰 | 退出登录/关窗主动断开 |
在哪看:侧栏底部的 ✉ 进「消息中心」,右上角就是状态灯与那行状态文字;连不上时状态栏
也会提示一句。「设置 → 诊断」里能导出诊断报告,滚动日志在数据目录的
`logs/client.log`(Windows:`%LOCALAPPDATA%\svfs-client\logs\client.log`)——里面按时间记着
`MQTT 状态 …`、每一条订阅、以及总线的越权丢弃(`forbidden`)与积压丢弃(`dropped`)。
### 消息中心行为
* 侧栏 ✉ 带未读数(`✉3`),tooltip 是「消息中心(3 条未读)」;**进入消息中心就算全部已读**;
* 顶部按钮:立即同步 / 全部已读 / 清空;
* 每条卡片显示级别、标题、正文、时间、来源,组织广播带「广播」标记;
* `warn` / `error` 级别的消息会顺手弹一条系统托盘通知(6 秒);`info` 不打扰;
* 上限 500 条(超了丢最旧的),按时间倒序;列表只显示该登录用户自己的消息 + 组织广播;
* 退出登录/切账号:断开连接、清空列表,此后再来的推送一律丢弃(不是"藏起来",是真的不收)。
### 插件怎么收消息(插件前端)
清单里声明 `messages` 权限(桥 API 版本要 ≥ 5):
```json
{
"id": "com.example.dash",
"api_version": 5,
"permissions": ["storage", "net", "messages"]
}
```
```js
// 插件页面里
const svfs = await window.svfsReady;
if (svfs.messages) {
const off = svfs.messages.subscribe((message) => {
// message.text 是原文;能解析成对象时另有 message.json
render(message.json ?? message.text);
});
}
```
消息对象是 `{text: string, json?: object, time: string}`——`time` 目前恒为空串
(投递路径里没带时间),要时间戳就自己把它放在载荷里。
**不用怕"消息比页面先到"**,这条链路上有三层缓冲:
1. 插件页面是懒加载的,可能还没打开;
2. 总线按插件缓存最近 50 条(超了丢最旧的并计 `dropped`),去重记忆 200 条;
3. 页面打开时宿主把攒下的投进去;页面里的 `window.__svfsPush` 在 DocumentCreation 阶段
就立好了,订阅之前到的先排队、`subscribe` 时补发。
所以 `subscribe` 只写"收到消息怎么办"就够了,不需要自己去拉历史。调试时可以用客户端
「设置 → 桥调试台 → 安装调试插件」里的 messages 面板对照(`sample.py` 里的实现)。
---
## 四、服务端开发
### 要做的两件事
1. **发布**:把消息 publish 到 broker 的对应主题;
2. **下发**:在登录响应里带上 broker 信息,让客户端自动连上(否则用户得自己填地址)。
### 4.1 拼主题别手写
```python
from svfs_client.topics import (
user_message_topic, user_plugin_topic,
broadcast_message_topic, broadcast_plugin_topic,
)
```
装 `pip install sqlite-vfs` 就能复用(纯标准库,不需要 PySide6)。**为什么必须复用**:
用户名与插件 id 要占一个主题层级,得先过一层可逆的百分号编码(安全字符
`[A-Za-z0-9._@-]` 原样保留,其余编码),两端规则不一致就会"发得出去、收不到",
而且 `a b` 与 `a_b` 会撞成同一个主题、串了租户。示例服务端就是直接 import 这个模块的。
### 4.2 主题与通道
| 主题 | 谁收 | 进消息中心 |
|---|---|---|
| `<prefix>/<org>/user/<user>/message` | 该用户 | 是 |
| `<prefix>/<org>/user/<user>/plugin/<plugin_id>` | 该用户的该插件 | 否 |
| `<prefix>/<org>/broadcast/message` | 整个组织 | 是(带广播标记) |
| `<prefix>/<org>/broadcast/plugin/<plugin_id>` | 整个组织的该插件 | 否 |
客户端登录后只订阅两条(最小权限,不是整个组织的 `#`):
```text
<prefix>/<org>/user/<user>/#
<prefix>/<org>/broadcast/#
```
即使 broker 的 ACL 配错、把别人的消息推了过来,总线还会按当前组织/用户再挡一道
(记 `forbidden` 计数 + 诊断日志)。
### 4.3 发布代码
示例服务端(`examples/plugin_server.py` 的 `_mqtt_publish`)为了演示简单,是
"连上 → 发一条 → 断开",每条消息一次握手;生产上换成常驻连接更合适:
```python
import json
import os
import threading
from datetime import datetime
import paho.mqtt.client as mqtt
from svfs_client.topics import user_message_topic, user_plugin_topic
MQTT_HOST = os.environ.get("SVFS_MQTT_HOST", "")
MQTT_PORT = int(os.environ.get("SVFS_MQTT_PORT", "") or 1883)
MQTT_PREFIX = os.environ.get("SVFS_MQTT_PREFIX", "svfs")
ORG = os.environ.get("SVFS_ORG", "").strip() or "default"
_lock = threading.Lock()
_client = None
def _connection():
"""进程内共用一条连接(paho 自己管心跳与重连)。"""
global _client
with _lock:
if _client is None:
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
client_id="svfs-server")
user = os.environ.get("SVFS_MQTT_USER", "").strip()
if user: # broker 开了认证就必须给账号
client.username_pw_set(user, os.environ.get("SVFS_MQTT_PASS") or None)
if os.environ.get("SVFS_MQTT_TLS", "").lower() in ("1", "true", "yes"):
client.tls_set() # 用系统信任库;自签证书见第二节「档 C:TLS」
client.connect_async(MQTT_HOST, MQTT_PORT, 30)
client.loop_start()
_client = client
return _client
def publish(topic, payload, qos=0):
"""发一条;没配 broker / 没连上都返回说明字符串(不抛,消息通道不能拖垮业务)。"""
if not MQTT_HOST:
return "skipped: 未配置 SVFS_MQTT_HOST"
client = _connection()
if not client.is_connected():
# connect_async 不阻塞:进程刚起或断线重连中就是 False。如实报这一条失败
# (paho 会自己连回来),别把业务请求堵在握手上
return "error: 还没连上 broker(重连中)"
try:
info = client.publish(topic, payload, qos=qos)
if info.rc != mqtt.MQTT_ERR_SUCCESS:
return f"error: {mqtt.error_string(info.rc)}"
info.wait_for_publish(timeout=5) # QoS 0 一般立刻返回
except Exception as exc:
return f"error: {exc}"
return "published"
def push_message(user, title, body, level="info", msg_id=""):
message = {
"id": msg_id, # 建议给:客户端按它去重
"title": title,
"body": body,
"level": level, # info | warn | error
"time": datetime.now().isoformat(timespec="seconds"),
"source": "my-service",
}
topic = user_message_topic(MQTT_PREFIX, ORG, user)
return topic, publish(topic, json.dumps(message, ensure_ascii=False))
```
上面这段有两个刻意的选择,都是踩过才知道的:
* `connect_async` 不阻塞,所以进程刚起的那一小会儿 `is_connected()` 还是 False——
这期间的推送会返回 `error: 还没连上 broker(重连中)`。**宁可如实报错误,也不要让业务
请求卡在 TCP 握手上**;要"启动即确认连上",就把 `connect_async` 换成同步的 `connect`
(连不上会抛,自己接住),或者在连上之前先不对外提供服务;
* 返回字符串而不是抛异常:`/api/push` 这类接口即使发不出去也要能正常返回——消息本来
就会进待收列表,客户端登录后还能补齐。
给插件发数据就是换主题、载荷随便(原样投递):
```python
topic = user_plugin_topic(MQTT_PREFIX, ORG, user, "com.example.dash")
publish(topic, json.dumps({"cpu": 0.42}))
```
### 4.4 登录响应里下发 broker
客户端会读登录响应里的 `mqtt` 段,据此自动连接:
```json
{
"ok": true,
"token": "…",
"username": "alice",
"org": "acme",
"mqtt": {
"host": "10.0.0.5",
"port": 1883,
"tls": false,
"prefix": "svfs",
"org": "acme"
}
}
```
| 键 | 必填 | 说明 |
|---|---|---|
| `host` | 是 | 客户端能访问到的 broker 地址(不是 broker 眼里的自己) |
| `port` | 否 | 不给就按 `tls` 推 1883 / 8883 |
| `tls` | 否 | true 走 mqtts |
| `prefix` | 否 | 缺省 `svfs`,两端必须一致 |
| `org` | 否 | 缺省 `default`;也是客户端订阅时的租户段 |
| `username` / `password` | 否 | 给客户端一套独立的 broker 账号时用(客户端优先用它,`settings.py` 的 `mqtt_config`) |
`GET /api/me`(启动时静默校验 token)也会带上同样的段,所以客户端重启后不用重新登录
也能拿到 broker 信息。**没配 broker 就别带 `mqtt` 键**(示例服务端的
`_mqtt_announcement` 就返回 None)——客户端会走"只补齐、不实时"的降级,这是正常状态。
### 4.5 主机消息的封套
```json
{
"id": "b7c1…",
"title": "备份完成",
"body": "3 个包已同步",
"level": "info",
"time": "2026-09-16T10:00:00",
"source": "backup"
}
```
| 字段 | 类型 | 上限 | 缺省 | 说明 |
|---|---|---|---|---|
| `id` | 字符串 | 120 | 空 | 去重键,**强烈建议给**(给了就不会重复显示) |
| `title` | 字符串 | 200 | 空 | 标题 |
| `body` | 字符串 | 4000 | 空 | 正文 |
| `level` | 字符串 | — | `info` | `info` / `warn` / `error`,认不出来的当 `info` |
| `time` | 字符串 | 40 | 收到的时间 | ISO8601;要能被**字符串比较**(增量补齐靠它) |
| `source` | 字符串 | 120 | 空 | 来源标记,界面上显示 |
解析很宽松,下面这些也能用:
* 载荷是**纯字符串**或 JSON 标量(数字、带引号的串)→ 整段当正文;
* 不是合法 JSON 的字符串 → 整段当正文;
* `title` 与 `body` **都空**的(空串、`{}`)→ 整条丢弃,不往消息中心塞空气。
**去重规则**:有 `id` 就按 `id`;没有就按 `时间|标题|正文`。所以同一秒里发两条内容
完全相同、又都没带 `id` 的消息,第二条会被当成重投丢掉——给 `id` 最省事。
广播的封套一样,只是走 `broadcast` 主题;建议把 `source` 写成能看出是谁发的
(示例是 `broadcast@<用户名>`)。
### 4.6 插件消息
* 载荷**原样投递**,宿主不解析、不加封套(插件自己的协议自己定);
* 不进消息中心、不进待收列表——它是实时业务载荷,补历史没有意义;
* 去重是"内容 + 秒"级别(同一插件、同一正文、同一秒视为重复),这是为了挡 QoS 1 重投
与推送/补齐的重叠。**所以:要分帧或累积的数据请自己带序号**,否则同一秒里两帧一模一样的
数据会被当成重投丢掉。
### 4.7 HTTP 补齐接口(离线消息)
客户端登录后(以及点「立即同步」时)会拉一次:
| 项 | 约定 |
|---|---|
| 方法与路径 | `GET <base_url>/api/messages` |
| 请求头 | `Accept: application/json`、`Authorization: Bearer <token>` |
| 参数 | `since`(可选):本地最新一条消息的 `time`,只要这之后的消息 |
| 响应 | `{"ok": true, "messages": [封套, …]}`;没有新消息就是空数组 |
| 没这个接口 | 返回 404,客户端当作"服务端不支持",静默降级(不报错、不刷状态) |
| 客户端限制 | 15 秒超时、失败重试 1 次、最多读 2MB、最多取 500 条 |
服务端侧要注意的:
* **只返回该 token 对应用户的消息**(示例服务端按 username 分桶存);
* `since` 用字符串比较 `>` 就够了(ISO8601 定长、字典序即时间序);
* 返回的封套与 MQTT 推送用的是同一套,客户端按 id 去重——所以你**可以放心把发过的消息
也留在待收列表里**,推送与补齐重叠不会显示两条;
* 示例的待收列表是内存里每人 100 条、重启即清(`MESSAGE_BACKLOG`),生产该入库;
* 广播不进待收列表(示例如此):**组织广播只有在线的人看得到**,要保证人人可见就得自己
把广播也存下来并在补齐接口里带上。
### 4.8 认证怎么落地
| 方案 | 客户端怎么连 | broker 侧 | 适合 |
|---|---|---|---|
| 内网匿名 | 用户名=登录名、密码=token,broker 不校验 | `allow_anonymous true` | 内网/开发/演示 |
| 静态账号 + 独立密码 | 服务端下发 `mqtt.password`,或客户端写 `mqtt_password` | `password_file` + `acl_file` | 多数生产场景 |
| broker 校验登录 token | **默认行为**(密码就是 token) | broker 的认证回调打到你的服务端:EMQX 5.x 的 HTTP 认证、mosquitto 的动态安全插件或 mosquitto-go-auth 这类外部插件 | 想"零配置 + 和登录体系联动" |
第三种最贴合本模块的现成设计(客户端本来就把登录 token 当 broker 密码发),代价是
broker 侧要接一个认证回调:收到 `(username, password)` 就调你的 `/api/verify` 校验这个
token 是否有效、属于谁,顺便还能把 ACL 一起下发("你只能读写 `svfs/<org>/user/<你>/#`")。
### 4.9 端到端跑一遍
```bash
# 0) broker
cd deploy/mqtt && docker compose up -d
# 1) 服务端:下发 broker 地址(客户端登录后自动连上)
SVFS_MQTT_HOST=127.0.0.1 SVFS_MQTT_PORT=1883 SVFS_ORG=acme \
uv run python examples/plugin_server.py
# 2) 登录取 token
token=$(curl -s -X POST http://127.0.0.1:8000/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' \
| python -c "import json,sys; print(json.load(sys.stdin)['token'])")
# 3) 主机消息 → 消息中心
curl -s -X POST http://127.0.0.1:8000/api/push -H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d '{"kind":"message","title":"备份完成","body":"3 个包已同步","level":"info"}'
# 4) 插件数据 → 只给 com.svfs.demo 这个插件
curl -s -X POST http://127.0.0.1:8000/api/push -H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d '{"kind":"plugin","plugin":"com.svfs.demo","data":{"cpu":0.42}}'
# 5) 组织广播 → 组织内所有在线客户端
curl -s -X POST http://127.0.0.1:8000/api/broadcast -H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d '{"title":"停机维护","body":"今晚 22:00 开始","level":"warn"}'
```
(curl 的中文参数在 Git Bash 下没问题;PowerShell 里更稳的做法是把 JSON 写进文件再用 `-d @body.json`。)
注意示例服务端把 uvicorn 绑在 `127.0.0.1`(源码里写死的),所以这套跑法只适用于
"客户端和服务端在同一台机器";要让别的机器连,改 `examples/plugin_server.py` 结尾那句
`uvicorn.run(...)` 的 host(同时记得 `SVFS_MQTT_HOST` 要下发**客户端能访问到的**地址,
不是服务端眼里的 `127.0.0.1`)。
响应里看 `"mqtt"` 字段就知道有没有真的发出去:`"published"` 是发出去了,
`"skipped: …"` 是没配 broker 或服务端没装 paho,`"error: …"` 是连不上/被拒。
即使 `skipped`,消息也已经进了待收列表,客户端登录后能补齐看到。
### 4.10 边界与降级
* 没配 `SVFS_MQTT_HOST`:`/api/push` 只写待收列表,响应 `mqtt=skipped`,客户端靠补齐看到;
* 服务端没装 paho:同上(`skipped: 服务端没装 paho-mqtt`),不影响接口本身;
* 推送只对**已登录**的客户端有效;退出登录即断开(客户端侧行为,见第三节);
* QoS 0 不保证送达:要"必达"就靠待收列表 + 补齐 + `id` 去重,别指望 broker 重投;
* 广播不落库(示例如此):离线的人看不到组织广播,除非你自己也存一份。
---
## 五、速查
### 5.1 示例服务端的环境变量
| 变量 | 默认 | 作用 |
|---|---|---|
| `SVFS_MQTT_HOST` | 空 | broker 地址;**空 = 不启用 MQTT**(推送只进待收列表) |
| `SVFS_MQTT_PORT` | 空 | broker 端口;空则按 tls 推 1883 / 8883 |
| `SVFS_MQTT_TLS` | 空 | `1` / `true` / `yes` 走 TLS |
| `SVFS_MQTT_PREFIX` | `svfs` | 主题前缀 |
| `SVFS_ORG` | `default` | 组织/租户标识 |
| `SVFS_USERS` | `admin:admin123` | 登录账号表,`user:pass,user2:pass2` |
| `SVFS_PORT` | 8000 | HTTP 端口 |
| `SVFS_PLUGINS_DIR` | `./svfs-plugins-root` | 插件包根目录 |
| `SVFS_UPLOAD_TOKEN` | 空 | 上传插件时的 `X-SVFS-Token` |
| `SVFS_CLIENT_VERSION` / `_URL` / `_NOTES` | 空 | 客户端自更新清单(不设则 404) |
### 5.2 客户端 settings.json 的 MQTT 相关键
| 键 | 默认 | 说明 |
|---|---|---|
| `mqtt_host` | `""` | 空 = 不启用推送(但服务端下发的仍会用) |
| `mqtt_port` | `1883` | |
| `mqtt_tls` | `false` | |
| `mqtt_prefix` | `"svfs"` | |
| `mqtt_username` | `""` | 空 = 登录用户名 |
| `mqtt_password` | `""` | 空 = 登录 token(加密存储的那份) |
| `org` | `""` | 空 = 服务端下发 → `default` |
### 5.3 总线处理结果与计数
`deliver()` 返回的 `action`:`host`(进消息中心)/ `plugin`(投给插件)/ `duplicate`
(重复,丢弃)/ `unknown`(主题不认识)/ `forbidden`(不是发给当前身份的)/ `empty`
(解析不出正文)。这些动作各自都有计数,`MessageBus.stats()` 能拿到快照(`received`、
`host`、`plugin`、`duplicate`、`unknown`、`forbidden`、`empty`、`dropped`)——所以
`forbidden` / `dropped` 有两条线索可查:诊断日志里的那两行文字,以及这个计数快照。
### 5.4 客户端连接参数(写死在代码里,没有配置项)
| 参数 | 值 | 出处 |
|---|---|---|
| 客户端 id | `svfs-<org>-<user>-<6 位随机>` | 随机后缀是为了"同一人开两个客户端不互相踢下线" |
| clean session | 是 | 不指望 broker 记会话 |
| keepalive | 30 秒 | |
| 重连退避 | 1 → 30 秒(指数) | 交给 paho,重连成功后自动重新订阅 |
| 发布 / 订阅 QoS | 0 | 服务端示例也是 0 |
---
## 六、测试
```bash
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_plugin_server.py" # 服务端 /api/push → broker → 客户端
uv run python tests/run_tests.py -p "test_client_ui.py" # 设置界面与消息中心
```
`test_client_mqtt.py` 里的 `_MiniBroker` 是测试里手写的 MQTT 3.1.1 broker(QoS 0,
认 CONNECT / SUBSCRIBE / PUBLISH / PINGREQ / DISCONNECT 这几种报文),
所以**这些测试不需要 Docker、也不需要真 broker**,CI 上直接跑。覆盖到的点:
主题拼装/解析与转义、封套解析与长度上限、
去重(id 与"内容+秒"两种)、越权消息丢弃、订阅两条主题、重连后重新订阅、拒连报错、
TLS 开关、以及"服务端 push → broker → 客户端总线"的全链路。