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:
+190
-4
@@ -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))}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 客户端自更新信息(可选)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user