Files
NixMsg/docs/DEVIATIONS.md
T

1514 lines
116 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实现与文档的偏差
开发中凡是实现和 [PRD.md](./PRD.md)、[DEVELOPMENT.md](./DEVELOPMENT.md) 不一致的地方,以及文档没写清、开发中自己拿主意的地方,都记在这里,交给负责人事后审阅。开发全程自动推进,记下来就继续,不等审阅。
每条写清:日期、原条款(文档和小节)、实际做法、原因、备选方案、影响。各线只写自己那一节,避免多条线同时改同一段。
## 总控 L
### T0.1 2026-09-30
1. **前端嵌入方式**
- 原条款:DEVELOPMENT 第 3 节 `web/embed.go` 用 `//go:embed all:dist`;TASKS T0.1;`.gitignore` 忽略 `web/dist`。
- 实际做法:默认构建用 `-tags` 以外的 `embed_stub.go` 嵌入 `web/stub/`;`task build` 加 `-tags embeddist` 嵌入真实 `web/dist`。
- 原因:`web/dist` 不进仓库,且要求没有前端产物时也能 `go build`。
- 备选方案:提交最小 `dist`;或构建前脚本生成占位目录。
- 影响:裸 `go build` 不含真实前端;正式产物必须走 `task build`。
2. **listen.addr 写入时机**
- 原条款:DEVELOPMENT 4.1「端口写 0 时」写 `<data_dir>/listen.addr`。
- 实际做法:只要 `serve` 启动成功就写入实际监听地址。
- 原因:测试启动器统一读取该文件更简单,固定端口场景也无害。
- 备选方案:仅当配置端口为 0 时写入。
- 影响:多一个小文件;行为超集,兼容文档要求。
3. **迁移占位**
- 原条款:T0.3 才写完整表与 `VACUUM INTO` 备份;T0.1 允许空执行器加空 0001。
- 实际做法:`internal/store.Migrate` 建 `schema_migrations` 并应用 `0001_init.sql`(内容为 `SELECT 1;`);不做迁移前备份。
- 原因:保证 serve 可跑通迁移路径,表结构留给 T0.3。
- 备选方案:完全空文件 + 只记版本。
- 影响:T0.3 需替换 0001 正文并补备份逻辑;已应用的占位版本号仍为 1。
4. **Taskfile 引入 taskfiles**
- 原条款:TASKS T0.1 / 4.2 引入 `taskfiles/*.yml`。
- 实际做法:`includes: '*': taskfile: taskfiles/*.yml, optional: true`,并放 `_init.yml` 占位。
- 原因:空目录 glob 可能失败;各线稍后加自己的 yml。
- 备选方案:主文件逐条 optional include 各线文件名。
- 影响:无。
5. **deploy/Dockerfile**
- 原条款:完整多架构镜像属 Q4;T0.1 需要 `task docker` 目标。
- 实际做法:提供单架构多阶段 Dockerfile 骨架,供 `task docker` 使用;T0.1 验证另用官方 `golang` 镜像跑 `task check`。
- 原因:让 docker 目标可执行,又不抢 Q4 范围。
- 备选方案:docker 目标仅 echo 提示。
- 影响:镜像发布流程仍由 Q4 定稿。
### L-WIRE 2026-09-30
1. **serve 真实接线范围**
- 原条款:TASKS 总控接线;listener/broker/admin/注册/消息周期循环挂到进程。
- 实际做法:`cmd/nixmsg/serve.go` 注入真实 `auth.NewPool`/`NewSessionTokens`/`NewAPITokens`/`NewLoginLocks`,挂注册与管理路由(踢线调 `Session.Kick`),listener 识别 HTTP/WebSocket/`OnMQTT` 裸 TCP,broker `Login`+`Session`,握手/断线/`OnPublishDropped` 接到 `message.App`,周期 `DispatchDue`/`PushPending`/`CleanupOnce`,下行 `PublishDown`。集成测覆盖:管理登录、写 settings 后注册、密码 MQTT 握手拿 `session_token`。
- 原因:第二波收尾;三件验收必须通。
- 备选方案:分文件多阶段接线。
- 影响:进程可对外登录/注册/握手。
2. **未接线 / 未完成部分(已被 L-UPLINK 部分取代)**
- 原条款:上行应用帧完整分发到 identity/group/presence/message 业务方法。
- 实际做法(L-WIRE 当时):`Session` 处理 hello/logout;其余 `HandleUplink` 仍为 Stub。管理注册设置 HTTP(A3)未实现,测试直接写 `settings` 表。
- 原因:本波强制三件验收;完整协议分发属后续波次。
- 备选方案:本波同时实现 HandleUplink 大 multiplex。
- 影响:见 L-UPLINK。
3. **listen.addr 始终写入**
- 原条款:listener N1 仅端口 0 写文件;T0.1 超集为启动即写。
- 实际做法:listener 按 N1 写端口 0;serve 成功后再强制写一次 `listen.addr`(及分离时的 `admin.addr`)。
- 原因:与 harness / T0.1 一致。
- 备选方案:改 listener 始终写。
- 影响:固定端口也会有地址文件。
### L-UPLINK 2026-09-30
1. **HandleUplink 分发到已有业务服务**
- 原条款:TASKS 总控接线;DEVELOPMENT 第 6 节上行帧由服务端处理后回 `resp`;群事件/在线通知/消息走下行。
- 实际做法:`cmd/nixmsg/uplink.go` 的 `appUplink` 在已握手连接上解码并调用 `message`/`identity`/`presence`/`group` 的既有方法;结果编成 `resp` 经 `PublishDown`(QoS 1)回本连接。`Session` 仍独占 `hello`/`self.logout` 与未握手 `not_ready`。`serve` 把四个 App 与 broker Downlink 注入 uplink。不重写业务状态机。
- 原因:main 上业务实现已齐,缺上行入口。
- 备选方案:各包自建 MQTT 钩子(与 port 契约不符)。
- 影响:端侧 send/ack/recall/status/unlock/self.*/presence.*/directory.list/group.* 可走真实进程。
2. **presence.get 未知编号编码**
- 原条款:DEVELOPMENT 6.5「未知编号为 not_found」;`StatusItem.NotFound` 为内部标记。
- 实际做法:`presence.EncodeGetData` 薄封装,resp data 为 `{"items":[...]}`;未知项 `{"id","not_found":true}`,已知项含 `id`/`online`/`since_ms`。
- 原因:Service 层未规定 JSON 形状,接线需固定可编解码形式。
- 备选方案:整请求失败 `not_found`;或把 `not_found` 塞进 `online` 旁字符串字段。
- 影响:SDK 若只认 items 数组两种形状均可(Go SDK 已兼容 wrap/array)。
3. **resp 超限改发 response_too_large**
- 原条款:DEVELOPMENT 7.5「resp 超限改发 response_too_large」。
- 实际做法:发布前按连接表 `MaxReceiveBytes` 与 `MaxPacketSize` 取较小正上限;超限则改发错误 resp(不再发原 data)。未做 MQTT 包头开销扣减(与 message 推送里的 overhead 预算不完全同一函数)。
- 原因:接线层最小可用检查。
- 备选方案:复用 `message.effectivePayloadLimit`(未导出)。
- 影响:接近包上限的大分页可能比推送路径略严或略松。
4. **断线清 presence.watch**
- 原条款:presence.watch 断线清空。
- 实际做法:`OnDisconnect` 额外调 `ClearWatch`;`SetOffline` 路径本身也会清。顶号旧连接未走 `SetOffline(isCurrent)` 时仍能清订阅。
- 原因:避免旧连接订阅泄漏。
- 备选方案:仅依赖 Session 对 isCurrent 调 SetOffline。
- 影响:无。
5. **仍未接线 / 本波未覆盖**
- 管理注册设置 HTTP(A3)仍未挂;集成测继续写 `settings` 表开注册。
- 下行通知帧(`msg`/`receipt`/`revoked`/`presence`/`group_event`/`fatal`)不是上行分发对象,由既有服务经 PublishDown 发出。
- `self.logout`/`hello` 仍在 Session,不经 appUplink。
- 身份 I5 停用/删除级联、管理端群等非本任务范围。
- 集成测覆盖:双端单聊、离线 keep 后上线、群发不含发送者、延迟内撤回对方无回调;未穷尽 presence.watch 通知与全部 group.* 变体。
### T0.2 2026-09-30
1. **请求指纹规范化格式**
- 原条款:DEVELOPMENT 7.3「下列字段规范化后的 SHA-256」,未规定字节布局。
- 实际做法:对 `to.kind`、`to.id`、`body.enc`、有效 `content_type`、解码后正文、`meta` 规范 JSON、`send_at_ms`、`delay_ms`、有效 `offline.keep`/`ttl_seconds`、有效 `receipt` 做长度前缀(或有无标记)串联后算 SHA-256,输出小写十六进制;`meta` 用 `encoding/json` 对 `map` 键排序序列化;不含 `talk_password`、`rid`。
- 原因:文档未给规范格式,需固定、与键顺序无关、含可选字段区分。
- 备选方案:整段规范 JSON 对象再哈希。
- 影响:各语言 SDK / 服务端必须共用同一布局,否则防重失效。
2. **指纹使用文档默认值后的有效字段**
- 原条款:指纹字段列表含 `receipt`、`offline.*`,未说明缺省如何表示。
- 实际做法:`receipt` 缺省按 true;`offline.keep` 缺省 false;`keep` 为 true 且未给 `ttl_seconds` 时按 86400;`keep` 为 false 时 ttl 记 0;`content_type` 按 enc 补默认。
- 原因:重试时省略与显式默认应视为同一请求。
- 备选方案:按原始 JSON 有无字段区分,省略与显式默认算冲突。
- 影响:SDK 省略默认字段时防重仍命中。
3. **JSON Encoder 去掉尾部换行**
- 原条款:用 `json.Encoder` 且 `SetEscapeHTML(false)`。
- 实际做法:Encode 后去掉 `Encoder.Encode` 追加的 `\n`,整帧字节数不含该换行。
- 原因:MQTT 一发布一帧,示例 JSON 无尾换行;保留换行会抬高帧长并与本地 `frame_too_large` 判断不一致。
- 备选方案:保留换行并在 DEVELOPMENT 写明。
- 影响:线上帧比「裸 Encoder.Encode」少 1 字节。
4. **协议包校验范围**
- 原条款:T0.2 要求编号规则、正文/meta 大小、`send_at_ms`/`delay_ms` 互斥、登录密码不以 `nst_` 开头。
- 实际做法:上述必做之外,顺带校验各帧 `v`/`type`/`rid`、目标 kind、分页 limit、致命 reason 枚举等结构字段;业务错误(目标不存在、配额等)不在本包判定。
- 原因:无结构校验则编解码测试无法覆盖「合法帧」边界。
- 备选方案:协议包只做编解码,校验留给各 app 模块。
- 影响:服务端应复用本包 `Validate`,避免重复规则。
### T0.3 2026-09-30
1. **完整表放在 0002,不改已发布的 0001**
- 原条款:TASKS T0.3 / 4.2「`0001_init.sql` 包含第 7.7 节全部表」;T0.1 偏差曾写「T0.3 需替换 0001 正文」。
- 实际做法:保留 `0001_init.sql` 为 `SELECT 1;`;新增 `0002_schema.sql` 写入 DEVELOPMENT 7.7 全部业务表与索引(含 `api_tokens`、`settings`、`session_hash` 等)。`schema_migrations` 仍由迁移执行器 `CREATE TABLE IF NOT EXISTS` 维护,不放入 0002。
- 原因:T0.1 的 0001 可能已记入已有库的 `schema_migrations`;改写已发布迁移语义会导致「版本已应用但表不存在」。
- 备选方案:对未迁移库特殊检测并改写 0001(复杂且易错)。
- 影响:新库会有版本 1+2 两行;与 TASKS「表在 0001」字面不一致,与「不改已发布迁移」一致。
2. **写入队列先做一操作一事务**
- 原条款:DEVELOPMENT 7.2 合并提交(最多 256 或凑满 2ms,SAVEPOINT);TASKS T0.3 允许简单实现,P2 换合并。
- 实际做法:`store.Queue` 用互斥锁串行,每请求一个事务;注释与本条标明 P2 再改为写 goroutine 合并提交。
- 原因:本任务范围;合并留给平台 P2。
- 备选方案:T0.3 直接做合并(抢 P2 范围)。
- 影响:高并发写入落盘次数偏多,正式压测前需完成 P2。
3. **空库不备份;仅已有 db 文件且有未应用版本时 VACUUM INTO**
- 原条款:DEVELOPMENT 7.7「有未应用版本时先 VACUUM INTO」;未区分空库。
- 实际做法:`Open` 在打开前检查 `nixmsg.db` 是否已存在;不存在则跳过备份;存在且有 pending 则写入 `<data_dir>/backup/pre-migrate-<UTC时间>.db`。迁移失败返回错误,不自动从备份恢复。
- 原因:空库备份无意义;失败退出与文档一致,恢复交给运维。
- 备选方案:失败时自动还原备份再退出。
- 影响:与任务说明一致;运维需知备份路径。
### T0.5 2026-09-30
1. **admin init / 管理员密码**
- 原条款:TASKS T0.5「自动执行 `admin init` 拿到管理员密码」;命令行启动器 JSON 含管理员密码。
- 实际做法:定义 `AdminInitializer`(默认 `CLIAdminInit`);二进制尚无 `admin` 子命令时返回 `ErrAdminInitUnsupported`,启动器跳过并继续起 serve;`admin_password` 字段为空字符串。示例集成测试只验 `/healthz`。
- 原因:`admin init` 属 P1,当前 main 仅有 `version`/`serve`。
- 备选方案:harness 内嵌假密码写入库(无表结构可写);或阻塞等 P1。
- 影响:P1 合入后无需改调用方接口,密码解析约定见 `parseAdminPassword`;Q/SDK 集成测试在拿到非空密码前勿依赖管理登录。
2. **MQTT 测试客户端范围**
- 原条款:能收发 DEVELOPMENT 第 6 节应用帧。
- 实际做法:提供 TCP / WebSocket(`/mqtt`,子协议 `mqtt`)传输层 `MQTTClient`,收发原始 MQTT 控制包字节;不实现 CONNECT/hello/主题业务。
- 原因:内置 broker 与协议处理尚未合入(连接 N / 后续任务);T0.5 先给可连传输与占位 API。
- 备选方案:引入完整 MQTT 客户端库并编假 broker。
- 影响:业务级帧测试在 broker 可用后由各线基于 `Send`/`Recv` 或再包一层完成。
3. **进程停止方式**
- 原条款:优雅停机(DEVELOPMENT 7.8 / P1)。
- 实际做法:测试启动器对子进程使用 `Kill`(Windows 上 `Interrupt` 不可靠)。
- 原因:保证并行测试与清理在 Windows 上稳定。
- 备选方案:Unix 发 SIGTERM;Windows 用 Job Object / Ctrl+Break。
- 影响:不覆盖优雅停机验收;该验收仍归 P1/Q。
### T0.4 2026-09-30
1. **broker↔app 契约包放在 `internal/app/port`**
- 原条款:TASKS T0.4「broker 和 app 之间的接口」;示例路径 `internal/app/port` 或 `internal/broker/port`。
- 实际做法:放在 `internal/app/port`:`UplinkHandler`(broker→app)、`Downlink` / `ConnControl`(app→broker)。不依赖 mochi。
- 原因:契约由 app 消费形态主导,避免 broker 包在 N 线实现前成为空壳;N 线实现 broker 时 import 本包即可。
- 备选方案:放在 `internal/broker/port` 或单独 `internal/port`。
- 影响:N/M/I 依赖路径固定为 `internal/app/port`。
2. **接口方法先返回未实现或空操作,不做业务状态机**
- 原条款:T0.4 要求 Go 接口与测试假实现;不要实现真正业务逻辑。
- 实际做法:`auth` / `message` / `identity` / `group` 的写路径假实现返回 `ErrNotImplemented`;调度/推送/清理/在线查询等返回空成功或固定假数据;`wire()` 组装这些假实现,`serve` 仅调用 `RecoverOnStart`(空操作)并保留依赖引用。
- 原因:让后续各线有可编译的替换点,且不抢 P/N/M/I/A 实现范围。
- 备选方案:接口方法全部 panic;或完全不接线 serve。
- 影响:在假实现替换前,端协议与管理 API 仍不可用(本任务预期)。
3. **管理契约补充 `GET /api/admin/groups/{id}`**
- 原条款:DEVELOPMENT 第 8 节路由表列出 groups 的 GET/POST 列表创建与 PATCH/DELETE,未单列群详情。
- 实际做法:`docs/api/admin-api.md` 增加 `GET /api/admin/groups/{id}`(成员分页),供后台详情页使用。
- 原因:改名/解散/成员管理需要详情;与端协议 `group.get` 对称。
- 备选方案:详情拼进列表项或仅用 PATCH 回显。
- 影响:A/W 按契约实现该只读路由。
4. **CSV 导入校验失败时用信封外的 `data.errors`**
- 原条款:写明返回出错行号和原因;未规定 JSON 形状。
- 实际做法:HTTP 400,`ok=false`,`error.code=bad_request`,同行号列表放在顶层 `data.errors`。
- 原因:通用 `error` 只有 code/message,放不下多行明细。
- 备选方案:把明细塞进 `error.message` 字符串。
- 影响:W 线按 `data.errors` 渲染。
## 平台 P
### P1 2026-09-30
1. **admin set-password 传参方式**
- 原条款:DEVELOPMENT 11.2 仅列命令名,未规定密码如何传入。
- 实际做法:支持 `--password <pwd>`、位置参数,或从 stdin 读一行;最短 12 位。
- 原因:自动化测试与 Docker 非交互环境需要非交互传参。
- 备选方案:仅交互式 prompt。
- 影响:运维文档需写明推荐用 `--password` 或管道,勿把密码写进 shell 历史时可改用 stdin。
2. **admin init 密码字符集**
- 原条款:生成 20 位密码,未规定字符集。
- 实际做法:从去掉易混字符(0/O/1/I/l)的字母数字中均匀抽样 20 位。
- 原因:终端抄写友好。
- 备选方案:全 ASCII 可打印字符。
- 影响:熵略低于全字符集,对 20 位仍足够。
3. **PHC 哈希辅助先放在 auth,池在 P3**
- 原条款:argon2 并发池属 P3;admin init 需落库哈希属 P1。
- 实际做法:P1 在 `internal/auth` 提供 `HashPassword`/`VerifyPassword`(PHC),admin 命令直接调用;P3 再用同参数实现带并发上限的 `HashPool`。
- 原因:避免 P1 在 cmd 内复制算法,又不等到 P3 才做 init。
- 备选方案:P1 cmd 内联 argon2;或 P1/P3 合并提交。
- 影响:P3 合入后 admin 可改为走 HashPool(非必须,单次 init 无并发压力)。
4. **优雅停机顺序**
- 原条款:停接受 → 写队列最多 10 秒 → 断开连接退出。
- 实际做法:`http.Server.Shutdown`(先停接受并等待进行中的 HTTP)后,再 `Queue.Drain` 最多 10 秒;本期尚无长连接表,断开连接由 Shutdown 覆盖。
- 原因:当前 main 仅有 HTTP 健康检查;N 线接入后应在 Drain 前后显式踢连接。
- 备选方案:先关 listener、Drain、再 Shutdown。
- 影响:有 MQTT 长连接后需 N/P 联调停机路径。
### P2 2026-09-30
1. **合并写入队列替换 T0.3 简单实现**
- 原条款:DEVELOPMENT 7.2;TASKS P2。
- 实际做法:单写 goroutine;批次上限 256 或等待 2ms;每操作用 `SAVEPOINT`/`ROLLBACK TO`/`RELEASE`;Begin/Commit/SAVEPOINT 基础设施失败返回 `errors.Join(ErrBusy, err)` 并令 `IsReady()=false`;业务操作错误只回滚该 SAVEPOINT,不标 busy。
- 原因:满足每秒约 200 条写入的落盘合并需求。
- 备选方案:按固定时间窗无条件合并。
- 影响:调用方需用 `errors.Is(err, store.ErrBusy)` 映射协议 `busy`;`/readyz` 读 `DB.Ready`。
2. **调用方 context 取消与已入队任务**
- 原条款:未规定入队后取消。
- 实际做法:入队前检查 ctx;批次执行前再检查;若调用方在等待结果时取消,最多再等 30 秒取结果以免泄漏。
- 原因:写 goroutine 仍可能已执行该操作,不能静默丢结果。
- 备选方案:取消即从队列摘除(需可取消数据结构)。
- 影响:极端取消场景下调用方可能多等一会儿。
### P3 2026-09-30
1. **管理员/注册锁定阈值沿用登录 IP 档**
- 原条款:登录/对话密码阈值写清;管理员登录与注册安全码仅写「临时锁定」,未给数字。
- 实际做法:`LockAdminIP`、`LockRegisterIP`、`LockTalkPair`/`LockLoginEndpointIP` 均为 5 分钟窗口 10 次、锁 5 分钟;`LockTalkTarget`/`LockLoginEndpoint` 为 1 小时 50 次、锁 1 小时。
- 原因:与 PRD D18/F23「默认 5 分钟 10 次」叙述一致。
- 备选方案:管理员单独更严阈值。
- 影响:A/I/N 线直接用 `LoginLocks` 即可。
2. **真实 auth 实现未改 wire.go**
- 原条款:平台不改 `wire.go`;P3 实现接口。
- 实际做法:提供 `NewPool`/`NewSessionTokens`/`NewAPITokens`/`NewLoginLocks`;`wire()` 仍用 Stub,由总控或各线接线时替换。
- 原因:分工禁止改 wire.go。
- 备选方案:在 serve 旁路替换(会绕过 wire)。
- 影响:合入后需有一次接线才能在进程内用上真实哈希池。
3. **令牌随机部分用 RawURLEncoding**
- 原条款:32 字节随机数的 base64url。
- 实际做法:`encoding/base64.RawURLEncoding`(无 padding)。
- 原因:URL/Header 友好,与常见 token 惯例一致。
- 备选方案:StdEncoding 带 padding。
- 影响:SDK/文档示例需无 `=` 结尾。
### P4 2026-09-30
1. **指标包放在 `internal/metrics`**
- 原条款:TASKS 分工表未列 metrics 目录;P4 要求用 prometheus/client_golang 建注册表。
- 实际做法:新建 `internal/metrics`,提供 `New`/`Handler`/`Registry` 字段供各线打点;不在 `serve` 挂路由(访问规则属 A 线)。
- 原因:不宜塞进 auth/store/config。
- 备选方案:放 `internal/httpx`(A 线目录)。
- 影响:A/N 接线时 import 本包并挂 `/metrics`。
2. **指标命名**
- 原条款:列了指标含义,未规定 Prometheus 名字。
- 实际做法:`nixmsg_connections{transport}`、`nixmsg_endpoints`、`nixmsg_deliveries_pending`、`nixmsg_messages_scheduled`、`nixmsg_dispatch_to_push_duration_seconds`、`nixmsg_ack_duration_seconds`、`nixmsg_write_queue_length`、`nixmsg_write_batch_commit_duration_seconds`、`nixmsg_password_hash_queue_length`、`nixmsg_errors_total{code}`。
- 原因:固定可抓取文本便于联调。
- 备选方案:更短前缀或 HistogramVec。
- 影响:仪表盘按上述名字配置。
### 复审修复 D-01 2026-09-30
1. **写队列 busy 后恢复 IsReady**
- 原条款:DEVELOPMENT 4.3 `/readyz` 已能读写数据库;DEVIATIONS P2 只写失败时 `IsReady()=false`。
- 实际做法:`runBatch` 提交成功后在锁内 `ready=true` 并清除 `lastWriteErr`。
- 原因:短暂 busy_timeout / 磁盘瞬时错误后不应永久 503。
- 备选方案:要求最近 30 秒无失败才恢复(防抖动)。
- 影响:`/readyz` 在随后一次成功写入后恢复。
### 复审修复 D-02 2026-09-30
1. **写队列关闭安全与非事务执行**
- 原条款:DEVELOPMENT 7.6 清理后 `PRAGMA wal_checkpoint(TRUNCATE)`;L-03 / C-03 依赖存储层能力。
- 实际做法:`Close` 先置 `closed`,再在写锁内关闭数据通道,并发 `Do` 不会向已关闭 channel 发送;关闭后返回已有的 `ErrQueueClosed`。新增 `ExecOnWriter` / `Checkpoint` / `Optimize`,在写 goroutine 上、事务外执行。不改 `message` 包,不在此调用 checkpoint(留给 C-03)。
- 原因:避免停机 panic,并为 C-03 提供非事务接口。
- 备选方案:只关 stop 通道、永不 close 数据通道。
- 影响:L-03 可安全 Close;C-03 可调用 `DB.Checkpoint`。
### 复审修复 D-04 2026-09-30
1. **backup 空库与恢复步骤**
- 原条款:PRD F22;OPS 第 3 节。
- 实际做法:备份前检查 `nixmsg.db` 绝对路径,不存在则报错且不创建数据目录/空库;成功打印源库绝对路径;输出文件 chmod 0600。OPS 示例 `data_dir` 改为绝对路径;恢复改为同时移走 `-wal`/`-shm`。未做 `nixmsg restore` 命令(允许文件清单未含 restore.go)。
- 原因:cron 相对路径会在错误位置新建空库并当成功备份。
- 备选方案:增加 `restore --from` 命令。
- 影响:空目录 backup 失败;运维按 OPS 恢复时不会叠旧 WAL。
### 复审修复 D-05 2026-09-30
1. **迁移备份按版本命名**
- 原条款:DEVELOPMENT 7.7 迁移前 VACUUM INTO。
- 实际做法:`pre-migrate-v{当前}-to-v{目标}.db`,已存在则复用;复制前尽力检查剩余磁盘空间。
- 原因:迁移稳定失败时 `restart: unless-stopped` 会写满磁盘。
- 备选方案:按秒时间戳并在启动失败时删除本次备份。
- 影响:同一版本区间反复失败只保留一份备份。
2. **构建注入 Version;grace_seconds: 0 按 0 生效**
- 原条款:DEVELOPMENT 6.1 `server_version`;11.1 `grace_seconds` Validate `>= 0`。
- 实际做法:Taskfile / q.yml / Dockerfile 用 `-ldflags -X main.Version=…`(默认 `git describe`)。去掉 `applyEmptyDefaults` 对数值 0 的回填;显式 `grace_seconds: 0` 表示无宽限(不在 Validate 里报错)。`max_frame_bytes` 上限 786432。`admin set-password` 清空全部 `admin_sessions`。TLS 仅明文关闭时 `healthcheck` 走 HTTPS(本机跳过证书校验)。K-05 是 SDK 打包,与本次 ldflags 无冲突。
- 原因:显式 0 被改回 60 会让运维误以为关掉了宽限;hello 的 max_frame 不能超过 broker 包长。
- 备选方案:`grace_seconds: 0` 在 Validate 报错,强制至少 1 秒。
- 影响:未写该字段仍为默认 60;命令行改密后旧 Cookie 一律 401。
## 连接 N
### N1 / N2 2026-09-30
1. **未接线 `cmd/nixmsg`**
- 原条款:serve 最终应挂上端口识别、broker、`/mqtt`。
- 实际做法:本任务只交付 `internal/listener`、`internal/broker`;按总控要求不改 `cmd/nixmsg`。
- 原因:避免与平台/总控并行改 wire 冲突;合并时再接线。
- 备选方案:本分支顺带改 `wire.go`(与指令冲突)。
- 影响:当前 `serve` 仍是 T0.4 的简单 `/healthz` 监听,不含 MQTT。
2. **`listen.addr` / `admin.addr` 仅端口为 0 时写入**
- 原条款:DEVELOPMENT 4.1「端口写 0 时」写地址文件;T0.1 偏差曾改为 always write。
- 实际做法:`listener.Server` 仅当配置地址端口为 `0` 时写 `listen.addr` / `admin.addr`。
- 原因:本任务说明与 DEVELOPMENT 4.1 字面一致;T0.1 的 always write 在 `cmd/nixmsg`,本线未改。
- 备选方案:接线时统一为 always write 以兼容 harness。
- 影响:固定端口场景下 harness 若只读地址文件会读不到;接线时建议沿用 T0.1 超集或改 harness。
3. **登录校验为可替换接口,默认拒绝**
- 原条款:第 5 节完整会话令牌/密码/锁定属 N3。
- 实际做法:`broker.Authenticator` 接口 + 默认 `RejectAuthenticator`;内部错误在 `OnConnect` 返回 error;测试提供 `AllowAuthenticator`。
- 原因:N3 范围;N2 需可跑通装配与钩子。
- 备选方案:N2 内做假登录表(超出范围)。
- 影响:真实端连不上直到 N3;总控接线时注入 Authenticator。
4. **大帧并发名额释放策略**
- 原条款:DEVELOPMENT 7.5 大于 64KiB 全局同时不超过 64;PUBACK / 超时 / 断线释放。
- 实际做法:发布前申请名额;QoS 0 发布成功立即释放;QoS 1 在 `OnQosComplete` 且 payload>64KiB 时释放,断线 `releaseAllLarge`;未单独做「确认超时」计时释放(确认超时属 M 线推送循环)。
- 原因:N2 无投递确认计时器;与 M 线推送超时释放衔接。
- 备选方案:broker 内对大帧自建超时(与 M 重复)。
- 影响:若客户端永不 PUBACK 且不断线,名额可能占满直到断开;M 线超时踢线或回调 Disconnect 可释放。
5. **`OnPublishDropped` 仅打日志**
- 原条款:清「已推送」标记并 1 秒后重推。
- 实际做法:钩子记录 debug 日志;清标记/重推留给消息 M。
- 原因:投递状态在 M/store,N2 无投递表。
- 备选方案:N2 暴露回调给 M 注册。
- 影响:接线后 M 需订阅或包装该钩子;当前接口可后续加 `OnPublishDropped` 回调字段。
### N3 2026-09-30
1. **仍未接线 `cmd/nixmsg`**
- 原条款:serve 最终应挂上真实 Authenticator / Session。
- 实际做法:交付 `broker.Login`、`broker.Session` 与 F02 测试;不改 `cmd/nixmsg`/`wire.go`。
- 原因:与总控/其他线并行改 wire 冲突;N1/N2 已约定合并时接线。
- 备选方案:本分支改 wire(与隔离指令冲突)。
- 影响:进程默认仍 RejectAuthenticator,需接线注入 `Login`+`Session`。
2. **`session_hash` 存十六进制文本**
- 原条款:库中存 SHA-256;列为 TEXT,未规定编码。
- 实际做法:存 32 字节哈希的小写 hex(与后台 API 令牌存法一致)。
- 原因:TEXT 列无法直接存原始字节;hex 便于排查。
- 备选方案:BLOB 列或 base64。
- 影响:其他线读写 `session_hash` 需按 hex 编解码。
3. **上下线通知走 `PresenceSink` + port 回调**
- 原条款:写 `online_since`/`offline_since` 并通知;通过现有 port 接口供身份线订阅。
- 实际做法:N3 自己写时间戳;可选注入 `PresenceSink`(对齐 `presence.Service.SetOnline/SetOffline`);并继续调用 `OnHandshakeComplete`/`OnDisconnect`。旧连接断开用连接代号判断,只有当时仍是 current 才标离线。
- 原因:I3 尚未合入,不能依赖具体 presence 实现;双通道便于接线。
- 备选方案:只靠 port、由 I 线写库(与「N3 写 online_since」字面不符)。
- 影响:接线时避免 I 线重复写同一时间戳即可。
4. **InlineClient 的 `OnPublish` 必须放行**
- 原条款:客户端上行 `OnPublish` 返回 `CodeSuccessIgnore`。
- 实际做法:`cl.Net.Inline` 时原样返回,不 Ignore,否则 `PublishDown` 无法送达订阅者。
- 原因:mochi `Publish` 经 InlineClient `InjectPacket` 再进 `OnPublish`。
- 备选方案:不用 InlineClient,改直接 `publishToClient`(偏离文档装配)。
- 影响:N2 既有 PublishDown 测试此前未读回包,此缺陷在 N3 才暴露并修复。
5. **管理员踢线类入口挂在 `Session`**
- 原条款:停用/删除/重置密码先 fatal 再断开;踢下线只断开。
- 实际做法:`Session.Disable`/`Deleted`/`ResetPassword`/`Kick` 可调用;管理 HTTP 未接。
- 原因:A2 管理接口尚未接线。
- 备选方案:放到 `internal/admin`(超出 N 目录)。
- 影响:A/I 接线时调用这些方法即可。
### 复审修复 L-02
1. **accept 临时错误退避重试**
- 原条款:无(issue #21);net/http `Serve` 对 Accept 临时错误退避。
- 实际做法:`acceptLoop` 仅在已关闭或 `errors.Is(err, net.ErrClosed)` 时退出;其余错误记日志后从 5ms 倍增到最多 1s 再 Accept。
- 原因:文件描述符短暂耗尽不应永久停收新连接。
- 备选方案:只对 `net.Error.Timeout` 重试(Go 已弃用 Temporary)。
- 影响:进程在瞬时 EMFILE/ENOBUFS 后可自行恢复。
### 复审修复 L-01
1. **握手前超时、CONNECT 预读上限、HTTP IdleTimeout、握手前信号量**
- 原条款:DEVELOPMENT 4.2 仅规定首字节 10s;issue #20。
- 实际做法:TLS `Handshake` 前 `SetDeadline(10s)`;MQTT CONNECT 剩余长度 >64KiB 立即关闭;预读 CONNECT 头超时同样 10s;`OnMQTT` / `AttachWS` 前再设读超时;`http.Server` 设 `IdleTimeout=120s`、`MaxHeaderBytes=64KiB`;accept 到分流完成占用容量 1024 的信号量,满则关新连接。测试用 `HandshakeTimeout`/`IdleTimeout`/`PreHandshakeLimit` 缩短等待。
- 原因:`MaximumClients` 只统计认证后会话,握手前可被慢连接占满。
- 备选方案:给 HTTP 再加 `ReadTimeout`(须在 WS Accept 前清掉,改动面更大)。
- 影响:不完整 TLS/CONNECT 约 10s 内断开;超长 remaining length 的 CONNECT 不再让 mochi 预分配近 768KiB。
- 未做:L-01 验收里「接真实 broker 只发 0x10」在 listener 层用 `OnMQTT` 回调等价覆盖(预读阶段即关闭,不进入 broker)。WebSocket 升级后超时只在 `ws.go` 设读 deadline,未另写 broker 测试(范围限制)。
### 复审修复 L-04
1. **TLS 包装连接实现 ConnectionState**
- 原条款:DEVELOPMENT 第 8、12 节 HTTPS 时 Cookie 加 Secure;issue #23。
- 实际做法:HTTP 且已 TLS 时返回 `tlsBufferedConn`,`ConnectionState()` 转发内层 `*tls.Conn`;明文仍用 `bufferedConn`,不加该方法。未改 `admin.Deps.SecureCookies`(serve.go 只允许改 SPA 与 listener 装配;`r.TLS` 恢复后 `IsHTTPS` 已足够)。未加 HSTS。
- 原因:peek 把 `*tls.Conn` 包进 `bufferedConn` 后 net/http 不填 `r.TLS`。
- 备选方案:ConnContext 回填(审查 A-01);或只靠 SecureCookies 兜底(本线明确不采用)。
- 影响:直连 TLS 的管理员 Cookie 带 Secure。
### 复审修复 L-05
1. **后台静态页 SPA 回退**
- 原条款:DEVELOPMENT 4.3「未知前端路由回 index.html」;issue #24。
- 实际做法:`httpx.SPA`;`staticFileHandler` 改为调用它。目录与无扩展名路径回 `index.html`(`Cache-Control: no-cache`);有扩展名且不存在返回 404。未加 embeddist 的 Playwright e2e(属网页线;本线用 Go 单测与 serve `/endpoints` 覆盖)。
- 原因:`http.FileServer` 找不到路径即 404。
- 备选方案:在 listener mux 里写回退。
- 影响:刷新 `/endpoints` 等前端路由得到 HTML。
### 复审修复 L-06
1. **健康检查走 HTTPS**
- 原条款:PRD F21/F22;issue #25。
- 实际做法:`cert_file` 非空且 `allow_plaintext=false` 时 `healthcheck` 请求 `https://`,`InsecureSkipVerify`(仅连 127.0.0.1 存活探测)。`deploy/config.docker.yaml` 改回 `allow_plaintext: false`。
- 原因:配证书关明文后明文 `/healthz` 会被 listener 断开。
- 备选方案:健康检查走独立明文端口。
- 影响:推荐 TLS 部署下容器 HEALTHCHECK 可通过。issue 要求改 `healthcheck.go` 与 docker 示例,超出 serve.go 两段限制,按 issue 正文执行。
### 复审修复 L-07
1. **XFF / TLS 配置复用 / metrics 常量时间 / 单次 New**
- 原条款:DEVELOPMENT 4.5、12;issue #26。
- 实际做法:XFF 合并多行,无法解析则停并回退对端;带端口与 IPv6 方括号可解析。`tls.Config` 只建一次,证书检查 2 分钟。`/metrics` 令牌 SHA-256 后 `ConstantTimeCompare`。serve 先 `ParseTrustedProxies` 再只 `listener.New` 一次。WS 调用 `httpx.ClientIP`。未把失败计入 `LockAdminIP`。
- 原因:两份 XFF 实现可伪造;每连接新 Config 无法会话恢复;二次 New 泄漏证书重载 goroutine。
- 备选方案:metrics 失败锁定管理员 IP。
- 影响:锁定按真实客户端 IP;TLS 重连可 DidResume。
### 复审修复 L-03
1. **有 MQTT 连接时停机**
- 原条款:DEVELOPMENT 7.8;issue #22。
- 实际做法:listener 拆成 `StopAccept()`(关 TCP 监听并对 HTTP 调 Shutdown)和带超时的 `Wait(ctx)`(超时强关仍阻塞在 `OnMQTT` 的连接)。`serve` 在 `<-ctx.Done()` 后立刻调用 `stop()`;顺序为 StopAccept → 取消并等待消息循环 → Drain(10 秒)→ `brk.Shutdown`(5 秒)→ 用剩余时间再 Drain → `Wait` → `db.Close()`。compose `stop_grace_period: 30s`;OPS 写明 systemd `TimeoutStopSec` 至少 30 秒。未改 `PublishDown` 签名。
- 原因:原先 `Close` 的 `wg.Wait` 会卡在裸 TCP/WS 的 `AttachTCP`/`AttachWS`,连 Drain 都走不到;`signal.NotifyContext` 的 stop 要等 `cmdServe` 返回才调用,卡住期间第二次 SIGTERM 被吞掉。
- 备选方案:先强关全部 MQTT 再 HTTP Shutdown(会丢在途 HTTP)。
- 影响:保持已登录 TCP/WS 客户端时 `runServe` 应在约 15 秒内返回,客户端收到 DISCONNECT `0x8B`。
## 消息 M
### M1 2026-09-30
1. **提交时分发做成最小正确版**(已被 M2 取代)
- 原条款:DEVELOPMENT 7.3 步骤 8 / 7.4:`send_at` 已到则同一写操作内完整分发。
- 实际做法(M1):单聊/群插 `pending` 后改 `dispatched`,不做停用/配额/宽限。
- 现状(M2):`Submit` 到点与 `DispatchDue` 均走完整 `dispatchFullTx`(7.4)。
- 原因 / 备选 / 影响:见 M2。
2. **请求频率突发容量写死为 100**
- 原条款:DEVELOPMENT 6.10 每端每秒 50、突发 100;配置示例仅有 `requests_per_second`。
- 实际做法:`Limits.RequestBurst` 默认 100;`requests_per_second<=0` 时不限速(便于测试)。`message.App.AllowRequest` 导出同一令牌桶;`HandleUplink` 在分发前对 ack/receipt_ack 以外的帧调用。`Submit` 不再单独扣桶,避免 send 计两次。
- 原因:配置无独立 burst 字段。
- 备选方案:配置增加 `request_burst`。
- 影响:改 `requests_per_second` 不改突发;非 send 请求也受同一桶限制。
3. **未接线 `cmd/nixmsg`**
- 原条款:可替换 T0.4 假实现。
- 实际做法:`message.App` 实现 Service;保留 `Stub`;未改 `cmd/nixmsg`/`wire.go`。
- 原因:本任务隔离;总控接线。
- 备选方案:本任务直接改 `wire.go`。
- 影响:进程内仍用 Stub,需显式 `message.New` 并注入 `Downlink`/`ConnRegistry`。
4. **防重键在、消息行已删时返回 `not_found`**
- 原条款:防重命中返回原消息当前状态;未写明消息行已被清理时的提交重试行为。
- 实际做法:`send_keys` 指纹相同但 `messages` 无行时返回 `not_found`。
- 原因:无法构造 `send_at`/`state`。
- 备选方案:在 `send_keys` 冗余存结果快照。
- 影响:保留天数 0 完成后重试不再幂等成功(与 F18 防重「记录还在时」一致)。
### M2 / M3 / M4 2026-09-30
1. **下行与在线用可注入接口,测试用假实现**
- 原条款:推送经 broker `Downlink`;连接表在 N 线内存。
- 实际做法:`WithDownlink` / `WithConnRegistry`;测试用 `RecordingDownlink`、`MemoryConns`。状态机全在 `message` 包。未接真实 MQTT/mochi。
- 原因:N3 握手与 wire 本波未强制合入;任务允许假下行。
- 备选方案:直接依赖 `internal/broker.Broker`。
- 影响:接线方需在握手/断线时调用 `OnHandshakeComplete`/`OnDisconnect`,登记连接,并把 `OnPublishDropped` 转到 `App`。
2. **大帧并发名额只由 broker 按 PacketID 归还**
- 原条款:大于 64KiB 全局同时不超过 64(DEVELOPMENT 7.5);N2 broker 已有信号量。
- 实际做法(C-01):删除 message 包 `largeSem`/`largeHeld`,发布失败(含 `ErrBackpressure`/`ErrNotSubscribed`/`ErrNoConnection`)清标记并 1 秒后重推;假下行如需限流在其实现里模拟。
- 原因:消息包名额在断线/撤回/作废时泄漏;broker 已按 PacketID 在 PUBACK/断线归还。
- 备选方案:message 内改用 (connID, seq) 计数并对账。
- 影响:单元测试的 `RecordingDownlink` 不再限制大帧并发。
3. **确认超时按库内 `pushed_at` 判定,不另开每连接计时器 goroutine**
- 原条款:推送循环在内存里计时。
- 实际做法:`PushPending` 开头扫描该连接已推且 `now - pushed_at >= ack_timeout` 的投递,再按 keep/expire 规则处理。
- 原因:与崩溃恢复一致、测试可拨钟;避免无调度器时泄漏计时器。
- 备选方案:每连接 `time.AfterFunc`。
- 影响:需周期性调用 `PushPending`(或 `WakePush`)才会触发超时。
4. **调度/到期/清理循环由 App.StartLoops 启动**
- 原条款:调度按 `send_at` 唤醒;清理约每秒;推送每连接一循环。
- 实际做法(C-01):`StartLoops` 起分发(最早 `send_at` 定时 + `NotifyDispatch`)、到期每秒、清理每小时;握手启动每连接 worker(容量 1 唤醒通道)。`cmd/nixmsg` 的 `messageLoops` 只调 `StartLoops` 并 15 秒采指标。停机等待留给 L-03。
- 原因:原先单协程串行、握手前也推送、WakePush 每次新协程。
- 备选方案:继续由 serve 逐秒扫全部连接。
- 影响:未握手连接不再 claim;测试需 `LiveConn.Ready` 或 `OnHandshakeComplete`。
5. **回执按连接记在途,产品仍允许断线后重复**
- 原条款:回执窗口默认 64,确认一笔再推下一笔;可能重复,SDK 按 `receipt_id` 去重。
- 实际做法(C-02):每连接 `rcptInflight`;发布前标记、失败撤销;超过确认超时才允许重发;先收集查询结果再 `PublishDown`。重复 ack 不唤醒;`ReceiptAck` 成功后移出在途并唤醒。
- 原因:原先每次推送全量重发最早 64 条,并发必重复,第 65 条饿死。
- 备选方案:把在途写入 receipts 表。
- 影响:断线重连后未确认回执仍各重发一次(协议允许);裸设备须 `receipt_ack` 或 `receipt:false`。
6. **`Status` 返回自建 map,非独立协议类型**
- 原条款:6.4 状态响应字段。
- 实际做法:`map[string]any`(`state`/`reason`/`counts`/`deliveries`/`next_cursor`)。
- 原因:`protocol` 无 StatusData 结构且不可改共享协议包时取稳妥形状。
- 备选方案:总控在 `protocol` 增类型。
- 影响:接线编码 `resp.data` 时直接 Marshal 该 map 即可。
### 复审修复 C-04
1. **退群/踢人/解散/停用/删除作废投递走统一终态函数**
- 原条款:DEVELOPMENT 7.6 投递进入 rejected 时写回执;没有 pending 时收尾 completed、删正文;保留 0 天同一事务删行。PRD F14/F18。
- 实际做法:message 导出 `RejectPendingTx` / `TryFinalizeTx` / `FinalizeMessageTx`。group `void.go` 与 identity `lifecycle.go` 的作废/收尾改为调用它们,去掉复制 SQL。`sender_disabled`/`sender_deleted` 仍不写回执。`CleanupOnce` 分批收尾「dispatched 且无 pending」的卡住消息。作废路径未接线 `record_retention_days` 时按默认 7 天收尾(不在同一事务删行);保留 0 天由 message 自己的 finalize 覆盖。不改 group `emit`。
- 原因:原先 group 只改投递状态,identity 收尾但不写回执,最后一个 pending 被作废后消息永远停在 dispatched。
- 备选方案:在 group/identity 各自补写回执与收尾(继续分叉)。
- 影响:退群/解散/停用后发送方可收到 rejected 回执,配额释放,正文删除。
### 复审修复 C-05
1. **每端请求限速覆盖非 send 帧**
- 原条款:PRD F05 / DEVELOPMENT 6.10:除 ack、receipt_ack 外共用一个桶,默认每秒 50、突发 100。
- 实际做法:message 导出 `AllowRequest`;`cmd/nixmsg/uplink.go` 的 `HandleUplink` 解码后、分发前检查;超限回 `rate_limited`。去掉 `Submit` 内扣桶。不改 uplink 生命周期与 `publishResp`。
- 原因:原先只有 send 限速,unlock/status/目录/群等可打满哈希池与读库。
- 备选方案:把桶挪到 broker 层(B-09 范围)。
- 影响:开放注册后的非 send 请求也计入配额;直接调 `Submit` 的单测不再覆盖限速。
### 复审修复 C-06
1. **推送 meta 数字用 UseNumber 解码**
- 原条款:PRD F07 / D11 自定义键值送达应与提交一致。
- 实际做法:`decodeMetaJSON` 改用 `protocol.Unmarshal`(`UseNumber`),超过 2^53 的整数以 `json.Number` 保留原文再编码进推送帧。不改 `Msg.Meta` 类型与协议包。
- 原因:标准 `json.Unmarshal` 把数字变成 float64,雪花 ID 会被改掉。
- 备选方案:`Meta` 改为 `json.RawMessage` 原样输出(需改 protocol,牵动 SDK)。
- 影响:仅推送路径;入库仍是提交时的规范 JSON。
### 复审修复 C-07
1. **提交校验、停用检查、入群时间过滤、保留期按完成时刻**
- 原条款:DEVELOPMENT 6.2 ttl/定时上限;PRD F01 停用后不能再发;F06 发送时刻之后入群的端收不到;F18 记录保留从完成起算。
- 实际做法:`keep` 且 `ttl_seconds<=0` 回 `bad_request`;`delay_ms` 先与 `max_schedule_seconds*1000` 比较再加法。`Send.Validate` 同步(`protocol.Limits` 增加可选 MaxTTL/MaxSchedule,0 表示不查上限)。写事务内检查发送方 `enabled`,停用回 `unauthorized`。群分发 `joined_at <= send_at`。未做 C-03 的 `completed_at` 列,清理暂用 `MAX(deliveries.updated_at)` 否则 `send_at` 近似完成时刻。不改对话密码锁键语义。
- 原因:ttl=0/负数、delay 溢出、停用窗口内仍能提交、晚入群仍能收到、按 created_at 清理会误删长定时/长保留消息。
- 备选方案:等 C-03 迁移后改用 `completed_at`;发送方停用改用 `endpoint_disabled`(与目标停用混用)。
- 影响:发送方停用错误码为 `unauthorized`;保留期口径在 C-03 合入前对无投递的 scheduled 作废行用 `send_at` 近似。
### 复审修复 C-01
1. **只对已握手连接推送,每连接一个 worker**
- 原条款:DEVELOPMENT 6.1 / 7.5 握手完成才推送;每连接一循环。
- 实际做法:`LiveConn.Ready` / `handshook`;`PushPending`/`WakePush` 未就绪直接返回。`OnHandshakeComplete` 清本连接旧 `pushed_conn` 后启动 worker。一轮写操作 claim 全部条目再按 `(send_at, seq)` 发布。`RecoverOnStart` 只做 SQL 修正。不改 `PublishDown` 签名,不改 uplink 生命周期,serve 停机段留给 L-03。
- 原因:握手前推送会被 mochi 静默丢弃却占窗口。
- 备选方案:只靠 broker `ErrNotSubscribed` 兜底。
- 影响:分发在线判定仍含握手中(7.4)。
### 复审修复 C-02
1. **回执在途表**
- 见上文 M2/M3/M4 第 5 条。不改「可能重复」的产品约定。
### 复审修复 C-03
1. **到期与小时清理拆分,迁移避开 U-02 的 0003**
- 原条款:DEVELOPMENT 7.5 每秒到期;7.6 每小时分批删并 `wal_checkpoint`。
- 实际做法:`ExpireOnce` 每写最多 500 条,补僵尸不保留投递的宽限;`PurgeOnce` 三类删除独立写操作,结束后 `Queue.Checkpoint`。迁移 `0004_cleanup_indexes.sql`(U-02 已占用 0003,原计划 0003/0005 合并改号为 0004):`messages.completed_at`、回执/防重/完成时刻索引。收尾写入 `completed_at`;清理按 `COALESCE(completed_at, send_at)`。保留 0 天删全部 completed。断线 UPDATE 带 `endpoint_id`。读池 `MaxOpenConns=64`、`MaxIdleConns=16`、DSN `query_only`。
- 原因:每秒全表扫描加级联大删除会卡住写队列;按 `created_at` 会误删长定时刚完成的记录(C-07)。
- 备选方案:继续用 `MAX(deliveries.updated_at)` 近似完成时刻。
- 影响:旧 completed 行由迁移回填;无投递的作废行仍可能用 `send_at` 兜底。
## 身份 I
### I1 2026-09-30
1. **注册做成可挂载 Handler,不改 cmd/listener**
- 原条款:TASKS I1 / DEVELOPMENT 6.9 在 `listen` 上提供 `POST /api/client/register`;依赖 N1 端口识别。
- 实际做法:`identity.NewRegisterHandler` / `identity.NewServer().Handler()` 返回 `http.Handler`,由接线方 `mux.Handle("/api/client/register", h)`;本线不改 `cmd/nixmsg`、`internal/listener`(N 线未合入)。
- 原因:隔离交付,避免抢 N/P 接线。
- 备选方案:本线直接改 `wire.go` 挂路由。
- 影响:合入后需总控或 N/A 接线才对外可访问。
2. **密码哈希与锁定走 auth 接口,本分支用可替换假实现测**
- 原条款:依赖 P3 argon2 池与锁定计数器。
- 实际做法:`RegisterConfig.Hash`/`Locks` 注入 `auth.HashPool`、`auth.LoginLocks`;测试用 `auth.NewStubHashPool` + 仅实现 `LockRegisterIP`(5 分钟 10 次)的测试锁定器,不在本线重写 argon2。
- 原因:P3 尚未在本分支。
- 备选方案:等 P3 合入后再写 I1。
- 影响:生产须注入 P3 实现;StubLoginLocks 永不锁定,不能直接用于开放注册。
3. **settings 开关取值**
- 原条款:`settings.registration_enabled`,未规定字符串字面量。
- 实际做法:`1`/`true`/`yes`/`on`(大小写不敏感)视为开启,其余(含缺省)关闭;安全码键 `registration_code`。
- 原因:与 store 测试写入的 `"0"`/`"1"` 对齐并兼容常见布尔字面量。
- 备选方案:仅认 `"1"`。
- 影响:A 线写注册设置时宜写 `"1"`/`"0"`。
4. **客户端 IP**
- 原条款:DEVELOPMENT 4.5 受信任代理下用 `X-Forwarded-For`。
- 实际做法:Handler 默认取 `RemoteAddr` 的 host;可通过 `RegisterConfig.ClientIP` 注入。本线不做 `trusted_proxies` 解析(属 listener/接线)。
- 原因:不改 listener;代理 IP 应由外层在挂载前算好或注入。
- 备选方案:在 identity 内复制 4.5 逻辑。
- 影响:经代理部署时接线方必须注入真实 IP,否则锁定按直连 IP 计。
5. **生成登录密码长度**
- 原条款:F01 留空则生成,8–128 字符,不以 `nst_` 开头;未规定生成长度。
- 实际做法:生成 20 位字母数字;若偶然以 `nst_` 开头则重抽。
- 原因:与管理员 init 量级接近,满足规则。
- 备选方案:16/32 位。
- 影响:无产品行为差异。
### I2 / I3 / I4 2026-09-30
1. **服务方法可直接调用,未接 MQTT 分发**
- 原条款:端协议帧经 broker 上行分发到 app。
- 实际做法:`identity.App` / `presence.App` / `group.App` 实现 Service 方法;单测直接调用,不经 mochi。`cmd/nixmsg`/`wire.go` 未改。
- 原因:任务要求可被协议分发调用的服务方法 + 直接调用验证;N3 连接事件与总控接线另波。
- 备选方案:本分支顺带改 wire(与隔离冲突)。
- 影响:合入后需总控/N 接线 HandleUplink → 各 Service;MQTT 帧路径未测。
2. **在线状态:库字段 + 可注入连接表 + 本包握手表**
- 原条款:在线以真实连接为准;依赖 N3 连接事件。
- 实际做法:`presence.ConnTable` 可注入;另用 `SetOnline`/`SetOffline` 维护内存表并写 `endpoints.online_since`/`offline_since`;查询优先 ConnTable,其次内存表,再回退库字段(`online_since` 晚于 `offline_since` 或后者为空)。
- 原因:N3 可能尚未合入,不阻塞 I3。
- 备选方案:阻塞等 N3。
- 影响:未接线时须调用 SetOnline/SetOffline 或写库字段;拔网线心跳超时属 N 线,本波单测不覆盖。
3. **presence / group_event 经 Downlink QoS 0,可 nil**
- 原条款:上下线与群事件尽力推送、不落库。
- 实际做法:注入 `port.Downlink` 时编码帧并 `PublishDown`(presence/group_event QoS 0;退群已推送投递的 `revoked` 用 QoS 1);Downlink 为 nil 时跳过推送,业务库操作仍完成。
- 原因:无 MQTT 时仍可测库逻辑。
- 备选方案:强制假 Downlink。
- 影响:接线后必须注入真实 Downlink 才有通知。
4. **self.logout 踢线可选**
- 原条款:回 resp 后断开连接。
- 实际做法:清 `session_hash`;若注入 `port.ConnControl` 则 `Disconnect`,否则仅清令牌。
- 原因:未接 broker。
- 备选方案:无。
- 影响:接线方应注入 ConnControl。
5. **session_hash 存 SHA-256 十六进制**
- 原条款:库中存会话令牌 SHA-256。
- 实际做法:`hex.EncodeToString(hash)` 写入 TEXT 列。
- 原因:文档未规定编码;十六进制便于调试与比对。
- 备选方案:BLOB/Base64。
- 影响:N3 校验须用同一编码。
6. **self.update 空 name 不写库**
- 原条款:可更新 name。
- 实际做法:`name` 非空才 UPDATE name;仅改 `default_delay_ms` 时不碰 name(JSON omitempty 无法区分省略与空串)。
- 原因:避免误清空名称。
- 备选方案:用指针字段区分。
- 影响:端无法通过协议把名称改成空字符串(可用空格等)。
7. **进群密码校验不产生单聊授权**
- 原条款:拉人须当次带密码;已有授权不能代替。
- 实际做法:`CheckTalkPasswordForJoin` 只校验,不写 `talk_grants`。
- 原因:与 F15「进群仍要密码」一致,避免进群副作用放宽单聊。
- 备选方案:校验成功顺带写 password 授权。
- 影响:仅进群成功后,单聊仍须 unlock/发送带密。
8. **群作废在 group 包内写 deliveries/messages**
- 原条款:退群/踢人/解散的投递作废属 7.6,消息线亦相关。
- 实际做法:I4 在 `group` 写操作里直接改 `pending→rejected`、`scheduled→completed/group_dissolved`,删正文行,需要时插消息级回执,已推送则经 Downlink 发 `revoked`。
- 原因:I4 验收依赖作废规则;M 线完整推送循环可能未合入。
- 备选方案:只调 message 钩子(接口尚未暴露)。
- 影响:与后续 M 作废路径需保持同语义,避免重复作废。
9. **UnlockTalk / SelfChangeLoginPassword / CheckTalkPasswordForJoin 增加 remoteIP**
- 原条款:锁定按发送方+对方 / 编号+IP。
- 实际做法:Service 方法增加 `remoteIP` 参数供锁定计数;T0.4 Stub 同步改签名。
- 原因:无 ConnInfo 的直接调用测试需要显式 IP。
- 备选方案:塞进 context。
- 影响:协议分发接线时从 `ConnInfo.RemoteIP` 传入。
10. **F06 群发断言与 M2 分发语义对齐(2026-09-30)**
- 原条款:F06 验收「发到当时成员」;早期单测在仅写 `pending` 的桩分发下断言 Submit 返回 `dispatched`。
- 实际做法:成员离线且默认不保留时,投递立即 `dropped`,消息为 `completed`(DEVELOPMENT 7.4/7.6);`TestF06GroupSendMembership` 改断言 `completed`;另加 `TestF06GroupSendKeepOfflinePending`:选离线保留时期望 `dispatched` 且接收者有 `pending`。不改消息分发实现。
- 原因:合入含 M2 完整分发的 main 后,旧断言与产品规则冲突;永远 `dispatched` 才是错的。
- 备选方案:测试里注入在线连接表使默认不保留也走 pending(与「离线不保留」场景重复覆盖)。
- 影响:仅测试期望;产品行为不变。
### I5 2026-09-30
1. **停用/删除级联在 identity 包内完成,admin 可选注入**
- 原条款:DEVELOPMENT 7.6 / PRD F01;TASKS I5「提供给 A 线调用」。
- 实际做法:`identity.App.Disable`/`Enable`/`Delete` 在一个写操作里完成启停、清令牌、作废消息/投递、发 `revoked`(可选 Downlink)、退群/转让群主/解散、清授权/回执/防重/发出记录。`admin.Deps.Identity` 非空时,`setEndpointEnabled`/`deleteEndpointBasic`(及 PATCH enabled)委托上述方法;为空时保留 A2 仅改库行为。未改 `cmd/nixmsg`、未改消息上行分发。
- 原因:隔离要求不改接线;A 线已有路由,只接级联。
- 备选方案:在 admin 内复制级联 SQL;或强制 Identity 必填。
- 影响:生产须在挂载 admin 时注入 `identity.App`(及 KickEndpoint/ConnControl/Downlink),否则停用/删除仍无完整作废。
2. **消息级作废回执 state 用 `rejected`**
- 原条款:DEVELOPMENT 6.4 消息级作废写 `endpoint_id` 空、`state=rejected`;I4 解散群对 scheduled 曾写 `completed`。
- 实际做法:I5 对「发给停用/删除端的 scheduled 单聊」及删除时解散群的 scheduled,回执 `state=rejected`,原因分别为 `endpoint_*` / `group_dissolved`。
- 原因:与 6.4 字面一致。
- 备选方案:与 I4 一样写 `completed`。
- 影响:后台/SDK 若按 state 过滤回执需同时认 rejected。
3. **删除时群主转让按 `joined_at` 最早,并列按编号**
- 原条款:转给最早加入的其他成员。
- 实际做法:`ORDER BY joined_at ASC, endpoint_id ASC LIMIT 1`。
- 原因:同时加入时需稳定次序。
- 备选方案:仅按 joined_at。
- 影响:同毫秒加入时编号小者优先。
### 复审修复 U-02
1. **群写操作在同一写事务内复核**
- 原条款:PRD F16 群主同时是成员、停用端不能加入、成员上限、新群收不到旧群消息;issue #40。
- 实际做法:加人/踢人/退群/转让/改名/解散在 `Queue.Do` 内重读群主、成员关系和成员数;加人再复核目标端 `enabled`。对话密码(argon2)仍在事务外,事务里只做廉价 SQL。`INSERT OR IGNORE` 改为先复核再 `INSERT`;外键失败按 `not_found`。不改 `emit`,不改 message `RejectPendingTx`。
- 原因:读后写会在解散后留下孤儿成员、并发加人超过上限、转让后群主不在成员里。
- 备选方案:只靠外键、事务外校验(否决,无法给出原错误码)。
- 影响:加人与解散并发时整次加人返回 `not_found`,不写孤儿行。
2. **建群/加人先去重再截断,单请求成员数设上限**
- 原条款:部分失败仍建群;成员上限。
- 实际做法:先去掉自己和重复编号,再按剩余名额截断,超出记 `group_full`,然后才做密码校验。整表请求成员数超过 `2*max_group_members`(至少 256)回 `bad_request`。原先「校验通过人数加群主超上限则整次建群失败」改为截断后仍建群。
- 原因:重复编号会校验两次并在插入时主键冲突,客户端按 `busy` 一直重试;一个请求可带上万个成员打满哈希池。
- 备选方案:协议层去重(禁止改 protocol)。
- 影响:带重复成员的建群会成功且只留一条;超上限的多余成员在 `failed` 里而不是整次失败。
3. **后台建群校验群主并补推 `member_added`**
- 原条款:群主必须是已启用的端。
- 实际做法:`createAdmin` 校验群主编号格式、存在且 `enabled`;成员去重;建成后按与客户端建群相同方式 `emit` `member_added`。群主不存在 `invalid_target`,已停用 `endpoint_disabled`,格式非法 `bad_request`。
- 原因:原先可不存在/已停用的编号当群主,成员也不去重,也不推事件。
- 备选方案:由 admin HTTP 层预校验(仍会与写路径竞态)。
- 影响:后台建群失败码与加人目标错误码对齐。
4. **可选迁移 `0003_group_members_fk.sql`**
- 原条款:TASKS 4.2 改表加新文件,rebase 时取当时最大号加一;issue 写「排在 C-03 的 0003 之后」。
- 实际做法:本分支基于 C-04,当时最大号 0002,按 TASKS 4.2 用 0003:重建 `group_members` 并 `REFERENCES groups(id) ON DELETE CASCADE`。C-03 尚未合入。
- 原因:无外键时同编号新建群会继承旧孤儿成员。
- 备选方案:等 C-03 占用 0003 后再用 0004(rebase 时改号)。
- 影响:若 C-03 先合入并占用 0003,本文件 rebase 时改号。
## 后台接口 A
### A1 2026-09-30
1. **A1 仅交付可挂载 Handler,未改 `cmd/nixmsg`**
- 原条款:管理接口由服务进程提供;TASKS 要求路由可挂载。
- 实际做法:`admin.New(Deps) *Handler` 实现 `http.Handler`,路径为完整 `/api/admin/...`;总控/后续接线在 mux 上 `Handle("/api/admin/", h)` 即可。本任务按隔离要求不改 `cmd/`、`listener`、`protocol`。
- 原因:与并行线隔离;A1 验收用 httptest。
- 备选方案:本分支同时改 `serve.go` 挂载(易与 N/P 冲突)。
- 影响:合入后需在 `serve`/`wire` 挂载并注入真实 DB/Hash/Locks。
2. **P3 未合入时的假哈希与本地锁定/令牌生成**
- 原条款:密码与令牌哈希用 `internal/auth`;锁定与 argon2 池由 P3 实现。
- 实际做法:依赖 `auth.HashPool` / `auth.APITokens` / `auth.LoginLocks` 接口;测试注入 `auth.StubHashPool`。提供 `admin.MemoryLoginLocks`(5 分钟 10 次锁 5 分钟)与 `admin.RandomAPITokens`(`nxm_`+32 字节 base64url,SHA-256)供本线与测试使用,不实现 argon2。
- 原因:第 1 波允许用假实现;P3 合入后替换注入即可。
- 备选方案:阻塞等待 P3。
- 影响:生产接线应改用 P3 实现;`MemoryLoginLocks`/`RandomAPITokens` 可保留作测试替身。
3. **API 令牌 `id` 为字符串**
- 原条款:`docs/api/admin-api.md` 示例 `"id": 1`(数字)。
- 实际做法:遵循库表 `api_tokens.id TEXT`,响应 `id` 为 16 字节随机十六进制字符串。
- 原因:不改已发布迁移;与 schema 一致。
- 备选方案:另加 INTEGER 列或把数字存成文本并在 JSON 里发数字。
- 影响:W 线类型应按 `string` 解析令牌 id。
4. **A1 范围外路由当时返回 501(A2 已实现端管理)**
- 原条款:DEVELOPMENT 第 8 节完整路由表。
- 实际做法(A1 当时):已实现 `login`/`logout`/`me`/`password` 与 `/api/admin/tokens` 全套;其余经鉴权后 `501`。
- A2 起:端相关路由已改为真实现,见下节;仍为 501 的有 `overview`、`registration`、`groups`、`messages`、`settings`(属 A3)。
- 原因:A1 范围仅鉴权、令牌、操作日志。
- 备选方案:无。
- 影响:W/集成测试在 A3 前勿依赖尚未实现的业务响应。
5. **操作日志用 `slog` 结构化字段**
- 原条款:写结构化日志(操作者、动作、对象、结果、来源 IP)。
- 实际做法:`Logger.Info("admin_audit", "actor", ..., "action", ..., "object", ..., "result", ..., "ip", ...)`;改状态请求写日志;不写密码/令牌/正文。
- 原因:文档未规定日志后端。
- 备选方案:独立 audit 表。
- 影响:日志采集需按 msg=`admin_audit` 过滤。
### A2 2026-09-30
1. **停用/删除完整级联留给 I5**
- 原条款:PRD F01 / DEVELOPMENT 7.6:停用作废未送达消息与发送中消息;删除另含退群、群主转让/解散、清授权与回执/发出记录等。
- 实际做法:A2 直接写库:停用立刻 `enabled=0` 并清空 `session_hash`;删除删 `endpoints` 行并清相关 `talk_grants`;二者均调用可注入的 `Deps.KickEndpoint` 踢连接。不作废投递/消息、不转让群主、不清理群成员与回执。
- 原因:本分支尚无 I5;TASKS 允许先接现有存储并在偏差中写明。
- 备选方案:阻塞等待 I5;或在 A 线内复制级联 SQL(易与 I5 重复冲突)。
- 影响:合入 I5 后应由身份服务 `Disable`/`Delete` 接管级联;总控接线把 kick 钩子接到 broker。`KickEndpoint` 为 nil 时踢线为 no-op(单元测试可注入)。
2. **在线状态读库字段,不依赖 presence 服务**
- 原条款:列表含是否在线、最近上下线;可按在线筛选。
- 实际做法:用 `endpoints.online_since` / `offline_since` 判定在线(`online_since` 非空且大于 `offline_since` 或后者为空);列表项带 `online` / `online_since_ms` / `offline_since_ms`。
- 原因:presence/N3 未在本 Handler 注入;库字段是契约字段。
- 备选方案:注入 `presence.Service.IsOnline`。
- 影响:上下线列未由 N/I 写入前,列表会显示离线。
3. **`login_locked` 仅反映按编号的登录锁定**
- 原条款:列表含 `login_locked`。
- 实际做法:查 `LockLoginEndpoint`;不枚举「编号+IP」锁定。`unlock` 仍调用 `ClearEndpoint` 清两种。
- 原因:`LoginLocks` 接口无「是否任一 IP 锁定」查询。
- 备选方案:扩展 locks 接口。
- 影响:仅 IP 档锁定时列表可能仍显示未锁定,但 unlock 可解除。
4. **A2 时未挂载到 `cmd/nixmsg`;L-WIRE / A3 已接线**
- A2 当时:可挂载 Handler;接线与 `KickEndpoint` 注入留给总控。
- 现状:`serve` 已挂载 Admin Handler;A3 起注入 `Groups`/`Config`/`Version`/`KickEndpoint`。
- 影响:无。
### A3 2026-09-30
1. **概览增加 `endpoints_self`(自助注册数)**
- 原条款:PRD F17「端数量(其中自助注册的数量)」;`admin-api.md` 示例未列该字段。
- 实际做法:`GET /api/admin/overview` 同时返回契约字段与 `endpoints_self`(`source='self'` 计数)。
- 原因:以 PRD / 本任务说明为准补齐。
- 备选方案:只返回 api 文档字段,自助数由前端筛端列表。
- 影响:W 线可选用该字段;旧 mock 类型可增补。
2. **群列表/详情直接读库;变更走 `group.Service`**
- 原条款:群操作调用 `internal/app/group` 已有方法。
- 实际做法:创建/加人用 `AdminCreate`/`AdminAddMembers`;改名/解散/移除/转让先查群主再以群主为 actor 调 `Rename`/`Dissolve`/`Remove`/`Transfer`。列表与详情(含 `joined_at_ms`)因 `List`/`Get` 按成员可见且 Get 无 joined_at,改为管理侧 SQL。
- 原因:端协议 API 按成员视角,后台需全局列表。
- 备选方案:在 group 包增加 AdminList/AdminGet。
- 影响:列表不依赖 Groups 注入;写操作未注入 Groups 时返回 `503 busy`。
3. **后台建群不接受自定义 `id`**
- 原条款:admin-api「id 留空则生成」。
- 实际做法:`AdminCreate` 无自定义 id 参数;请求带非空 id 返回 `400`。
- 原因:不改身份线接口签名。
- 备选方案:扩展 `AdminCreate` 接受可选 id。
- 影响:后台只能服务器生成群编号。
4. **`/metrics` 门禁抽到 `internal/httpx.MetricsGate`**
- 原条款:A 线负责 metrics 访问规则(DEVELOPMENT 4.3)。
- 实际做法:规则实现放 `httpx.MetricsGate`;`listener.NewMux` RoleShared 调用之(N 目录一行替换)。单独后台监听仍直接挂 Handler 不鉴权。
- 原因:A 负责目录是 `admin`+`httpx`;listener 仅接线。
- 备选方案:门禁留在 listener。
- 影响:`serve` 已传 `MetricsToken`;共用端口无令牌 404、错令牌 401。
5. **注册安全码写入审计不含明文**
- 原条款:安全码明文返回已登录管理员,不写日志。
- 实际做法:GET/PUT 响应含 `code`;`admin_audit` 的 object 为空,不记安全码。存库键 `registration_enabled`=`1`/`0`,与 I1 一致。
- 原因:D14 / DEVELOPMENT 12 节。
- 备选:无。
- 影响:无。
6. **消息查询不碰 `message_bodies`**
- 原条款:只读 messages 与 deliveries;响应无正文。
- 实际做法:SELECT 不含 `body`/`body_enc` 以外的正文列(messages 仍有 `body_enc` 列但查询不选它);不 JOIN `message_bodies`。
- 原因:任务硬性要求。
- 备选:无。
- 影响:无。
### 复审修复 H-01
- 日期:2026-09-30
- 原条款:PRD §8 管理员登录防暴力;审查 #44。
- 实际做法:`httpx.DecodeJSON` 内部 `MaxBytesReader` 1 MiB;`admin.Handler.ServeHTTP` 按路由限制(登录 8 KiB、导入 8 MiB、其余 1 MiB)并设读截止时间;超限 413 JSON `payload_too_large`。CSV 导入不再在 8 MiB 处静默截断。不在 listener 加全局 `ReadTimeout`。
- 原因:公开登录接口与 JSON 解码原先不限大小。
- 备选方案:登录 4 KiB(与注册一致);未采用,8 KiB 对口令字段更宽裕。
- 影响:超大请求快速失败;合法批量 JSON(约 200 KiB)仍低于 1 MiB。
## 后台网页 W
1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`**
- 原条款:TASKS W1–W3「先按契约用假数据」;admin-api.md 第 11 节。
- 实际做法:`web/src/api/admin.ts` 统一导出接口函数,内部调用 `mock.ts`;`http.ts` 已实现带 `X-Nixmsg-Request: 1` 的真实请求封装,供 W4 切换。
- 原因:A 线管理接口尚未合入,页面与契约可并行开发。
- 备选:用 MSW 拦截 fetch;当前集中换 `admin.ts` 更简单。
- 假登录口令:`admin` / `adminpassword`(仅本地 mock,不进后端)。
- **W4 起已切换**:生产与 e2e 走真实 `/api/admin`;组件 Vitest 通过 `vi.mock("@/api/admin")` → `admin-mock.ts` 仍用假数据。登录页假数据提示已去掉。
2. **列表分页用页码映射 cursor 偏移**
- 原条款:admin-api 使用 `cursor`/`limit` 游标分页。
- 实际做法:假数据把 `page` 编成数字偏移 cursor(`String((page-1)*limit)`),`n-data-table` remote 分页照常。
- 原因:Naive UI 表格以页码交互;契约游标对前端透明即可。
- 备选:W4 若后端 cursor 非偏移编码,在 `admin.ts` 内适配,页面仍用页码。
- **W4**:后端 cursor 为不透明字符串;首页(空 cursor)主路径 e2e 通过。翻到第 2 页若用数字偏移可能无效,未改页面交互;后续若要稳定翻页,应在 `admin.ts` 缓存服务端 `next_cursor`。
3. **帮助图标不用独立图标库**
- 原条款:DEVELOPMENT 2.4 用 `n-tooltip`;协作规则要求只用 Naive UI。
- 实际做法:`HelpTip` 用带边框的 `?` 文字触发 tooltip,不引入 `@vicons/*`。
- 原因:避免第二套依赖;视觉已够用。
- 备选:若设计要求统一图标,可只加 `@vicons/ionicons5` 作图标资源(仍非第二套组件库)。
4. **操作日志页本期未做**
- 原条款:PRD F17 含管理操作日志;admin-api 写明改状态请求记日志,但未单列日志查询路由。
- 实际做法:W3 菜单不含操作日志页。
- 原因:契约无列表接口,无法按契约做假数据页。
- 备选:A 线补查询接口后 W4/后续补页。
5. **端「踢下线」放在行内操作,未单独成页**
- 原条款:PRD F17 含踢下线。
- 实际做法:端列表行操作调用 `POST .../kick`。
- 原因:与重置密码、解锁同级的行内动作更贴桌面工作流。
- 备选:无。
### W4 2026-09-30
1. **接真实接口并适配 A 线字段**
- 原条款:TASKS W4;admin-api 令牌 `id` 示例为数字。
- 实际做法:`admin.ts` 全部改为 `requestAdmin`;令牌 `id` 类型改为 `string`(对齐 A1 TEXT);`talk-password` 用 `PUT`;概览可选 `endpoints_self`;建群省略空 `id`(A3 不接受自定义 id)。不改服务器。
- 原因:与已合并 A 线实现一致。
- 备选:无。
- 影响:组件测用 `admin-mock.ts`;页面逻辑不变。
2. **未完成投递由 e2e 用 MQTT seed 造出**
- 原条款:DEVELOPMENT 第 13 节后台主路径「看到未完成记录」;管理 messages 只读。
- 实际做法:Playwright 流程里用管理接口开通第二端后,运行 `web/e2e/seedpending`(MQTT hello + 未来 `send_at_ms` 的 send),再打开投递记录页断言 `scheduled`。测完杀进程并删临时目录。
- 原因:管理 API 不能写消息;不改服务器业务代码。
- 备选:插入 SQLite(并发/锁风险);或依赖已有记录(空库无)。
- 影响:e2e 依赖本机 `go` 与 Chromium。
3. **Playwright 挂入 `task itest` / `task w:e2e`**
- 原条款:TASKS W4「端到端测试在 task itest 里通过」;Taskfile 由总控维护、各线写 `taskfiles/<线>.yml`。
- 实际做法:新增 `taskfiles/w.yml`;主 `Taskfile.yml` 显式 `includes.w`(本机 Task 3.53 下 `taskfiles/*.yml` glob 未挂上各线任务)并把 `itest` 在 `go test ./test/...` 后追加 `task: w:e2e`。Playwright 的 `webServer` 先于 `globalSetup` 启动,故用 `e2e/start-stack.mjs` 同时起临时 nixmsg 与 Vite;代理目标 `NIXMSG_PROXY_TARGET`(开发默认仍 7443)。浏览器用 `pnpm exec playwright install chromium`,不改全局配置。
- 原因:满足 W4 验收且隔离安装。
- 备选:仅文档要求手跑 e2e;未采用。
- 影响:`task itest` 变长;需本机 Go/Node/Chromium。
### S1.1 传输层可注入假实现(Go / JS)
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
- 实际做法:Go 与 JS SDK 均以 `transport` 接口隔离 MQTT;单测注入 `FakeTransport`,自动回复 `hello`、可注入下行帧与 `resp`。真实路径仍分别走 autopaho / MQTT.js。
- 原因:否则无法在无服务端时稳定覆盖 Clean Start、去重再 ack、本地超限、令牌回调、重交不变号。
- 备选:起嵌入式 mochi;被否是因为任务明确禁止真实服务器,且会与并行 Agent 抢端口。
### S1.2 Go autopaho `OnConnectionUp` 内握手改异步
- 相关文档:autopaho 要求 `OnConnectionUp` 不得阻塞;DEVELOPMENT 第 9 节要求订阅 down 后发 `hello` 并等待成功。
- 实际做法:订阅与 `hello` 放在 `OnConnectionUp` 触发的 goroutine 中;`Connect` 轮询 `handshook` 直至超时(默认 30s)。
- 原因:在回调里同步 `hello` 会违反库约束并可能死锁。
- 备选:自定义连接循环不用 autopaho 的 `OnConnectionUp`;未采用,因文档指定 autopaho。
### S1.3 重连退避与「稳定在线 60s」状态机自管
- 相关文档:DEVELOPMENT 第 9 节退避规则。
- 实际做法:自实现 `reconnectBackoff`(1s 起、加倍、上限 30s、±30% 抖动;在线满 60s 将 base 恢复为 1s)。Go 将其接到 autopaho 的 `ReconnectBackoff`;JS 因 `reconnectPeriod: 0` 自管重连循环并使用同一算法。
- 原因:autopaho 自带指数退避参数模型与文档不完全一致,且每次 `establishServerConnection` 会重置 attempt,无法单独表达「未稳定在线则跨周期继续抬升」。
- 备选:直接用 `autopaho.NewExponentialBackoff`;未采用,以免与文档抖动与 60s 恢复语义偏离。
### S1.4 Go 模块许可证标注
- 相关文档:DEVELOPMENT 第 9 节「Go 写 SEE LICENSE,包里带上仓库根目录 LICENSE」。
- 实际做法:`sdk/go/LICENSE` 为仓库根 `LICENSE` 副本;`doc.go` 注明专有许可见 LICENSE(Go modules 无 npm 式 license 字段)。
- 原因:go.mod 无标准 license 键。
- 备选:另加 `LICENSE.md` 指向根目录相对路径;副本更利于 `go get` 后独立阅读。
### S1.5 JS 回调命名与文档概念名
- 相关文档:第 9 节概念方法名 `onSession` / `onMessage` 等。
- 实际做法:TypeScript 对外提供 `onSessionHandler`、`onMessageHandler` 等,避免与 EventEmitter 风格或属性赋值混淆;语义与文档一致。
- 原因:`onSession` 作方法名在部分风格指南中易被误认为事件订阅属性。
- 备选:完全同名方法;可在任务 5 文档化时再加别名。
### S1.6 任务 1–3 当时未做范围(已被 S1.7 取代)
- 当时:任务 4 真实服务器接入清单、任务 5 README/示例/打包试跑未做。
- 现况:见 S1.7。
### S1.7 2026-09-30 任务 4–5 接入清单与打包文档
1. **集成测试自建启动器(不 import 根模块 harness)**
- 原条款:TASKS 用 T0.5 harness 起真实服务端。
- 实际做法:`sdk/go` / `sdk/js` 各自在测试里向上查找含 `cmd/nixmsg` 的仓库根,编译二进制,`admin init` + `listen: 127.0.0.1:0` + 临时 `data_dir` 后 `serve`;注册开关与安全码走管理 `PUT /api/admin/registration`。
- 原因:`sdk/go` 是独立 Go 模块,从该目录跑测时 `harness.Binary` 会先命中 `sdk/go/go.mod` 找不到 `./cmd/nixmsg`;JS 也无法直接 import Go 包。
- 备选:给 harness 加 `NIXMSG_ROOT` 环境变量;未改共享目录以免越界。
- 影响:行为与 harness 一致,仅实现重复约百行。
2. **清单第 4 条「ack 丢失后自动再确认」**
- 原条款:模拟 ack 丢失后服务器重推,SDK 自动再确认且不重复回调。
- 实际做法:真实服务用 `ManualAck` + `keep` 消息:收一次不 ack → 管理踢线重连 → 断言回调仍为 1(去重),再手动 `Ack`;「已确认后再推则自动再 ack」仍由 FakeTransport 单测覆盖。
- 原因:自动模式下难以在不改服务器的前提下可靠丢掉已发出的 ack;确认超时默认约 5 分钟,不适合常规集成测。
- 备选:缩短测试用 ack_timeout(需改服务配置/代码,越界)。
- 影响:真实环境覆盖「不重复回调 + 终态确认」;自动再 ack 路径依赖既有单测。
3. **断线期间发送**
- 实际做法:管理 `POST .../kick`(AdministrativeAction,非 `0x8E`)断开连接,SDK 按网络故障重连;在重连窗口调用 `send` 入队,恢复后送达且回调一次。
- 原因:文档写明管理员踢下线只断线、令牌仍可用、SDK 应重连。
- 备选:本地 TCP 代理掐线;未采用以减少测试基础设施。
4. **JS 第 14 条跨源**
- 实际做法:另起本地 HTTP 端口作为「页面」Origin,对注册接口发带 `Origin` 的 OPTIONS/POST,断言 `Access-Control-Allow-Origin: *`;再用 SDK 从「页面」视角连服务器另一端口的 `/mqtt`。未起真实浏览器。
- 原因:Vitest/Node 无完整浏览器;服务端 CORS 与 WS Origin 策略已由身份/连接线保证。
- 备选:Playwright 实浏览器;本期为控制依赖未引入。
5. **任务 5 打包与文档**
- Go:`sdk/go/README.md` + `example/minimal`;不打 `sdk/go/v*` git tag(发布在 Z3)。
- JS:`README.md` + `example/minimal.mjs`;`license` 已为 `SEE LICENSE IN LICENSE`;`npm pack --dry-run` 试跑,**不** `npm publish`。
- 影响:无。
6. **真实 MQTT 收包路径与 request 死锁**
- 原条款:DEVELOPMENT 第 9 节收发/确认;回调串行。
- 实际做法:`resp` 在 MQTT `OnPublishReceived` 路径同步解挂起;`msg`/`receipt`/事件进入 `downCh` 由 `downLoop` 串行处理(可在其中 `request` 发 ack)。
- 原因:若在收包回调里同步 `request` 等 `resp`,而 `resp` 也走同一回调,真实 autopaho 会卡死;假传输因同栈注入 `resp` 掩盖了问题。
- 备选:ack 发后不等 `resp`;未采用,以免丢「ack 结果当 revoked」语义。
- 影响:行为更接近文档;单测仍绿。
## SDK 二 S2
### S2-PY/JAVA 1–3 2026-09-30
1. **HiveMQ「websocket 模块」用 netty-codec-http 显式依赖**
- 原条款:DEVELOPMENT 2.3「HiveMQ MQTT Client,加上 websocket 模块」。
- 实际做法:依赖 `com.hivemq:hivemq-mqtt-client:1.3.5`,并额外声明 `io.netty:netty-codec-http`;连接时用 `webSocketConfig().subprotocol("mqtt")`。未使用独立 artifact `hivemq-mqtt-client-websocket`(Maven Central 上该坐标未作为独立稳定模块发布)。
- 原因:与 HiveMQ 官方 WebSocket 用法一致,满足子协议 `mqtt`。
- 备选方案:若日后官方拆出独立 websocket 模块再改坐标。
- 影响:无行为差异。
2. **JSON 库选型**
- 原条款:未指定 Java JSON 库。
- 实际做法:Java 用 Gson(`disableHtmlEscaping`);Python 用标准库 `json`(`ensure_ascii=False`)。
- 原因:满足「不转义 HTML / 非 ASCII」;不引入过重依赖。
- 备选方案:Jackson。
- 影响:无。
3. **假传输单测,未接真实服务器**
- 原条款:任务 1–3 单元测试用假传输;任务 4 才做接入清单。
- 实际做法:Python `FakeTransport`、Java `FakeTransport` 覆盖 Clean Start、去重再 ack、本地超限、令牌回调、重交不改 `send_at_ms` / 消息号;未做对真实服务器的接入清单(任务 4)。
- 原因:本波范围。
- 备选方案:无。
- 影响:真实联调留待 S2 任务 4。
4. **Python 发布元数据**
- 原条款:`license = { file = "LICENSE" }` 与专有分类。
- 实际做法:`pyproject.toml` 已按此写;包内复制仓库根 `LICENSE`。未配置/执行 PyPI 发布。
- 原因:发布在阶段 3。
- 备选方案:无。
- 影响:无。
5. **Java 编译器用 JDK 21,目标字节码 8**
- 原条款:字节码目标 Java 8。
- 实际做法:`maven.compiler.release=8`,本机用 Temurin 21 编译。
- 原因:环境已有 JDK 21。
- 备选方案:用 JDK 8 工具链。
- 影响:无。
6. **Paho / HiveMQ 库内自动重连关闭,退避自管**
- 原条款:四种 SDK 同一套重连:1s 起加倍上限 30s ±30% 抖动,稳定 60s 恢复;每次 Clean Start、会话过期 0。
- 实际做法:两端均由 SDK 连接循环实现退避与停止条件;HiveMQ 不启库内 automaticReconnect;Paho 每次 `connect(..., clean_start=True)` 并设 `SessionExpiryInterval=0`。
- 原因:与 Go/JS 要求一致,避免两套重连。
- 备选方案:依赖库自带重连再改 Clean Start(易漏)。
- 影响:无。
### S2-PY/JAVA 4–5 2026-09-30
1. **接入清单对真实 nixmsg,跳过仅 JS 跨域**
- 原条款:DEVELOPMENT 第 9 节 15 条;任务 4 用 T0.5 启动器起真实服务端。
- 实际做法:Python `tests/harness.py` + `test_checklist.py`、Java `TestHarness` + `ChecklistTest` 自行 `go build`/`admin init`/`serve`(临时目录、`127.0.0.1:0`),管理登录后 `PUT /api/admin/registration` 开注册。覆盖清单 1–13、15;第 14 条(仅 JS 跨域)不做。
- 原因:总控指示跳过 JS 专属跨域;不改服务器业务代码。
- 备选方案:复用 Go `test/harness` 包(SDK 测试不便依赖)。
- 影响:无。
2. **清单第 4 条「ack 丢失后服务器重推」未在真机选择性复现**
- 原条款:模拟 ack 丢失后服务器重推,SDK 自动再确认且不重复回调。
- 实际做法:集成测验证同消息号防重与断线入队重交送达一次;「选择性丢弃 SDK 发出的 ack 帧」在真实 broker 上做不到,去重再 ack 仍由假传输单测覆盖。
- 原因:不改服务器、无中间代理注入丢包。
- 备选方案:toxiproxy 按包过滤(超出本任务、且难按 MQTT 应用帧过滤)。
- 影响:清单 4 真机为部分通过;假传输路径完整。
3. **下行 `resp` 与业务帧分流,避免 auto_ack 自死锁**
- 原条款:自动模式回调后发 ack;回调串行。
- 实际做法:MQTT/`publishes` 回调里对 `resp` 立即完成 pending;`msg` 等进单线程队列再处理(可在队列线程里同步 `ack`/`request`)。Paho 使用 `MQTTv5` 常量与 `transport=websockets`,并等待 SUBACK。
- 原因:若 `resp` 与 `msg` 同队列,auto_ack 等待 `resp` 会永久卡住。
- 备选方案:ack 只发布不等待(弱化协议确认)。
- 影响:与 DEVELOPMENT 行为一致,修复真机联调阻塞。
4. **HiveMQ 鉴权失败与顶号原因码解析**
- 原条款:CONNACK 鉴权失败停重连;`0x8E` 顶号停重连。
- 实际做法:`connect().get()` 抛出的 `Mqtt5ConnAckException` / 文案含 `BAD_USER_*` 时归为 `bad_credentials`(令牌场景 Client 层改为 `session_invalid`);断开原因从 `Mqtt5DisconnectException` 读 `SESSION_TAKEN_OVER`。`connectSync` 等到终态再返回,避免与 attemptConnect 竞态报 `busy/RECONNECTING`。
- 原因:HiveMQ 失败路径多为异常而非成功返回的 CONNACK 对象。
- 备选方案:无。
- 影响:无。
5. **任务 5:README/示例与打包试跑,不发布**
- 原条款:包名与许可证;工具试跑确认能打包;README 与最小示例;真正发布在 Z3。
- 实际做法:更新两端 README;Python `examples/minimal.py`;Java `asia.asio.nixmsg.examples.MinimalExample`;`python -m build` 产出 wheel/sdist;`mvn package -DskipTests` 产出 jar;`javap` major version 52(Java 8)。未上传 PyPI/Maven。
- 原因:本波范围。
- 备选方案:无。
- 影响:无。
## 测试交付 Q
### Q1 / Q4 骨架 2026-09-30
1. **压测客户端未做到 1000 连接 / 10 分钟**
- 原条款:TASKS Q1「1000 连接的压测客户端」;验证「能保持 1000 个连接」。
- 实际做法:`test/load` 提供 MQTT 3.1.1 CONNECT/CONNACK 骨架与 `mqttbench` 命令,单元测试用进程内假 broker 验证多连接与连接数打印;未对真实 broker 压到 1000,也未跑 10 分钟吞吐。
- 原因:本波只做 Q1 基础设施骨架;完整弱网/压测属 Q3,且当前 main 尚无完整 MQTT broker 业务。
- 备选方案:立刻接 mochi 假 broker 或外部 mosquitto 压到 1000。
- 影响:Q3 需补真实压测与报告数字。
2. **未做弱网压测与验收用例**
- 原条款:TASKS Q2/Q3;本波总控指示「不要做弱网压测和验收用例」。
- 实际做法:只交付 toxiproxy/netem 辅助、报告生成器与空结果样例;F01–F23 全部为未测。
- 原因:波次范围。
- 备选方案:无。
- 影响:交付标准第 2、4 条待后续波次。
3. **为 Docker 健康检查在 `cmd/nixmsg` 增加 `healthcheck`**
- 原条款:DEVELOPMENT 11.2/11.4;TASKS 第 5 节 `cmd/nixmsg` 属平台 P。
- 实际做法:Q 线最小实现 `nixmsg healthcheck`(读 `listen.addr` 或配置 `listen`,请求本机 `/healthz`),否则 compose 健康检查无法按文档工作。
- 原因:Q4 镜像骨架硬依赖该子命令;改动面小。
- 备选方案:等 P 实现后再写 compose;或健康检查改用外部 curl(与 distroless 无 shell/curl 冲突)。
- 影响:P 线后续可替换或扩展实现;注意勿重复注册同名命令。
4. **Docker 镜像仅当前架构骨架,不推送**
- 原条款:DEVELOPMENT 11.4 多架构 `buildx` 推送;TASKS Q4 验证两个架构。
- 实际做法:完善多阶段 Dockerfile + `deploy/docker-compose.yml`(镜像名 `git.asio.asia/nixevol/nixmsg`,健康检查 `/nixmsg healthcheck`);本机只 `docker build` 当前架构并冒烟 `/healthz`;不做 `docker push`、不做 arm64。
- 原因:总控本波只要骨架;推送在 Z3。
- 备选方案:本波强制 buildx 双架构(耗时长、非阻塞目标)。
- 影响:多架构与仓库推送留到 Q4 定稿 / 阶段 3。
5. **compose 示例端口仍写 7443**
- 原条款:测试隔离「不要写死 7443」;DEVELOPMENT 11.4 示例为 `7443:7443`。
- 实际做法:`deploy/docker-compose.yml` 与文档示例一致使用 7443;混沌辅助与压测工具通过参数传入上游地址,不写死;本地冒烟可用其它宿主机端口映射。
- 原因:部署示例需与 DEVELOPMENT 对齐;隔离约束针对并行测试而非产品默认端口。
- 备选方案:compose 用变量 `${NIXMSG_HOST_PORT:-7443}`。
- 影响:多 Agent 同时起官方 compose 会端口冲突,应改映射或错开项目名。
6. **toxiproxy 镜像与 netem 旁路镜像选型**
- 原条款:用 toxiproxy 官方镜像;Linux 丢包用 netem。
- 实际做法:`ghcr.io/shopify/toxiproxy:2.12.0`;netem 说明用 `nicolaka/netshoot` 挂脚本(需 `NET_ADMIN`)。
- 原因:官方镜像无 tc;本机 Windows 不能本机 netem。
- 备选方案:自建含 iproute2 的旁路镜像。
- 影响:首次拉取 netshoot 需网络;脚本不进业务镜像。
7. **增加根目录 `.dockerignore`**
- 原条款:未要求;Dockerfile 原为 `COPY web/` 在 `pnpm install` 之后。
- 实际做法:忽略 `**/node_modules`、`web/dist`、`bin` 等,避免本机 Windows 的 `node_modules` 覆盖 Linux 安装结果导致 `vue-tsc` 找不到。
- 原因:本机 `task check` 会生成 `web/node_modules`,直接进构建上下文会破坏前端阶段。
- 备选方案:Dockerfile 在 `COPY web/` 后再跑一次 `pnpm install`。
- 影响:镜像构建依赖 dockerignore;与任务目录无关的本地产物不再进上下文。
8. **命名卷首次需 chown 给 nonroot**
- 原条款:DEVELOPMENT 11.4「挂载的数据目录要可写」,uid 65532。
- 实际做法:冒烟前对命名卷执行 `chown -R 65532:65532 /data`;compose 未内置 init 容器。
- 原因:空命名卷属主为 root 时,distroless nonroot 无法建库(`unable to open database file`)。
- 备选方案:compose 增加一次性 init 服务;或文档要求宿主机目录预授权。
- 影响:按示例首次 `up` 前需处理权限,否则 serve 立即退出。
### Q2 第一部分(已合并功能验收)2026-09-30
1. **对真实进程探测,未接线则记「未测」而非改业务代码**
- 原条款:TASKS Q2「PRD 第 10 节每条至少有一个集成测试」;本波只做已合并功能的第一部分。
- 实际做法:`test/accept` 用 `test/harness`(随机端口 + 临时目录)起真实 `nixmsg`;对 `/api/admin/login`、`/api/client/register`、`POST /api/admin/endpoints` 先探测。404 则该条写「未测」并注明缺总控接线 / A2 / I 等,不修改 `cmd/nixmsg` 或业务包求绿。
- 原因:main@d357082 上 A1/I1 等仅为可挂载 Handler,`serve` 只挂了 `/healthz`、`/readyz`(见各线 DEVIATIONS「未改 cmd」)。
- 备选方案:测试进程内自行 `admin.New` 挂路由(不反映交付二进制行为,否决)。
- 影响:本波 F17/F01/F23 多为未测;接线后同一测试会自动跑登录锁定、CSRF、注册与开通路径。
2. **F22 只验收 init + 健康检查子集**
- 原条款:F22 含备份恢复、升级迁移、证书重载、Docker、指标。
- 实际做法:集成测试覆盖空目录 `admin init`、`serve`、`/healthz`、`/readyz`,并断言管理员密码不出现在 serve 的 stdout/stderr;其余 F22 子项仍标未测。
- 原因:本波范围是「现在就能测的路径」。
- 备选方案:本波强行跑 Docker/证书(超出第一部分)。
- 影响:对照表 F22 为「通过」但备注写明未覆盖项。
3. **验收报告写入方式**
- 原条款:用 `test/report` 生成 F01–F23 对照表。
- 实际做法:`TestQ2AcceptAndReport` 汇总探测结果;默认写临时目录。`task q:accept`(`NIXMSG_WRITE_ACCEPT_REPORT=1`)写入 `test/report/testdata/q2_results.json` 与 `test/accept/ACCEPTANCE.md`;`task q:report-q2` 可再生成 Markdown。普通 `go test`/`task check` 不改仓库文件。
- 原因:避免每次单测改 `generated_at` 弄脏工作区。
- 备选方案:固定时间戳始终写入仓库。
- 影响:交付审阅以 `ACCEPTANCE.md` / `q2_results.json` 为准,需先跑过 `task q:accept`。
### Q2 补齐 + Q3(本机 Windows)2026-09-30
1. **Q2 对照表按已接线能力重跑,不再把已挂路由写成未测**
- 原条款:TASKS Q2;PRD 第 10 节 F01–F23。
- 实际做法:在 `main@77d2dbd` 上重跑管理登录/CSRF/锁定、注册开关错码对码换码、开通端、单聊送达、延迟撤回、群发发送者不收到、离线保留上线送达、崩溃后续传;结果写入 `test/report/testdata/q2_results.json` 与 `ACCEPTANCE.md`。未覆盖项仍标未测并写明原因。不改业务逻辑求绿。
- 原因:L-WIRE / L-UPLINK / A3 / I5 已合入,旧报告过时。
- 备选方案:无。
- 影响:对照表通过项增加;未测项收窄到 SDK、拨钟类与部分身份/回执场景。
2. **Q3 弱网用 toxiproxy,不用本机 netem**
- 原条款:DEVELOPMENT 第 13 节 toxiproxy + Linux netem 20% 丢包。
- 实际做法:`test/chaos` 以项目名 `q3-chaos`、容器名 `q3-toxiproxy` 起官方镜像;注入延迟与 `reset_peer`,验证离线保留期内消息最终送达。Linux netem 20% 丢包记**未测**:本机 Windows,宿主无 tc;仅对代理容器挂 netshoot 不等于 NixMsg 端到端丢包验收。
- 原因:机器限制;TASKS 允许 Docker 内 Linux 测丢包,但本波未把业务进程放进同网络 Linux 容器做 netem。
- 备选方案:后续用 Linux 宿主或 compose 把 nixmsg 与 netem 旁路同网再测。
- 影响:丢包数字不进交付;延迟/断开路径有集成测。
3. **Q3 压测达不到 1000 连接 / 10 分钟**
- 原条款:PRD 第 8 节 / DEVELOPMENT 第 13 节「1000 连接、每秒 200 条、10 分钟」。
- 实际做法:`test/load` 短时 32 连接(16 对)真实登录+单聊收发成功;不宣称 1000/10min 通过。
- 原因:本机 Windows 开发机资源与并行 Agent 负载;强行 1000 长时间易误伤其他线。
- 备选方案:专用压测机或 Linux 服务器上再跑满指标。
- 影响:对照表与 DEVIATIONS 明示机器限制,不假装通过。
4. **崩溃续传用 accept.ManagedServer 启停**
- 原条款:提交成功后杀进程,重启后续传。
- 实际做法:`test/accept.ManagedServer`(Kill + 同 data_dir Restart)+ Q2/Q3 用例;不改 harness 公共 API(harness 属总控)。
- 原因:隔离目录约束。
- 备选方案:扩展 harness.Restart(需总控改)。
- 影响:Q 线自带启停辅助。
### Q4 定稿 + Q5 文档 2026-09-30
1. **多架构 buildx 本波不执行推送与双架构验证**
- 原条款:DEVELOPMENT 11.4 / TASKS Q4「两个架构的镜像都能初始化、启动并通过健康检查」;`docker buildx` 一次推送 amd64+arm64。
- 实际做法:完善 Dockerfile(`TARGETOS`/`TARGETARCH`)、compose、`task q:docker-build` / `q:docker-push` / `q:docker-buildx`;本机只对当前架构(linux/amd64)`docker build` 并冒烟 `/healthz`;**不** `docker push`、**不**打版本 git 标签、**不**发布 SDK。本机 `docker buildx` 已列出 `linux/arm64`,但本波按总控指示不执行多架构构建与推送,留给 Z3。
- 原因:总控本波明确禁止真正 push / 打标签 / 发 SDK;双架构留给 Z3。
- 备选方案:在 Linux 宿主或已装 binfmt 的环境执行 `task q:docker-buildx`。
- 影响:交付标准「两架构镜像」与仓库推送仍待阶段 3。
2. **交叉编译三平台,本波至少验证 windows/amd64**
- 原条款:DEVELOPMENT 11.2 三平台二进制。
- 实际做法:`task q:release-bins`(`CGO_ENABLED=0`)产出 `bin/nixmsg-linux-amd64`、`nixmsg-linux-arm64`、`nixmsg-windows-amd64.exe`;说明写入 README;二进制不提交。
- 原因:纯 Go + embed,交叉编译可行。
- 备选方案:仅本机 `task build`。
- 影响:发布物打包在 Z3。
3. **compose 容器/卷名带 q4 前缀**
- 原条款:DEVELOPMENT 11.4 示例无固定 `container_name`;测试隔离要求名字带线前缀。
- 实际做法:`deploy/docker-compose.yml` 使用 `q4-nixmsg` / `q4-nixmsg-data`;正式部署可去掉 `container_name`。
- 原因:多 Agent 并行不抢容器名。
- 备选方案:compose 用项目名 `-p` 隔离而不写死 container_name。
- 影响:与文档示例略有差异,行为等价。
4. **验收未测项保持未测**
- 原条款:交付标准要求 F01–F23 有结果;TASKS 本波 Q4/Q5 不做假装通过。
- 实际做法:`ACCEPTANCE.md` / `docs/OPS.md` 第 9 节明示 F03、F04、F07、F10、F11、F14、F15、F18、F19 仍为未测;不改对照表状态。
- 原因:本波范围是 Docker 定稿与文档。
- 备选方案:无。
- 影响:阶段 3 / 负责人审阅时须看到未测清单。
5. **Q5 文档落点**
- 原条款:README、运维手册、SDK 文档汇总。
- 实际做法:重写根 `README.md`;新增 `docs/OPS.md`;SDK 汇总为 README 链到已有 `sdk/{go,js,python,java}/README.md`(各 SDK 已有最短使用说明,本波不重复扩写)。
- 原因:避免四份说明与 SDK 线漂移。
- 备选方案:在 docs/ 再建 SDK 汇总页。
- 影响:无。
### Q accept-rest(补齐短时可测验收)2026-09-30
1. **补测 F03/F04/F07/F10/F11/F14/F15/F18;F19 引用既有 SDK 清单**
- 原条款:PRD 第 10 节;总控要求跳过 1000×10min、Linux netem 20%、1000 端全表 1s。
- 实际做法:`test/accept/rest_accept_test.go` 用随机端口与临时目录;`grace_seconds`/`ack_timeout_seconds` 调到数秒;`record_retention_days=0` 另起进程;F19 对照表改为通过并写明四套 SDK checklist 证据路径,本波不重跑全量。
- 原因:短时可测项应收口;长时/环境限制项不假装通过。
- 备选方案:专用压测机与 Linux 宿主再补长时项。
- 影响:`ACCEPTANCE.md` 汇总通过 23 / 失败 0 / 未测 0;长时子项仍写在备注。
2. **harness MQTT 握手后清除 SetDeadline**
- 原条款:`test/harness` 属总控;Dial 时 `SetDeadline(now+timeout)`。
- 实际做法:WebSocket 升级成功与 TCP dial 成功后 `SetDeadline(time.Time{})`,避免长会话在 dial timeout 到期后读写全部失败。
- 原因:F10 等短宽限仍需跨数秒保持连接;未清 deadline 时旧 10s dial 会在会话中途使 Recv 失败,表现为 `timeout waiting resp`。
- 备选方案:每次读写刷新 deadline(更繁琐)。
- 影响:跨线改了 harness;行为仅更正测试客户端,不改产品。
3. **F15 带密建群用独立短生命周期进程**
- 原条款:拉进群须当次带对话密码。
- 实际做法:主会话用 `group.create` 无密断言失败;带密成功在干净进程上立刻建群。
- 原因:与第 4 条同一死锁,补测时先用隔离进程覆盖校验路径。
- 备选方案:仅依赖第 4 条修复后在同一长会话上测 `group.add`。
- 影响:验收覆盖仍成立。
4. **群事件 `emit` 改为异步 PublishDown**
- 原条款:群变更向成员推 `group_event`(QoS 0)。
- 实际做法:`internal/app/group/app.go` 的 `emit` 在独立 goroutine 里延迟约 20ms 再 `PublishDown`,让上行 worker 先把 `resp` 推完。
- 原因:同一连接上 `group.create`/`group.add` 同步向本连接注入下行时,与 mochi InlineClient 互相等待,`resp` 回不去(`TestUplinkDMOfflineGroupRecall` 在清掉测试客户端 dial deadline 后稳定复现)。
- 备选方案:broker 层对 Inline 发布做无锁队列。
- 影响:`group_event` 可能略晚于 `resp` 到达;业务结果仍以 `resp` 为准。
### fix-issue-1
1. **管理员 IP 锁定不再阻断已认证会话**
- 原条款:PRD D18 / F02(密码锁只拦密码登录,不拦已有会话令牌);DEVELOPMENT 第 5/8 节(管理员登录锁定、错误令牌按 IP 计入锁定);issue #1。
- 实际做法:去掉 `internal/admin/auth.go` 的 `auth()` 鉴权前 `Check(LockAdminIP)`;登录入口仍 `Check`/`Fail`,错误或停用 API 令牌仍经 `authFail` 计入锁定。有效 Cookie 与合法 Bearer 在锁定期可继续调管理接口。
- 原因:先前把「防暴力登录」扩成「封整个管理面」,同 NAT 下刷错误 Bearer 即可锁死已登录管理员,与端侧 nst_ 重连语义不一致。
- 备选方案:锁定期对 Cookie 与令牌也拒绝(否决,违背 D18 对齐)。
- 影响:仅管理后台鉴权中间件;端侧登录锁定未改。
### fix-issue-6
1. **解散群时 scheduled 消息级回执 state 改为 rejected**
- 原条款:DEVELOPMENT 6.4 消息级作废写 `endpoint_id` 空、`state=rejected`;7.6 解散群将 `scheduled` 消息改为 `completed`/`group_dissolved` 并写消息级回执。I4 旧实现把回执 state 误写成消息状态 `completed`。
- 实际做法:`internal/app/group/void.go` 的 `voidGroupAllTx` 插入回执时改用 `rejected`(与 I5.2 / identity lifecycle 一致);消息行仍为 `completed`。
- 原因:`completed` 不在回执枚举(accepted|recalled|expired|dropped|rejected)内,会误导 SDK/后台。
- 备选方案:沿用 `completed`(违反协议)。
- 影响:仅修正解散路径回执字段;不改 emit / PublishDown。
### fix-issue-2
1. **自助注册接入 trusted_proxies 客户端 IP**
- 原条款:PRD F23 / D18 注册安全码按来源 IP 锁定;DEVELOPMENT 4.5 来自受信代理时用 `X-Forwarded-For`;I1.4 曾写「经代理部署时接线方必须注入真实 IP」。
- 实际做法:`cmd/nixmsg/serve.go` 在 `identity.New` 注入与管理接口相同的 `httpx.ClientIP(r, trustedNets)`;不改锁定阈值与注册开关/安全码语义,不在 identity 内复制解析。
- 原因:L-WIRE 已挂注册 Handler,管理与 WS 已接 `trusted_proxies`,唯独注册漏接,反向代理后会把安全码锁定计到代理 IP。
- 备选方案:在 listener 层统一改写 `RemoteAddr` 后再交给注册 Handler。
- 影响:经受信代理开放注册时,输错安全码按真实客户端 IP 锁定。
### fix-issue-4
1. **接线补齐 Downlink 与停用/删除/重置密码 fatal**
- 原条款:DEVELOPMENT 6.8 / 7.6:停用、删除、重置密码先发 `fatal` 再断开;已推送作废投递尽力发 `revoked`。
- 实际做法:`serve` 给 `identity.New` 注入 `Downlink: brk`(作废后 `publishRevokes`);`DisableKick`/`DeleteKick`/`PasswordResetKick` 分别接到 `Session.Disable`/`Deleted`/`ResetPassword`;`KickEndpoint` 仍只 `Kick`。Identity 在未接 Kick 钩子时仍可用 `ConnControl` 异步断开兜底。
- 原因:原先 Downlink 未注入导致 revoked 丢失;管理路径只 `Kick`/`Disconnect` 不发 fatal。
- 备选方案:仅在 identity 内 `PublishDown(fatal)` 再断开;联调中该路径不如 Session.fatalKick 稳,故生产致命踢线统一走 Session。
- 影响:管理「踢下线」语义不变;SDK 可按 fatal 停止重连;接收方能收到已推送消息的 revoked。
### fix-issue-5
1. **指标在真实事件点打点,不新造名字**
- 原条款:PRD F22 / DEVELOPMENT 4.3 / issue #5;DEVIATIONS P4 已定名但从未接线。
- 实际做法:`nixmsg_connections{transport}` 在 broker `OnSessionEstablished`/`OnDisconnect` 末尾 Inc/Dec;`endpoints`/`deliveries_pending`/`messages_scheduled` 与写队列、哈希排队在 `messageLoops` 每秒按库/队列真实长度采样;`dispatch_to_push`/`ack` 直方图在成功推送与确认路径 Observe;写批提交耗时经 `store.Queue.OnBatchCommit`;`errors_total` 仅在上行 `replyErr` 时按错误码递增。门禁不变。
- 原因:空指标等于监控未交付;采样避免在每条写路径上改大段分发逻辑,并减小与 #3/#6 的合并面。
- 备选方案:全部改为纯事件加减(pending 等需在每处状态迁移维护计数)。
- 影响:仪表盘按既有名字即可看在线连接与待投递;无对应事件时计数保持 0,不做假数。
### 死锁未修(issue #3)
issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是核对过的调用链、三次尝试和仍留在 `main` 上的绕过。不改产品行为。
1. **现象**
- 清掉测试客户端 dial deadline 后,`cmd/nixmsg/uplink_integration_test.go` 的 `TestUplinkDMOfflineGroupRecall` 在 `group.create`(`rid=g1`)稳定超时,`resp` 回不去。
- 行号以本次合入后的 `main` 为准。
2. **调用链**
- 每端一条上行队列。`internal/broker/queue.go` 的 `loop`(约 41 行)同步调用 `HandleUplink`。
- `cmd/nixmsg/uplink.go` 的 `HandleUplink`(约 64 行)先 `dispatch`,再在同一调用栈里 `replyOK` → `publishResp`(约 273 行)用 QoS 1 调 `PublishDown`。`group.create` / `group.add` 走 `groups.Create` / `Add`,在返回 `resp` 之前就 `emit`(`internal/app/group/app.go` 约 168、223 行)。
- `Broker.PublishDown`(`internal/broker/broker.go` 约 202 行)进入 `server.Publish`(约 234 行)。`New` 设了 `InlineClient: true`(约 148 行,DEVELOPMENT 要求保持)。Inline 发布走到 `InjectPacket` → `OnPublish`。
- `internal/broker/hooks.go` 的 `OnPublish`(约 114 行)对 `cl.Net.Inline` 必须直接放行,否则 `PublishDown` 送不到订阅者(见本文更早的 InlineClient 偏差)。
- 群操作因此在同一次上行调用栈里,再向本连接 `PublishDown` `group_event`。`InjectPacket`(`NextPacketID` / 写路径)与读循环随后写 PUBACK 抢同一把 Client 锁,两边互等,`resp` 出不去。
- `presence.notify`(`internal/app/presence/app.go` 约 317 行)仍在业务调用栈里同步 `PublishDown`(约 341 行),不在 20ms 绕过的覆盖范围内。
3. **已尝试**
- 尝试 1:`emit` 改成立刻起 goroutine 做 `PublishDown`。仍死锁,因为 `group_event` 与同连接上的 `resp` 一起抢注入。
- 尝试 2(已在 `main`,来自 `479a08e` 的 Q accept-rest 第 4 条):`emit` 起 goroutine 后 `time.Sleep(20ms)` 再下发(`internal/app/group/app.go` 约 672–685 行),让 `resp` 先出去。当时 `task check` 通过。这是时间差绕过,不是根因修复;presence 以及其他同步 `PublishDown` 仍可能卡。
- 尝试 3(负责人叫停,未合入、未验证):工作树 `e:\code\NixMsg-wt\fix3`,分支 `feat/fix-3-downlink-deadlock` 停在 `479a08e`,与合入前的 `origin/main` 相同,**没有可合的提交**。未提交改动在 broker 层:
- `hooks.go` `OnPublish`:客户端 QoS≥1 先在读循环里 `WritePacket(PUBACK)`,再把包降成 QoS 0 并 `Ignore`,然后入队,返回 `nil`(不再用 `CodeSuccessIgnore` 让 mochi 事后写 PUBACK)。注释写明:若先入队,worker 的 `PublishDown` → `InjectPacket` → `NextPacketID` 会与随后的 `WritePacket(PUBACK)` 争 Client 锁。
- `queue.go` `loop`:`HandleUplink` 前后调用 `beginUplink` / `endUplink`。
- `broker.go`:该端 `depth>0` 时,对本端的 `PublishDown` 只推进延后队列,handler 返回后由上行 worker 再 `server.Publish`;其他端仍同步下发。`InlineClient` 放行未改。
- `group/app.go` `emit` 改回同步 `PublishDown`,去掉 20ms sleep。
- 同目录 `docs/DEVIATIONS.md` 有一段未提交的 `### fix-issue-3` 草稿。
- 本会话没有跑这套未提交代码的 `task check`,不把它们合进 `main`。工作树保留,给工程师看。
4. **仍在 main 上的做法**
- 继续用尝试 2 的 20ms 绕过。群事件可能略晚于 `resp`;业务结果仍以 `resp` 为准。
5. **建议的正确方向**
- 在 broker 把对本连接的下行 `InjectPacket` 与上行 worker 解耦:上行读循环先写完 PUBACK,处理 `HandleUplink` 期间不要同步向本连接注入;handler 返回后再发 `resp` 和 `group_event`。不要靠固定 `Sleep`。`InlineClient: true` 保持,`OnPublish` 对 InlineClient 继续放行。
- 覆盖 presence 等其他同步 `PublishDown`,而不只包一层 `emit`。
### 复审修复 B-01
- 日期:2026-09-30
- 原条款:DEVELOPMENT 第 5 节装配 mochi;未写客户端 Receive Maximum。Gitea #8。
- 实际做法:`OnConnect` 在心跳校正后调用 `cl.State.Inflight.ResetSendQuota(0)`,不 fork mochi。CONNECT 声明的 Receive Maximum 小于 256 时打 warn,连接仍接受。应用层窗口(推送 32、回执 64、在途 resp 等)约束未确认的 QoS 1。
- 原因:mochi v2.7.9 在 `sendQuota>0` 时走 `NextImmediate` 递归读锁,并可因补发后删除 inflight 泄漏配额;已验证置 0 绕开整条路径。
- 备选方案:fork 修补 mochi(只修递归读锁仍观察到停滞)。
- 影响:服务端不再执行客户端 Receive Maximum;裸设备若带过小的 Receive Maximum,实际在途可能超过该值。
### 复审修复 B-02
- 日期:2026-09-30
- 原条款:DEVELOPMENT 7.5 / DEVIATIONS N1/N2 第 4 条:大帧名额在 PUBACK、丢弃、断线时归还。Gitea #9。
- 实际做法:`OnQosPublish` 按 PacketID 记下超过 64KiB 的出站包;`OnQosComplete`/`OnQosDropped`/断线按 ID 归还。获取名额最多等 5 秒,超时返回 `ErrLargeFrameTimeout`。Publish 未产生 inflight(无订阅者、队列丢弃)时立即归还。不采用「发布完成即归还」。
- 原因:mochi 传给 `OnQosComplete` 的是 PUBACK,没有载荷,旧实现从未归还。
- 备选方案:发布后立即归还(会把卡死点挪到消息包那份名额)。
- 影响:只在 broker 保留一份全局 64 名额;确认超时仍由消息线踢线/清标记触发断线归还。
### 复审修复 B-05
- 日期:2026-09-30
- 原条款:DEVELOPMENT 第 5 节连接表;Gitea #12。
- 实际做法:`OnConnect` 只在认证通过时写入 `byClient`/`byConnID`;拒绝与内部错误不登记。`connState` 增加 `established` 与 `createdAt`,每分钟清扫未建立且已关闭超过 1 分钟的条目。按连接代号查找改为 O(1)。
- 原因:mochi 在认证失败路径不调用 `OnDisconnect`,旧实现会永久泄漏。
- 备选方案:失败路径也登记再在 Authenticate 返回 false 时删除(仍覆盖不了 CONNACK 失败)。
- 影响:失败连接不再占用查找路径;行为对客户端不变(仍回 0x86 或不回 CONNACK)。
### 复审修复 B-07
- 日期:2026-09-30
- 原条款:PRD §8 日志无正文、无密码、无令牌。Gitea #14。
- 实际做法:`broker.New` 给 mochi 包一层 slog.Handler,把 `packets.Packet` / `*packets.Packet` 换成类型、QoS、包号、主题、正文长度。
- 原因:默认 info 下第二个 CONNECT、3.1.1 发到错误主题等会把整包写入 JSON 日志。
- 备选方案:改 mochi 日志调用点(需 fork)。
- 影响:排障时看不到载荷与密码,只见摘要。
### 复审修复 B-03
- 日期:2026-09-30
- 原条款:DEVELOPMENT 第 5 节每端串行队列;Gitea #10。不改 `PublishDown` 签名。
- 实际做法:每连接独立下行队列(256 帧 / 16MiB)和发送 goroutine。`PublishDown` 只入队;发送与上行读循环解耦。队列满返回 `ErrBackpressure`。
- 原因:同连接同步 `InjectPacket` 与读循环写 PUBACK 会互相等待。
- 备选方案:改 `PublishDown` 签名或继续用 20ms sleep。
- 影响:调用方入队即返回;慢客户端只挡住该连接的发送 goroutine。
### 复审修复 B-06
- 日期:2026-09-30
- 原条款:Gitea #13。`PublishDown` 校验当前连接与下行订阅;导出有效载荷上限。
- 实际做法:非空 `connID` 必须仍是当前连接。未订阅 down 返回 `ErrNotSubscribed`。导出 `EffectivePayloadLimit`(Maximum Packet Size 减 128 字节包头预留)。`uplink.publishResp` 改用该函数。新连接建立时把旧连接标为 `superseded`。
- 原因:旧连接或未订阅时写入会静默失败或写错连接。
- 备选方案:发送时再检查(入队后连接可能已换)。
- 影响:无订阅时下行立即失败,不再占用大帧名额。
### 复审修复 B-04
- 日期:2026-09-30
- 原条款:Gitea #11。写出后再断开,不用固定 sleep。`serve.go` 只改 `identity.New` 的 ConnControl。
- 实际做法:`PublishThenDisconnect` 把帧与断开原因一并入队,发送 goroutine 写完再 `Disconnect`。logout / fatalKick 改走该原语。`identity.New` 的 `ConnControl` 置 nil,踢线仍走 Session 钩子。
- 原因:固定 20ms/50ms sleep 在慢客户端上会先断开,在快路径上又多余等待。
- 备选方案:继续 sleep;或改 identity 生命周期(本线不改)。
- 影响:identity 未接 ConnControl 时不再自己 20ms 踢线,生产路径统一由 Session 写出后断开。
### 复审修复 B-09
- 日期:2026-09-30
- 原条款:Gitea #16。生命周期串行化。不改 presence/app.go。
- 实际做法:broker 与 `appUplink` 按端编号加锁串行 `OnSessionEstablished` / `OnDisconnect` / 握手。uplink 另记 hello 握手表,仅已握手连接的断开才按当前连接通知消息线。
- 原因:顶号时旧连接 `OnDisconnect` 可能和新连接登记交错。
- 备选方案:改 presence 在线表(超出本线允许文件)。
- 影响:未 hello 的断开不再把消息连接表当成已握手在线来清推送标记。
### 复审修复 B-10
- 日期:2026-09-30
- 原条款:Gitea #17。登录写库条件更新;hello 重读令牌。不改 identity/self.go。
- 实际做法:密码登录 `UPDATE ... WHERE COALESCE(session_hash,'') = 读到的旧值`,影响行数为 0 则 `ErrSessionWriteConflict`。hello 用 `TokenMatchesDB` 核对明文,库已被换则响应里不带回旧令牌。
- 原因:两处同时密码登录会互相覆盖;hello 可能把已作废明文交给客户端。
- 备选方案:写库后无条件返回本次签发明文。
- 影响:写冲突时 OnConnect 返回 error(不回 0x86),客户端按网络故障重连。
### 复审修复 B-11
- 日期:2026-09-30
- 原条款:Gitea #18。令牌闲置按在线计。
- 实际做法:闲置判断取 `session_used_at` / `online_since` / `offline_since` 的较新者;当前在线(`online_since >= offline_since`)视为未闲置。
- 原因:只看 `session_used_at` 会让长期在线却很少写库的令牌过期。
- 备选方案:在线时每小时强制刷新 used_at(已有 touch,但仍可能窗口不够)。
- 影响:在线设备不会因为闲置天数被踢;离线后从最后一次在线/离线时刻起算。
### 复审修复 B-12
- 日期:2026-09-30
- 原条款:Gitea #19。认证超时与每端校验并发。不改 auth 池/PHC。
- 实际做法:`Authenticate` 套 30 秒超时;argon2 `Verify` 前每端信号量 2。`OnConnect` 同样带 30 秒 ctx。
- 原因:慢哈希或卡住的校验会堵住 mochi 读循环;同一编号并发登录会打满全局哈希池。
- 备选方案:改全局 Pool 大小(超出允许文件)。
- 影响:超时表现为内部错误断开(不回 0x86)。
### 复审修复 B-08
- 日期:2026-09-30
- 原条款:Gitea #15。Shutdown API。完整 HTTP 停机依赖 L-03。
- 实际做法:`Broker.Shutdown` 对现有连接发 MQTT 5 `0x8B`,清空上行队列并 `Close`。`serve` 在 listener Close 之前调用。HTTP `Shutdown` 留给 L-03。
- 原因:只关 listener 时 MQTT 客户端看不到规范的停机原因码。
- 备选方案:等 L-03 一并做(本线仍提供 broker API,避免监听线无法调用)。
- 影响:进程退出时端会收到 server shutting down;监听器 HTTP 优雅停机仍未做。
### 复审修复 U-01
1. **开启自助注册必须已有 8–64 字符安全码**
- 原条款:PRD F23 / D14:管理员开启并设置注册安全码(8–64 字符);注册必须带当前安全码。
- 实际做法:`register()` 在锁定检查前,若存储码不是 8–64 字符则 403 `registration_closed` 并记不含码的警告,不计入锁定。`PUT /api/admin/registration` 在同一写操作末尾读回开关与安全码,开启且码不合法则 400 并回滚;允许一次提交 `enabled`+`code`/`generate`。后台无已保存安全码时禁用开关。
- 原因:新库无码行或空码时 `constantTimeEqual("", "")` 为真,只开开关即可裸注册。
- 备选方案:仅拦管理 PUT、不拦已处于「开启+空码」的旧库(否决,缺少纵深防御)。
- 影响:原先「先开开关再设码」的两步会 400;须先设码或一次提交开启与码。
### 复审修复 U-04
1. **锁定计数表过期清理与总量上限**
- 原条款:DEVELOPMENT 第 5 节锁定计数;issue #42。
- 实际做法:`internal/auth/locks.go` 的 Fail/Check 顺手删除已过期且最近失败在窗口外的条目;每 1024 次或每分钟全表扫描;默认上限 65536,超出时优先淘汰最旧的非锁定条目。生产接线使用 `auth.NewLoginLocks()`。
- 原因:注册安全码错误与错误 API 令牌按 IP 建条目,轮换地址会使 map 只增不减。
- 备选方案:一并改 `internal/admin/memlock.go`(否决,本波只改 locks.go;admin 测试用内存锁若仍独立注入需后续对齐)。未改对话密码锁键(U-03)。
- 影响:过期未锁定条目会被回收;极端并发失败时最早的非锁定计数可能被挤出。
### 复审修复 U-05
1. **目录 LIKE 转义;注册拒绝编号 inline**
- 原条款:DEVELOPMENT 6.5 按编号前缀或名称包含匹配;issue #43。
- 实际做法:目录查询对 `\`、`%`、`_` 转义并 `ESCAPE '\'`。注册拒绝编号 `inline`(与 mochi 内联客户端 ClientID 同名)。
- 原因:未转义时搜 `e_ab` 会命中 `exab…`,搜 `%` 返回全部端。
- 备选方案:在 `internal/protocol.ValidEndpointID` 加保留字,使开通/导入一并拒绝(否决,本波不改 protocol 与 `internal/admin/endpoints.go`)。后台开通与批量导入仍可能使用 `inline`,留给后续波次。
- 影响:目录下划线按字面匹配;自助注册不能占用 `inline`。