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

34 KiB
Raw Blame History

消息模块说明: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 去重, 所以推送与补齐重叠不会重复显示。

消息怎么走

服务端 ──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。

客户端侧依赖:

pip install "sqlite-vfs[mqtt]"     # paho-mqtt,实时推送通道需要

没装不会坏事:状态显示「未安装 paho-mqtt,实时推送不可用」,补齐照常把消息带回来。 开发环境 uv sync 已经带了 paho(dev 依赖里有);打包客户端时,构建环境里装了它就会一并打进去。


二、Docker 部署 broker(mosquitto)

五分钟起步

cd deploy/mqtt
docker compose up -d
docker compose logs -f mqtt      # 看到 "Opening ipv4 listen socket on port 1883" 就是好了

目录长这样:

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,不用另装客户端:

# 终端 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: …,不会假装成功)。给它加账号:

# 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 端口映射。证书自签即可:

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。

自测(在客户端机器上跑,验证信任链):

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) 加上「· 来自服务端下发」或「· 已保存并重连」)。手改文件也可以,路径是

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):

{
  "id": "com.example.dash",
  "api_version": 5,
  "permissions": ["storage", "net", "messages"]
}
// 插件页面里
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 拼主题别手写

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> 整个组织的该插件 否

客户端登录后只订阅两条(最小权限,不是整个组织的 #):

<prefix>/<org>/user/<user>/#
<prefix>/<org>/broadcast/#

即使 broker 的 ACL 配错、把别人的消息推了过来,总线还会按当前组织/用户再挡一道 (记 forbidden 计数 + 诊断日志)。

4.3 发布代码

示例服务端(examples/plugin_server.py 的 _mqtt_publish)为了演示简单,是 "连上 → 发一条 → 断开",每条消息一次握手;生产上换成常驻连接更合适:

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 这类接口即使发不出去也要能正常返回——消息本来 就会进待收列表,客户端登录后还能补齐。

给插件发数据就是换主题、载荷随便(原样投递):

topic = user_plugin_topic(MQTT_PREFIX, ORG, user, "com.example.dash")
publish(topic, json.dumps({"cpu": 0.42}))

4.4 登录响应里下发 broker

客户端会读登录响应里的 mqtt 段,据此自动连接:

{
  "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 主机消息的封套

{
  "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 端到端跑一遍

# 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

六、测试

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 → 客户端总线"的全链路。