modify: 消息模块
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
# 消息模块说明: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 → 客户端总线"的全链路。
|
||||
Reference in New Issue
Block a user