From 2ab09507ff4fa5fea7fd33a058c04f32a51f5d44 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 05:11:26 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E4=BB=93?= =?UTF-8?q?=E5=BA=93=EF=BC=8C=E5=8A=A0=E5=85=A5=E9=9C=80=E6=B1=82=E3=80=81?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E8=AF=B4=E6=98=8E=E5=92=8C=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/nixmsg-dev.mdc | 41 ++ .gitattributes | 15 + .gitignore | 49 ++ README.md | 14 + docs/DEVELOPMENT.md | 1198 ++++++++++++++++++++++++++++++++++ docs/DEVIATIONS.md | 45 ++ docs/PRD.md | 581 +++++++++++++++++ docs/TASKS.md | 367 +++++++++++ 8 files changed, 2310 insertions(+) create mode 100644 .cursor/rules/nixmsg-dev.mdc create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/DEVIATIONS.md create mode 100644 docs/PRD.md create mode 100644 docs/TASKS.md diff --git a/.cursor/rules/nixmsg-dev.mdc b/.cursor/rules/nixmsg-dev.mdc new file mode 100644 index 0000000..40df4c9 --- /dev/null +++ b/.cursor/rules/nixmsg-dev.mdc @@ -0,0 +1,41 @@ +--- +description: NixMsg 多 Agent 开发的分工、分支提交和测试隔离约束 +alwaysApply: true +--- + +# NixMsg 开发协作 + +开工前先读 `docs/TASKS.md`,确认自己是哪条线、做哪个任务。产品行为以 `docs/PRD.md` 为准,实现以 `docs/DEVELOPMENT.md` 为准,库用法特别看它的第 5 节和第 15 节。 + +## 分工 + +- 只改自己线负责的目录(TASKS.md 第 5 节);要动共享文件,按 TASKS.md 第 4.2 节的规则。 +- 文档没写清或互相矛盾时,停下来问总控,不要自己改产品行为。实现和文档不一致,必须写进 `docs/DEVIATIONS.md` 自己那一节。 + +## 分支与提交 + +- 在自己的工作树和 `feat/<任务号>-<简述>` 分支上工作,不直接改 `main`;只有总控合并 `main`。 +- 一功能一验证一提交:补测试 → `task check` 通过 → 提交 → 推送自己的分支。 +- 提交信息 `type: 中文一句话`,type 取 feat、fix、refactor、style、docs、test、chore、revert。 +- 不提交构建产物、`web/dist`、数据库文件、证书私钥、密码和令牌、`aidocs/`。 +- 任务完成:rebase 到 `origin/main`,全部验证通过后推送,在 MemRelay 存标签为 `ready-to-merge` 的检查点。 +- rebase 后用 `git push --force-with-lease` 更新自己的 `feat/*`、`fix/*` 分支(负责人已同意);任何时候都不许强推 `main`。 + +## 测试隔离 + +- 测试里的服务监听随机端口(`:0`),数据放临时目录,不写死 7443。 +- Docker 容器、网络、卷的名字带线名前缀,用完删除。 +- 不改全局 git 配置、系统环境变量和全局 npm 配置。 + +## 技术栈 + +- 后端:Go 标准库 `net/http`、`database/sql` 手写 SQL、SQLite;不引入 Web 框架、ORM、Redis。 +- 前端:Vue 3 + TypeScript + Naive UI,只用这一套组件库。 +- 缺工具用 scoop 安装;已确认的决定和依赖库核对结论在 MemRelay 项目记忆(项目 NixMsg)里。 + +```yaml +# ✅ 测试用的配置:随机端口,数据放临时目录 +listen: "127.0.0.1:0" +data_dir: "<临时目录>/data" +# ❌ 写死 listen: ":7443",多个 Agent 同时跑会端口冲突 +``` diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ff8edf9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=lf + +*.ps1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.jar binary +*.db binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..720da61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# 构建产物 +/bin/ +/dist/ +/web/dist/ +node_modules/ +*.exe +*.test +*.out +coverage/ +*.coverprofile + +# 运行数据和本地配置 +/data/ +*.db +*.db-shm +*.db-wal +/config.yaml + +# 证书、密钥和环境变量(测试用证书在测试里生成) +*.pem +*.key +*.crt +!**/testdata/** +.env +.env.* + +# SDK 构建输出 +__pycache__/ +*.egg-info/ +.venv/ +/sdk/python/dist/ +/sdk/js/dist/ +/sdk/java/**/build/ +/sdk/java/.gradle/ + +# 测试报告 +/test/reports/ +/web/test-results/ +/web/playwright-report/ + +# 编辑器和系统文件 +.idea/ +.vscode/* +!.vscode/extensions.json +.DS_Store +Thumbs.db + +# 本地离线记忆,不提交 +aidocs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e02805 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# NixMsg + +自建的消息中转服务。设备、程序、App 作为「端」连到同一台服务器,端之间互发消息或群发。单个 Go 程序,内置 MQTT,SQLite 存储,自带管理后台,提供 Go、JS/TS、Python、Java/Android SDK。 + +## 文档 + +- [产品需求](docs/PRD.md) +- [开发说明](docs/DEVELOPMENT.md) +- [开发任务拆分与多 Agent 协作](docs/TASKS.md) +- [与文档的偏差](docs/DEVIATIONS.md) + +## 状态 + +需求和设计已完成,正在开发。构建、运行和部署说明在开发完成后补充。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..a39d094 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,1198 @@ +# NixMsg 开发说明 + +读者是实现本期功能的开发组。产品行为以 [PRD.md](./PRD.md) 为准,本文对应 PRD 0.4,规定怎么实现。两者冲突时改代码以 PRD 为准,并把差异写进 `docs/DEVIATIONS.md`(日期、原条款、实际做法、原因),交给后续审核。 + +文中「必须」是验收项,「不要」是已知会做错的实现。本文对 mochi-mqtt、coder/websocket、autopaho、modernc sqlite 内部行为的说明,已在 2026-09-30 对照它们主分支的源码核对过;升级这些依赖的大版本时要重新核对。 + +## 1. 总结构 + +一个 Go 进程,一个可执行文件。端只连一个端口(端接入端口);管理后台默认也在这个端口,可以配置到单独端口。 + +```mermaid +flowchart LR + sdk[SDK 或裸 MQTT 设备] + admin[浏览器后台] + port[端接入端口] + aport[后台端口 可选] + mux[协议识别] + http[HTTP 注册接口与后台] + mqtt[内置 MQTT] + app[提交 调度 投递 群 授权 注册] + db[(SQLite)] + + sdk --> port + admin --> port + admin -.-> aport + port --> mux + aport --> mux + mux --> http + mux --> mqtt + http --> app + mqtt --> app + app --> db + app --> mqtt +``` + +设备之间不直连。MQTT 只负责连接、心跳和把字节送到在线端。消息留不留、何时发、能否撤回,都在应用层和 SQLite,不用 MQTT 的会话队列、保留消息或遗嘱。 + +在线状态以「这个编号当前有没有完成握手的连接」为准,不看遗嘱。 + +不需要 Redis 或其他中间件:在线状态就是进程内存里的连接表,持久数据都在 SQLite。只有做多机集群才需要共享状态,本期不做。 + +## 2. 技术栈 + +### 2.1 负责人选定 + +2026-09-30 由负责人选定。开发组不要自行更换,确有必要先提出,改动写进 `docs/DEVIATIONS.md`。 + +| 方面 | 选择 | 说明 | +|---|---|---| +| 服务端语言 | Go,用开发时最新的稳定版,写进 `go.mod` | 单个可执行文件,`CGO_ENABLED=0` 交叉编译 | +| HTTP | Go 标准库 `net/http` | Go 1.22 起的 `ServeMux` 支持按方法和路径参数路由(如 `GET /api/admin/endpoints/{id}`)。不用 Gin、chi、Echo 等框架 | +| 数据库 | SQLite,驱动 `modernc.org/sqlite`(纯 Go) | 嵌在进程里,不另外部署 | +| 数据库访问 | `database/sql` 手写 SQL | 不用 ORM,也不用代码生成 | +| 缓存和队列 | 不用 | 不引入 Redis、消息队列等中间件(第 1 节) | +| 后台前端 | Vue 3 + TypeScript + Naive UI | Vite 构建成静态文件,`go:embed` 嵌进服务端程序;运行时不需要 Node.js | +| 部署 | 单文件二进制为主,同时提供 Docker 镜像(`linux/amd64`、`linux/arm64`)和 docker-compose 示例 | 第 11 节 | +| 监控 | Prometheus 格式的 `/metrics` | 访问限制见第 4.3 节 | +| 证书 | 程序读取证书文件,文件变了自动重载 | 生产环境由 1Panel 申请和自动续签,推送到本地目录给程序读(第 11.3 节) | + +### 2.2 按推荐定下的其余部分 + +开发组如需调整,先提出,并写进 `docs/DEVIATIONS.md`。 + +| 方面 | 选择 | +|---|---| +| 内置 MQTT | `github.com/mochi-mqtt/server/v2` | +| WebSocket | `github.com/coder/websocket` | +| 密码哈希 | `golang.org/x/crypto/argon2`(argon2id) | +| 配置文件 | YAML,库用 `go.yaml.in/yaml/v3`(YAML 官方组织维护;`gopkg.in/yaml.v3` 已在 2025 年停止维护,不要用;`go.yaml.in/yaml/v4` 出正式版后可换) | +| 日志 | 标准库 `log/slog`,JSON 格式输出到标准输出 | +| 指标 | `github.com/prometheus/client_golang` | +| 数据库迁移 | 自己实现:嵌入的有序 SQL 文件加 `schema_migrations` 表(第 7.7 节) | +| 前端工程 | Vite、Vue Router、Pinia;请求用 `fetch` 薄封装;包管理用 pnpm 并提交 `pnpm-lock.yaml`;Node.js 用当前 LTS(2026-09 为 24),只在构建时需要 | +| 代码检查 | Go:`gofmt`、golangci-lint;前端:ESLint、Prettier、`vue-tsc` 类型检查 | +| 测试 | Go 标准库 `testing`;弱网用 toxiproxy、netem;前端单元测试用 Vitest;后台端到端测试用 Playwright | +| 构建脚本 | Taskfile(go-task),Windows、Linux、macOS 都能直接用 | +| 容器基础镜像 | `gcr.io/distroless/static` 的 `nonroot` 变体 | + +主版本写死在 `go.mod`、`package.json` 里,并提交锁文件。不要换 EMQX、Mosquitto,不要换成 CGO 版 SQLite。 + +### 2.3 SDK 依赖 + +这里只列各语言 SDK 依赖的 MQTT 客户端库,SDK 的行为见第 9 节。 + +| SDK | 依赖 | 说明 | +|---|---|---| +| Go | `github.com/eclipse/paho.golang/autopaho` | MQTT 5、自动重连、支持 WebSocket | +| JS/TS | MQTT.js 5.x | 浏览器和 Node 都能用 | +| Python | paho-mqtt 2.x,`CallbackAPIVersion.VERSION2` | 同步接口为主,另给 asyncio 包装 | +| Java/Android | HiveMQ MQTT Client,加上 websocket 模块 | Java 8+,Android API 24+ | + +### 2.4 后台界面 + +界面要求: + +- 中文。 +- 普通表单标题在左、控件在右,间距紧凑;Markdown、多行文本等大块内容标题在上、内容在下。 +- 页头在滚动时保持可见,页面级按钮放在页头右侧。 +- 帮助说明用标题旁的图标,悬停显示。错误和关键状态直接写出来。 +- 弹窗的确定、取消始终可见。 +- 表格和长文本在控件内部滚动。 + +用 Naive UI 的做法: + +- 根组件用 `n-config-provider` 设中文(`zhCN`、`dateZhCN`),组件尺寸统一用 `small`;外面套 `n-message-provider`、`n-dialog-provider`,提示和确认框都走它们。 +- 表单用 `n-form`,`label-placement="left"` 并统一 `label-width`;大块内容的表单项用 `label-placement="top"`。 +- 布局用 `n-layout`:页头固定,内容区单独滚动。 +- 帮助图标用 `n-tooltip`;错误和关键状态用 `n-alert` 或表单校验信息直接显示。 +- 弹窗用 `n-modal`(`preset="card"`),按钮放在 `footer` 插槽,正文放进 `n-scrollbar` 单独滚动。 +- 表格用 `n-data-table`:`remote` 做服务端分页,设 `max-height` 让表格内部滚动,长列表开 `virtual-scroll`。 +- 只用 Naive UI 一套组件库,不要混用 Element Plus 等其他库。 + +## 3. 仓库 + +```text +cmd/nixmsg/ 主程序 +internal/config/ +internal/listener/ 端口监听,识别 TLS、HTTP、MQTT +internal/broker/ mochi 装配、钩子、ACL +internal/protocol/ 帧的编码解码和校验 +internal/auth/ 密码哈希池、会话令牌和 API 令牌、锁定计数 +internal/app/message/ 提交、调度、推送、确认、撤回、回执、清理、启动恢复 +internal/app/identity/ 注册、自己的资料、对话密码与授权、停用删除级联 +internal/app/group/ 群 +internal/app/presence/ 在线、目录、上下线订阅 +internal/store/ SQLite、写入队列与迁移 +internal/admin/ 管理接口、API 令牌 +internal/httpx/ 路由、注册接口、健康检查、指标 +web/ Vue 源码;web/embed.go 用 //go:embed all:dist 导出构建产物 +deploy/ Dockerfile、docker-compose.yml、配置示例 +sdk/go/ +sdk/js/ +sdk/python/ +sdk/java/ +docs/ +Taskfile.yml +``` + +`go:embed` 不能引用上级目录,所以嵌入写在 `web/embed.go` 里,由 `internal/httpx` 引用。开发时前端用 Vite 开发服务器,把 `/api` 代理到本机的服务端;正式构建先 `pnpm build` 再 `go build`,由 Taskfile 串起来。 + +提交说明使用 `type: 中文一句话`,type 取 `feat`、`fix`、`refactor`、`style`、`docs`、`test`、`chore`、`revert`。 + +## 4. 端口与协议识别 + +### 4.1 监听 + +| 配置 | 默认 | 提供 | +|---|---|---| +| `listen` | `:7443` | 端接入端口:WebSocket `/mqtt`、裸 MQTT TCP、注册接口 `/api/client/`、`/healthz`、`/readyz`。`admin_listen` 为空时也提供后台 | +| `admin_listen` | 空 | 设置后(例如 `127.0.0.1:7444`),后台页面和 `/api/admin/` 只在这里提供,`listen` 上这些路径一律 404 | + +两个监听用同一套 TLS 设置、同一套识别代码。端只需要知道 `listen`。 + +端口写 0 时由系统随机分配。启动后把实际地址写进 `/listen.addr`(后台单独监听时另写 `admin.addr`),供测试启动器读取;多个测试同时运行时靠这个避免端口冲突。 + +### 4.2 识别 + +每个连接先读首字节再分流,读过的字节要放回连接。10 秒内读不到首字节就关闭。 + +| 首字节 | 处理 | +|---|---| +| `0x16` | TLS ClientHello。握手后对解密出的首字节再识别一次 | +| `A`–`Z`(HTTP 方法) | HTTP | +| `0x10`(MQTT CONNECT) | 裸 MQTT。只在 `listen` 上接受,`admin_listen` 上直接关闭 | +| 其他 | 关闭 | + +HTTP 连接通过一个自己实现的 `net.Listener`(从通道里取连接)交给同一个 `http.Server`。 + +`tls.Config` 不设置 `NextProtos`,HTTP 只走 1.1,WebSocket 走 HTTP/1.1 升级。不要写成 `NextProtos = ["http/1.1"]`:Go 会拒绝声明了 ALPN 但和服务端没有交集的客户端,例如声明 `mqtt` 的设备。 + +配置了证书:默认只接受 TLS,`allow_plaintext: true` 才同时接受明文。没配证书:只跑明文,启动时打一条警告日志。证书在进程内缓存,每小时看一次文件修改时间,变了就重新加载,已建立的连接不受影响;新证书读取或解析失败时继续用旧证书,并记错误日志。程序本身不申请证书(不做 ACME),续签交给 1Panel 等外部工具(第 11.3 节)。 + +### 4.3 HTTP 路径 + +| 路径 | 用途 | +|---|---| +| `/mqtt` | WebSocket,子协议必须是 `mqtt` | +| `/api/client/` | 端侧接口,本期只有注册(第 6.9 节)。允许跨域 | +| `/api/admin/` | 管理接口,网页登录或 API 令牌(第 8 节) | +| `/metrics` | Prometheus 指标,只有计数和耗时,不含正文和编号明细 | +| `/healthz` | 进程活着 | +| `/readyz` | 已能读写数据库 | +| 其余 | 后台静态页,未知前端路由回 `index.html` | + +后台单独监听时,`listen` 上只保留 `/mqtt`、`/api/client/` 和两个健康检查;`admin_listen` 上只有 `/api/admin/`、`/metrics`、静态页和两个健康检查,`/metrics` 不要求鉴权。后台和端共用端口时,`/metrics` 要求请求头 `Authorization: Bearer `:没配 `metrics.token` 返回 404,令牌不对返回 401。 + +指标至少包括:在线连接数(按 ws、tcp 分)、端总数、待投递数、定时消息数、从到点到推送的耗时分布、确认耗时分布、写队列长度和每次合并提交的耗时、密码哈希排队数、各错误码计数。 + +### 4.4 WebSocket + +```go +c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Subprotocols: []string{"mqtt"}, + InsecureSkipVerify: true, +}) +``` + +- `InsecureSkipVerify` 关掉的是 Origin 校验。浏览器 SDK 跑在接入方自己的域名下,默认的同源校验会直接回 403。这里认证靠 MQTT 用户名和密码,不用 Cookie,没有跨站伪造的问题。库文档也建议放开任意来源时用这个选项,不要把 `OriginPatterns` 写成 `*`。 +- `Accept` 不会因为客户端没带 `mqtt` 子协议而拒绝。之后检查 `c.Subprotocol() == "mqtt"`,不是就关闭。 +- 然后 `websocket.NetConn(ctx, c, websocket.MessageBinary)`,把得到的 `net.Conn` 交给 `Server.EstablishConnection("ws", conn)`。 +- `ctx` 用 `context.Background()` 派生,在连接关闭时主动 cancel。不要用请求的 `Context`:连接被劫持后继续用请求上下文,行为不可预期(库文档原话)。 +- `NetConn` 的 `RemoteAddr` 是 TCP 对端。请求来自 `trusted_proxies` 时,用第 4.5 节算出的真实 IP 包一层 `net.Conn`(只改 `RemoteAddr`)再交给 mochi,登录锁定按这个 IP 计。 +- `NetConn` 会把读限制设成不限制。单条大小不要靠这层限制,靠第 5 节的 MQTT 包上限,以及应用层对正文字节的检查。 +- `EstablishConnection` 会阻塞到连接结束。WebSocket 在 handler 里直接调用;裸 TCP 每个连接起一个 goroutine 调用 `EstablishConnection("tcp", conn)`。 +- 不要使用 mochi 自带的 TCP 或 WebSocket 监听器,否则会多出端口,也绕过了识别和 TLS 设置。 + +### 4.5 反向代理 + +`trusted_proxies` 列出代理的地址段。来自这些地址的 HTTP 和 WebSocket 请求,取 `X-Forwarded-For` 从右往左第一个不在 `trusted_proxies` 里的地址作为客户端 IP,按 `X-Forwarded-Proto` 判断是否 HTTPS。其他来源的请求忽略这两个头。 + +对外只露 443 时,代理必须同时支持 WebSocket `/mqtt`;裸 TCP 设备要么直连 `listen`,要么改走 WebSocket。不要把裸 MQTT TCP 交给只会转发 HTTP 的代理配置。 + +## 5. 内置 MQTT + +装配约束: + +```text +InlineClient = true +Capabilities: + MaximumClients = 2000 + MaximumQos = 1 + MaximumPacketSize = 786432 + MaximumSessionExpiryInterval = 0 + ReceiveMaximum = 1024 + MaximumInflight = 1024 + MaximumClientWritesPending = 1024 + RetainAvailable = 0 + WildcardSubAvailable = 0 + SharedSubAvailable = 0 + TopicAliasMaximum = 0 + Compatibilities.ObscureNotAuthorized = true +``` + +- `MaximumSessionExpiryInterval = 0` 会把客户端申请的会话保留时间压成 0,mochi 在断开时直接删会话(3.1.1 的非 Clean 会话在下一次清理时删)。应用层不要依赖 MQTT 会话排队。 +- 连接数达到 `MaximumClients` 时,mochi 回「服务器忙」(3.1.1 回「服务器不可用」),SDK 按可重试处理。 + +客户端必须: + +- MQTT 5。3.1.1 可以连,但是被踢原因不如 5 清楚。 +- `CleanStart = true`(3.1.1 则 `CleanSession = true`),会话过期间隔 0。 +- ClientID、Username 都等于端编号。Password 是登录密码,或者上次握手拿到的会话令牌(`nst_` 开头,见下文「登录与会话令牌」)。 +- 心跳 10–600 秒,SDK 默认 30。服务器在 `OnConnect` 里校正:超出范围时改写 `cl.State.Keepalive` 并设 `cl.State.ServerKeepalive = true`,MQTT 5 客户端会从 CONNACK 得知服务器指定的心跳。 +- 只订阅 `nix/c/{端编号}/down`,只向 `nix/c/{端编号}/up` 发布。 +- 应用帧使用 QoS 1。mochi 会把 QoS 2 降成 1,SDK 不要发 QoS 2。 + +钩子: + +| 钩子 | 行为 | +|---|---| +| `OnConnect` | 分配连接代号,校正心跳,然后查编号、停用,按下文「登录与会话令牌」校验会话令牌或登录密码(含锁定),把结论记在这个连接上。数据库出错等内部故障时返回 error:mochi 不回 CONNACK 直接断开,客户端按网络故障重连 | +| `OnConnectAuthenticate` | 只返回 `OnConnect` 记下的结论。返回 false 时 mochi 回「用户名或密码错误」,SDK 会停止重连,所以只有编号不存在、已停用、密码错误、已锁定这几种情况能返回 false | +| `OnACLCheck` | 只允许上面这一对主题。发布检查 `write=true`;订阅和服务器下发检查 `write=false` | +| `OnPublish` | 拷贝 topic 和 payload 后交给该端的串行队列。返回 `packets.CodeSuccessIgnore`,让客户端拿到 PUBACK,同时不把这条转发给任何订阅者 | +| `OnPublishDropped` | 下行没写进连接的发送队列。把对应投递或回执的「已推送」标记清掉,1 秒后重推,别对慢客户端空转 | +| `OnSessionEstablished` | 记下这是该编号的当前连接 | +| `OnDisconnect` | 按第 7.5 节做断线处理:总是清掉这个代号的推送标记;是当前连接才标离线 | + +连接代号:同一编号的新旧连接 ClientID 相同,用 `*mqtt.Client` 指针映射到自己生成的随机代号,写进投递的 `pushed_conn`。 + +`OnPublish` 在客户端读循环里同步执行,PUBACK 在它返回后才发。把 topic 和 payload 拷贝出来再投入该端自己的串行 worker(当前版本每个包已经是新切片,但这不是公开约定,拷贝成本可以忽略)。worker 队列长度 256,满了就堵住读循环形成背压,不要丢帧。worker 里不要同步等待「断开这个同一连接」完成,否则可能和读循环互相等待。踢人放到独立的 goroutine。 + +`CodeSuccessIgnore` 会让 `publishToSubscribers` 直接返回,QoS 1 仍然回 PUBACK。不要返回 `ErrRejectPacket`:那条路径不回 PUBACK,客户端会在重连后重发。 + +业务成功以应用帧 `resp` 为准,不以 PUBACK 为准。`resp` 必须在数据库提交之后再发。提交成功但还没发出 `resp` 就断线时,客户端用同一消息号重试,服务器返回原来的结果。 + +同一编号新连接到来时,mochi 会用「会话被接管」断开旧连接(MQTT 5 原因码 `0x8E`)。旧连接的 `OnDisconnect` 可能晚于新连接的握手。连接表必须带连接代号,旧代号的断开事件不能把新连接标成离线,也不能清掉新连接已经写下的推送标记。 + +SDK 被 `0x8E` 踢下线:停止重连,事件 `kicked`,原因 `taken_over`。管理员停用、删除、重置密码:先发 `fatal` 帧再断开,SDK 同样停止重连。即使 SDK 没看到这些原因,它的会话令牌也已经作废,重连会被拒绝,不会两处来回互踢。只有每次都用密码登录的设备(通常是收不到 `0x8E` 的 3.1.1 裸设备)会在重连时再把新连接顶掉;接入文档要写明这类设备不要在两处使用同一编号。 + +登录与会话令牌: + +- Password 以 `nst_` 开头时按会话令牌处理:算 SHA-256,和该端的 `session_hash` 常量时间比较,一致且距 `session_used_at` 没超过 `session_idle_days` 就通过;不一致或过期返回 false(CONNACK `0x86`),不计入锁定。通过后 `session_used_at` 在内存里更新,最多每小时写一次库。 +- 否则按登录密码处理:先查锁定,再在 argon2 池里校验。成功后生成新令牌(`nst_` 加 32 字节随机数的 base64url),用一个写操作把 `session_hash` 换成新令牌的 SHA-256,`session_issued_at`、`session_used_at` 设为现在,提交后才继续;旧令牌从这一刻起作废。新令牌记在这个连接上,在握手响应里交给客户端(第 6.1 节)。失败计入锁定。 +- 两种方式通过后,都照常由 mochi 接管同一编号的旧连接(`0x8E`)。 +- 用令牌重连不换令牌。端自己改登录密码时换新令牌并在响应里返回;管理员重置密码、停用、删除,以及端自己 `self.logout` 时清空 `session_hash`。 +- 令牌和 IP 无关:设备换网络、换 IP 后直接用令牌重连。服务器重启后的大批重连也基本走令牌,不需要做密码哈希。 + +下行大小:mochi 写下行包时,超过客户端在 CONNECT 里声明的 Maximum Packet Size 就直接丢掉,只打一条 debug 日志,不触发任何钩子。所以所有下行帧都要在发布前自己检查大小(第 7.5 节)。 + +下行 QoS:`msg`、`receipt`、`revoked`、`resp`、`fatal` 用 QoS 1;上下线通知和群事件本来就是尽力送达,用 QoS 0,不占 inflight 额度。mochi 在单个客户端的 inflight 达到 `MaximumInflight` 时会静默丢弃新的 QoS 1 下行,同样不触发钩子,只能靠第 7.5 节的确认超时兜底。 + +锁定: + +- 登录密码按「编号 + 来源 IP」计数:5 分钟内错 10 次,锁定这个组合 5 分钟。 +- 登录密码另按编号计总数:1 小时内错 50 次,暂停这个编号的密码登录 1 小时,防止有人不断换 IP 猜密码。 +- 这两种锁定只拦密码登录,不拦会话令牌重连:已登录的设备换 IP、断线重连都不受影响,所以按编号计总数不会让别人把在线设备锁死。管理员可以用 `unlock` 清掉某个编号的锁定(第 8 节)。 +- 会话令牌不对不计数:令牌是 256 位随机数,猜不中,校验只是一次哈希查表。 +- 对话密码按「发送方 + 对方」计数,阈值相同。另按对方计总数:1 小时内错 50 次,暂停这个端的对话密码验证 1 小时,期间 `unlock`、带密码的发送和拉人进群都返回 `rate_limited`,已有授权不受影响;它改对话密码时清零。 +- 管理员登录和注册安全码按来源 IP 计数,阈值相同。 +- 计数放内存,重启清零。锁定期内直接拒绝,不做哈希计算。 + +## 6. 端协议 + +MQTT 上的应用帧:UTF-8 JSON,一个 MQTT 发布里一帧。字段名用蛇形。时间用 Unix 毫秒。`v` 固定为 `1`,不认识的版本返回 `bad_request`。 + +序列化时不要转义非 ASCII 和 HTML 字符:Go 用 `json.Encoder` 并 `SetEscapeHTML(false)`,Python 用 `ensure_ascii=False`,其他语言按同样要求。否则中文、emoji、`<>&` 会膨胀 2 到 6 倍,接近上限的正文会超出帧上限。 + +上行主题 `nix/c/{端编号}/up`,下行 `nix/c/{端编号}/down`。 + +请求都带 `rid`,由客户端生成,同一连接内不重复。响应: + +```json +{"v":1,"type":"resp","rid":"1","ok":true,"data":{}} +{"v":1,"type":"resp","rid":"1","ok":false,"error":{"code":"talk_password_required","message":"需要对话密码"}} +``` + +`message` 给开发者读,SDK 用 `code` 分支。 + +### 6.1 握手 + +连接并订阅 down 之后,客户端发布: + +```json +{"v":1,"type":"hello","rid":"1","max_receive_bytes":262144,"client":"go-sdk/0.1"} +``` + +`max_receive_bytes` 是这个连接一次能收的最大下行帧字节数(整条 JSON),不小于 1024。省略表示不限,但仍受 CONNECT 里 Maximum Packet Size 的约束。服务器在订阅完成后回复: + +```json +{"v":1,"type":"resp","rid":"1","ok":true,"data":{ + "server_time_ms": 1750000000000, + "server_version": "0.1.0", + "max_body_bytes": 262144, + "max_meta_bytes": 4096, + "max_frame_bytes": 786432, + "max_ttl_seconds": 2592000, + "max_schedule_seconds": 31536000, + "ack_timeout_seconds": 300, + "session_token": "nst_..." +}} +``` + +- `session_token` 只在这次连接用登录密码认证时返回。SDK 收到后立刻交给应用保存,之后重连都用它(第 5 节「登录与会话令牌」)。 +- 没收到这个成功响应之前,服务器拒绝其他请求,错误 `not_ready`。握手完成才算在线,才开始推送。 +- 收到 `hello` 时这个连接还没订阅 down:直接断开(响应发不出去)。 +- 连接后 30 秒内没完成握手:断开。 + +### 6.2 发送 + +```json +{ + "v": 1, + "type": "send", + "rid": "2", + "id": "018f...", + "to": {"kind": "endpoint", "id": "device-1"}, + "body": {"enc": "utf8", "content_type": "text/plain", "data": "hello"}, + "meta": {"k": "v"}, + "delay_ms": 10000, + "offline": {"keep": true, "ttl_seconds": 86400}, + "receipt": true, + "talk_password": "" +} +``` + +规则: + +- `id` 1–64 字符,字母、数字、`_`、`.`、`-`,区分大小写。SDK 用 UUIDv7 的 36 字符形式。 +- `to.kind` 是 `endpoint` 或 `group`。 +- `body.enc` 是 `utf8` 或 `base64`。大小按解码后的字节,上限 `max_body_bytes`。 +- `content_type` 最长 128 字符。`utf8` 默认 `text/plain; charset=utf-8`,`base64` 默认 `application/octet-stream`。 +- `meta` 是 JSON 对象,序列化后不超过 4096 字节。 +- 可以带 `send_at_ms`(指定发送时刻)或 `delay_ms`,最多出现一个,都出现返回 `bad_request`。都不出现时使用该端默认延迟;`delay_ms` 显式为 0 表示立即发送。`send_at_ms` 早于现在则立即发送。两者算出的发送时刻都不能晚于「现在 + `max_schedule_seconds`」。 +- `offline.keep` 默认 false。为 true 时 `ttl_seconds` 默认 86400,最大 `max_ttl_seconds`。 +- `receipt` 默认 true。 +- 已有有效授权时忽略 `talk_password`,不要因为又带了错误密码而失败。没有授权且对方设了密码时,密码正确则写入授权并继续,错误则 `talk_password_invalid`,没带则 `talk_password_required`。发给自己不校验对话密码。 + +成功: + +```json +{"v":1,"type":"resp","rid":"2","ok":true,"data":{"id":"018f...","send_at_ms":1750000010000,"state":"scheduled"}} +``` + +`state` 是返回时的消息状态:还没到发送时刻为 `scheduled`;立即发送的已在提交时分发,为 `dispatched`。防重命中时返回与第一次相同的 `id`、`send_at_ms`,`state` 为当前状态。 + +### 6.3 下行消息与确认 + +```json +{ + "v": 1, + "type": "msg", + "id": "018f...", + "from": "app-1", + "to": {"kind": "group", "id": "g_ab12cd34"}, + "body": {"enc": "utf8", "content_type": "text/plain", "data": "hello"}, + "meta": {}, + "send_at_ms": 1750000010000 +} +``` + +单聊的 `to.kind` 为 `endpoint`。群消息的 `msg` 帧对所有成员相同,只序列化一次。确认: + +```json +{"v":1,"type":"ack","rid":"3","from":"app-1","id":"018f..."} +``` + +- 服务器对 `ack` 回 `resp`。只要这条投递还是 pending 就生效,不要求是推给当前连接的那一次。 +- 重复确认已收下的消息,返回成功,不改变结果。 +- 确认到达时投递已经撤回、过期、丢弃或作废:`resp` 成功且 `data.result` 为当时的最终状态,SDK 按收到 `revoked` 处理。 + +推送窗口默认 32:同一连接上已推送未确认的消息达到 32 就暂停继续推,确认一笔再推下一笔。确认超时(默认 5 分钟)的处理见第 7.5 节。 + +### 6.4 撤回、状态、回执 + +撤回: + +```json +{"v":1,"type":"recall","rid":"4","id":"018f..."} +``` + +```json +{"v":1,"type":"resp","rid":"4","ok":true,"data":{ + "result": "partial", + "recalled": 2, + "accepted": 1, + "other": 0 +}} +``` + +`result`:至少撤回一个且 `accepted` 为 0 时为 `recalled`;至少撤回一个且 `accepted` 大于 0 时为 `partial`;一个都没撤回为 `failed`。`other` 是已过期、丢弃、拒绝的数量,不影响 `result`。还没到发送时刻的消息撤回时没有投递行,`result` 为 `recalled`,各计数为 0。群很大时也不要在这个响应里列出全部成员,明细走状态查询或后台。 + +状态: + +```json +{"v":1,"type":"status","rid":"5","id":"018f...","cursor":"","limit":100} +``` + +只能查自己发出的消息。返回消息级 `state`(`scheduled`、`dispatched`、`completed`)和 `reason`(第 7.1 节),各投递状态的数量,以及按 `cursor` 分页的接收端明细 `endpoint_id`、`state`、`reason`,`limit` 最大 200。正文不返回。记录已按保留天数删掉时返回 `not_found`。防重标记还在但记录已删时,撤回返回 `failed`,状态返回 `not_found`。 + +回执下行: + +```json +{"v":1,"type":"receipt","receipt_id":"9001","id":"018f...","endpoint_id":"device-1","state":"accepted","reason":"","at_ms":1750000012000} +``` + +`state` 取 `accepted`、`expired`、`dropped`、`rejected`。撤回不发回执。消息级作废(发送者已退群、群已解散、群里没有其他成员)时写一条 `endpoint_id` 为空、`state` 为 `rejected` 的回执,`reason` 说明原因。客户端确认: + +```json +{"v":1,"type":"receipt_ack","rid":"6","receipt_id":"9001"} +``` + +回执也按窗口推送,默认 64。可能重复,SDK 按 `receipt_id` 去重。 + +已推送尚未确认就撤回或作废时,下行: + +```json +{"v":1,"type":"revoked","id":"018f...","from":"app-1","reason":"recalled"} +``` + +`reason` 取 `recalled`、`expired`、`dropped`、`left_group`、`group_dissolved`、`endpoint_disabled`、`endpoint_deleted`、`sender_disabled`、`sender_deleted`。这帧尽力送达,不单独要求确认。接收方若还没把消息交给应用,直接丢弃;已经交给应用但还没确认,则发出「已撤回」事件并不再确认;已经确认的忽略这帧。 + +### 6.5 在线、目录、订阅 + +```json +{"v":1,"type":"presence.get","rid":"7","ids":["a","b"]} +{"v":1,"type":"directory.list","rid":"8","cursor":"","limit":100,"query":""} +{"v":1,"type":"presence.watch","rid":"9","ids":["a"],"all":false} +``` + +- `presence.get` 的 `ids` 最多 200 个。每项:`id`、`online`、`since_ms`。未知编号为 `not_found`。 +- 目录按编号排序。`query` 非空时按编号前缀或名称包含匹配,不区分大小写。每项:`id`、`name`、`online`、`online_since_ms`、`offline_since_ms`、`talk_password_set`。`limit` 最大 200。 +- `presence.watch` 的 `ids` 最多 1000 个;`all: true` 表示订阅全部。再次调用覆盖本连接的订阅。断线清空。 + +```json +{"v":1,"type":"presence","id":"a","online":true,"at_ms":1750000000000} +``` + +上下线通知用 QoS 0 尽力推送,不落库;连接的发送队列满时会被丢弃。应用需要准确状态时重新查询。 + +### 6.6 对话密码与自己的资料 + +```json +{"v":1,"type":"unlock","rid":"10","endpoint_id":"b","talk_password":"secret"} +{"v":1,"type":"self.get","rid":"11"} +{"v":1,"type":"self.update","rid":"12","name":"门口","default_delay_ms":10000} +{"v":1,"type":"self.talk_password","rid":"13","talk_password":""} +{"v":1,"type":"self.login_password","rid":"14","old_password":"","new_password":""} +{"v":1,"type":"self.logout","rid":"24"} +``` + +- `talk_password` 空字符串表示清除。修改对话密码时增加版本号,旧授权全部失效。 +- 修改登录密码要求旧密码。旧密码错误返回 `unauthorized`,并计入这个编号的登录密码失败次数(两种锁定都算)。成功时当前连接保留,响应里的 `data.session_token` 是换好的新令牌,旧令牌作废。新密码同样不能以 `nst_` 开头。 +- `self.logout`:服务器清空该端的会话令牌,回 `resp` 后断开连接,SDK 停止重连。 +- `default_delay_ms` 不超过 `max_schedule_seconds` 对应的毫秒数。 +- `unlock` 对没设密码的端直接成功。 + +注册不走 MQTT,见第 6.9 节。 + +### 6.7 群 + +```json +{"v":1,"type":"group.create","rid":"15","id":"","name":"一组","members":[{"id":"b","talk_password":"secret"}]} +{"v":1,"type":"group.add","rid":"16","group_id":"g_ab12cd34","members":[{"id":"c","talk_password":""}]} +{"v":1,"type":"group.remove","rid":"17","group_id":"g_ab12cd34","endpoint_id":"c"} +{"v":1,"type":"group.leave","rid":"18","group_id":"g_ab12cd34"} +{"v":1,"type":"group.transfer","rid":"19","group_id":"g_ab12cd34","endpoint_id":"b"} +{"v":1,"type":"group.rename","rid":"20","group_id":"g_ab12cd34","name":"新名"} +{"v":1,"type":"group.dissolve","rid":"21","group_id":"g_ab12cd34"} +{"v":1,"type":"group.list","rid":"22","cursor":"","limit":100} +{"v":1,"type":"group.get","rid":"23","group_id":"g_ab12cd34","cursor":"","limit":100} +``` + +建群者成为群主和成员。`group.create` 和 `group.add` 的部分成员失败时群仍然创建(或其余成员照常加入),响应里列出失败的编号和 `code`,例如 `talk_password_required`、`talk_password_invalid`、`endpoint_disabled`、`invalid_target`。群编号已存在返回 `id_taken`。 + +`group.list` 分页返回我加入的群:编号、名称、群主、成员数。`group.get` 返回群信息和分页的成员(编号、名称、是否在线),`limit` 最大 200。非成员调用 `group.get` 返回 `not_member`。 + +群事件用 QoS 0 尽力推送,不落库,推给当前在线的成员;被移除或退出的那个端也收到自己的那条: + +```json +{"v":1,"type":"group_event","group_id":"g_ab12cd34","event":"member_added","endpoint_id":"c","at_ms":1750000000000} +``` + +`event`:`member_added`、`member_removed`、`left`、`owner_changed`、`renamed`、`dissolved`。 + +### 6.8 致命错误 + +```json +{"v":1,"type":"fatal","reason":"disabled"} +``` + +`reason`:`disabled`、`deleted`、`password_reset`、`protocol`。发出后断开,SDK 停止重连。被顶号不走这个帧,走 MQTT 5 的 `0x8E`(第 5 节)。管理员「踢下线」也不发这个帧,只断开连接,SDK 会自动重连。 + +### 6.9 注册(HTTP) + +```http +POST /api/client/register +Content-Type: application/json + +{"registration_code":"...","id":"","login_password":"","name":"门口","talk_password":""} +``` + +成功(HTTP 200): + +```json +{"ok":true,"data":{"id":"e_ab12cd34","login_password":"只在请求里留空时返回"}} +``` + +失败时用与 `resp` 相同的 `error` 结构: + +| HTTP | code | 情况 | +|---|---|---| +| 400 | `bad_request` | 字段不合法 | +| 403 | `registration_closed` | 注册未开启 | +| 403 | `registration_code_invalid` | 安全码错误 | +| 409 | `id_taken` | 编号已存在 | +| 429 | `rate_limited` | 该 IP 输错安全码次数过多,已临时锁定 | +| 503 | `busy` | 服务器忙,可重试 | + +规则: + +- 只在 `listen` 上提供。允许跨域:回 `Access-Control-Allow-Origin: *`,处理 `OPTIONS` 预检,不读也不设 Cookie。 +- 请求体不超过 4 KiB。字段规则同后台开通(PRD F01)。新端的 `source` 记为 `self`。 +- 处理顺序:开关 → 该 IP 是否锁定 → 常量时间比较安全码(错误计入锁定)→ 字段校验 → 哈希密码 → 插入。编号冲突返回 `id_taken`。 +- 日志只记结果、编号和来源 IP,不记安全码和密码。 + +SDK 从连接地址推出注册地址:`wss://host:port/mqtt` 对应 `https://host:port/api/client/register`,`ws://` 对应 `http://`;裸 TCP 的 `mqtts://host:port` 对应 `https://host:port/api/client/register`,`mqtt://` 对应 `http://`。 + +### 6.10 错误码 + +| code | 含义 | +|---|---| +| `bad_request` | 字段不合法 | +| `not_ready` | 还没握手 | +| `unauthorized` | 旧密码错误等身份校验失败 | +| `forbidden` | 没有群主权限等 | +| `not_found` | 消息、群或查询的编号不存在 | +| `invalid_target` | 发送目标不存在 | +| `conflict` | 消息号相同但请求内容不同 | +| `id_taken` | 编号已存在(注册、开通、建群) | +| `body_too_large` | 正文超限 | +| `meta_too_large` | 自定义字段超限 | +| `frame_too_large` | 整帧超过 `max_frame_bytes`。只在 SDK 本地产生:服务器收到超过包上限的 MQTT 包会直接断开连接,回不了 `resp` | +| `response_too_large` | 响应超过本连接声明的接收上限,换小一点的分页再查 | +| `talk_password_required` | 需要对话密码 | +| `talk_password_invalid` | 对话密码错误 | +| `rate_limited` | 请求过快,或因多次输错被临时锁定 | +| `not_member` | 不是群成员 | +| `owner_cannot_leave` | 群主不能直接退出 | +| `group_full` | 超过成员上限 | +| `quota_exceeded` | 自己未完成的消息已达上限(`max_pending_per_sender`) | +| `endpoint_disabled` | 对方已停用 | +| `registration_closed` | 注册未开启(仅注册接口) | +| `registration_code_invalid` | 注册安全码错误(仅注册接口) | +| `busy` | 服务器过载,可重试 | + +请求频率:除 `ack`、`receipt_ack` 外的请求共用一个桶,默认每个端每秒 50 个,突发容量 100,超出返回 `rate_limited`。 + +## 7. 状态与数据库 + +### 7.1 状态 + +消息: + +| 状态 | 含义 | +|---|---| +| `scheduled` | 已提交,没到发送时刻 | +| `dispatched` | 已生成各接收端的投递 | +| `completed` | 没有未完成的投递,正文已删 | + +消息级 `reason`:正常分发的为空;发送前就结束的记原因:`recalled`、`sender_left`、`no_recipients`、`group_dissolved`、`sender_disabled`、`sender_deleted`,以及单聊目标被停用或删除时的 `endpoint_disabled`、`endpoint_deleted`。 + +投递: + +| 状态 | 含义 | +|---|---| +| `pending` | 还没收下。`pushed_conn` 为空表示还没推;非空表示已推给那个连接 | +| `accepted` | 已确认 | +| `recalled` | 撤回成功 | +| `expired` | 保留的消息到期,`reason` 为 `ttl` | +| `dropped` | 不保留的消息没送到,`reason` 为 `offline`(宽限结束仍离线)或 `not_acked`(推送后确认超时) | +| `rejected` | 退群、解散、停用、删除、超限、发送者停用等,看 `reason` | + +所有变更都用带条件的 `UPDATE ... WHERE state = ...`。受影响行数是 0 就表示别人先改了,按当前状态结束,不要覆盖。 + +### 7.2 写入 + +- 写连接 `SetMaxOpenConns(1)`,由一个写 goroutine 独占。所有写操作排队交给它;它把排队的操作合并进一个事务(最多 256 个,或凑满 2 毫秒),提交成功后再逐个回复。每个操作用 `SAVEPOINT` 包住,单个失败只回滚自己。 +- 必须合并:`synchronous=FULL` 下每次提交都要等磁盘落盘,而一条消息从提交、分发、推送标记、确认到回执要写好几次。一操作一事务,在普通云盘上撑不住每秒 200 条。 +- 读用另一个连接池(多个连接),DSN 不带 `_txlock=immediate`。WAL 下读不挡写。 + +### 7.3 提交 + +一个写操作内: + +1. 解析请求,算出请求指纹。格式或大小不合法直接返回错误。 +2. 查防重行:同一发送方、消息号已存在时,指纹相同返回原消息的结果,指纹不同返回 `conflict`,都不再往下做。必须先查防重再做后面的校验:否则第一次其实已经提交成功、只是响应丢了,重试时却因为对方刚停用或刚改了对话密码而报错,发送方会误以为没发出去。 +3. 校验目标、延迟和定时、成员或授权;发送方未完成的消息数达到 `max_pending_per_sender` 时返回 `quota_exceeded`。 +4. 若带对了对话密码,写入或更新授权。 +5. 若发送方自己设了对话密码,且这是发给别人的单聊,给对方写一条回复授权(版本等于发送方当前对话密码版本)。群发不写。 +6. 插入消息和正文。`send_at` 按第 6.2 节计算。 +7. 插入防重行:发送方、消息号、请求指纹、时间。 +8. `send_at` 不晚于现在的,在同一操作里做第 7.4 节的分发。 + +提交后才发 `resp`,并唤醒有新投递的端的推送循环。 + +请求指纹是下列字段规范化后的 SHA-256:`to.kind`、`to.id`、`body.enc`、`content_type`、解码后的正文、`meta`(按键排序后序列化)、`send_at_ms`、`delay_ms`、`offline.keep`、`offline.ttl_seconds`、`receipt`。不含 `talk_password` 和 `rid`。 + +### 7.4 到点发送(分发) + +调度循环按最早的 `send_at` 定时唤醒,提交新消息时也唤醒。按 `send_at`、`seq` 的顺序取 `state = scheduled AND send_at <= now`,逐条作为写操作处理: + +1. 解析接收者。 + - 单聊:目标端。目标已停用则这条投递直接 `rejected`,原因 `endpoint_disabled`。 + - 群:发送时刻的成员,去掉发送者。已停用的成员投递直接 `rejected`,原因 `endpoint_disabled`。发送者已不在群里:不生成投递,消息 `completed`,`reason = sender_left`。群里没有其他成员:同样处理,`reason = no_recipients`。 +2. 每个接收者插入 `pending` 投递,带上 `send_at` 和 `keep`: + - 接收者排队未收下的投递已达 `max_pending_per_receiver`:这条投递直接 `rejected`,原因 `queue_full`。 + - 对方有连接(含握手中):`keep` 时 `expire_at = now + ttl`;不 `keep` 时 `expire_at` 为空。推送循环马上推。 + - 对方没有连接且 `keep`:`expire_at = now + ttl`。 + - 对方没有连接且不 `keep`:从 `offline_since` 算起没超过宽限,`expire_at = offline_since + grace`;否则投递直接 `dropped`,原因 `offline`。 +3. 消息改为 `dispatched`。没有未完成的投递则 `completed` 并删除正文。需要回执的按第 7.6 节写。 + +`now` 用服务器时钟。保留时间从这次分发的时间算,不从当初提交的时间算。 + +### 7.5 推送 + +每个已握手连接一个推送循环: + +1. 取该端 `pending` 且 `pushed_conn` 为空的投递,按 `send_at`、`seq` 排序,数量不超过「窗口减去已推未确认」。 +2. 算出整条 `msg` 帧的字节数。超过该连接的 `max_receive_bytes`,或超过 CONNECT 里声明的 Maximum Packet Size 减去 128 字节(主题和包头),投递改为 `rejected`、原因 `too_large`,不发布。 +3. 用条件更新写入 `pushed_conn`、`pushed_at`、`attempts+1`(`WHERE state='pending' AND pushed_conn IS NULL`),提交后再发布。推送、清理、撤回三方靠这个条件更新互斥。崩溃后残留的标记在启动时统一清掉(第 7.8 节)。 +4. 发布失败或 `OnPublishDropped`:若 `pushed_conn` 仍是这个连接,清掉,1 秒后再推。 +5. 确认超时(默认 5 分钟)由这个循环在内存里计时: + - 不 `keep`:投递改为 `dropped`,原因 `not_acked`,尽力发 `revoked`。 + - `keep` 且已过 `expire_at`:改为 `expired`,尽力发 `revoked`。 + - 否则清掉 `pushed_conn` 重推。 + +大帧(超过 64 KiB)全局同时在发的不超过 64 个:拿到名额才发布,客户端回 PUBACK(mochi 的 `OnQosComplete`)、确认超时或连接断开时释放。否则一条 256 KiB 的消息发给 1000 人的群,每个连接各编码一份,服务器会瞬时多占几百 MB 内存。 + +其他下行帧发布前也检查大小:`resp` 超限改发 `response_too_large` 错误;回执、事件等超限直接丢弃并记日志(它们远小于 1024 字节,正常不会发生)。 + +握手完成时:`online_since` 设为现在;该端不 `keep` 的 `pending` 投递清空 `expire_at`(在线期间不按宽限作废,排队等窗口也不会被丢);然后开始推送。 + +任一连接断开时(包括被新连接顶掉的旧连接),在一个写操作里: + +- 断开的是该端的当前连接时,先做三件事:`offline_since` 设为现在并推送下线通知;该端不 `keep` 的 `pending` 投递,`expire_at` 设为 `now + grace`(原值更晚则保留原值);`keep` 且 `pushed_conn` 是这个代号的,`expire_at` 早于 `now + grace` 时延长到 `now + grace`。 +- 不论是不是当前连接,都清掉 `pushed_conn` 等于这个代号的标记,丢掉这个连接的确认计时。该端已经有新连接时,唤醒新连接的推送循环,把这些投递重推过去。只按代号清,不会碰到新连接写下的标记。 + +清理循环大约每秒一次:`pending`、未推送、`expire_at <= now` 的投递,`keep` 为 true 改 `expired`(原因 `ttl`),否则改 `dropped`(原因 `offline`)。已推送的由推送循环的确认超时处理,清理循环不管。 + +### 7.6 确认、撤回、回执、收尾 + +确认:按 `(from, id)` 找到消息的 `seq`,`UPDATE deliveries SET state='accepted' WHERE seq=? AND endpoint_id=? AND state='pending'`(`endpoint_id` 是确认方)。成功则写回执并尝试收尾;失败则读出现状返回。 + +撤回: + +- 消息仍是 `scheduled`:改为 `completed`,`reason = recalled`,删正文,响应 `recalled`,计数为 0。 +- 已分发:把仍是 `pending` 的投递改为 `recalled`,其中已推送的尽力发 `revoked`。条件更新失败的是刚被确认或刚结束的。按第 6.4 节算 `result`。 + +回执:投递进入 `accepted`、`expired`、`dropped`、`rejected` 时写回执(消息要求回执、且发送方仍存在时)。`recalled` 不写。消息级作废也写一条(第 6.4 节)。发送方不在线则回执留到 `receipt_retention_days`(默认 7 天)。 + +收尾:没有 `pending` 投递时,消息 `completed`,删除正文行。记录保留天数是 0 则同一事务删除消息和投递行。回执表独立保留,不随消息行删除。 + +对话密码版本变化只影响之后的新提交,已经 `scheduled` 或 `pending` 的不追溯作废。 + +作废(各自在一个写操作里完成;已推送的投递改状态后尽力发 `revoked`): + +| 事件 | 处理 | +|---|---| +| 成员退群或被踢 | 这个成员在该群消息里的 `pending` 投递改 `rejected`,原因 `left_group` | +| 解散群 | 该群消息的 `pending` 投递改 `rejected`,原因 `group_dissolved`;发往该群的 `scheduled` 消息改 `completed`、`reason = group_dissolved`,要回执的写消息级回执 | +| 停用端 X | 清空 X 的会话令牌;发给 X 的 `pending` 投递改 `rejected`(`endpoint_disabled`);发给 X 的 `scheduled` 单聊改 `completed`,要回执的写回执;X 发出的 `scheduled` 消息改 `completed`(`sender_disabled`),X 发出的消息的 `pending` 投递改 `rejected`(`sender_disabled`),这两项不写回执 | +| 删除端 X | 同停用,原因换成 `endpoint_deleted`、`sender_deleted`;再退出所有群并处理群主;删除 X 相关的双向授权、X 的待送回执、X 的防重行、X 发出的消息记录 | + +每小时清理一次:删除过期的记录、回执和防重行。防重行只删「超过 `idempotency_hours` 且对应消息记录已不存在」的。分批删除(每批几千行),不要一条语句删几百万行卡住写队列。清理后执行一次 `PRAGMA wal_checkpoint(TRUNCATE)`,让已删的正文尽快从 WAL 文件里消失。 + +### 7.7 表 + +```sql +CREATE TABLE endpoints ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'admin', -- admin | self + login_hash TEXT NOT NULL, + talk_hash TEXT, + talk_version INTEGER NOT NULL DEFAULT 0, + default_delay_ms INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + online_since INTEGER, + offline_since INTEGER, + session_hash TEXT, -- 当前会话令牌的 SHA-256;空表示没有有效会话 + session_issued_at INTEGER, + session_used_at INTEGER +); + +CREATE TABLE settings ( + key TEXT PRIMARY KEY, -- admin_password_hash | registration_enabled | registration_code + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE talk_grants ( + sender_id TEXT NOT NULL, + target_id TEXT NOT NULL, + target_talk_version INTEGER NOT NULL, + kind TEXT NOT NULL, -- password | reply + created_at INTEGER NOT NULL, + PRIMARY KEY (sender_id, target_id) +); + +CREATE TABLE groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE group_members ( + group_id TEXT NOT NULL, + endpoint_id TEXT NOT NULL, + joined_at INTEGER NOT NULL, + PRIMARY KEY (group_id, endpoint_id) +); + +CREATE TABLE messages ( + seq INTEGER PRIMARY KEY, + id TEXT NOT NULL, + sender_id TEXT NOT NULL, + dest_kind TEXT NOT NULL, + dest_id TEXT NOT NULL, + meta TEXT NOT NULL DEFAULT '{}', + content_type TEXT NOT NULL, + body_enc TEXT NOT NULL, + send_at INTEGER NOT NULL, + keep INTEGER NOT NULL, + ttl_seconds INTEGER NOT NULL DEFAULT 0, + receipt INTEGER NOT NULL, + state TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + UNIQUE (sender_id, id) +); + +CREATE TABLE message_bodies ( + seq INTEGER PRIMARY KEY REFERENCES messages(seq) ON DELETE CASCADE, + body BLOB NOT NULL +); + +CREATE TABLE deliveries ( + seq INTEGER NOT NULL REFERENCES messages(seq) ON DELETE CASCADE, + endpoint_id TEXT NOT NULL, + send_at INTEGER NOT NULL, + keep INTEGER NOT NULL, + state TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + expire_at INTEGER, + pushed_conn TEXT, + pushed_at INTEGER, + attempts INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (seq, endpoint_id) +); + +CREATE TABLE receipts ( + receipt_id INTEGER PRIMARY KEY, + sender_id TEXT NOT NULL, + msg_id TEXT NOT NULL, + endpoint_id TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + acked INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE send_keys ( + sender_id TEXT NOT NULL, + msg_id TEXT NOT NULL, + request_sha256 BLOB NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (sender_id, msg_id) +); + +CREATE TABLE admin_sessions ( + token_hash TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + last_used_at INTEGER +); + +CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +CREATE INDEX idx_messages_due ON messages(state, send_at); +CREATE INDEX idx_messages_dest ON messages(dest_kind, dest_id, state); +CREATE INDEX idx_messages_created ON messages(created_at); +CREATE INDEX idx_messages_sender ON messages(sender_id, created_at); +CREATE INDEX idx_messages_sender_state ON messages(sender_id, state); +CREATE INDEX idx_deliveries_outbox ON deliveries(endpoint_id, state, send_at, seq); +CREATE INDEX idx_deliveries_expire ON deliveries(state, expire_at); +CREATE INDEX idx_receipts_outbox ON receipts(sender_id, acked, receipt_id); +CREATE INDEX idx_talk_grants_target ON talk_grants(target_id); +CREATE INDEX idx_group_members_endpoint ON group_members(endpoint_id); +``` + +正文和消息分开,避免后台扫记录时读到正文。后台查询只访问 `messages` 和 `deliveries`。 + +打开库的 DSN 必须用 modernc 的写法,否则 WAL 和超时会被静默忽略: + +```text +file:/nixmsg.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(FULL)&_pragma=foreign_keys(ON)&_pragma=secure_delete(ON)&_txlock=immediate +``` + +- 读连接池去掉 `_txlock=immediate`。 +- `secure_delete(ON)` 让删除的正文被覆盖,而不是只把页标成空闲。 +- `synchronous=FULL` 是为了进程崩溃和断电后尽量不丢已返回成功的提交。可以配置成 `NORMAL`,运维说明里写清断电可能丢掉最后几秒。 + +迁移是嵌入的有序 SQL。启动时若有未应用的版本,先 `VACUUM INTO` 一份 `/backup/pre-migrate-<时间>.db`,再迁移。失败则退出,不带半新半旧的库继续服务。 + +内存里的连接表:编号、连接代号、是否已握手、`max_receive_bytes`、CONNECT 声明的 Maximum Packet Size、订阅的上下线编号、下行窗口计数、各已推送投递的确认计时。重启后全部由客户端重连重建。 + +### 7.8 启动恢复 + +服务启动后、开始接受连接前,在一个事务里: + +1. 所有 `pending` 投递清空 `pushed_conn`(连接代号不跨进程),`expire_at` 不早于「启动时间 + grace」,原值为空的也设成这个值。也就是把重启当作所有端刚断线:端在宽限内重连就能收到,包括保留期在停机期间已过的消息(PRD D23)。 +2. 停机前在线的端(`online_since` 晚于 `offline_since`,或 `offline_since` 为空而 `online_since` 不为空),`offline_since` 设为启动时间。 +3. 停机期间到点的 `scheduled` 消息由调度循环正常处理,立即分发。 + +停止:收到停止信号后先停止接受新连接,等写队列里已排队的操作提交完(最多 10 秒),再断开所有连接并退出。 + +写库失败(例如磁盘满):对应请求返回 `busy`,`/readyz` 返回失败并记错误日志。不要把没提交成功的操作当成功回给客户端。 + +## 8. 管理接口 + +Cookie 名 `nixmsg_admin`,HttpOnly,SameSite=Lax,HTTPS 时(含经受信任代理转来的 HTTPS)加 Secure。会话服务端只存令牌哈希,默认 12 小时。 + +网页登录(Cookie)发起的改变状态的请求必须带请求头 `X-Nixmsg-Request: 1`,否则 403。这是为了挡住浏览器跨站表单。管理接口不开跨域。 + +API 令牌,给接入方后台用程序调用管理接口: + +- 管理员在后台创建令牌并填名称。令牌形如 `nxm_` 加 32 字节随机数的 base64url 编码,只在创建时显示一次;库里只存 SHA-256(令牌本身是高熵随机数,不需要 argon2)。 +- 请求带 `Authorization: Bearer <令牌>`。带令牌的请求不看 Cookie,也不要求 `X-Nixmsg-Request` 头。 +- 权限等同管理员,但不能调用 `/api/admin/password` 和 `/api/admin/tokens`:改管理员密码和管理令牌只能在网页登录后做。 +- 令牌不过期,停用或删除后立即失效。`last_used_at` 在内存里合并,最多每分钟写一次库。 +- 用错误令牌请求,按来源 IP 计入管理员登录锁定。 +- 令牌只在管理接口所在的监听上有效:后台单独监听时,接入方后台要能访问 `admin_listen`。 + +每个改变状态的管理请求写一条结构化日志:操作者(`admin` 或 `token:<名称>`)、动作、对象编号、结果、来源 IP。不写密码、令牌和正文。 + +| 方法 | 路径 | 作用 | +|---|---|---| +| POST | `/api/admin/login` | 用户名固定 `admin`,密码来自初始化 | +| POST | `/api/admin/logout` | 作废会话 | +| GET | `/api/admin/me` | 当前管理员 | +| POST | `/api/admin/password` | 改管理员密码,要旧密码 | +| GET | `/api/admin/overview` | 数量和版本 | +| GET/POST | `/api/admin/endpoints` | 列表(可按 `source` 筛选)、开通 | +| POST | `/api/admin/endpoints/import` | 批量开通 | +| POST | `/api/admin/endpoints/batch` | 多选批量停用、启用、删除 | +| GET/PATCH/DELETE | `/api/admin/endpoints/{id}` | 详情、修改、删除 | +| POST | `/api/admin/endpoints/{id}/kick` | 踢下线(只断开,不阻止重连) | +| POST | `/api/admin/endpoints/{id}/reset-login-password` | 重置,响应里的新密码只出现一次;会话令牌作废,当前连接收到 `fatal` 后断开 | +| POST | `/api/admin/endpoints/{id}/unlock` | 解除这个编号的登录锁定(两种都清) | +| PUT | `/api/admin/endpoints/{id}/talk-password` | 设置或清除,同样增加对话密码版本号,旧授权失效 | +| GET/PUT | `/api/admin/registration` | 注册设置。GET 返回 `enabled`、`code`、`updated_at`;PUT 可改 `enabled`,可传 `code`,或传 `generate: true` 让服务器生成 16 位 | +| GET/POST | `/api/admin/tokens` | 令牌列表(名称、状态、创建时间、最近使用时间,不含令牌本身)、创建(响应里的令牌只出现一次)。只接受网页登录 | +| PATCH/DELETE | `/api/admin/tokens/{id}` | 改名、停用或启用、删除。只接受网页登录 | +| GET/POST | `/api/admin/groups` | 列表、创建 | +| PATCH/DELETE | `/api/admin/groups/{id}` | 改名、解散 | +| POST | `/api/admin/groups/{id}/members` | 加人,后台不要求对话密码 | +| DELETE | `/api/admin/groups/{id}/members/{endpointId}` | 移除 | +| POST | `/api/admin/groups/{id}/transfer` | 转让 | +| GET | `/api/admin/messages` | 记录列表,筛选见 PRD | +| GET | `/api/admin/messages/{seq}` | 各接收端结果,无正文 | +| GET | `/api/admin/settings` | 只读运行参数 | + +批量开通:`POST /api/admin/endpoints/import` 接收 CSV(UTF-8,可带 BOM),表头 `id,name,login_password,talk_password,default_delay_seconds,remark`,最多 1000 行。先整体校验,任何一行出错就一行都不建,返回出错的行号和原因;全部通过才在一个事务里创建。响应里给出每行的编号和本次生成的密码,前端提供一次性下载。密码哈希在第 12 节的并发池里算,1000 行可能要十几秒:前端显示进度,服务端这个接口的写超时要够长。 + +所有 JSON 响应在序列化前去掉正文。测试里对管理接口的响应做一次「不含 body 字段」的检查。 + +`nixmsg serve` 发现库里没有管理员密码时拒绝启动,提示先运行 `nixmsg admin init`。不要自动生成密码再打到日志里。`admin init` 生成 20 位密码,只在终端打印一次,库里存哈希;已经初始化过的库拒绝执行,改用 `admin set-password`。 + +## 9. SDK + +四种语言同一行为。应用只调用下面这些概念,不接触主题和 JSON。方法名按各语言习惯调整,含义不变。 + +```text +register(url, registrationCode, options) -> {id, loginPassword?} // 静态方法,不需要先连接 +connect(url, endpointId, credential, options) // credential 为 {password} 或 {sessionToken} +onSession(handler) // 拿到新的会话令牌时回调,应用负责保存 +send(to, body, options) -> SendResult +recall(id) -> RecallResult +status(id, cursor?) +unlock(endpointId, talkPassword) +onMessage(handler) +ack(message) // 仅手动确认模式 +onReceipt / onRevoked / onPresence / onGroupEvent +onConnection(state) // connecting online reconnecting offline kicked auth_failed +presence(ids) +directory(cursor, query) +watchPresence(ids | all) +getSelf / updateSelf / setTalkPassword / changeLoginPassword(old, new) +groups... // 与第 6.7 节一一对应 +logout() // 作废会话令牌并断开,不再重连 +close() +``` + +`send` 的 `options` 含 `sendAt`、`delay`、`keep`、`ttl`、`receipt`、`talkPassword`、`contentType`、`meta`。`register` 的 `options` 含 `id`、`loginPassword`、`name`、`talkPassword`。 + +连接: + +1. WebSocket 到 `路径/mqtt`,子协议 `mqtt`。裸 TCP 只在选项里显式打开时使用。浏览器页面是 HTTPS 时,服务器也必须用 TLS:浏览器不允许 HTTPS 页面连 `ws://` 或请求 `http://`。 +2. 每次连接都带 Clean Start,包括重连。Go 的 autopaho 只在第一次连接使用 `CleanStartOnInitialConnection`,必须用 `ConnectPacketBuilder` 在每一次连接把 Clean Start 设为 true,会话过期间隔设为 0。 +3. 订阅 down,发 `hello`,等到成功响应。连接超时默认 30 秒(autopaho 默认 10 秒,要改):服务器重启后大量端同时重连,密码校验要排队。 +4. 重连退避:1 秒起,加倍,上限 30 秒,加减 30% 抖动。稳定在线 60 秒后把退避恢复到 1 秒。 +5. 会话令牌:用密码连上后,把握手响应里的 `session_token` 通过 `onSession` 交给应用,之后自动重连都用这个令牌;应用下次启动可以直接用保存的令牌 `connect`。SDK 自己不落盘保存令牌和密码。 +6. 停止重连的情况:收到 `fatal`;被 `0x8E` 接管;CONNACK 为 `0x86` 用户名或密码错误、`0x87` 未授权、`0x8A` 已封禁(3.1.1 为返回码 4、5)。这时事件 `auth_failed` 带原因:用令牌连接被拒为 `session_invalid`(在别处登录过、令牌过期、密码被改或重置、已退出登录、编号被停用或删除),用密码被拒为 `bad_credentials`。其他失败,包括网络错误、`0x88` 服务器不可用、`0x89` 服务器忙、连接被直接关闭,都继续重连。 + +发送: + +- 发送队列在内存,默认最多 1000 条,包括已发出但没收到 `resp` 的。连不上时 `send` 入队;重连后按原消息号、原请求内容再交。 +- `sendAt` 在调用 `send` 时就换算成 `send_at_ms`,重交时不重算,否则请求指纹变了会被当成冲突。 +- 同时在途(已发出、没收到 `resp`)的请求不超过 100 个。发送收到 `rate_limited` 时按退避自动重交,不算失败;其他请求收到 `rate_limited` 直接返回给应用。 +- 进程退出则队列丢失。SDK 停止重连(被踢、认证失败、`close`)时,队列里的发送全部以对应错误结束。 +- 其他调用在未握手时返回未连接。 + +接收: + +- 按「from + id」记录处理状态,默认容量 10000,超出丢最旧的: + - 已确认过的再次到达:直接再发一次 `ack`,不交给应用。上次的 `ack` 可能丢了,不回 `ack` 服务器会一直重推。 + - 已交给应用、还没确认的再次到达:忽略。 + - 自动模式下回调抛错:删掉这条的记录,等服务器重推时重新处理。 +- 回调串行。 +- 自动模式:回调正常返回后发 `ack`。回调抛错则不发,打出错误,等服务器重推:保留的消息在保留期内大约每 5 分钟重推一次,不保留的消息确认超时后被服务器丢弃。永久性失败要改用手动确认并自己 `ack`。 +- 手动模式:只有应用调用 `ack` 才确认。 +- `ack` 的 `resp` 里 `data.result` 不是 `accepted` 时,按收到 `revoked` 处理。 + +时间:`sendAt` 使用「本机时间 + 服务器偏差」。偏差 = `server_time_ms` −(发 `hello` 的本机时刻 + 收到响应的本机时刻)/ 2,每次握手更新。 + +本地检查:正文超限返回 `body_too_large`,与服务器相同;整帧超过握手给出的 `max_frame_bytes` 返回 `frame_too_large`。服务器收到超过包上限的 MQTT 包会直接断开连接,不会回 `resp`;不在本地拦住的话,SDK 重连后重交同一帧会无限循环。 + +注册:按第 6.9 节调用 HTTP 接口。返回的生成密码只出现这一次,SDK 原样交给应用,不自己保存。 + +各语言包装: + +| 语言 | 包 | 接口形态 | +|---|---|---| +| Go | `github.com/nixmsg/nixmsg-sdk-go` | context,方法返回 error | +| JS/TS | `@nixmsg/sdk` | Promise,ESM 和 CJS 都发 | +| Python | `nixmsg` | 同步为主;`asyncio` 包装放在同一包 | +| Java | `com.nixmsg:nixmsg-sdk` | `CompletableFuture`。Android 最低 API 24,长连接由应用自己放到前台服务 | + +包名是占位。正式名称和发布渠道(GitHub 组织、npm scope、PyPI 名、Maven groupId)由负责人确认拥有权后再定。Go 模块路径必须和实际仓库地址一致,放在本仓库 `sdk/go` 下时就是「仓库路径/sdk/go」。 + +接入清单(每种语言一份集成测试,对真实服务器二进制): + +1. 登录并收到握手参数。 +2. 单聊收发,应用回调只有一次。 +3. 断开期间发送,重连后送达且不重复。 +4. 发送队列在进程内重试同一消息号;模拟 `ack` 丢失后服务器重推,SDK 自动再确认且不重复回调。 +5. 10 秒延迟内撤回,对方无回调。 +6. 定时 2 秒后送达。 +7. 离线保留:对方晚 1 秒上线能收到;保留 1 秒且 3 秒后才上线则收不到,发送方收到过期回执。 +8. 建群、两人收到同一内容、发送者自己不收到。 +9. 对话密码:拒绝、解锁、改密后失效、对方先发则可以回复。 +10. 第二处登录把第一处踢下线且第一处不再重连。 +11. 超过 256 KiB 在本地失败。 +12. 注册:关闭时失败;安全码错误失败;成功后能登录;换码后旧码失败、已注册的端照常登录。 +13. 改登录密码后用新密码重连成功、旧密码失败;认证失败后 SDK 不再重连。 +14. 仅 JS:浏览器页面和服务器不同域名时,注册和 WebSocket 连接都成功。 +15. 会话令牌:密码登录后收到令牌;断开后用令牌重连成功;另一处用密码登录后,原来那处用旧令牌重连被拒(`session_invalid`)且不再重连;`logout` 后令牌失效。 + +## 10. 无 SDK 的设备 + +使用 MQTT 5 客户端(例如 ESP-IDF 的 mqtt,走 WebSocket 或 TCP)。ClientID 和用户名是端编号,密码是登录密码。订阅 `nix/c/{编号}/down`,向 `nix/c/{编号}/up` 发第 6 节的 JSON。收到 `msg` 后处理完发 `ack`。用「from + id」去重,已确认过的再到达时再回一次 `ack`。在 `hello` 里按自己的接收缓冲声明 `max_receive_bytes`(不小于 1024)。 + +不要开持久会话,不要订阅通配符,不要发保留消息。能发 HTTP 请求的设备可以按第 6.9 节自助注册,不能的由管理员开通。设备可以每次都用登录密码连接(每次都算新登录、会换令牌),也可以把握手拿到的 `session_token` 存起来,重连时当作密码用。3.1.1 客户端看不到被顶号的原因;每次都用密码登录的设备不要和别的设备共用编号,否则会来回互踢。 + +## 11. 配置与部署 + +### 11.1 配置文件 + +```yaml +listen: ":7443" # 端接入端口 +admin_listen: "" # 后台单独监听,如 "127.0.0.1:7444";空表示后台也在 listen 上 +tls: + cert_file: "" + key_file: "" + allow_plaintext: false +trusted_proxies: [] # 反向代理的地址段,如 ["127.0.0.1/32"] +data_dir: "./data" +limits: + max_body_bytes: 262144 # 只能调小 + max_meta_bytes: 4096 + max_frame_bytes: 786432 + max_ttl_seconds: 2592000 + max_schedule_seconds: 31536000 + max_group_members: 1000 + grace_seconds: 60 + ack_timeout_seconds: 300 + delivery_window: 32 + receipt_window: 64 + requests_per_second: 50 # 除 ack、receipt_ack 外,突发容量为 2 倍 + max_pending_per_sender: 10000 # 等待发送或投递中的消息数,群消息算一条;0 表示不限 + max_pending_per_receiver: 10000 # 排队未收下的投递数;0 表示不限 +session_idle_days: 30 # 会话令牌多少天没用就失效;0 表示不失效 +record_retention_days: 7 # 0 表示完成后不留记录 +idempotency_hours: 24 +receipt_retention_days: 7 +sqlite_synchronous: FULL # 或 NORMAL +metrics: + token: "" # 后台和端共用端口时,访问 /metrics 要带它;空表示共用端口时不提供 +log: + level: info +``` + +注册开关、注册安全码和 API 令牌存在库里,在后台修改,不在配置文件里。 + +环境变量 `NIXMSG_CONFIG` 指向配置文件,默认 `./config.yaml`。`check-config` 要拒绝 `max_body_bytes` 大于 262144 的配置。 + +### 11.2 命令与构建 + +命令: + +| 命令 | 作用 | +|---|---| +| `nixmsg serve` | 启动 | +| `nixmsg admin init` | 生成管理员密码(只能执行一次) | +| `nixmsg admin set-password` | 重置管理员密码 | +| `nixmsg backup --out file.db` | 对运行中的库 `VACUUM INTO` | +| `nixmsg check-config` | 检查配置 | +| `nixmsg healthcheck` | 请求本机的 `/healthz`,成功退出码 0;给 Docker 健康检查用(镜像里没有 curl) | + +构建:Taskfile 先在 `web/` 里 `pnpm install`、`pnpm build`(产物在 `web/dist`),再 `CGO_ENABLED=0 go build`,目标 `linux/amd64`、`linux/arm64`、`windows/amd64`。 + +运行目录: + +```text +config.yaml +data/nixmsg.db +data/backup/ +``` + +- 备份文件包含备份当时还没送完的正文,要按敏感数据保管。 +- systemd 示例只需要 `ExecStart`、`WorkingDirectory`、`Restart=on-failure`。Windows 上用 WinSW、NSSM 之类的工具托管成服务,本期不在程序里内置服务安装。 +- 服务器开启 NTP 对时。 +- 对公网开放时建议设置 `admin_listen`,让后台只监听内网或本机地址。 +- 运维说明写清:服务器时钟被人为大改时,定时消息按新时钟触发。 + +### 11.3 证书与 1Panel + +生产服务器装有 1Panel 时,证书由 1Panel 申请和续签,程序只读文件: + +1. 在 1Panel 的证书管理里申请证书(DNS 账户或 HTTP 验证),打开自动续签。 +2. 勾选「推送证书到本地目录」,指定一个目录,例如 `/opt/nixmsg/certs`。1Panel 在申请和每次续签后写入 `fullchain.pem` 和 `privkey.pem`。 +3. 配置 `tls.cert_file: /opt/nixmsg/certs/fullchain.pem`、`tls.key_file: /opt/nixmsg/certs/privkey.pem`。程序每小时检查一次,续签后自动换上新证书,不用重启。 +4. 私钥要让 NixMsg 进程能读:在 1Panel 的「申请证书后执行脚本」里调整 `privkey.pem` 的属主或权限。Docker 部署时容器用户是 nonroot(uid 65532)。 + +不要用 1Panel 的 OpenResty 反向代理来终止 TLS:它的 TCP/UDP 代理不做 TLS,裸 TCP 设备就没法加密,而且端会被拆到两个端口。只有全部端都走 WebSocket 时,才可以改成「OpenResty 终止 HTTPS/WSS,NixMsg 只监听本机明文」;这时要配 `trusted_proxies`,反向代理里设置 `proxy_http_version 1.1`、`Upgrade`、`Connection`,并用 `Host $http_host`(1Panel 默认生成的 `$host` 会丢端口)。 + +开发环境可以不配证书跑明文,或用 mkcert 之类的工具生成本机信任的证书。 + +### 11.4 Docker + +- 镜像多阶段构建:Node 构建前端 → Go 编译(嵌入前端)→ `gcr.io/distroless/static` 的 `nonroot` 变体。入口是 `/nixmsg`。发布 `linux/amd64`、`linux/arm64` 两个架构。 +- 容器里的路径:配置 `/etc/nixmsg/config.yaml`(`NIXMSG_CONFIG` 指向它),数据目录 `/data`(配置里写 `data_dir: /data`),证书目录 `/certs` 只读挂载。 +- 首次使用先执行 `docker compose run --rm nixmsg admin init`,记下只打印一次的管理员密码,再 `docker compose up -d`。 +- 容器以 uid 65532 运行:挂载的数据目录要可写,证书私钥要可读。 +- 装有 1Panel 的服务器可以直接在它的容器编排里使用下面的 compose 文件。 + +```yaml +services: + nixmsg: + image: nixmsg:0.1.0 + restart: unless-stopped + command: ["serve"] + environment: + NIXMSG_CONFIG: /etc/nixmsg/config.yaml + ports: + - "7443:7443" + volumes: + - ./config.yaml:/etc/nixmsg/config.yaml:ro + - ./data:/data + - /opt/nixmsg/certs:/certs:ro + healthcheck: + test: ["CMD", "/nixmsg", "healthcheck"] + interval: 30s + timeout: 5s + retries: 3 +``` + +## 12. 安全 + +- 登录密码、对话密码、管理员密码都用 argon2id,参数:内存 19 MiB、迭代 2、并行 1(OWASP 密码存储速查表给出的最低配置)。用 PHC 字符串格式保存,参数随哈希一起存;以后调高参数时,在下次校验成功后重新哈希。比较用常量时间。 +- 所有 argon2 计算走一个并发池,默认大小等于 CPU 核数,其余排队。每次计算约占 19 MiB 内存,池大小决定峰值内存。服务器重启后的大批重连基本都用会话令牌,只是一次哈希查表;只有用密码登录的连接才排队算 argon2(SDK 连接超时 30 秒)。 +- 会话令牌是 32 字节随机数,库里只存 SHA-256,比较用常量时间;不绑定 IP;登录密码不能以 `nst_` 开头。 +- 注册安全码明文存库,只在后台注册设置里返回;比较用 `crypto/subtle.ConstantTimeCompare`。 +- 日志、崩溃转储、管理接口里不出现正文、任何密码和注册安全码。 +- 端不能订阅别人的 down,不能向上行以外的主题发布。 +- 后台 Cookie 按第 8 节。对公网开放时用 `admin_listen` 把后台放到内网或本机地址。 +- 生产配置了证书就不要开 `allow_plaintext`。对公网开放注册时必须配证书:注册请求里有安全码和密码。 +- API 令牌只存 SHA-256,只在创建时显示一次,不能用来改管理员密码或管理令牌。 +- `/metrics` 只有计数和耗时,不含正文、编号等明细;后台和端共用端口时必须带 `metrics.token`。 + +## 13. 测试 + +- 状态机用表驱动测试:提交、到点、宽限、保留到期、确认超时(保留和不保留两种)、确认与撤回竞态、退群、解散、停用、删除、防重与冲突、改密不影响旧消息、启动恢复。 +- 集成测试起真实进程和真实 MQTT 客户端,覆盖 PRD 第 10 节。端口按两种配置各跑一遍:只有 `listen`;`listen` 加 `admin_listen`。 +- 弱网:用 toxiproxy 做延迟、带宽、超时和断开。Linux 上再用 netem 做 20% 丢包。保留期内消息全部送达,应用层去重后无重复。 +- 崩溃:提交返回成功后杀掉进程,重启后续传;推送中杀掉进程,重启后不保留的消息在宽限内重连仍能送到。 +- 下行超限:声明 `max_receive_bytes: 1024` 的连接收到 `too_large`,发送方收到回执,连接不断、不循环重推。 +- 锁定:登录、对话密码、注册安全码、管理员登录各自到达阈值后被拒;换一个 IP 用密码登录不受影响;多个 IP 轮流猜同一编号的登录密码,达到按编号计的总数后暂停密码登录,但已登录设备用令牌重连不受影响,`unlock` 后恢复;多个账号轮流猜同一个端的对话密码,达到按对方计的总数后被暂停。来源 IP 可以通过受信任代理的 `X-Forwarded-For` 模拟。 +- 会话令牌:密码登录拿到令牌;用令牌重连不换令牌;另一处用密码登录后,旧令牌被拒、旧连接收到 `0x8E`;改密码返回新令牌;重置密码、停用、删除、`self.logout` 后令牌失效;闲置超过 `session_idle_days` 后失效。 +- 配额:发送方未完成消息达到上限后返回 `quota_exceeded`;接收端排队满后新投递为 `queue_full`,发送方收到回执。 +- 服务器故障:数据库不可读时,客户端连接被直接关闭,而不是收到「用户名或密码错误」。 +- 管理接口响应体搜索不到测试正文。 +- API 令牌:带令牌能调用管理接口;调用 `/api/admin/password`、`/api/admin/tokens` 被拒;停用后立即失效。 +- `/metrics`:后台单独监听时可直接抓取;共用端口时不带令牌返回 401,没配令牌返回 404。 +- 证书重载:替换证书文件后 1 小时内新连接用上新证书,已有连接不断。 +- Docker:两个架构的镜像都能按第 11.4 节初始化、启动并通过健康检查。 +- 后台主路径用浏览器自动化走一遍:登录、开通、开启注册并设置安全码、建群、看到未完成记录。 +- 压测:1000 个连接保持,每秒 200 条 1 KiB 单聊,持续 10 分钟无失败。1000 人群发一条,5 秒内全部入推送队列。服务器重启后 1000 个端在 1 分钟内全部重新上线。 + +## 14. 里程碑 + +每段都可以单独交给审核。具体的任务拆分、分工和多 Agent 协作方式见 [TASKS.md](./TASKS.md)。 + +| 段 | 内容 | +|---|---| +| M0 | 仓库、Taskfile、配置、空库、管理员初始化、健康检查,前端打包嵌入流程跑通 | +| M1 | 端口识别(含后台单独监听)、证书加载与自动重载、内置 MQTT、登录与锁定、顶号、在线、注册接口 | +| M2 | 单聊、确认、离线保留、宽限、延迟、定时、撤回、回执、正文删除、配额、启动恢复 | +| M3 | 对话密码、群 | +| M4 | 管理接口和网页(Naive UI;含注册设置、批量开通、API 令牌、操作日志)、`/metrics` | +| M5 | 四种 SDK 与接入清单 | +| M6 | 弱网、崩溃、压测,Docker 镜像和部署说明(含 1Panel 证书),以及 `docs/DEVIATIONS.md` | + +## 15. 不要这样做 + +- 不要用 MQTT 保留消息或离线会话来实现「可选保留」和过期时间。那做不到按条选择,也做不到改保留时长。 +- 不要把 PUBACK 当成对方已收下。 +- 不要让别的 goroutine 长期持有 broker 给的 payload 切片,先拷贝。 +- 不要在旧连接的断开回调里无条件把编号标成离线。 +- 不要在 `OnConnectAuthenticate` 里把数据库错误等内部故障返回 false:客户端会收到「用户名或密码错误」并停止重连。 +- 不要只按编号锁定登录,也不要让密码锁定拦住会话令牌重连;不要把会话令牌绑定 IP;令牌重连不要走 argon2。 +- 不要用 mochi 自带的监听器再占端口。 +- 不要依赖 mochi 拦截超过客户端上限的下行包,它只会静默丢弃。 +- 不要把 `coder/websocket` 的 `NetConn` 绑在 HTTP 请求的 context 上;不要保留它默认的 Origin 校验;不要指望 `Accept` 替你拒绝错误的子协议。 +- 不要在 TLS 配置里写 `NextProtos = ["http/1.1"]`。 +- 不要给 SQLite 写 `?_journal_mode=WAL` 这种 mattn 语法。modernc 会悄悄忽略,库会运行在没有超时的回滚日志上。 +- 不要一操作一事务地写库;读连接池不要用 `_txlock=immediate`;不要一条语句删几百万行。 +- 不要为了分块去改协议。超过 256 KiB 就拒绝。 +- 不要在四种 SDK 里各写一套不一样的重连、去重或确认。 +- Go SDK 不要只在第一次连接设置 Clean Start。 +- SDK 去重命中已确认的消息时不要静默丢弃,要再回一次 `ack`;重交发送时不要重算 `send_at_ms`。 +- 不要把正文写进日志和管理接口;不要把首次生成的管理员密码打进日志。 +- 后端不要引入 Web 框架或 ORM;前端不要混用第二套 UI 组件库;不要为了部署再加 Redis、Node 服务或独立数据库。 diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md new file mode 100644 index 0000000..8627a73 --- /dev/null +++ b/docs/DEVIATIONS.md @@ -0,0 +1,45 @@ +# 实现与文档的偏差 + +开发中凡是实现和 [PRD.md](./PRD.md)、[DEVELOPMENT.md](./DEVELOPMENT.md) 不一致的地方,都记在这里,交给负责人审核。 + +每条写清:日期、原条款(文档和小节)、实际做法、原因、影响。各线只写自己那一节,避免多条线同时改同一段。 + +## 总控 L + +暂无。 + +## 平台 P + +暂无。 + +## 连接 N + +暂无。 + +## 消息 M + +暂无。 + +## 身份 I + +暂无。 + +## 后台接口 A + +暂无。 + +## 后台网页 W + +暂无。 + +## SDK 一 S1 + +暂无。 + +## SDK 二 S2 + +暂无。 + +## 测试交付 Q + +暂无。 diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..c82371e --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,581 @@ +# NixMsg 产品需求 + +| 项 | 内容 | +|---|---| +| 版本 | 0.4 | +| 日期 | 2026-09-30 | +| 状态 | 待复核后交给开发 | +| 读者 | 产品负责人、开发组、后续审核 | +| 配套 | [DEVELOPMENT.md](./DEVELOPMENT.md) 是实现规范。两者冲突时,以本文已确认的产品行为为准,并在 `docs/DEVIATIONS.md` 记录 | +| 修订 | 见第 11 节 | + +## 1. 这是什么 + +NixMsg 是一套自建的消息中转服务。设备、程序、App 都作为「端」连到同一台服务器,端只用一个固定端口。端之间用端编号互发小消息,或在群里发同一条消息。 + +服务器负责:身份(开通、自助注册、登录)、在线状态、弱网重传、可选的离线暂存、定时和延迟发送、撤回、送达结果。服务器不保存聊天记录,也不提供业务聊天界面。业务界面由接入方自己做。我们提供管理后台和各语言 SDK。 + +一条消息的正文最大 256 KiB。更大的文件本期不传,以后用「先上传、再发链接」的方式做。 + +## 2. 要解决的问题 + +- 端与端之间隔着 NAT,不能互相直连,需要一台中转。 +- 网络会抖、会断。已经交给服务器的消息要尽量送到,对方短暂掉线时不要立刻丢掉。 +- 对方长时间不在线时,发送方自己决定留不留、留多久。 +- 发送方可以反悔,也可以指定过一会儿再发。 +- 多个端要能收同一条消息。 +- 有的端不希望陌生人给它发消息。 +- 接入方要在自己的 App 里让用户注册、登录、改密码,但不能让陌生人随便注册。 +- 接入方语言不同,需要少写连接代码;没有现成 SDK 的设备要能按协议直接接。 + +## 3. 名词 + +| 名词 | 含义 | +|---|---| +| 端 | 一个通讯身份。有唯一编号、登录密码,既能发也能收。程序、设备、App 登录后都是端。端由管理员开通,或在管理员开启注册后自己注册 | +| 群 | 若干端的集合。发到群里的一条消息,内容只有一份,每个成员各有自己的送达结果 | +| 登录密码 | 端连接服务器时使用。防止别人冒充这个编号 | +| 会话令牌 | 用密码登录成功后服务器发给端的一串随机字符。之后断线重连都用它,不用密码,也和 IP 无关。每次用密码登录都会换新的,旧的立即作废 | +| 对话密码 | 端自己可选的一道门。别人要单聊它,或把它拉进群,得先知道这道密码。不设置则谁都能给它发 | +| 对话授权 | 服务器记住的「甲可以向乙单聊」。输对一次对话密码,或乙主动给甲发过单聊,都会形成授权。乙改对话密码后授权失效 | +| 注册安全码 | 管理员设置的一串字符。开启自助注册后,注册时必须带上它。更换后只影响之后的注册 | +| 提交 | 发送方把消息交给服务器,服务器写入存储并返回成功 | +| 发送时刻 | 服务器真正开始投递的时间。可以是立即、延迟若干秒,或指定的未来时间 | +| 投递 | 服务器把消息推给接收端 | +| 收下 | 接收端处理完并向服务器确认。这时才算送到 | +| 确认超时 | 消息推给在线端后,等它确认的最长时间,默认 5 分钟 | +| 离线保留 | 发送时刻对方不在线时,把消息留在服务器,等它上线再推。按条选择 | +| 保留时间 | 离线保留的最长期限,从发送时刻起算。超时还没收下就作废 | +| 抖动宽限 | 对方刚断线的一小段时间(默认 60 秒)。这段里即使本条没有选择保留,也先等它重连 | +| 撤回 | 发送方取消一条对方还没收下、且还没作废的消息 | +| 回执 | 服务器告诉发送方某个接收端的最终结果:已收下、已过期、已丢弃、已拒绝。撤回的结果从撤回请求的返回里得到,不再发回执 | +| 记录 | 不含正文的投递痕迹:谁发给谁、何时提交、何时发送、结果如何。给管理后台查询 | +| 圈子 | 这一台服务器上的全部端。谁都能看到别人在不在线 | + +## 4. 谁在用 + +| 角色 | 做什么 | +|---|---| +| 程序或设备 | 主要使用者。自动收发,收到就处理 | +| 人 | 通过接入方自己的 App 或网页看消息、点发送;管理员开启注册时,也可以在接入方 App 里自己注册。本期不提供这套界面 | +| 管理员 | 使用自带的网页后台:开通端、开关注册和设置注册安全码、改密码、看在线、管群、查送没送到 | + +典型场景: + +1. 后台给设备下发一条指令。设备当时不在线,指令保留 24 小时,上线后送到。设备回复时,若后台设了对话密码且曾经给设备发过单聊,设备不用再输密码。 +2. 人在网页里给一个端发消息,发出后 10 秒内可以撤回。 +3. 约定明天 8 点发到一个群。到点时按当时的群成员投递;选了保留的,不在线的成员上线后仍能收到同一条内容。 +4. 发送前先看对方在不在线,或列出当前在线的端。 +5. 接入方在自己的 App 里内置注册安全码,用户在 App 里注册、登录、改密码。管理员更换安全码后,旧版 App 不能再注册新用户,已注册的用户照常使用。 + +## 5. 本期做、本期不做 + +### 5.1 本期做 + +| 编号 | 能力 | 优先级 | +|---|---|---| +| F01 | 开通和管理端 | P0 | +| F02 | 登录,同一编号只能一处在线 | P0 | +| F03 | 查看在线和端目录 | P0 | +| F04 | 上下线通知 | P0 | +| F05 | 单聊 | P0 | +| F06 | 群发 | P0 | +| F07 | 消息内容与 256 KiB 上限 | P0 | +| F08 | 至少送达一次、确认、去重、顺序 | P0 | +| F09 | 离线保留和保留时间 | P0 | +| F10 | 断线抖动宽限 | P0 | +| F11 | 定时发送 | P0 | +| F12 | 发送延迟(反悔窗口) | P0 | +| F13 | 撤回 | P0 | +| F14 | 回执 | P0 | +| F15 | 对话密码和授权 | P0 | +| F16 | 群管理 | P0 | +| F17 | 网页管理后台和管理 API 令牌 | P0 | +| F18 | 正文删除和投递记录 | P0 | +| F19 | Go、Java/Android、JS/TS、Python SDK | P0 | +| F20 | 无 SDK 时按协议用 MQTT 接入 | P0 | +| F21 | 端只用一个固定端口,后台可用单独端口 | P0 | +| F22 | 单文件或 Docker 部署、备份、升级、监控指标 | P0 | +| F23 | 端自助注册(管理员开关、注册安全码) | P0 | + +### 5.2 本期不做 + +- 大文件、分块、断点续传。后续另行做「上传文件后发送链接」。 +- 服务器保存历史供翻看。历史由各端自己存。 +- 同一编号同时在多台设备登录。要多台就开通多个端。 +- 多机集群。 +- 注册审核、邮箱或手机号验证。注册只靠注册安全码把关。 +- 忘记密码自助找回。忘记登录密码由管理员重置。 +- 端自己注销。需要时由管理员在后台删除。注意:接入方的 iOS App 如果提供注册,苹果审核要求 App 内能删除账号,届时需要补这项能力。 +- 已读(人有没有看)。只有「端有没有收下」。 +- 编辑已发消息。可以撤回后重发。 +- 端到端加密。传输可以用 TLS,服务器在投递完成前能看到正文。 +- 手机系统推送(APNs、FCM 等)。注意:手机 App 退到后台后,连接通常会被系统挂起,没有系统推送就收不到新消息提醒;人用的聊天场景要考虑这一点。 +- 管理员在后台代发或代撤回。 +- 多个管理员角色。本期一个管理员账号。 +- 嵌入式 C 语言 SDK。小设备按 F20 直接接 MQTT。 + +## 6. 功能需求 + +优先级 P0 为本期必须完成。每条都包含规则和验收。 + +### F01 开通和管理端 + +端由管理员开通,或在管理员开启注册后由端自己注册(F23)。两种方式开出来的端没有区别。 + +- 编号 1–64 字符,只能是小写字母、数字、`_`、`.`、`-`,全局唯一。留空则服务器生成,形如 `e_` 加 8 位小写字母数字。不允许大写,是为了避免 `Alice` 和 `alice` 这类看起来相同的编号互相冒充。 +- 名称可选,最多 64 个 Unicode 字符。备注可选,最多 200 字符。 +- 登录密码 8–128 字符,不能以 `nst_` 开头(这个前缀留给会话令牌)。留空则生成随机密码,只在创建成功时显示一次。 +- 对话密码可选,4–64 字符。空表示不设防。 +- 可设置默认发送延迟,单位秒,默认 0。 +- 支持批量开通:上传 CSV 文件(UTF-8),列为编号、名称、登录密码、对话密码、默认延迟、备注,一次最多 1000 行。先整体检查,有任何一行不合格就一行都不开通,并列出出错的行和原因。密码留空则生成,生成的密码只在结果里出现这一次,可以下载保存。 +- 可改名称、备注、默认延迟、启停、删除、重置登录密码、设置或清除对话密码、踢下线、解除登录锁定。重置登录密码会让它的会话令牌作废。踢下线只断开当前连接,令牌不变,SDK 会自动重连;要让它连不上,请停用。 +- 停用:立刻断开,不能再登录;发给它的新消息提交失败;尚未送到它的消息作废,原因 `endpoint_disabled`;它自己发出、还没推送出去的消息也作废,原因 `sender_disabled`。重新启用后可以正常登录,已作废的消息不恢复。 +- 删除:在停用的效果之外(原因记为 `endpoint_deleted`、`sender_deleted`),退出所有群;它当群主的群转给最早加入的成员,群里没有别人则解散;清掉与它相关的授权、它的待送回执和它发出的消息记录。编号删除后可以重新开通或注册,新端不继承任何旧数据。 + +验收: + +- 一次批量开通 1000 个端成功,生成的密码不写入日志。CSV 里有一行编号重复时,一个端都不开通,并指出是哪一行。 +- 停用后该端立刻离线,发给它的新消息被拒绝,发给它的定时未到点的消息作废;它停用前提交、还没到点的定时消息也不再发出。 +- 删除群主后,群主变为剩余成员里最早加入的那个。 +- 删除一个端后用同一编号重新开通,新端收不到旧端的回执。 + +### F02 登录、会话令牌与单连接 + +- 首次登录用端编号和登录密码。登录成功后服务器发给端一个会话令牌,之后断线重连都用这个令牌,不再用密码。令牌和 IP 无关:设备换网络、换 IP,或者服务器重启后,都直接用令牌重连。 +- 同一编号同时只有一个有效会话。每次用密码登录都换一个新令牌,旧令牌立即作废(D28): + - 旧设备当时在线:当场被踢下线,原因是「已在别处登录」,停止自动重连并通知应用。 + - 旧设备当时不在线:之后用旧令牌重连会被拒绝,SDK 通知应用「会话已失效」并停止重连,需要重新用密码登录。 + - 用令牌重连不算新登录,令牌不变。 +- 应用应该保存令牌,而不是密码。令牌 30 天没用过自动失效(可配置,0 表示不失效),之后要重新用密码登录(D29)。应用退出登录时调用 SDK 的退出,服务器作废令牌。 +- 端可以自己改登录密码,必须提供旧密码。当前连接保持,并拿到新令牌。 +- 管理员重置登录密码、停用、删除:令牌作废,当前连接被踢下线,停止自动重连。管理员「踢下线」只断开连接,令牌不变,SDK 会自动重连。 +- 密码输错会临时锁定。锁定只针对用密码登录,用令牌重连不受影响,所以已登录的设备换 IP、断线重连都不会被锁(D18): + - 同一编号在同一来源 IP 上 5 分钟内错 10 次,锁定这个组合 5 分钟。设备换了 IP,不会被别的 IP 上的失败牵连。 + - 同一编号不论来源 IP,1 小时内错 50 次,暂停这个编号的密码登录 1 小时,防止有人不断换 IP 猜密码。管理员可以在后台解除锁定。 +- 登录失败要分清两类:密码错误、会话已失效、编号不存在、已停用、已锁定,SDK 停止重连并通知应用;服务器暂时不可用(例如数据库故障、连接数已满),SDK 继续重连。 + +验收: + +- A 设备在线时,B 设备用密码登录同一编号:A 被踢下线且不再重连,B 可用。 +- A 设备离线期间,B 设备用密码登录;A 恢复网络后用旧令牌重连被拒,SDK 报「会话已失效」且不再重连。 +- 设备换了 IP(例如 Wi-Fi 切到 4G)后,用令牌自动重连成功,不需要密码。 +- 错误密码达到上限后,同一 IP 用正确密码在锁定期内也被拒绝,锁定期过后可以登录;换一个 IP 用正确密码不受影响。 +- 从多个 IP 轮流猜同一编号的密码,达到总数后这个编号暂停密码登录;已登录的设备断线后用令牌照常重连;管理员解除锁定后可以用密码登录。 +- 服务器数据库不可用时,SDK 不会进入「密码错误」状态,恢复后自动连上。 + +### F03 在线状态与目录 + +任何已登录的端都可以: + +- 查询若干编号是否在线。 +- 分页列出全部端。每项包含编号、名称、是否在线、最近上线时间、最近离线时间、是否设置了对话密码。不包含密码本身,也不包含「我是否已获授权」。 + +在线以服务器上的真实连接为准。心跳超时未收到数据即判离线。默认心跳 30 秒,超时按协议的 1.5 倍,约 45 秒。 + +验收: + +- 正常断开后 1 秒内,别人查到的状态为离线。 +- 拔掉网线后,在心跳超时窗口内变为离线。 +- 1000 个端同时在线时,拉全表在 1 秒内返回。 + +### F04 上下线通知 + +端可以订阅某些编号的上下线,也可以订阅全部。变化时收到通知。通知尽力送达,不落库、不补发。连接断开后订阅清空,SDK 在重连后按应用上次的选择重新订阅;应用需要准确状态时,重连后再查一次在线状态。 + +验收:订阅 A 后,A 上下线各收到一次通知;没订阅的 B 上下线不收到。 + +### F05 单聊 + +- 端可以给另一个端发消息,也可以给自己发(用于定时提醒)。发给自己不需要对话密码。 +- 每条消息由发送方提供消息号,SDK 自动生成。1–64 字符,字母、数字、`_`、`.`、`-`,区分大小写。同一发送方不要重复使用消息号。 +- 服务器把消息落入存储后才返回提交成功。返回前宕机,客户端按未提交处理并重试。 +- 用同一消息号、同一请求内容重试,返回原来的结果,不产生第二条;即使对方在两次之间停用或改了对话密码,也以第一次的结果为准。同一消息号但请求内容不同(目标、正文、选项有任何不同),返回冲突。服务器至少在 24 小时内(防重窗口,可配置)识别重试;这条消息的记录还在时也一直识别。 +- 对方不存在、已停用、已删除:提交失败并给出原因。 +- 对方设了对话密码且发送方没有有效授权:提交失败,错误为需要对话密码。本条请求里带上正确密码则授权并提交成功。 +- 每个端默认每秒最多 50 个请求(可短时突发到 100,确认类请求不算),可配置。用一个端集中下发大量消息的接入方,可以调高或改用群发。 +- 每个端同时处于「等待发送」或「投递中」的消息最多 10000 条(群消息算一条),超出时提交失败;每个端排队等它收下的投递最多 10000 条,超出时新来的投递直接拒绝,原因 `queue_full`,发送方收到回执。两个上限都可配置,设为 0 表示不设上限(D25),用来防止一个端把服务器磁盘塞满。 + +验收: + +- 提交成功后立刻杀掉服务器进程,重启后这条消息仍会投递。 +- 同一消息号连续提交两次,接收方只处理一次。同一消息号换了正文再提交,返回冲突。 +- 未授权时不带密码被拒绝;带对密码后成功,之后不带密码也能再发。 +- 一个端未完成的消息达到上限后,新提交失败;接收端排队达到上限后,新投递被拒绝,发送方收到回执。 + +### F06 群发 + +- 提交时发送方必须是群成员。到发送时刻发送者已不在群里:消息作废,不再投递。 +- 内容只存一份。每个接收成员一条投递结果。 +- 接收者是发送时刻的成员,不含发送者本人。已停用的成员收不到,结果记为已拒绝。 +- 发送时刻之后才入群的端收不到。发送时刻之前已退群的端收不到。 +- 群消息不看成员的对话密码。 + +验收: + +- 群内 A、B、C,A 发送,B 和 C 收到同一消息号、同一内容,A 自己不收到。 +- A 发送时选了离线保留,B 当时离线,B 在保留时间内上线后收到的内容与 C 相同。 +- B 在发送时刻之后入群,收不到这条。 + +### F07 内容与大小 + +- 正文是 UTF-8 文本,或二进制(线上用 Base64)。可带内容类型。 +- 自定义键值附在消息上,序列化后不超过 4 KiB。 +- 正文解码后最大 256 KiB(262144 字节)。这个上限可以调小,本期不能调大。超过则发送端直接失败,服务器不接收。 +- 接收端可在登录握手时声明自己一次最多能收多少字节(按服务器推来的整条数据计算,不小于 1024)。某条投递超过该值:这条投递拒绝,原因 `too_large`,不重试,并回执发送方。 +- 不做分块,不做断点续传。 + +验收: + +- 256 KiB 文本可以送达。多 1 字节在 SDK 本地失败;绕过 SDK 直接提交过大的正文,服务器拒绝且不产生投递。 +- 声明只能收 1024 字节的端,收到一条 2048 字节消息的投递结果是拒绝,发送方收到对应回执,这个端的连接不受影响。 + +### F08 投递、确认、重复和顺序 + +- 到了发送时刻且对方已完成登录握手:立刻推送。 +- 接收方处理完成后确认。SDK 默认在收到回调正常返回后自动确认;也可以改成应用自己调用确认。 +- 处理回调抛错则不确认。选了离线保留的消息,在保留时间内服务器会在重连后或确认超时后再推;不保留的消息推送后确认超时仍未确认,按已丢弃结束(D19)。应用必须能接受同一条到达多次。 +- 保证的是至少送到一次。弱网上可能重复。SDK 在进程运行期间按「发送方编号 + 消息号」去重后再交给应用,默认记住最近 10000 条;已经确认过的消息再次到达时,SDK 直接再确认一次,不交给应用。进程重启后,重启前没确认的消息可能再到一次。 +- 同一接收端按发送时刻排序;发送时刻相同则按提交先后。SDK 对同一连接上的回调串行执行。重推的消息可能排在后面的消息之后。 +- MQTT 传输层的确认只表示服务器收到了请求。对方收下以应用层确认为准。 + +验收: + +- 弱网(延迟、丢包、随机断开)下,选择了离线保留且在保留期内的消息最终全部送达;SDK 进程不重启时,交给应用的回调不重复。 +- 服务器在已返回提交成功后强制重启,未收下的消息会继续投递。 + +### F09 离线保留 + +- 每条消息选择是否保留,默认不保留。 +- 保留时指定时长,默认 24 小时,最长 30 天(可配置)。从发送时刻起算,定时等待的时间不算进去。 +- 超时仍未收下:结果为已过期,正文删除。 +- 已经推给在线端、正在等确认的消息,这一轮等待(最长一个确认超时)里不会因为保留时间到了而作废;这一轮结束仍未确认且已过保留期限,按已过期结束,否则再推。 + +验收: + +- 保留 1 小时,对方 30 分钟后上线,能收到。 +- 对方 2 小时后才上线,收不到,发送方得到已过期。 +- 指定明天 8 点发送、保留 1 小时:在明天 8 点之前一直处于等待发送;8 点才开始算 1 小时。 + +### F10 抖动宽限 + +- 不保留,且发送时刻对方不在线:若对方在宽限内刚断线(默认 60 秒),先等到宽限结束;期间重连则照常推送,宽限结束仍不在则丢弃。 +- 从未上线或断线已超过宽限:不保留的消息立即丢弃。 +- 正在推送时连接断开:无论是否保留,至少再等一个宽限。保留消息的截止时间若早于「现在 + 宽限」,延长到「现在 + 宽限」。 +- 服务器重启时,所有未完成的投递按对方刚断线处理:至少再等一个宽限,端在宽限内重连就能收到(D23)。 + +验收: + +- 不保留。对方断网 30 秒内恢复,消息送到。 +- 不保留。对方断网超过 2 分钟,消息丢弃,发送方收到已丢弃。 +- 不保留。服务器重启后端在 30 秒内重连,重启前没收下的消息仍能送到。 + +### F11 定时发送 + +- 发送方指定未来的发送时刻,以服务器时钟为准。SDK 用握手返回的服务器时间校正本机偏差。 +- 提交成功后发送方可以下线,到点由服务器投递,然后再走 F09、F10。 +- 最远可定到 365 天后(可配置)。指定时间已过则立即发送。 +- 服务器停机期间到点的定时消息,启动后立即发送。 +- 定时与「延迟秒数」不要同时使用。同时使用则提交失败。指定了发送时刻时,不再叠加端的默认延迟。 + +验收:提交一条 2 分钟后发送的消息,发送方立刻断开;2 分钟后接收方在线则收到,不在线则按是否保留处理。 + +### F12 发送延迟 + +- 端可以固定默认延迟。每条消息也可以单独指定延迟秒数,指定 0 表示这条立即发送。都没指定则立即发送。 +- 延迟由服务器时钟计算:发送时刻 = 服务器当前时间 + 延迟。 +- 在发送时刻之前消息还在服务器上,撤回一定成功。这就是反悔窗口。 + +验收:默认延迟 10 秒。发送后 3 秒撤回,接收方永远收不到。超过 10 秒且对方在线,消息已经进入投递。 + +### F13 撤回 + +- 只有发送方能撤回。管理员不能代撤。 +- 对象是已提交、接收方尚未收下、且尚未结束(过期、丢弃、拒绝)的消息。 +- 还没到发送时刻:一定成功,之后不再发送。 +- 已在离线保留中、尚未推送:一定成功。 +- 已经推给在线端但对方还没确认:撤回和确认谁先被服务器记下来谁生效。撤回先到,则对方之后的确认会得知已撤回,SDK 发出撤回事件;应用如果已经处理了这条,由应用自己决定怎么撤销(例如隐藏)。确认先到则撤回失败(或群里该成员失败)。 +- 已经收下、已过期、已丢弃、已拒绝:不能撤回。 +- 群消息:还没收下的成员全部撤回;已经收下的成员保持已收下。至少撤回一个且没有成员已收下,结果为「全部撤回」;有撤回也有已收下,为「部分撤回」;一个都没撤回,为「失败」。已经过期、丢弃、拒绝的成员不影响结果。 +- 撤回的结果在撤回请求的返回里给出,不再为撤回单独发回执(D20)。 +- 撤回不收回已经形成的回复授权(F15 第 2 种授权,D22)。 + +验收: + +- 延迟窗口内撤回,接收方无回调。 +- 对方离线且在保留中,撤回后上线也收不到。 +- 群里一人已确认、一人未推送:结果为部分撤回,未推送的成员收不到,已确认的成员仍保留本地副本。 + +### F14 回执 + +- 每条消息默认要回执,可以按条关闭。 +- 每个接收端一条最终回执:已收下、已过期、已丢弃、已拒绝。 +- 发送方当时不在线:回执保留,默认最多 7 天,上线后补送。回执可以重复到达,SDK 按回执号去重。 +- 关闭回执的消息不占这份队列。管理后台仍能按 F18 查记录。 + +验收:接收方确认后发送方收到已收下。发送方离线期间对方确认,发送方重连后补收到回执。 + +### F15 对话密码 + +- 不设置:任何端都能向它单聊。 +- 设置后,向它单聊必须有有效授权。授权只有两种: + 1. 发送时或单独解锁时输对当前对话密码。 + 2. 对方曾经成功提交过发给我的单聊。群消息不算。 +- 授权只看提交那一刻。提交成功之后对方才改密码,这条已提交的消息照常投递。改密码只让之后的新发送重新验证。 +- 对方改密码后,旧授权全部失效,包括「它先给我发过消息」带来的回复权。 +- 把它拉进群时,这一次请求里必须带上它当前的对话密码,已有单聊授权也不能省略。管理员在后台拉人例外,不需要密码。 +- 进群之后,群消息直接送达,不再逐条要密码。 +- 对话密码连续猜错会临时锁定这一对端。默认与登录锁定相同:5 分钟 10 次。另外按被猜的端计总数:1 小时内被猜错 50 次,暂停验证它的对话密码 1 小时,已有授权照常可用;它改对话密码时计数清零(D24)。这是为了防止有人注册很多账号轮流猜,4 位的对话密码经不起这样猜。 +- 不希望被大量回复的发送方(例如给很多端发通知的后台端),可以改用群发:群消息不产生回复授权。 + +验收: + +- B 设了密码。A 不带密码发送失败;带对一次后,第二条不再带密码也成功。 +- B 改密码后,A 的下一条失败,直到重新输对新密码。 +- B 先给 A 发单聊,A 立刻可以回复且不用密码。B 改密码后,A 再回复失败。 +- A 已能给 B 单聊,把 B 拉进群时仍要带对话密码;密码错误则 B 不进群。 +- B 改密码时,A 此前已提交、尚在延迟中的消息到点仍会送达。 +- 多个账号轮流猜 B 的对话密码,达到总数上限后,正确密码也暂时无法解锁;已有授权的端照常发给 B。 + +### F16 群 + +- 已登录的端可以建群,建群者是群主。管理员也可以建群并指定群主,群主同时是成员。 +- 编号规则同端编号(小写字母、数字、`_`、`.`、`-`),留空则生成,形如 `g_` 加 8 位。名称最多 64 字。 +- 群主可以改名、加人、踢人、转让群主、解散。成员可以自己退出。群主不能直接退出,要先转让;只剩群主一人时可以解散。 +- 加人时对设了对话密码的端适用 F15。已停用的端不能加入。一次请求里可以带多个成员,校验失败的成员不加入,其余加入,结果里列出失败原因。 +- 踢出或退出:尚未推送的投递作废,原因 `left_group`;已经推送、尚未确认的,向该端发作废通知。 +- 解散:全体未完成投递作废;发往该群、还没到发送时刻的消息也立即作废,原因 `group_dissolved`。群编号之后可以被重新使用,新群收不到旧群的任何消息。 +- 群主被删除:群主转给最早加入的其他成员;没有其他成员则解散。 +- 成员数默认上限 1000,可配置。 + +验收: + +- 非群主加人失败。群主把设密端拉入时密码错误,该端不在成员里,群本身创建成功。 +- 成员退出后收不到之后的消息,也收不到退出前已提交但还没推送的消息。 +- 解散群后立刻用同一编号建新群,旧群里还没到点的定时消息不会发给新群。 + +### F17 管理后台 + +网页后台,界面中文。功能: + +- 登录、修改管理员自己的密码、退出。 +- 概览:端数量(其中自助注册的数量)、在线数、群数量、待投递数、版本。 +- 端:查询(可按来源筛选:后台开通、自助注册)、开通、批量开通、编辑、启停、删除、重置登录密码、设置或清除对话密码、踢下线、解除登录锁定。列表显示是否在线、最近上下线时间、来源;支持多选后批量停用、删除,方便清理异常注册。 +- 注册:开启或关闭自助注册;查看、手填或生成注册安全码。 +- 群:查询、创建、改名、成员增减、转让、解散。 +- 投递记录:按发送方、接收方、群、状态、时间筛选。只显示记录,不显示正文。点开可看每个接收端的结果、原因、推送次数、时间。 +- API 令牌:管理员可以创建多个令牌(填名称),令牌只在创建时显示一次;可以停用、启用、删除,列表显示最近使用时间。接入方自己的后台带令牌调用同一套管理接口,用程序完成开通、停用、重置密码等操作。令牌不能改管理员密码,也不能管理令牌(D27)。 +- 每个改变状态的操作都记日志:谁做的(管理员或哪个令牌)、做了什么、对象是谁。 +- 运行参数本页只读,改参数通过配置文件。管理员密码、注册开关、注册安全码和 API 令牌在后台修改。 + +验收: + +- 用后台完成开通、改密、开启注册并设置安全码、建群、查看一条未完成投递。正文在页面、接口和浏览器网络响应里都不出现。 +- 创建一个 API 令牌,用它通过管理接口开通一个端并重置密码;用它改管理员密码被拒绝;停用令牌后立即不能再用。 + +### F18 正文删除与记录 + +- 一条消息的全部投递都进入最终状态,或在发送前被撤回、作废:立即删除正文。 +- 记录默认保留 7 天,可配置。设为 0 则完成后连记录一起删除,后台只能看到尚未完成的消息。 +- 防重标记另存「发送方 + 消息号 + 请求指纹」,不含正文。默认保留 24 小时,消息记录还在时一直保留。这是为了弱网重试不产生第二条,不是历史记录。 +- 日志里不写正文、登录密码、对话密码、注册安全码。 +- 备份文件里包含备份当时还没送完的正文,要按敏感数据保管。 + +验收:接收方确认后,数据库和后台都读不到正文。保留天数设为 0 时,完成后后台列表不再出现这条。24 小时内重试同一消息号仍不重复投递。 + +### F19 SDK + +第一批:Go、Java/Android、JavaScript/TypeScript(浏览器和 Node.js)、Python。 + +四种 SDK 行为一致: + +- 注册(管理员开启注册时可用,需要注册安全码,不需要先登录)。 +- 登录与会话:首次用密码登录,把拿到的会话令牌交给应用保存;之后重连用令牌;令牌失效时通知应用重新登录;退出登录时作废令牌。 +- 连接、自动重连、心跳。被踢下线、密码错误、会话已失效、编号停用或删除时停止重连;服务器暂时不可用时继续重连。 +- 发送(单聊、群、延迟、定时、是否保留、保留时长、是否要回执、附带对话密码)。 +- 撤回、查询本条状态、解锁对话密码。 +- 收消息、收回执、收撤回或作废、去重、自动或手动确认。 +- 在线查询、目录、上下线订阅。 +- 建群、改名、加人、踢人、退出、转让、解散、我的群列表。 +- 修改自己的名称、默认延迟、登录密码(需要旧密码)、对话密码。 +- 发送方暂时连不上服务器时,发送进入内存队列,重连后按原消息号再交;已经发出但没等到结果的也一样。进程退出则队列丢失。除发送以外的请求在离线时直接失败。 +- 本机时间与服务器偏差由 SDK 校正后再计算定时发送。 + +验收:每种语言都通过 DEVELOPMENT.md 里的同一份接入清单。清单覆盖注册、登录、收发、去重、重连、离线队列、撤回、定时、群、对话密码、改密、被踢、超限。 + +### F20 无 SDK 接入 + +不提供 C SDK。设备用 MQTT 5 按 DEVELOPMENT.md 的帧格式接入,至少能:登录、收消息、确认、发送、声明自己能接收的最大字节数。能发 HTTP 请求的设备也可以调用注册接口;不能的由管理员开通。设备可以每次都用密码登录(每次都算新登录、会换令牌,只有这一台设备时没有影响),也可以保存会话令牌用来重连。 + +验收:用一个不依赖四套 SDK 的 MQTT 客户端走通登录、发送、接收、确认。 + +### F21 端口 + +- 端只用一个固定端口连服务器(默认 7443)。这个端口同时提供:MQTT over WebSocket(路径 `/mqtt`,子协议 `mqtt`)、MQTT over TCP(给跑不动 WebSocket 的设备)、注册接口。TLS 和明文在同一端口上自动识别。 +- 管理后台默认也在这个端口。可以配置成单独端口,例如只监听内网或本机地址,不对公网暴露;配置后,端接入端口不再提供后台。 +- 内置 MQTT 不单独占端口。本期不需要 Redis 或其他中间件。 +- 配置了证书则默认只接受 TLS。没有证书时允许明文,并在启动日志里明确警告。是否额外允许明文可配置。 +- 证书文件更新后自动生效,不用重启。程序本身不申请证书;生产环境由服务器上的 1Panel 申请和自动续签,并推送到本地目录给程序读取(见 DEVELOPMENT.md 第 11.3 节)。 + +验收: + +- 默认配置只开一个端口:浏览器能打开后台,SDK 能用 WebSocket 注册和收发,一个裸 MQTT TCP 客户端能登录。 +- 配置单独的后台端口后:后台只能从后台端口打开,端接入端口上访问后台返回 404,端的注册和收发不受影响。 + +### F22 部署与运维 + +- 一个 Go 程序,一个可执行文件,内置 MQTT 和网页。不另外部署数据库、消息队列或缓存。 +- 同时提供 Docker 镜像(amd64、arm64)和 docker-compose 示例,装有 1Panel 的服务器可以直接用它的容器编排部署。 +- 数据在一个目录里的 SQLite 文件。 +- 首次使用先运行初始化命令生成管理员密码(只在终端显示一次,不写日志),再启动服务。 +- 提供备份命令。升级时自动迁移数据库;迁移前自动把库复制一份。 +- 提供健康检查接口,以及 Prometheus 格式的监控指标(在线数、待投递数、投递耗时等,不含正文和编号明细)。指标只在后台端口上开放;后台和端共用端口时,要带配置的令牌才能访问。 + +验收: + +- 空目录运行初始化命令后启动,可登录后台。二进制和 Docker 镜像(两个架构)都能这样启动。 +- 备份文件能在另一目录启动并看到原来的端。旧版本数据经过一次升级后端和未完成消息都在。 +- 替换证书文件后,不重启服务,新连接在 1 小时内用上新证书。 +- Prometheus 能抓到指标;共用端口时不带令牌抓不到。 + +### F23 端自助注册 + +管理员可以允许端自己注册,接入方就能在自己的 App 里做注册、登录、改密码。 + +- 默认关闭。管理员在后台开启,并设置注册安全码(8–64 字符,可以手填或让系统生成)。关闭时任何注册都失败。 +- 注册必须带当前的注册安全码,错误则失败。 +- 管理员随时可以更换安全码或关闭注册。已注册的端不受影响,照常登录收发;之后的注册必须用新安全码,旧码无法注册。 +- 注册时填写:编号(可留空,由服务器生成)、登录密码(可留空,由服务器生成并只返回一次)、名称(可选)、对话密码(可选)。规则同 F01。编号已被占用则失败。 +- 注册成功即可登录,不需要管理员审核。后台把这类端标记为「自助注册」,管理员可以像其他端一样停用、删除、重置密码。 +- 注册在端接入端口上完成,不需要先登录。同一来源 IP 连续输错安全码会临时锁定:默认 5 分钟 10 次,锁定 5 分钟。 +- 注册之后的改名称、改默认延迟、改登录密码(需要旧密码)、改对话密码,都由端登录后通过 SDK 完成(F19)。 +- 开放注册后,注册者和其他端一样能看到目录(全部端的编号、名称和在线状态),也能给没设对话密码的端发消息。圈子规则不变(D26)。需要防陌生人的端应设置对话密码。 +- 安全码内置在 App 里时,拿到安装包的人就能取出来。它挡的是没有 App 的人,不能证明注册者身份;泄露后换码即可,已注册的端不受影响。 +- 服务对公网开放注册时应配置证书,否则安全码和密码会以明文经过网络。 + +验收: + +- 注册关闭时,带正确安全码也注册失败。 +- 开启后,错误安全码失败,正确安全码成功,注册出的端能立即登录。 +- 管理员更换安全码后,旧码注册失败、新码成功;更换前已注册的端照常登录收发。 +- 同一 IP 连续输错安全码达到上限后,锁定期内带正确安全码也被拒绝。 +- 编号已存在时注册失败,原有端不受影响。 + +## 7. 消息怎么走 + +```mermaid +stateDiagram-v2 + [*] --> 等待发送: 提交成功 + 等待发送 --> 已撤回: 发送时刻前撤回 + 等待发送 --> 已拒绝: 到点前或到点时发现对方已停用或删除、群已解散、发送者已退群或被停用 + 等待发送 --> 投递中: 到发送时刻 + 投递中 --> 已收下: 接收端确认 + 投递中 --> 已撤回: 确认前撤回成功 + 投递中 --> 已过期: 保留期限到,或确认超时时已过保留期限 + 投递中 --> 已丢弃: 不保留且宽限结束仍离线,或推送后确认超时 + 投递中 --> 已拒绝: 退群、停用、删除、超过对方接收上限等 + 已收下 --> [*] + 已撤回 --> [*] + 已过期 --> [*] + 已丢弃 --> [*] + 已拒绝 --> [*] +``` + +群消息是同一张图的多份投递。全部投递都结束后删除正文。 + +| 结果 | 何时 | 发送方能否撤回 | +|---|---|---| +| 等待发送 | 没到发送时刻 | 能,一定成功 | +| 投递中,还没推送 | 离线保留、宽限中,或在排队等推送 | 能,一定成功 | +| 投递中,已推送 | 在线但还没确认 | 能发起,和确认抢先 | +| 已收下 | 端已确认 | 不能 | +| 已过期 / 已丢弃 / 已拒绝 | 已经结束 | 不能 | + +## 8. 非功能需求 + +| 项 | 要求 | +|---|---| +| 规模 | 单机 1000 个端同时在线,端总数按 1 万设计。内部连接上限按 2000 留余量 | +| 容量 | 记录量约等于每天消息数乘以保留天数。按每天 100 万条、保留 7 天设计,后台带筛选的查询在 1 秒内返回 | +| 吞吐 | 验收环境能持续每秒 200 条单聊提交;一条 1000 人群消息能在 5 秒内完成推送排队 | +| 延迟 | 双方在线、消息小于 1 KiB 时,从提交成功到对方回调,机房内 P95 小于 300 毫秒 | +| 可靠 | 已返回提交成功的消息,进程崩溃后仍在。断电最多丢失最后一次尚未落盘的写入;默认使用最严格的 SQLite 同步 | +| 弱网 | 20% 丢包、2 秒延迟、随机断开下,保留期内消息最终全部送达,SDK 进程不重启时应用层无重复 | +| 安全 | 密码单向哈希,会话令牌和管理 API 令牌只存哈希;登录、对话密码、注册安全码、管理员登录和令牌都防暴力尝试;后台 Cookie 不可被脚本读取;日志无正文、无密码、无注册安全码、无令牌;端只能订阅自己的下行 | +| 时间 | 服务器时钟为准。定时消息在时钟被人为大改时按新时钟触发,这一点写入运维说明 | +| 兼容 | MQTT 5 为主。MQTT 3.1.1 仅保证能连接和收发 QoS 1,帧格式仍是同一套 JSON;3.1.1 设备被顶号时看不到原因 | + +## 9. 写进本文的默认 + +下面是写文档时定下的默认规则。「已确认」是负责人已经认可的;「待确认」的在交给开发前还可以改,改的话同步改本文和 DEVELOPMENT.md 对应小节。D14 起是 0.2 新增,D26 起是 0.3 新增,D28 起是 0.4 新增。 + +| 编号 | 默认 | 状态 | +|---|---|---| +| D1 | 改对话密码不影响已经提交成功的消息 | 已确认 2026-09-30 | +| D2 | 拉人进群必须在当次请求里带对话密码;已有单聊授权不能代替。管理员后台例外 | 已确认 2026-09-30 | +| D3 | 回执默认开启,发送方离线时回执最多留 7 天 | 已确认 2026-09-30 | +| D4 | 不保留的消息,对方断线未超过 60 秒时先等重连 | 已确认 2026-09-30 | +| D5 | 投递记录默认留 7 天且不含正文;防重标记另留 24 小时 | 已确认 2026-09-30 | +| D6 | 离线保留默认 24 小时,最长 30 天;端默认延迟 0;定时最远 365 天 | 已确认 2026-09-30 | +| D7 | 允许给自己发消息 | 已确认 2026-09-30 | +| D8 | 接收端可声明最大接收字节,超限的投递直接拒绝 | 已确认 2026-09-30 | +| D9 | 停用或删除端时,尚未送到的消息作废 | 已确认 2026-09-30 | +| D10 | 群主被删除时,群主转给最早加入的成员 | 已确认 2026-09-30 | +| D11 | 正文用 UTF-8 或 Base64,自定义字段最多 4 KiB | 已确认 2026-09-30 | +| D12 | 一个管理员账号;后台不能代发、代撤回 | 已确认 2026-09-30 | +| D13 | 被踢下线的 SDK 停止自动重连 | 已确认 2026-09-30 | +| D14 | 自助注册默认关闭。注册安全码 8–64 字符,明文存库、可在后台查看(要分发给接入方;负责人确认不考虑数据库泄露的风险),不写日志 | 已确认 2026-09-30 | +| D15 | 注册时编号可以自选也可以留空生成;登录密码留空则服务器生成并只返回一次;注册不需要审核 | 已确认 2026-09-30 | +| D16 | 不做忘记密码自助找回和端自己注销,都由管理员处理 | 已确认 2026-09-30 | +| D17 | 端编号和群编号只允许小写字母、数字、`_`、`.`、`-`;消息号区分大小写 | 已确认 2026-09-30 | +| D18 | 登录密码失败按「编号 + 来源 IP」锁定(5 分钟 10 次),另按编号计总数暂停密码登录(1 小时 50 次);会话令牌重连不受锁定影响;注册安全码按来源 IP 锁定 | 按 2026-09-30 反馈修改,待确认 | +| D19 | 不保留的消息推给在线端后,确认超时(5 分钟)仍未确认就按已丢弃结束,不再重推;保留的消息在保留期内继续重推 | 已确认 2026-09-30 | +| D20 | 撤回不单独发回执,结果看撤回请求的返回 | 已确认 2026-09-30 | +| D21 | 停用或删除端时,它发出、还没推送的消息一并作废 | 已确认 2026-09-30 | +| D22 | 撤回不收回已经形成的回复授权 | 已确认 2026-09-30 | +| D23 | 服务器重启时,所有未完成投递至少再等一个宽限,包括保留期在停机期间已过的 | 已确认 2026-09-30 | +| D24 | 对话密码另按被猜的端限总数:1 小时错 50 次后暂停新的验证 1 小时,已有授权不受影响 | 已确认 2026-09-30 | +| D25 | 每个端未完成的发出消息最多 10000 条,排队待收的投递最多 10000 条;都可配置,设为 0 表示不设上限 | 已确认 2026-09-30 | +| D26 | 开放自助注册后,目录仍对所有端可见,谁都能列出全部端 | 已确认 2026-09-30 | +| D27 | 管理 API 令牌不过期,停用或删除后立即失效;权限等同管理员,但不能改管理员密码、不能管理令牌;令牌只在创建时显示一次 | 待确认 | +| D28 | 每次用密码登录都换新的会话令牌,旧令牌立即作废,旧设备自动退出 | 已确认 2026-09-30(负责人提出) | +| D29 | 用令牌重连不换令牌;令牌 30 天没用自动失效(可配置,0 表示不失效);登录密码不能以 `nst_` 开头;端可以主动退出登录、作废令牌 | 待确认 | + +## 10. 验收总表 + +开发完成时逐条给出结果:通过、失败或未测。未测要写原因。 + +| 编号 | 一句话 | +|---|---| +| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | +| F02 | 新设备登录后旧设备自动退出(在线被踢、离线的令牌失效)、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | +| F03 | 断开后状态及时变离线,全表可列出 | +| F04 | 只通知订阅了的端 | +| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | +| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | +| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | +| F08 | 弱网最终送达且应用层不重复,重启后续传 | +| F09 | 保留时间从发送时刻起算,超时过期 | +| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | +| F11 | 发送方离线后到点仍发送 | +| F12 | 延迟窗口内撤回对方收不到 | +| F13 | 未推送必撤成功;群部分确认得到部分撤回 | +| F14 | 回执能补送给当时离线的发送方 | +| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | +| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | +| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | +| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | +| F19 | 四种 SDK 通过同一清单 | +| F20 | 裸 MQTT 能登录、收、确认、发 | +| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | +| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | +| F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | + +## 11. 修订记录 + +| 版本 | 日期 | 说明 | +|---|---|---| +| 0.1 | 2026-09-30 | 初稿 | +| 0.2 | 2026-09-30 | 端口改为「端只用一个固定端口,后台可用单独端口」(F21)。新增端自助注册(F23),从「本期不做」中移除。按审查补充和修正:编号只允许小写(F01、F16);批量开通整批校验(F01);停用或删除端时作废它发出的消息,删除后编号可复用(F01);登录锁定按编号加 IP,并区分服务器故障(F02);消息号按请求内容判冲突,防重标记与记录并存(F05、F18);请求频率上限写入需求(F05);接收上限按整条数据计算(F07);不保留消息的确认超时(F08、F09);服务器重启后的宽限和补发(F10、F11);撤回结果的判定和不发回执(F13、F14);解散群时作废定时消息(F16);后台批量停用、删除和注册设置(F17);首次启动先初始化(F22);单位统一为 KiB;重试先认防重再校验(F05);每个端未完成消息和排队投递的上限(F05);对话密码另按被猜的端限总数(F15);后台端列表显示在线状态(F17);注册安全码和明文传输的提醒(F23);手机 App 在后台收不到提醒的说明(5.2);新增默认 D14–D25 | +| 0.3 | 2026-09-30 | 负责人确认第 9 节 D1–D26(D27 待确认):开放注册后目录仍对所有端可见(D26),配额可设为 0 表示不设上限(D25)。新增管理 API 令牌和管理操作日志(F17)。F22 增加 Docker 镜像和 Prometheus 监控指标。F21 写明证书文件自动重载、生产环境用 1Panel 申请和续签。技术栈写入 DEVELOPMENT.md 第 2 节:Go + 标准库 net/http、SQLite + 手写 SQL、不用 Redis、Vue 3 + TypeScript + Naive UI 打包嵌入程序 | +| 0.4 | 2026-09-30 | 登录改为会话令牌(F02):首次用密码登录后发令牌,重连用令牌、和 IP 无关;每次用密码登录都换新令牌,旧设备自动退出(D28);令牌闲置 30 天失效,可以主动退出登录(D29)。密码锁定改为按编号加 IP、按编号总数两种,只拦密码登录,不拦令牌重连(D18);后台可以解除锁定(F01、F17);登录密码不能以 `nst_` 开头。新增 docs/TASKS.md 开发任务拆分 | diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000..8767961 --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,367 @@ +# NixMsg 开发任务拆分与多 Agent 协作 + +| 项 | 内容 | +|---|---| +| 版本 | 0.1 | +| 日期 | 2026-09-30 | +| 对应 | [PRD.md](./PRD.md) 0.4、[DEVELOPMENT.md](./DEVELOPMENT.md)(对应 PRD 0.4) | +| 仓库 | https://git.asio.asia/nixevol/NixMsg.git,主分支 `main` | +| 读者 | 总控 Agent、各开发线 Agent、负责人 | + +## 1. 目标和交付标准 + +由多个 Agent 并行开发和测试,交付 NixMsg 服务端、管理后台、四种 SDK、Docker 镜像和文档。 + +下面全部满足才算交付: + +1. `main` 上 `task check`(构建、代码检查、单元测试)和全部集成测试通过。 +2. PRD 第 10 节 F01–F23 每条都有验收结果:通过;或未测,写明原因并经负责人认可。 +3. 四种 SDK 都通过 DEVELOPMENT 第 9 节的接入清单。 +4. DEVELOPMENT 第 13 节的弱网、崩溃、压测、端到端测试做完并有报告。 +5. 二进制(`linux/amd64`、`linux/arm64`、`windows/amd64`)和 Docker 镜像(amd64、arm64)能按文档启动。 +6. 和文档不一致的实现都写进 `docs/DEVIATIONS.md`,并经负责人确认。 +7. 最终代码按第 7 节审核过,打上 `v0.1.0` 标签并推送。 + +## 2. 资料 + +- `docs/PRD.md`:产品行为,以它为准。 +- `docs/DEVELOPMENT.md`:实现规范。第 5 节的库用法和第 15 节「不要这样做」必须看。 +- `docs/TASKS.md`:本文件。 +- MemRelay 项目记忆(项目 NixMsg,ID `25464650-ed5d-440e-b362-190f2852d451`):已确认的决定、技术栈选型、依赖库行为核对结论,以及各线的进度检查点。随时可以查。 +- 文档没写清或互相矛盾时,停下来问总控;总控拿不准的问负责人。不要自己改 PRD 的产品行为。 + +## 3. 环境 + +- 本机是 Windows,已有 Git、Go、Node.js LTS、pnpm、Python、JDK 21、Docker Desktop 和 scoop。缺的工具用 scoop 装,例如 `scoop install task golangci-lint`;包名不确定时先 `scoop search`。 +- 丢包模拟(netem)、多架构镜像等 Linux 相关的测试,在 Docker 的 Linux 容器里跑。toxiproxy 用它的官方 Docker 镜像。 +- 多个 Agent 在同一台机器上同时跑测试,必须互不干扰: + - 测试里的服务一律监听随机端口(`:0`),数据放临时目录,不要写死 7443。 + - Docker 容器、网络、卷的名字带上自己的线名前缀,用完删除。 + - 不改全局配置:全局 git 配置、系统环境变量、全局 npm 配置都不要动。 +- 推送 git.asio.asia 用本机 Windows 凭据管理器里已存好的账号,不要把账号密码写进仓库、脚本或命令行。 + +## 4. 协作规则 + +### 4.1 分支、工作树和提交 + +- `main` 只由总控合并,任何时候都要能构建、测试全绿。 +- 每条线在自己的 git 工作树(worktree)里、自己的分支上工作,不共用目录。用 Cursor 代理窗口的工作树功能创建,或者自己建: + + ```bash + git fetch origin + git worktree add ../NixMsg-wt/<任务号> -b feat/<任务号>-<简述> origin/main + ``` + +- 分支名:`feat/<任务号>-<简述>`,例如 `feat/n2-websocket-mochi`;修别人发现的问题用 `fix/<任务号>-<简述>`。 +- 一功能一验证一提交:做完一个能验证的小功能,就补测试、跑 `task check`、提交,并推送到自己的远端分支。 +- 提交信息:`type: 中文一句话描述`,type 取 feat、fix、refactor、style、docs、test、chore、revert。 +- 不提交:构建产物、`web/dist`、数据库文件、证书和私钥、任何密码或令牌、`aidocs/`。 +- 只改自己线负责的目录(第 5 节)。必须动别人的目录时,先告诉总控,改动尽量小。 + +### 4.2 共享文件 + +| 文件 | 规则 | +|---|---| +| `go.mod`、`go.sum` | 各线可以加依赖;冲突时以 main 为准重新 `go mod tidy` | +| `internal/protocol` | 总控在阶段 0 定好。之后要改先找总控,由总控改完合进 main,各线再 rebase | +| `internal/store/migrations` | `0001` 由总控在阶段 0 写好,包含全部表。之后要改表结构就加新文件,编号在 rebase 时取当时最大号加一 | +| `cmd/nixmsg/wire.go`(组装各模块) | 各线只加自己的注册代码;冲突时两边都保留 | +| `Taskfile.yml` | 总控维护;各线自己的任务写在 `taskfiles/<线名>.yml`,由主文件引入 | +| `docs/DEVIATIONS.md` | 按线分节,各线只写自己那节 | +| `docs/PRD.md`、`docs/DEVELOPMENT.md`、`docs/TASKS.md` | 只有总控改,而且要先得到负责人同意 | + +### 4.3 合并进 main + +1. 开发线:`git fetch` → `git rebase origin/main` → `task check` 和本任务的验证全过 → 推送分支 → 在 MemRelay 存一个检查点,标签 `ready-to-merge`,写清分支名、任务号、验证结果。 +2. 总控:按第 7 节清单审阅差异 → 在主工作区 `git merge --ff-only <分支>` → 跑 `task check` 和集成测试 → 推送 main → 通知各线 rebase。 +3. 不能快进(main 已经前进)时打回给开发线重新 rebase。冲突由开发线理解双方改动后解决。 +4. 功能分支 rebase 后,用 `git push --force-with-lease` 更新自己的远端分支(负责人已同意,见第 9 节)。只能用在自己的 `feat/*`、`fix/*` 分支上,不许用于别人的分支;任何人都不许强推 `main`。 +5. 如果仓库开了分支保护或合并请求审核,改成推分支、建合并请求,由总控合并。 + +### 4.4 进度记录 + +- 各 Agent 按协作规则使用 MemRelay:开工前读项目上下文和最新检查点;完成一个任务、遇到阻塞、结束一次会话时存检查点,写明线名、任务号、分支、做了什么、验证结果、下一步。 +- 不在仓库里另建进度文件,免得多条线同时改同一个文件。 + +## 5. 分工 + +| 线 | 负责目录 | 主要内容 | +|---|---|---| +| 总控 L | 根目录配置、`cmd/nixmsg/wire.go`、`internal/protocol`、迁移 `0001`、`test/harness`、`docs/` | 阶段 0、审阅合并、集成验证、最终验收 | +| 平台 P | `cmd/nixmsg`(`wire.go` 除外)、`internal/config`、`internal/store`(`0001` 除外)、`internal/auth` | 配置和命令、写入合并、密码哈希池、令牌和锁定计数、指标注册 | +| 连接 N | `internal/listener`、`internal/broker` | 端口识别、TLS、WebSocket、mochi、登录与会话令牌、连接表、握手 | +| 消息 M | `internal/app/message` | 提交、分发、推送、确认、撤回、回执、清理、启动恢复 | +| 身份 I | `internal/app/identity`、`internal/app/group`、`internal/app/presence` | 注册、自己的资料、对话密码、在线与目录、群、停用和删除 | +| 后台接口 A | `internal/admin`、`internal/httpx` | 管理接口、API 令牌、操作日志、指标接口的访问规则 | +| 后台网页 W | `web/` | Naive UI 后台和端到端测试 | +| SDK 一 S1 | `sdk/go`、`sdk/js` | Go、JS/TS SDK | +| SDK 二 S2 | `sdk/python`、`sdk/java` | Python、Java/Android SDK | +| 测试交付 Q | `test/`(`harness` 除外)、`deploy/`、`README.md` | 测试基础设施、验收用例、弱网和压测、Docker、发布、运维文档 | + +## 6. 阶段和任务 + +```mermaid +flowchart LR + T0[阶段0 总控 骨架与契约] --> P[平台 P] + T0 --> N[连接 N] + T0 --> M[消息 M] + T0 --> I[身份 I] + T0 --> A[后台接口 A] + T0 --> W[后台网页 W] + T0 --> Q[测试交付 Q] + N --> S[SDK S1 S2 接入清单] + M --> S + I --> S + A --> W2[网页联调 W4] + P & N & M & I & A & S & W2 --> V[阶段2 验收 弱网 压测] + V --> Z[阶段3 总控 审核与交付] +``` + +- **阶段 0**(只有总控,串行):T0.1–T0.5。全部合进 main 并推送后才开阶段 1。 +- **阶段 1**(并行):P、N、M、I、A、W、Q 同时开工,共 7 个 Agent。S1、S2 可以一起开工,先做不依赖服务端的部分(连接状态机、发送队列、去重、本地检查和单元测试),也可以等 N、M 合进 main 再开,看机器负载。 +- **阶段 2**(联调和验收):N、M、I、A 的核心任务合进 main 后,S1、S2 跑接入清单,W 接真实接口做端到端测试,Q 跑验收、弱网、崩溃和压测。发现的问题开 `fix/` 分支,交回原负责线修。 +- **阶段 3**(只有总控):全量回归、审核、偏差确认、打标签交付。 + +下面每个任务都要做到:代码、测试、文档依据里的规则都满足;验证项全部通过;`task check` 通过。 + +### 6.1 阶段 0:总控 L + +**T0.1 仓库骨架** · 依赖:无 + +- 做:按 DEVELOPMENT 第 3 节建目录;`go.mod`(模块 `git.asio.asia/nixevol/NixMsg`);`Taskfile.yml`,目标至少有 `build`、`test`、`lint`、`check`(构建加检查加单元测试)、`web:dev`、`web:build`、`itest`(集成测试)、`docker`,并引入 `taskfiles/*.yml`;golangci-lint 配置;`web/` 用 Vite、Vue 3、TypeScript、Naive UI、Vue Router、Pinia 初始化,加 `web/embed.go`;`cmd/nixmsg` 先能 `version`,`serve` 先只打开库、跑迁移、在 `listen` 上提供 `/healthz`,端口为 0 时写 `listen.addr`;`deploy/config.example.yaml`;`.cursor/worktrees.json`,让新工作树自动执行 `go mod download` 和 `pnpm install`。 +- 验证:Windows 上 `task check`、`task build` 通过,产物是嵌入了前端的单个可执行文件;在 Docker 的 Linux 容器里 `task check` 也通过。 + +**T0.2 协议包 `internal/protocol`** · 依赖:T0.1 + +- 做:DEVELOPMENT 第 6 节全部帧的 Go 类型,包括第 6.9 节注册的请求和响应、`session_token`、`self.logout`;第 6.10 节错误码常量;编码规则(不转义 HTML 和非 ASCII);字段校验(编号规则、正文和自定义字段大小、`send_at_ms` 和 `delay_ms` 互斥);第 7.3 节的请求指纹;整帧字节数计算。 +- 验证:每种帧的编码、解码、校验都有表驱动单元测试;指纹对 `meta` 的键顺序不敏感。 + +**T0.3 数据库结构和存储接口 `internal/store`** · 依赖:T0.1 + +- 做:第 7.7 节的迁移执行器(有未应用版本时先 `VACUUM INTO` 备份);`0001_init.sql` 包含第 7.7 节全部表和索引;按第 7.7 节的 DSN 打开写连接和读连接池;写入队列的接口(提交一个写操作、拿到结果)和一个先不合并的简单实现,P2 再换成合并提交。 +- 验证:空目录启动能建表;重复启动不会重复迁移;迁移前的备份文件存在;有单元测试。 + +**T0.4 模块接口和管理接口契约** · 依赖:T0.2、T0.3 + +- 做:`internal/auth` 和 `internal/app/*` 各服务的 Go 接口,以及测试用的假实现;broker 和 app 之间的接口(连接事件、上行帧分发、下行发布);`cmd/nixmsg/wire.go` 组装骨架;`docs/api/admin-api.md`,写清第 8 节每个管理接口的请求、响应、分页和错误格式,W 线据此先用假数据开发。 +- 验证:`task check` 通过;契约文档覆盖第 8 节全部路由。 + +**T0.5 集成测试启动器 `test/harness`** · 依赖:T0.1 + +- 做:编译一次服务端;每个测试用随机端口、临时目录启动一个进程(配置里 `listen` 的端口写 0,服务端启动后把实际地址写进 `/listen.addr`,见 DEVELOPMENT 第 4.1 节,启动器读它),自动执行 `admin init` 拿到管理员密码;提供管理接口客户端和 MQTT 测试客户端(WebSocket 和 TCP 都支持,能收发第 6 节的帧);测试结束时清理进程和目录。 +- 验证:示例集成测试能启动服务、访问 `/healthz`,结束后进程和目录都清干净;两个测试并行跑互不干扰。 + +### 6.2 平台 P + +**P1 配置和命令** · 依赖:T0.1、T0.3 + +- 做:第 11.1 节配置的加载和校验(`check-config` 拒绝 `max_body_bytes` 大于 262144);`admin init`、`admin set-password`、`backup`、`healthcheck`;`log/slog` JSON 日志;第 7.8 节的优雅停机。 +- 验证:各命令有测试;`admin init` 的密码只在终端打印一次、不进日志,已初始化的库拒绝再次执行。 + +**P2 写入合并** · 依赖:T0.3 + +- 做:第 7.2 节的合并事务(最多 256 个操作或凑满 2 毫秒)、每个操作一个 SAVEPOINT、读连接池;写库失败时请求返回 `busy`,`/readyz` 返回失败。 +- 验证:单个操作失败只回滚它自己;基准测试给出每秒能提交的写操作数,模拟每秒 200 条消息的写入量时队列不堆积。 + +**P3 认证工具 `internal/auth`** · 依赖:T0.1 + +- 做:argon2id 哈希池(第 12 节参数、PHC 格式、并发上限);会话令牌和 API 令牌的生成和哈希;锁定计数器(按键计数、时间窗口、到期解除、手动清除)。 +- 验证:单元测试;并发池能限制同时运行的哈希数;锁定窗口到期自动解除。 + +**P4 指标注册** · 依赖:T0.1 + +- 做:用 `prometheus/client_golang` 建注册表,定义第 4.3 节列出的指标,给各线打点用;`/metrics` 的处理函数(访问规则由 A 线在路由里加)。 +- 验证:单元测试能抓到指标文本。 + +### 6.3 连接 N + +**N1 端口与 TLS** · 依赖:T0.1 + +- 做:第 4.1–4.3 节和第 4.5 节:首字节识别、TLS 和证书每小时重载、`admin_listen` 分离、通道 Listener、路由骨架、受信任代理的真实 IP。 +- 验证:同一端口上明文 HTTP、TLS+HTTP、裸 TCP、TLS+TCP 都能识别;后台分离时的 404 规则;替换证书后新连接用上新证书;声明 ALPN `mqtt` 的客户端能完成握手。 + +**N2 WebSocket 与 mochi** · 依赖:N1、T0.2、T0.4 + +- 做:第 4.4 节;第 5 节的装配约束、连接代号、心跳校正、ACL、`OnPublish` 投进每端的串行队列(长度 256,满了形成背压);下行发布(整帧大小检查、QoS 选择、第 7.5 节的大帧并发名额)。 +- 验证:浏览器页面从别的域名连 `/mqtt` 成功;子协议不对的连接被关闭;超过客户端上限的下行不发布,并回调上层;QoS 0 的事件不占 inflight。 + +**N3 登录、会话令牌和握手** · 依赖:N2、P3、T0.3 + +- 做:第 5 节「登录与会话令牌」和锁定(用 P3 的工具);内部故障时让 `OnConnect` 返回 error;顶号和连接表;第 6.1 节握手(返回 `session_token`、30 秒没握手就断开、没订阅 down 就断开);`self.logout`;`online_since` 和 `offline_since`;把上下线事件交给 I 线。 +- 验证:F02 的全部验收(用测试启动器);数据库不可读时客户端连接被直接关闭,而不是收到 `0x86`。 + +### 6.4 消息 M + +**M1 提交** · 依赖:T0.2、T0.3、T0.4 + +- 做:第 6.2 节和第 7.3 节:先查防重,再校验、查配额、写授权和回复授权,发送时刻已到的立即分发;请求频率桶(第 6.10 节)。 +- 验证:表驱动测试覆盖防重命中、冲突、配额、授权、延迟和定时互斥。 + +**M2 分发与推送** · 依赖:M1、N2 + +- 做:第 7.4 节和第 7.5 节:调度器、`queue_full`、停用成员、推送窗口、确认计时和两种超时规则、`OnPublishDropped` 后重推、握手和断线时的投递调整、每秒清理循环。 +- 验证:F08–F12 的状态机测试和集成测试。 + +**M3 确认、撤回、回执、状态** · 依赖:M2 + +- 做:第 6.3 节、第 6.4 节、第 7.6 节:确认、撤回结果的判定、回执写入和推送窗口、`revoked`、`status` 分页、收尾删正文、每小时清理(防重行按条件删、分批删、`wal_checkpoint`)。 +- 验证:F13、F14、F18 的测试。 + +**M4 启动恢复** · 依赖:M2 + +- 做:第 7.8 节的启动恢复和写库失败处理。 +- 验证:推送中杀掉进程再重启,不保留的消息在宽限内重连仍能送到;停机期间到点的定时消息在启动后发出。 + +### 6.5 身份 I + +**I1 注册接口** · 依赖:T0.3、P3、N1 + +- 做:第 6.9 节:开关、安全码、锁定、跨域、字段规则、`source=self`;`settings` 表读写。 +- 验证:F23 的全部验收。 + +**I2 自己的资料和对话密码** · 依赖:N3、T0.2 + +- 做:第 6.6 节的 `self.*`(改登录密码时返回新令牌)、`unlock`、授权表(版本、两种授权、回复授权)、按对方计总数的暂停。 +- 验证:F15 的全部验收。 + +**I3 在线与目录** · 依赖:N3 + +- 做:第 6.5 节:`presence.get`、`presence.watch`(QoS 0 事件)、`directory.list`(分页和 `query`)。 +- 验证:F03、F04 的验收。 + +**I4 群** · 依赖:M1、I2 + +- 做:第 6.7 节全部帧、群事件、成员上限、加人时的对话密码、退群、踢人、解散、转让,以及第 7.6 节表里的作废规则。 +- 验证:F06、F16 的验收。 + +**I5 停用与删除** · 依赖:M3、I4 + +- 做:第 7.6 节表里停用、删除端的全部处理(清会话令牌、作废消息、转让群主、清理数据),提供给 A 线调用。 +- 验证:F01 里停用和删除相关的验收;删除后用同一编号重新开通,不会串到旧数据。 + +### 6.6 后台接口 A + +**A1 鉴权** · 依赖:T0.3、T0.4、P3 + +- 做:第 8 节的 Cookie 会话、`X-Nixmsg-Request` 头、管理员登录锁定、API 令牌鉴权和权限限制、令牌管理接口、操作日志。 +- 验证:F17 里令牌相关的验收;用令牌调用改管理员密码和令牌管理接口被拒绝。 + +**A2 端管理** · 依赖:A1、I5、N3 + +- 做:列表(按来源、在线状态筛选)、开通、CSV 批量开通(整批校验、结果只给一次)、批量停用、启用、删除、编辑、重置密码、踢下线、`unlock`、设置对话密码。 +- 验证:F01 的验收;导入 1000 行 CSV 成功。 + +**A3 其余接口** · 依赖:A1、I1、I4、M3、P4 + +- 做:注册设置、群管理、记录查询(筛选、分页、响应不含正文)、概览、只读设置、`/metrics` 的访问规则(第 4.3 节)。 +- 验证:F17 和 F22 里指标相关的验收;在所有管理接口的响应里搜不到测试正文。 + +### 6.7 后台网页 W + +**W1 框架** · 依赖:T0.1、T0.4 + +- 做:第 2.4 节的界面要求和 Naive UI 用法;布局、登录页、路由守卫、请求封装(CSRF 头、错误提示)、中文。先按契约用假数据开发。 +- 验证:`vue-tsc` 类型检查、ESLint、Vitest 通过;页面在 1366×768 和 1920×1080 下没有重叠和遮挡。 + +**W2 端管理页** · 依赖:W1 + +- 做:列表、筛选、多选批量操作、开通和编辑弹窗、CSV 导入和结果下载、重置密码的结果只显示一次、解除锁定、设置对话密码。 +- 验证:组件测试;假数据下主流程可用。 + +**W3 其余页面** · 依赖:W1 + +- 做:概览、注册设置、群、投递记录、API 令牌(创建后只显示一次)、改管理员密码、只读参数。 +- 验证:组件测试;假数据下主流程可用。 + +**W4 联调和端到端测试** · 依赖:A2、A3 + +- 做:接真实接口;用 Playwright 走第 13 节的后台主路径(登录、开通、开启注册并设置安全码、建群、看到未完成记录),再加 API 令牌的创建和停用。 +- 验证:端到端测试在 `task itest` 里通过。 + +### 6.8 SDK S1、S2 + +S1 先做 Go,再做 JS/TS;S2 先做 Python,再做 Java/Android。每种语言都按下面五个任务做,任务号带语言,例如 `S1-GO-1`、`S2-JAVA-3`。 + +| 任务 | 内容 | 依赖 | +|---|---|---| +| 1 连接和会话 | WebSocket(TCP 可选)、每次连接都带 Clean Start、握手、会话令牌(`onSession`、用令牌重连、`session_invalid`)、退避、停止重连的条件、连接超时 30 秒 | T0.2 | +| 2 收发 | 发送队列(上限 1000、在途 100、`rate_limited` 自动重交、`send_at_ms` 不重算)、去重和再确认、串行回调、自动和手动确认、`revoked`、本地大小检查 | 任务 1 | +| 3 其余接口 | 撤回、状态、回执、在线、目录、订阅、群、`self.*`、`logout`、注册 | 任务 2 | +| 4 接入清单 | 第 9 节的 15 条集成测试,对真实服务端二进制运行 | 任务 3,以及 N3、M3、I2、I4 已合进 main | +| 5 文档和示例 | 这种语言的 README 和最小示例 | 任务 4 | + +各语言的要点在 DEVELOPMENT 第 2.3 节和第 9 节:Go 用 `ConnectPacketBuilder` 每次设 Clean Start;JS 同时支持浏览器和 Node,发 ESM 和 CJS,要测跨域;Python 同步为主,另给 asyncio 包装;Java 编译成 Java 8 字节码,写清 Android API 24 的用法。 + +### 6.9 测试交付 Q + +**Q1 测试基础设施** · 依赖:T0.5 + +- 做:toxiproxy 和 netem 的 Docker 编排(名字带前缀);1000 连接的压测客户端;验收报告生成(F01–F23 对照表)。 +- 验证:能对一个运行中的服务端注入延迟、丢包、断开;压测客户端能保持 1000 个连接。 + +**Q2 验收用例** · 依赖:各线的核心任务 + +- 做:PRD 第 10 节每条至少有一个集成测试;各线写过的算数,Q 负责补齐和汇总成报告。 +- 验证:报告列出 F01–F23 每条的结果。 + +**Q3 弱网、崩溃、压测** · 依赖:Q1、Q2 + +- 做:DEVELOPMENT 第 13 节的全部相关测试,并出报告。 +- 验证:报告里的数字满足 PRD 第 8 节。 + +**Q4 Docker 与发布** · 依赖:T0.1,最终在阶段 2 完成 + +- 做:第 11.4 节的多阶段、多架构镜像;compose 示例;三个平台的二进制;镜像冒烟测试;第 11.3 节 1Panel 证书说明。 +- 验证:两个架构的镜像都能初始化、启动、通过健康检查。 + +**Q5 文档** · 依赖:各线完成 + +- 做:README(功能、构建、运行)、运维手册(配置、备份、升级、证书、时钟、指标)、SDK 文档汇总。 +- 验证:按 README 在一台干净机器上能构建和启动。 + +### 6.10 阶段 3:总控 L + +**Z1 全量回归**:在 main 上跑 `task check`、全部集成测试、端到端测试、四个 SDK 的接入清单、弱网和压测。 + +**Z2 审核**:按第 7 节清单逐项审核;做安全检查(日志和管理接口里没有正文、密码、令牌);检查依赖的许可证。 + +**Z3 交付**:汇总 `docs/DEVIATIONS.md` 并请负责人确认;打 `v0.1.0` 标签并推送;构建二进制和镜像;写交付说明(功能清单、验收结果、已知问题)。 + +## 7. 审核清单 + +总控每次合并前和最终审核时都要过一遍: + +- 行为和 PRD 一致,实现和 DEVELOPMENT 一致;不一致的已写进 `docs/DEVIATIONS.md`。 +- 没有违反 DEVELOPMENT 第 15 节的任何一条。 +- MemRelay 里「依赖库行为核对结论」涉及的点都做对了:`OnConnect` 和 `OnConnectAuthenticate` 的分工、`CodeSuccessIgnore`、下行大小自查、WebSocket 的 Origin 校验、TLS 的 `NextProtos`、每次连接都带 Clean Start。 +- 所有写库都走写入队列;条件更新都检查了影响行数。 +- 日志、错误信息、管理接口响应里没有正文、密码、令牌和注册安全码。 +- 新代码有测试;测试用随机端口和临时目录。 +- 没有引入技术栈以外的框架和中间件。 + +## 8. 负责人操作说明 + +用本地 Agent。仓库在自建的 Gitea 上,Cursor 的云端 Agent 只支持 GitHub、GitLab 等托管平台,而且用不了本机的 Docker。 + +1. 按 `Ctrl+Shift+P`,执行 `Open Agents Window` 打开代理窗口。每新建一个 Agent,先在输入框的模型选择器里选好 Grok 模型再发第一条消息。 +2. 阶段 0:新建一个 Agent 当总控,就在主工作区 `e:\code\NixMsg` 里运行,发「总控提示词」。等它报告阶段 0 已经合进 main 并推送。 +3. 阶段 1:每条线新建一个 Agent,让它在自己的工作树里运行(在代理窗口新建时选工作树;找不到这个入口就让 Agent 按第 4.1 节自己建)。发「开发线提示词」,把 `<线名>` 换成 P、N、M、I、A、W、Q。建议同时跑的不超过 7 个;S1、S2 在机器有余力时再开,或者等 N、M 合进 main 后再开。 +4. 日常:在代理窗口侧栏看各 Agent 的进度和改动。某条线说可以合并时,告诉总控「合并 <分支名>」,或者让总控定期查 MemRelay 里标了 `ready-to-merge` 的检查点。Agent 提问时在它的对话里回答,拿不准的转给总控判断。 +5. 阶段 2、3:N、M、I、A 的核心任务合进 main 后,让 S1、S2、W、Q 按任务表联调和验收;最后告诉总控「开始阶段 3」。 +6. 注意:Cursor 默认整台机器最多保留 25 个工作树,超出会自动删掉最旧的,所以要各 Agent 勤提交、勤推送;需要时在设置里调大 `cursor.worktreeMaxCount`。 + +总控提示词: + +```text +你是 NixMsg 的总控 Agent,在主工作区 e:\code\NixMsg 工作。先按协作规则读 MemRelay 项目记忆,再完整阅读 docs/PRD.md、docs/DEVELOPMENT.md、docs/TASKS.md。现在完成 TASKS.md 阶段 0 的 T0.1 到 T0.5:每个任务一个分支,一功能一验证一提交,全部验证通过后快进合并进 main 并推送。完成后向我汇报,并告诉我可以开启阶段 1。之后你负责按 TASKS.md 第 4.3 节和第 7 节审阅、合并各线分支并跑集成验证,最后执行阶段 3。 +``` + +开发线提示词: + +```text +你是 NixMsg 的开发线 <线名> Agent。先按协作规则读 MemRelay 项目记忆,再阅读 docs/TASKS.md(重点第 3、4、5 节和第 6 节里 <线名> 的任务)以及 PRD、DEVELOPMENT 的相关小节。在你自己的工作树和 feat/<任务号>-<简述> 分支上工作,只改本线负责的目录,按依赖顺序完成本线任务:一功能一验证一提交,并推送自己的分支。每个任务完成后 rebase 到 origin/main、跑全部验证,通过后在 MemRelay 存标签为 ready-to-merge 的检查点,然后告诉我。缺工具用 scoop 安装;文档有疑问先问我,不要自己改产品行为。 +``` + +## 9. 负责人已确认的协作授权 + +- 2026-09-30:允许各开发 Agent 在 rebase 后用 `git push --force-with-lease` 更新自己的 `feat/*`、`fix/*` 远端分支;`main` 任何时候都不许强推。