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
+190 -4
View File
@@ -39,12 +39,15 @@
└── blobs/<hash 前 2 位>/<hash>
"""
import json
import os
import secrets
import sqlite3
import tempfile
import threading
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, File, Header, HTTPException, Request, UploadFile
from fastapi.staticfiles import StaticFiles
@@ -53,6 +56,11 @@ from pydantic import BaseModel
from sqlite_vfs.core import SQLiteVFS
from sqlite_vfs.publish import export_objects
from svfs_client.manifest import ManifestError, read_from_vfs
from svfs_client.topics import (
broadcast_message_topic,
user_message_topic,
user_plugin_topic,
)
#: 插件包根:每个 <插件id>/ 一个子目录,里面是 svfs publish 的产物
PLUGINS_DIR = Path(os.environ.get("SVFS_PLUGINS_DIR", "./svfs-plugins-root")).resolve()
@@ -112,14 +120,27 @@ def login(body: LoginBody):
token = secrets.token_hex(32)
login_time = datetime.now().isoformat(timespec="seconds")
SESSIONS[token] = {"username": body.username, "login_time": login_time}
return {"ok": True, "token": token, "username": body.username,
"login_time": login_time}
return {"ok": True, "token": token, **_session_extra(body.username, login_time)}
def _session_extra(username, login_time):
"""登录/校验响应里的额外字段:组织标识 + broker 地址(有才带)。"""
extra = {"username": username, "login_time": login_time, "org": _org()}
mqtt = _mqtt_announcement()
if mqtt:
extra["mqtt"] = mqtt
return extra
@app.get("/api/me")
def me(session: dict = Depends(require_auth)):
"""客户端启动时校验记住的 token 是否仍然有效。"""
return {"ok": True, **session}
"""客户端启动时校验记住的 token 是否仍然有效。
这里也要下发组织与 broker:静默校验通过后就**不再弹登录窗**,是客户端
拿到推送配置的另一条主路径。
"""
return {"ok": True, **_session_extra(session["username"],
session.get("login_time", ""))}
@app.post("/api/logout")
@@ -218,6 +239,171 @@ async def upload_plugin(plugin_id: str, file: UploadFile = File(...),
}
# ----------------------------------------------------------------------
# 消息:服务端主动推给用户(MQTT),登录后客户端先同步一次补齐
# ----------------------------------------------------------------------
#
# 主题方案(组织标识在第一版就占好位子,多租户上线不用改格式):
#
# <prefix>/<org>/user/<用户名>/message 主机消息 → 客户端的消息中心
# <prefix>/<org>/user/<用户名>/plugin/<插件id> 插件消息 → 只给该插件
# <prefix>/<org>/broadcast/message 组织广播 → 消息中心
#
# 服务端与客户端用同一套拼主题的函数(直接 import svfs_client.topics),
# 用户名里的特殊字符两端编码一致,不会错位。
#
# 演示跑法(需要一个 broker,比如 mosquitto):
#
# SVFS_MQTT_HOST=127.0.0.1 SVFS_ORG=acme uv run python examples/plugin_server.py
# # 客户端登录后会自动连上(broker 地址由登录响应下发),然后:
# curl -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"}'
# # 插件数据(不进消息中心):
# curl -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}}'
#
# 没配 broker 时 /api/push 只写待收列表(响应里如实说明 mqtt=skipped),
# 客户端仍能靠登录后的同步看到消息——这条降级路径让演示不依赖 broker。
#: 每个用户的待收消息上限(演示用;生产该入库)
MESSAGE_BACKLOG = 100
#: 待收列表:username → [消息, …](内存,重启即清空)
MESSAGES: dict = {}
#: 同一条消息可能被多个客户端(同账号多端)各收一次,所以按用户存
_mqtt_lock = threading.Lock()
def _mqtt_prefix():
return os.environ.get("SVFS_MQTT_PREFIX", "svfs").strip() or "svfs"
def _org():
return os.environ.get("SVFS_ORG", "").strip() or "default"
def _mqtt_announcement():
"""登录响应里下发 broker 信息(客户端据此自动订阅,不用用户手填)。
没配 ``SVFS_MQTT_HOST`` 时返回 None——客户端就走"只同步、不推送"的降级。
"""
host = os.environ.get("SVFS_MQTT_HOST", "").strip()
if not host:
return None
tls = os.environ.get("SVFS_MQTT_TLS", "").strip().lower() in ("1", "true", "yes")
announcement = {"host": host, "tls": tls,
"prefix": _mqtt_prefix(), "org": _org()}
port = os.environ.get("SVFS_MQTT_PORT", "").strip()
if port.isdigit():
announcement["port"] = int(port)
return announcement
def _mqtt_publish(topic, payload):
"""发一条到 broker;没配 broker / 没装 paho 都返回说明字符串(不抛)。"""
host = os.environ.get("SVFS_MQTT_HOST", "").strip()
if not host:
return "skipped: 未配置 SVFS_MQTT_HOST(消息只进了待收列表)"
try:
import paho.mqtt.client as mqtt
except Exception:
return "skipped: 服务端没装 paho-mqtt(pip install paho-mqtt)"
tls = os.environ.get("SVFS_MQTT_TLS", "").strip().lower() in ("1", "true", "yes")
port = int(os.environ.get("SVFS_MQTT_PORT", "") or (8883 if tls else 1883))
kwargs = {"client_id": f"svfs-server-{secrets.token_hex(4)}"}
api_version = getattr(mqtt, "CallbackAPIVersion", None)
if api_version is not None: # paho 2.x 要求显式声明回调 API 版本
kwargs["callback_api_version"] = api_version.VERSION2
client = mqtt.Client(**kwargs)
if tls:
client.tls_set()
try:
with _mqtt_lock: # 演示用:一次一条,连上→发→断开
client.connect(host, port, 10)
client.loop_start()
info = client.publish(topic, payload, qos=0)
info.wait_for_publish(timeout=5)
client.loop_stop()
client.disconnect()
except Exception as exc:
return f"error: {exc}"
return "published"
class PushBody(BaseModel):
"""推送请求:``kind`` 决定走哪条通道。"""
kind: str = "message" # message = 给人看的;plugin = 给插件的业务载荷
user: str = "" # 目标用户(空 = 发给调用者自己)
plugin: str = "" # kind=plugin 时必填:目标插件 id
title: str = ""
body: str = ""
level: str = "info"
data: Optional[dict] = None # kind=plugin 的载荷(原样发给插件)
id: str = ""
@app.get("/api/messages")
def list_messages(since: str = "", session: dict = Depends(require_auth)):
"""客户端登录后/点「立即同步」时补齐消息(``since`` = 本地最新一条的时间)。"""
items = list(MESSAGES.get(session["username"], []))
if since:
items = [item for item in items if str(item.get("time") or "") > since]
return {"ok": True, "messages": items}
@app.post("/api/push")
def push_message(body: PushBody, session: dict = Depends(require_auth)):
"""服务端主动推一条:写待收列表(离线也能补齐)+ 发到 MQTT(在线实时到)。"""
target = (body.user or session["username"]).strip()
if not target:
raise HTTPException(status_code=400, detail="缺少目标用户")
if body.kind == "plugin":
if not body.plugin:
raise HTTPException(status_code=400, detail="kind=plugin 需要 plugin 字段")
topic = user_plugin_topic(_mqtt_prefix(), _org(), target, body.plugin)
payload = json.dumps(body.data if body.data is not None else {},
ensure_ascii=False)
# 插件数据**不进**消息中心,也不进待收列表:它是实时的业务载荷,
# 补历史没有意义(仪表盘要的是当下)。
return {"ok": True, "topic": topic, "mqtt": _mqtt_publish(topic, payload),
"stored": False}
message = {
"id": body.id or secrets.token_hex(8),
"title": body.title,
"body": body.body,
"level": body.level if body.level in ("info", "warn", "error") else "info",
"time": datetime.now().isoformat(timespec="seconds"),
"source": "example-server",
}
backlog = MESSAGES.setdefault(target, [])
backlog.append(message)
del backlog[:-MESSAGE_BACKLOG]
topic = user_message_topic(_mqtt_prefix(), _org(), target)
return {"ok": True, "topic": topic, "message": message, "stored": True,
"mqtt": _mqtt_publish(topic, json.dumps(message, ensure_ascii=False))}
@app.post("/api/broadcast")
def broadcast_message(body: PushBody, session: dict = Depends(require_auth)):
"""组织广播:发给整个组织(消息中心显示,带"广播"标记)。"""
topic = broadcast_message_topic(_mqtt_prefix(), _org())
message = {
"id": body.id or secrets.token_hex(8),
"title": body.title or "组织通知",
"body": body.body,
"level": body.level if body.level in ("info", "warn", "error") else "info",
"time": datetime.now().isoformat(timespec="seconds"),
"source": f"broadcast@{session['username']}",
}
return {"ok": True, "topic": topic, "message": message, "stored": False,
"mqtt": _mqtt_publish(topic, json.dumps(message, ensure_ascii=False))}
# ----------------------------------------------------------------------
# 客户端自更新信息(可选)
# ----------------------------------------------------------------------