diff --git a/cmd/nixmsg/serve.go b/cmd/nixmsg/serve.go index debb3f7..7ad7383 100644 --- a/cmd/nixmsg/serve.go +++ b/cmd/nixmsg/serve.go @@ -29,7 +29,7 @@ func cmdServe(_ []string) error { } func runServe(ctx context.Context, cfg config.Config) error { - wire() + deps := wire() if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { return fmt.Errorf("mkdir data_dir: %w", err) @@ -41,6 +41,22 @@ func runServe(ctx context.Context, cfg config.Config) error { } defer func() { _ = db.Close() }() + // 启动恢复入口已挂上(假实现为空操作);M 线替换 message.Service 后生效。 + if recoverErr := deps.Messages.RecoverOnStart(ctx); recoverErr != nil { + return fmt.Errorf("message recover: %w", recoverErr) + } + // 其余 deps 供后续 admin / broker / httpx 接线;此处显式引用避免未使用告警。 + _ = deps.Identity + _ = deps.Groups + _ = deps.Presence + _ = deps.Downlink + _ = deps.Conns + _ = deps.Uplink + _ = deps.HashPool + _ = deps.SessionTokens + _ = deps.APITokens + _ = deps.LoginLocks + mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/cmd/nixmsg/wire.go b/cmd/nixmsg/wire.go index 06e9b86..283d6b0 100644 --- a/cmd/nixmsg/wire.go +++ b/cmd/nixmsg/wire.go @@ -1,4 +1,46 @@ package main -// wire 组装各业务模块。T0.1 仅占位,T0.4 起由各线注册。 -func wire() {} +import ( + "git.asio.asia/nixevol/NixMsg/internal/app/group" + "git.asio.asia/nixevol/NixMsg/internal/app/identity" + "git.asio.asia/nixevol/NixMsg/internal/app/message" + "git.asio.asia/nixevol/NixMsg/internal/app/port" + "git.asio.asia/nixevol/NixMsg/internal/app/presence" + "git.asio.asia/nixevol/NixMsg/internal/auth" +) + +// appDeps 是 serve 组装出的模块依赖。各线在后续任务中替换假实现为真实实现。 +type appDeps struct { + HashPool auth.HashPool + SessionTokens auth.SessionTokens + APITokens auth.APITokens + LoginLocks auth.LoginLocks + + Messages message.Service + Identity identity.Service + Groups group.Service + Presence presence.Service + + Uplink port.UplinkHandler + Downlink port.Downlink + Conns port.ConnControl +} + +// wire 组装各业务模块的骨架依赖(T0.4:假实现;后续各线替换)。 +func wire() appDeps { + return appDeps{ + HashPool: auth.NewStubHashPool(), + SessionTokens: auth.NewStubSessionTokens(), + APITokens: auth.NewStubAPITokens(), + LoginLocks: auth.NewStubLoginLocks(), + + Messages: message.NewStub(), + Identity: identity.NewStub(), + Groups: group.NewStub(), + Presence: presence.NewStub(), + + Uplink: port.StubUplinkHandler{}, + Downlink: &port.StubDownlink{}, + Conns: &port.StubConnControl{}, + } +} diff --git a/cmd/nixmsg/wire_test.go b/cmd/nixmsg/wire_test.go new file mode 100644 index 0000000..83caf09 --- /dev/null +++ b/cmd/nixmsg/wire_test.go @@ -0,0 +1,16 @@ +package main + +import "testing" + +func TestWireAssemblesStubs(t *testing.T) { + d := wire() + if d.HashPool == nil || d.SessionTokens == nil || d.APITokens == nil || d.LoginLocks == nil { + t.Fatal("auth deps missing") + } + if d.Messages == nil || d.Identity == nil || d.Groups == nil || d.Presence == nil { + t.Fatal("app deps missing") + } + if d.Uplink == nil || d.Downlink == nil || d.Conns == nil { + t.Fatal("port deps missing") + } +} diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 90ddfb7..49b1f99 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -119,6 +119,36 @@ - 备选方案: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 diff --git a/docs/api/admin-api.md b/docs/api/admin-api.md new file mode 100644 index 0000000..37abe91 --- /dev/null +++ b/docs/api/admin-api.md @@ -0,0 +1,733 @@ +# 管理接口契约(Admin API) + +| 项 | 内容 | +|---|---| +| 版本 | 0.1(T0.4 契约) | +| 对应 | [DEVELOPMENT.md](../DEVELOPMENT.md) 第 8 节、[PRD.md](../PRD.md) F17 | +| 读者 | 后台接口 A、后台网页 W、集成测试 | + +本文约定管理后台 HTTP 接口的方法、路径、请求字段、成功响应、分页与错误格式。W 线可先按本文用假数据开发;A 线实现时响应字段名与语义须与本文一致。 + +**硬性规则** + +- 所有 JSON 响应在序列化前去掉消息正文;响应中不得出现 `body` 字段(含嵌套)。 +- 管理接口不开跨域。 +- 时间字段为 Unix 毫秒(整数),JSON 字段名蛇形。 + +--- + +## 1. 鉴权与通用约定 + +### 1.1 Cookie 会话(网页登录) + +| 项 | 值 | +|---|---| +| Cookie 名 | `nixmsg_admin` | +| 属性 | `HttpOnly`;`SameSite=Lax`;经 HTTPS(含受信任代理认定的 HTTPS)时加 `Secure` | +| 服务端 | 只存令牌哈希;默认有效 12 小时 | + +改变状态的请求(POST / PATCH / PUT / DELETE)在使用 Cookie 鉴权时,必须带请求头: + +```http +X-Nixmsg-Request: 1 +``` + +缺少该头返回 **403**(防跨站表单)。`GET` 不要求此头。 + +### 1.2 API 令牌(程序调用) + +```http +Authorization: Bearer nxm_... +``` + +- 带 Bearer 令牌时**不看** Cookie,也**不要求** `X-Nixmsg-Request`。 +- 权限等同管理员,但不能调用: + - `POST /api/admin/password` + - `/api/admin/tokens` 及其子路径 +- 令牌不过期;停用或删除后立即失效。 +- 错误令牌按来源 IP 计入管理员登录锁定。 + +### 1.3 通用响应信封 + +成功(业务成功): + +```json +{"ok": true, "data": { }} +``` + +失败: + +```json +{"ok": false, "error": {"code": "unauthorized", "message": "未登录"}} +``` + +| HTTP | 常见 code | 说明 | +|---|---|---| +| 400 | `bad_request` | 字段不合法 | +| 401 | `unauthorized` | 未登录、密码错误、令牌无效 | +| 403 | `forbidden` | 缺 `X-Nixmsg-Request`、令牌无权访问该路由、已锁定等 | +| 404 | `not_found` | 资源不存在 | +| 409 | `id_taken` / `conflict` | 编号占用等 | +| 429 | `rate_limited` | 登录/令牌试错锁定 | +| 500 | `internal` | 服务器错误 | +| 503 | `busy` | 暂时不可用,可重试 | + +列表类成功时 `data` 形如: + +```json +{ + "items": [ ], + "next_cursor": "", + "total": 0 +} +``` + +- `next_cursor` 为空表示没有下一页。 +- `total` 为符合筛选条件的总数(可选实现;契约要求有该字段,假数据可给 `items.length`)。 +- 默认 `limit` 为 50,最大 200(除非单接口另有说明)。 + +### 1.4 操作日志 + +每个改变状态的请求写结构化日志:操作者(`admin` 或 `token:<名称>`)、动作、对象编号、结果、来源 IP。不写密码、令牌和正文。 + +--- + +## 2. 认证与账号 + +### 2.1 登录 + +`POST /api/admin/login` + +不需要已登录。不要求 `X-Nixmsg-Request`。 + +请求: + +```json +{"username": "admin", "password": "..."} +``` + +- `username` 固定为 `admin`。 +- 成功时设置 Cookie `nixmsg_admin`。 + +成功 `data`: + +```json +{"username": "admin"} +``` + +失败:密码错误 `401 unauthorized`;锁定 `429 rate_limited`。 + +### 2.2 登出 + +`POST /api/admin/logout` + +作废当前会话 Cookie。成功 `data` 可为 `{}`。 + +### 2.3 当前管理员 + +`GET /api/admin/me` + +成功 `data`: + +```json +{"username": "admin", "auth": "cookie"} +``` + +`auth` 为 `cookie` 或 `token`(API 令牌访问时)。 + +### 2.4 修改管理员密码 + +`POST /api/admin/password` + +**仅 Cookie 会话**;API 令牌调用返回 `403 forbidden`。 + +请求: + +```json +{"old_password": "...", "new_password": "..."} +``` + +- 新密码至少 12 位。 +- 成功后可选择保持当前会话或全部作废(实现默认:当前会话保留,其它会话作废)。成功 `data`:`{}`。 + +--- + +## 3. 概览 + +`GET /api/admin/overview` + +成功 `data`: + +```json +{ + "version": "0.1.0", + "endpoints_total": 0, + "endpoints_online": 0, + "endpoints_disabled": 0, + "groups_total": 0, + "messages_pending": 0, + "messages_scheduled": 0, + "uptime_ms": 0 +} +``` + +不含正文、不含端编号明细。 + +--- + +## 4. 端(endpoints) + +### 4.1 列表 + +`GET /api/admin/endpoints` + +查询参数: + +| 参数 | 说明 | +|---|---| +| `cursor` | 分页游标 | +| `limit` | 默认 50,最大 200 | +| `source` | 可选:`admin` / `self` | +| `online` | 可选:`true` / `false` | +| `enabled` | 可选:`true` / `false` | +| `query` | 可选:编号前缀或名称包含,不区分大小写 | + +成功 `data.items[]`: + +```json +{ + "id": "device-1", + "name": "门口", + "remark": "", + "source": "admin", + "enabled": true, + "online": false, + "online_since_ms": null, + "offline_since_ms": 1750000000000, + "talk_password_set": false, + "default_delay_ms": 0, + "created_at_ms": 1750000000000, + "login_locked": false +} +``` + +无密码、无正文。 + +### 4.2 开通 + +`POST /api/admin/endpoints` + +请求: + +```json +{ + "id": "", + "name": "门口", + "remark": "", + "login_password": "", + "talk_password": "", + "default_delay_seconds": 0 +} +``` + +字段规则同 PRD F01(编号小写、密码不以 `nst_` 开头等)。`id` / `login_password` 留空则服务器生成。 + +成功 `data`: + +```json +{ + "id": "e_ab12cd34", + "login_password": "只在本次生成时返回" +} +``` + +冲突:`409 id_taken`。 + +### 4.3 批量开通(CSV) + +`POST /api/admin/endpoints/import` + +- `Content-Type: text/csv` 或 `multipart/form-data`(字段名 `file`)。 +- UTF-8,可带 BOM。 +- 表头:`id,name,login_password,talk_password,default_delay_seconds,remark` +- 最多 1000 行。先整体校验,任一行出错则一行都不建。 + +校验失败(HTTP 400): + +```json +{ + "ok": false, + "error": {"code": "bad_request", "message": "CSV 校验失败"}, + "data": { + "errors": [{"line": 3, "reason": "编号不合法"}] + } +} +``` + +(实现可将 `errors` 放在 `error` 旁的 `data`;W 线按 `data.errors` 读取。) + +成功 `data`: + +```json +{ + "items": [ + {"id": "e_1", "login_password": "生成或原文(仅此一次)", "name": "..."} + ] +} +``` + +### 4.4 多选批量 + +`POST /api/admin/endpoints/batch` + +请求: + +```json +{"ids": ["a", "b"], "action": "disable"} +``` + +`action`:`disable` | `enable` | `delete`。 + +成功 `data`: + +```json +{"ok_ids": ["a"], "failed": [{"id": "b", "code": "not_found"}]} +``` + +### 4.5 详情 + +`GET /api/admin/endpoints/{id}` + +成功 `data` 同列表项,可额外含: + +```json +{"session_issued_at_ms": null, "session_used_at_ms": null} +``` + +仍无密码哈希与正文。 + +### 4.6 修改 + +`PATCH /api/admin/endpoints/{id}` + +请求(皆可选): + +```json +{ + "name": "新名", + "remark": "", + "default_delay_seconds": 0, + "enabled": true +} +``` + +成功 `data` 为更新后的详情(同 4.5)。 + +### 4.7 删除 + +`DELETE /api/admin/endpoints/{id}` + +成功 `data`:`{}`。级联行为见 PRD F01 / DEVELOPMENT 7.6。 + +### 4.8 踢下线 + +`POST /api/admin/endpoints/{id}/kick` + +只断开当前连接,不阻止令牌重连。成功 `data`:`{"kicked": true}`(当时不在线可为 `false`)。 + +### 4.9 重置登录密码 + +`POST /api/admin/endpoints/{id}/reset-login-password` + +请求体可空 `{}`,或 `{"login_password": ""}`(留空则生成)。 + +成功 `data`: + +```json +{"login_password": "只出现一次"} +``` + +会话令牌作废;在线连接收到 `fatal`(`password_reset`)后断开。 + +### 4.10 设置对话密码 + +`PUT /api/admin/endpoints/{id}/talk-password` + +请求: + +```json +{"talk_password": ""} +``` + +空字符串表示清除。增加对话密码版本号,旧授权失效。成功 `data`:`{"talk_password_set": false}`。 + +### 4.11 解除登录锁定 + +`POST /api/admin/endpoints/{id}/unlock` + +清除该编号的两种登录锁定。成功 `data`:`{}`。 + +--- + +## 5. 注册设置 + +### 5.1 获取 + +`GET /api/admin/registration` + +成功 `data`: + +```json +{ + "enabled": false, + "code": "明文安全码(后台可查看)", + "updated_at_ms": 1750000000000 +} +``` + +### 5.2 更新 + +`PUT /api/admin/registration` + +请求(字段皆可选,至少一项): + +```json +{"enabled": true, "code": "新码", "generate": false} +``` + +- `generate: true` 时服务器生成 16 位安全码并忽略请求里的 `code`。 +- 成功 `data` 同 GET。 + +--- + +## 6. API 令牌 + +以下路由**仅 Cookie 会话**;Bearer 调用返回 `403 forbidden`。 + +### 6.1 列表 + +`GET /api/admin/tokens` + +成功 `data.items[]`: + +```json +{ + "id": 1, + "name": "ops", + "enabled": true, + "created_at_ms": 1750000000000, + "last_used_at_ms": null +} +``` + +不含令牌明文。 + +### 6.2 创建 + +`POST /api/admin/tokens` + +请求: + +```json +{"name": "ops"} +``` + +成功 `data`: + +```json +{ + "id": 1, + "name": "ops", + "token": "nxm_只出现一次", + "created_at_ms": 1750000000000 +} +``` + +### 6.3 修改 + +`PATCH /api/admin/tokens/{id}` + +请求(可选字段): + +```json +{"name": "new", "enabled": false} +``` + +成功 `data` 同列表项。 + +### 6.4 删除 + +`DELETE /api/admin/tokens/{id}` + +成功 `data`:`{}`。 + +--- + +## 7. 群(groups) + +### 7.1 列表 + +`GET /api/admin/groups` + +查询:`cursor`、`limit`、`query`(名称包含)。 + +成功 `data.items[]`: + +```json +{ + "id": "g_ab12cd34", + "name": "一组", + "owner_id": "a", + "member_count": 3, + "created_at_ms": 1750000000000 +} +``` + +### 7.2 创建 + +`POST /api/admin/groups` + +请求: + +```json +{ + "id": "", + "name": "一组", + "owner_id": "a", + "member_ids": ["b", "c"] +} +``` + +后台**不要求**对话密码。`id` 留空则生成。部分成员失败时群仍创建,响应列出失败项。 + +成功 `data`: + +```json +{ + "id": "g_ab12cd34", + "name": "一组", + "owner_id": "a", + "failed": [{"id": "c", "code": "not_found"}] +} +``` + +### 7.3 改名 + +`PATCH /api/admin/groups/{id}` + +请求:`{"name": "新名"}`。成功 `data` 为群摘要(同列表项)。 + +### 7.4 解散 + +`DELETE /api/admin/groups/{id}` + +成功 `data`:`{}`。 + +### 7.5 加人 + +`POST /api/admin/groups/{id}/members` + +请求:`{"member_ids": ["d"]}`。 + +成功 `data`:`{"failed": []}`(结构同创建时的 `failed`)。 + +### 7.6 移除成员 + +`DELETE /api/admin/groups/{id}/members/{endpointId}` + +成功 `data`:`{}`。 + +### 7.7 转让群主 + +`POST /api/admin/groups/{id}/transfer` + +请求:`{"endpoint_id": "b"}`。成功 `data`:`{"owner_id": "b"}`。 + +### 7.8 群详情(成员分页) + +契约补充:`GET /api/admin/groups/{id}`(DEVELOPMENT 路由表未单列,但列表改名/解散需要详情;W 线可用) + +查询:`cursor`、`limit`。 + +成功 `data`: + +```json +{ + "id": "g_ab12cd34", + "name": "一组", + "owner_id": "a", + "created_at_ms": 1750000000000, + "members": [ + {"id": "a", "name": "", "online": true, "joined_at_ms": 1750000000000} + ], + "next_cursor": "" +} +``` + +--- + +## 8. 消息记录(messages) + +响应**不得**含正文或 `body` 字段。 + +### 8.1 列表 + +`GET /api/admin/messages` + +查询参数(PRD F17): + +| 参数 | 说明 | +|---|---| +| `cursor` / `limit` | 分页 | +| `sender_id` | 发送方 | +| `endpoint_id` | 接收端(投递表) | +| `group_id` | 目标群 | +| `state` | 消息级状态:`scheduled` / `dispatched` / `completed` | +| `from_ms` / `to_ms` | 按 `created_at` 时间范围 | + +成功 `data.items[]`: + +```json +{ + "seq": 1, + "id": "018f...", + "sender_id": "a", + "dest_kind": "endpoint", + "dest_id": "b", + "state": "completed", + "reason": "", + "send_at_ms": 1750000010000, + "created_at_ms": 1750000000000, + "keep": false, + "receipt": true, + "content_type": "text/plain", + "delivery_counts": { + "pending": 0, + "accepted": 1, + "recalled": 0, + "expired": 0, + "dropped": 0, + "rejected": 0 + } +} +``` + +### 8.2 详情(各接收端) + +`GET /api/admin/messages/{seq}` + +成功 `data`: + +```json +{ + "seq": 1, + "id": "018f...", + "sender_id": "a", + "dest_kind": "group", + "dest_id": "g_1", + "state": "dispatched", + "reason": "", + "send_at_ms": 1750000010000, + "created_at_ms": 1750000000000, + "meta": {}, + "content_type": "text/plain", + "deliveries": [ + { + "endpoint_id": "b", + "state": "pending", + "reason": "", + "attempts": 1, + "pushed_at_ms": 1750000010100, + "updated_at_ms": 1750000010100 + } + ], + "next_cursor": "" +} +``` + +`deliveries` 可分页(`cursor` / `limit` 查询参数)。无正文。 + +--- + +## 9. 只读运行参数 + +`GET /api/admin/settings` + +成功 `data`(只读,对应配置与生效值;不含密钥): + +```json +{ + "listen": ":7443", + "admin_listen": "", + "session_idle_days": 30, + "record_retention_days": 7, + "idempotency_hours": 24, + "receipt_retention_days": 7, + "sqlite_synchronous": "FULL", + "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, + "max_pending_per_sender": 10000, + "max_pending_per_receiver": 10000 + } +} +``` + +--- + +## 10. 路由速查 + +| 方法 | 路径 | Cookie 可变 | Bearer | 节 | +|---|---|---|---|---| +| POST | `/api/admin/login` | — | — | 2.1 | +| POST | `/api/admin/logout` | 要 | 可 | 2.2 | +| GET | `/api/admin/me` | — | 可 | 2.3 | +| POST | `/api/admin/password` | 要 | **否** | 2.4 | +| GET | `/api/admin/overview` | — | 可 | 3 | +| GET | `/api/admin/endpoints` | — | 可 | 4.1 | +| POST | `/api/admin/endpoints` | 要 | 可 | 4.2 | +| POST | `/api/admin/endpoints/import` | 要 | 可 | 4.3 | +| POST | `/api/admin/endpoints/batch` | 要 | 可 | 4.4 | +| GET | `/api/admin/endpoints/{id}` | — | 可 | 4.5 | +| PATCH | `/api/admin/endpoints/{id}` | 要 | 可 | 4.6 | +| DELETE | `/api/admin/endpoints/{id}` | 要 | 可 | 4.7 | +| POST | `/api/admin/endpoints/{id}/kick` | 要 | 可 | 4.8 | +| POST | `/api/admin/endpoints/{id}/reset-login-password` | 要 | 可 | 4.9 | +| PUT | `/api/admin/endpoints/{id}/talk-password` | 要 | 可 | 4.10 | +| POST | `/api/admin/endpoints/{id}/unlock` | 要 | 可 | 4.11 | +| GET | `/api/admin/registration` | — | 可 | 5.1 | +| PUT | `/api/admin/registration` | 要 | 可 | 5.2 | +| GET | `/api/admin/tokens` | — | **否** | 6.1 | +| POST | `/api/admin/tokens` | 要 | **否** | 6.2 | +| PATCH | `/api/admin/tokens/{id}` | 要 | **否** | 6.3 | +| DELETE | `/api/admin/tokens/{id}` | 要 | **否** | 6.4 | +| GET | `/api/admin/groups` | — | 可 | 7.1 | +| POST | `/api/admin/groups` | 要 | 可 | 7.2 | +| GET | `/api/admin/groups/{id}` | — | 可 | 7.8 | +| PATCH | `/api/admin/groups/{id}` | 要 | 可 | 7.3 | +| DELETE | `/api/admin/groups/{id}` | 要 | 可 | 7.4 | +| POST | `/api/admin/groups/{id}/members` | 要 | 可 | 7.5 | +| DELETE | `/api/admin/groups/{id}/members/{endpointId}` | 要 | 可 | 7.6 | +| POST | `/api/admin/groups/{id}/transfer` | 要 | 可 | 7.7 | +| GET | `/api/admin/messages` | — | 可 | 8.1 | +| GET | `/api/admin/messages/{seq}` | — | 可 | 8.2 | +| GET | `/api/admin/settings` | — | 可 | 9 | + +「Cookie 可变」列:改变状态且走 Cookie 时必须带 `X-Nixmsg-Request: 1`。 + +--- + +## 11. 假数据开发提示(W 线) + +- 可用静态 JSON 或 MSW;列表返回 2~3 条样例即可。 +- 重置密码 / 创建令牌 / 开通生成密码的响应只展示一次,刷新后不再出现明文。 +- 任意管理接口响应用测试断言:序列化字符串中不出现 `"body"` 键。 diff --git a/internal/app/group/service.go b/internal/app/group/service.go new file mode 100644 index 0000000..22ad56e --- /dev/null +++ b/internal/app/group/service.go @@ -0,0 +1,73 @@ +// Package group 定义 DEVELOPMENT 第 6.7 节群操作的接口。 +package group + +import ( + "context" + "errors" + + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +// ErrNotImplemented 表示假实现未提供业务能力。 +var ErrNotImplemented = errors.New("group: not implemented") + +// MemberFail 是建群/加人时部分失败的项。 +type MemberFail struct { + ID string `json:"id"` + Code string `json:"code"` +} + +// CreateResult 是建群结果。 +type CreateResult struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Failed []MemberFail `json:"failed,omitempty"` +} + +// AddResult 是加人结果。 +type AddResult struct { + Failed []MemberFail `json:"failed,omitempty"` +} + +// ListItem 是 group.list 一项。 +type ListItem struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + MemberCount int `json:"member_count"` +} + +// MemberItem 是 group.get 成员项。 +type MemberItem struct { + ID string `json:"id"` + Name string `json:"name"` + Online bool `json:"online"` +} + +// GetResult 是群详情。 +type GetResult struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + Members []MemberItem `json:"members"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// Service 是群子系统契约(第 6.7 节)。 +type Service interface { + Create(ctx context.Context, actorID string, req *protocol.GroupCreate) (CreateResult, error) + Add(ctx context.Context, actorID string, req *protocol.GroupAdd) (AddResult, error) + Remove(ctx context.Context, actorID string, req *protocol.GroupRemove) error + Leave(ctx context.Context, actorID string, req *protocol.GroupLeave) error + Transfer(ctx context.Context, actorID string, req *protocol.GroupTransfer) error + Rename(ctx context.Context, actorID string, req *protocol.GroupRename) error + Dissolve(ctx context.Context, actorID string, req *protocol.GroupDissolve) error + List(ctx context.Context, actorID string, req *protocol.GroupList) (items []ListItem, nextCursor string, err error) + Get(ctx context.Context, actorID string, req *protocol.GroupGet) (GetResult, error) + + // AdminCreate 后台建群(不要求对话密码)。 + AdminCreate(ctx context.Context, name string, ownerID string, memberIDs []string) (CreateResult, error) + // AdminAddMembers 后台加人。 + AdminAddMembers(ctx context.Context, groupID string, memberIDs []string) (AddResult, error) +} diff --git a/internal/app/group/service_test.go b/internal/app/group/service_test.go new file mode 100644 index 0000000..fab8ca5 --- /dev/null +++ b/internal/app/group/service_test.go @@ -0,0 +1,25 @@ +package group + +import ( + "context" + "errors" + "testing" + + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +func TestStubCreateNotImplemented(t *testing.T) { + s := NewStub() + _, err := s.Create(context.Background(), "a", &protocol.GroupCreate{Name: "g"}) + if !errors.Is(err, ErrNotImplemented) { + t.Fatalf("got %v", err) + } +} + +func TestStubListEmpty(t *testing.T) { + s := NewStub() + items, cursor, err := s.List(context.Background(), "a", &protocol.GroupList{}) + if err != nil || len(items) != 0 || cursor != "" { + t.Fatalf("items=%v cursor=%q err=%v", items, cursor, err) + } +} diff --git a/internal/app/group/stub.go b/internal/app/group/stub.go new file mode 100644 index 0000000..e2c07c9 --- /dev/null +++ b/internal/app/group/stub.go @@ -0,0 +1,58 @@ +package group + +import ( + "context" + + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +// Stub 是测试用假实现。 +type Stub struct{} + +func NewStub() *Stub { return &Stub{} } + +func (s *Stub) Create(context.Context, string, *protocol.GroupCreate) (CreateResult, error) { + return CreateResult{}, ErrNotImplemented +} + +func (s *Stub) Add(context.Context, string, *protocol.GroupAdd) (AddResult, error) { + return AddResult{}, ErrNotImplemented +} + +func (s *Stub) Remove(context.Context, string, *protocol.GroupRemove) error { + return ErrNotImplemented +} + +func (s *Stub) Leave(context.Context, string, *protocol.GroupLeave) error { + return ErrNotImplemented +} + +func (s *Stub) Transfer(context.Context, string, *protocol.GroupTransfer) error { + return ErrNotImplemented +} + +func (s *Stub) Rename(context.Context, string, *protocol.GroupRename) error { + return ErrNotImplemented +} + +func (s *Stub) Dissolve(context.Context, string, *protocol.GroupDissolve) error { + return ErrNotImplemented +} + +func (s *Stub) List(context.Context, string, *protocol.GroupList) ([]ListItem, string, error) { + return nil, "", nil +} + +func (s *Stub) Get(context.Context, string, *protocol.GroupGet) (GetResult, error) { + return GetResult{}, ErrNotImplemented +} + +func (s *Stub) AdminCreate(context.Context, string, string, []string) (CreateResult, error) { + return CreateResult{}, ErrNotImplemented +} + +func (s *Stub) AdminAddMembers(context.Context, string, []string) (AddResult, error) { + return AddResult{}, ErrNotImplemented +} + +var _ Service = (*Stub)(nil) diff --git a/internal/app/identity/service.go b/internal/app/identity/service.go new file mode 100644 index 0000000..e5ce538 --- /dev/null +++ b/internal/app/identity/service.go @@ -0,0 +1,63 @@ +// Package identity 定义注册、self.*、对话密码授权、停用与删除级联的接口。 +package identity + +import ( + "context" + "errors" + + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +// ErrNotImplemented 表示假实现未提供业务能力。 +var ErrNotImplemented = errors.New("identity: not implemented") + +// RegisterRequest 对应 POST /api/client/register 与后台开通的公共字段。 +type RegisterRequest struct { + RegistrationCode string + ID string + LoginPassword string + Name string + TalkPassword string + Remark string + DefaultDelayMs int64 + Source string // admin | self + RemoteIP string +} + +// RegisterResult 是注册/开通结果。 +type RegisterResult struct { + ID string + LoginPassword string // 仅当请求留空由服务器生成时非空 +} + +// SelfInfo 是 self.get 返回。 +type SelfInfo struct { + ID string `json:"id"` + Name string `json:"name"` + DefaultDelayMs int64 `json:"default_delay_ms"` + TalkPasswordSet bool `json:"talk_password_set"` +} + +// Service 是身份子系统契约。 +type Service interface { + // Register 处理自助注册或供管理开通复用(第 6.9 节)。 + Register(ctx context.Context, req RegisterRequest) (RegisterResult, error) + + SelfGet(ctx context.Context, endpointID string) (SelfInfo, error) + SelfUpdate(ctx context.Context, endpointID string, req *protocol.SelfUpdate) error + SelfSetTalkPassword(ctx context.Context, endpointID string, talkPassword string) error + SelfChangeLoginPassword(ctx context.Context, endpointID string, oldPassword, newPassword string) (sessionToken string, err error) + SelfLogout(ctx context.Context, endpointID string) error + + // UnlockTalk 校验并写入对话密码授权(第 6.6 节 unlock)。 + UnlockTalk(ctx context.Context, senderID, targetID, talkPassword string) error + // HasTalkGrant 查询发送方对目标是否有有效授权。 + HasTalkGrant(ctx context.Context, senderID, targetID string) (bool, error) + + // Disable 停用端并作废相关消息/令牌(第 7.6 节)。 + Disable(ctx context.Context, endpointID string) error + // Enable 重新启用。 + Enable(ctx context.Context, endpointID string) error + // Delete 删除端并做级联清理。 + Delete(ctx context.Context, endpointID string) error +} diff --git a/internal/app/identity/service_test.go b/internal/app/identity/service_test.go new file mode 100644 index 0000000..742b7ae --- /dev/null +++ b/internal/app/identity/service_test.go @@ -0,0 +1,23 @@ +package identity + +import ( + "context" + "errors" + "testing" +) + +func TestStubRegisterNotImplemented(t *testing.T) { + s := NewStub() + _, err := s.Register(context.Background(), RegisterRequest{Source: "self"}) + if !errors.Is(err, ErrNotImplemented) { + t.Fatalf("got %v", err) + } +} + +func TestStubHasTalkGrantFalse(t *testing.T) { + s := NewStub() + ok, err := s.HasTalkGrant(context.Background(), "a", "b") + if err != nil || ok { + t.Fatalf("ok=%v err=%v", ok, err) + } +} diff --git a/internal/app/identity/stub.go b/internal/app/identity/stub.go new file mode 100644 index 0000000..41738a3 --- /dev/null +++ b/internal/app/identity/stub.go @@ -0,0 +1,48 @@ +package identity + +import ( + "context" + + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +// Stub 是测试用假实现。 +type Stub struct{} + +func NewStub() *Stub { return &Stub{} } + +func (s *Stub) Register(context.Context, RegisterRequest) (RegisterResult, error) { + return RegisterResult{}, ErrNotImplemented +} + +func (s *Stub) SelfGet(context.Context, string) (SelfInfo, error) { + return SelfInfo{}, ErrNotImplemented +} + +func (s *Stub) SelfUpdate(context.Context, string, *protocol.SelfUpdate) error { + return ErrNotImplemented +} + +func (s *Stub) SelfSetTalkPassword(context.Context, string, string) error { + return ErrNotImplemented +} + +func (s *Stub) SelfChangeLoginPassword(context.Context, string, string, string) (string, error) { + return "", ErrNotImplemented +} + +func (s *Stub) SelfLogout(context.Context, string) error { return ErrNotImplemented } + +func (s *Stub) UnlockTalk(context.Context, string, string, string) error { + return ErrNotImplemented +} + +func (s *Stub) HasTalkGrant(context.Context, string, string) (bool, error) { + return false, nil +} + +func (s *Stub) Disable(context.Context, string) error { return ErrNotImplemented } +func (s *Stub) Enable(context.Context, string) error { return ErrNotImplemented } +func (s *Stub) Delete(context.Context, string) error { return ErrNotImplemented } + +var _ Service = (*Stub)(nil) diff --git a/internal/app/message/service.go b/internal/app/message/service.go new file mode 100644 index 0000000..fc90eea --- /dev/null +++ b/internal/app/message/service.go @@ -0,0 +1,54 @@ +// Package message 定义消息提交、分发、推送、确认、撤回、回执、清理与启动恢复的接口。 +package message + +import ( + "context" + "errors" + + "git.asio.asia/nixevol/NixMsg/internal/app/port" + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +// ErrNotImplemented 表示假实现未提供业务能力。 +var ErrNotImplemented = errors.New("message: not implemented") + +// SubmitResult 是发送提交的结果(对应 send 的 resp data)。 +type SubmitResult struct { + ID string + SendAtMs int64 + State string // scheduled | dispatched | ... +} + +// AckResult 是确认结果。 +type AckResult struct { + Result string // accepted | recalled | expired | dropped | rejected 等最终态 +} + +// Service 是消息子系统对外契约。方法签名供 M 线实现;T0.4 不写状态机。 +type Service interface { + // Submit 处理端发送请求(第 7.3 节)。 + Submit(ctx context.Context, senderID string, conn port.ConnInfo, req *protocol.Send) (SubmitResult, error) + // Ack 处理确认(第 7.6 节)。 + Ack(ctx context.Context, endpointID string, req *protocol.Ack) (AckResult, error) + // Recall 处理撤回。 + Recall(ctx context.Context, senderID string, req *protocol.Recall) (protocol.RecallData, error) + // Status 查询自己发出的消息状态。 + Status(ctx context.Context, senderID string, req *protocol.Status) (any, error) + // ReceiptAck 确认回执已收下。 + ReceiptAck(ctx context.Context, endpointID string, req *protocol.ReceiptAck) error + + // DispatchDue 分发已到点的 scheduled 消息(第 7.4 节);由调度循环调用。 + DispatchDue(ctx context.Context, nowMs int64, limit int) (dispatched int, err error) + // PushPending 向已握手连接推送 pending 投递与回执(第 7.5 节)。 + PushPending(ctx context.Context, endpointID string, connID port.ConnID) error + // OnPublishDropped 下行未写入发送队列时,清推送标记并安排重推。 + OnPublishDropped(ctx context.Context, endpointID string, connID port.ConnID, payload []byte) error + + // CleanupOnce 执行一轮过期投递与记录清理(第 7.5 / 7.6 节)。 + CleanupOnce(ctx context.Context, nowMs int64) error + // RecoverOnStart 启动恢复:清残留 pushed_conn、宽限、补发定时等(第 7.8 节)。 + RecoverOnStart(ctx context.Context) error + + // WakePush 唤醒某端推送循环(提交/分发后由内部或其它模块调用)。 + WakePush(endpointID string) +} diff --git a/internal/app/message/service_test.go b/internal/app/message/service_test.go new file mode 100644 index 0000000..d6f5b53 --- /dev/null +++ b/internal/app/message/service_test.go @@ -0,0 +1,25 @@ +package message + +import ( + "context" + "errors" + "testing" + + "git.asio.asia/nixevol/NixMsg/internal/app/port" + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +func TestStubSubmitNotImplemented(t *testing.T) { + s := NewStub() + _, err := s.Submit(context.Background(), "a", port.ConnInfo{}, &protocol.Send{}) + if !errors.Is(err, ErrNotImplemented) { + t.Fatalf("got %v", err) + } +} + +func TestStubRecoverNoop(t *testing.T) { + s := NewStub() + if err := s.RecoverOnStart(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/message/stub.go b/internal/app/message/stub.go new file mode 100644 index 0000000..edbb10c --- /dev/null +++ b/internal/app/message/stub.go @@ -0,0 +1,53 @@ +package message + +import ( + "context" + + "git.asio.asia/nixevol/NixMsg/internal/app/port" + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +// Stub 是测试用假实现:方法返回 ErrNotImplemented 或固定空结果。 +type Stub struct{} + +func NewStub() *Stub { return &Stub{} } + +func (s *Stub) Submit(context.Context, string, port.ConnInfo, *protocol.Send) (SubmitResult, error) { + return SubmitResult{}, ErrNotImplemented +} + +func (s *Stub) Ack(context.Context, string, *protocol.Ack) (AckResult, error) { + return AckResult{}, ErrNotImplemented +} + +func (s *Stub) Recall(context.Context, string, *protocol.Recall) (protocol.RecallData, error) { + return protocol.RecallData{}, ErrNotImplemented +} + +func (s *Stub) Status(context.Context, string, *protocol.Status) (any, error) { + return nil, ErrNotImplemented +} + +func (s *Stub) ReceiptAck(context.Context, string, *protocol.ReceiptAck) error { + return ErrNotImplemented +} + +func (s *Stub) DispatchDue(context.Context, int64, int) (int, error) { + return 0, nil +} + +func (s *Stub) PushPending(context.Context, string, port.ConnID) error { + return nil +} + +func (s *Stub) OnPublishDropped(context.Context, string, port.ConnID, []byte) error { + return nil +} + +func (s *Stub) CleanupOnce(context.Context, int64) error { return nil } + +func (s *Stub) RecoverOnStart(context.Context) error { return nil } + +func (s *Stub) WakePush(string) {} + +var _ Service = (*Stub)(nil) diff --git a/internal/app/port/port.go b/internal/app/port/port.go new file mode 100644 index 0000000..ff017e2 --- /dev/null +++ b/internal/app/port/port.go @@ -0,0 +1,81 @@ +// Package port 定义 broker 与 app 之间的契约。 +// +// broker(连接 N)实现 Downlink / ConnControl;app 各模块通过这些接口下行发布或踢线。 +// broker 在连接生命周期与上行帧到达时调用 UplinkHandler。 +// 本包不依赖 mochi,也不包含业务状态机。 +package port + +import ( + "context" +) + +// ConnID 是连接代号(同一端编号新旧连接 ClientID 相同,用独立代号区分)。 +type ConnID string + +// Transport 区分接入方式。 +type Transport string + +const ( + TransportTCP Transport = "tcp" + TransportWS Transport = "ws" +) + +// ConnInfo 描述一条已建立的 MQTT 连接(登录校验通过之后)。 +type ConnInfo struct { + ConnID ConnID + EndpointID string + Transport Transport + RemoteIP string + // SessionToken 非空表示本次用密码登录后新签发的令牌(握手响应里交给端)。 + SessionToken string + // MaxPacketSize 来自 CONNECT;0 表示未声明。 + MaxPacketSize uint32 +} + +// HandshakeInfo 是握手完成时的补充信息。 +type HandshakeInfo struct { + ConnInfo + MaxReceiveBytes int // 0 表示不限(仍受 MaxPacketSize 约束) + Client string +} + +// DisconnectReason 说明断开原因,便于 app 区分当前连接与被顶号的旧连接。 +type DisconnectReason string + +const ( + DisconnectNormal DisconnectReason = "normal" + DisconnectTakenOver DisconnectReason = "taken_over" + DisconnectKicked DisconnectReason = "kicked" + DisconnectFatal DisconnectReason = "fatal" + DisconnectIdle DisconnectReason = "idle" +) + +// UplinkHandler 由 app 实现,broker 在钩子里调用。 +type UplinkHandler interface { + // OnSessionEstablished 在 MQTT 会话建立、登录已通过后调用(握手前)。 + OnSessionEstablished(ctx context.Context, conn ConnInfo) error + // OnHandshakeComplete 在 hello 成功处理后调用;此后该连接算在线并可推送。 + OnHandshakeComplete(ctx context.Context, hs HandshakeInfo) error + // OnDisconnect 在连接断开时调用;是否为当前连接由 app 按 ConnID 判断。 + OnDisconnect(ctx context.Context, conn ConnInfo, reason DisconnectReason) + // HandleUplink 处理上行应用帧原始 JSON(已从 MQTT 发布拷贝)。 + HandleUplink(ctx context.Context, conn ConnInfo, payload []byte) error +} + +// PublishOpts 控制下行发布。 +type PublishOpts struct { + QoS byte // 0 或 1;msg/receipt/revoked/resp/fatal 用 1,presence/group_event 用 0 +} + +// Downlink 由 broker 实现,供 app 向下行主题发布。 +type Downlink interface { + // PublishDown 向 nix/c/{endpointID}/down 发布一帧。 + // connID 非空时仅在该连接仍是当前连接时发布;空表示发给该端当前连接。 + PublishDown(ctx context.Context, endpointID string, connID ConnID, payload []byte, opts PublishOpts) error +} + +// ConnControl 由 broker 实现,供 app/admin 踢线或发 fatal 后断开。 +type ConnControl interface { + // Disconnect 断开指定连接;connID 为空则断开该端当前连接。 + Disconnect(ctx context.Context, endpointID string, connID ConnID, reason DisconnectReason) error +} diff --git a/internal/app/port/port_test.go b/internal/app/port/port_test.go new file mode 100644 index 0000000..48bb38b --- /dev/null +++ b/internal/app/port/port_test.go @@ -0,0 +1,23 @@ +package port + +import ( + "context" + "testing" +) + +func TestStubDownlinkPublish(t *testing.T) { + d := &StubDownlink{} + if err := d.PublishDown(context.Background(), "a", "c1", []byte(`{}`), PublishOpts{QoS: 1}); err != nil { + t.Fatal(err) + } + if d.Published != 1 { + t.Fatalf("published=%d", d.Published) + } +} + +func TestStubUplinkHandlerCompile(t *testing.T) { + var h UplinkHandler = StubUplinkHandler{} + if err := h.OnSessionEstablished(context.Background(), ConnInfo{EndpointID: "a"}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/port/stub.go b/internal/app/port/stub.go new file mode 100644 index 0000000..16c2d53 --- /dev/null +++ b/internal/app/port/stub.go @@ -0,0 +1,40 @@ +package port + +import "context" + +// StubDownlink 吞掉所有下行发布。 +type StubDownlink struct { + Published int +} + +func (s *StubDownlink) PublishDown(_ context.Context, _ string, _ ConnID, _ []byte, _ PublishOpts) error { + s.Published++ + return nil +} + +// StubConnControl 记录断开请求。 +type StubConnControl struct { + Calls []string +} + +func (s *StubConnControl) Disconnect(_ context.Context, endpointID string, _ ConnID, _ DisconnectReason) error { + s.Calls = append(s.Calls, endpointID) + return nil +} + +// StubUplinkHandler 空实现,供 broker 接线测试。 +type StubUplinkHandler struct{} + +func (StubUplinkHandler) OnSessionEstablished(context.Context, ConnInfo) error { return nil } + +func (StubUplinkHandler) OnHandshakeComplete(context.Context, HandshakeInfo) error { return nil } + +func (StubUplinkHandler) OnDisconnect(context.Context, ConnInfo, DisconnectReason) {} + +func (StubUplinkHandler) HandleUplink(context.Context, ConnInfo, []byte) error { return nil } + +var ( + _ Downlink = (*StubDownlink)(nil) + _ ConnControl = (*StubConnControl)(nil) + _ UplinkHandler = StubUplinkHandler{} +) diff --git a/internal/app/presence/service.go b/internal/app/presence/service.go new file mode 100644 index 0000000..754082e --- /dev/null +++ b/internal/app/presence/service.go @@ -0,0 +1,53 @@ +// Package presence 定义在线查询、目录与上下线订阅的接口。 +package presence + +import ( + "context" + "errors" + + "git.asio.asia/nixevol/NixMsg/internal/app/port" + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +// ErrNotImplemented 表示假实现未提供业务能力。 +var ErrNotImplemented = errors.New("presence: not implemented") + +// StatusItem 是 presence.get 单项。 +type StatusItem struct { + ID string `json:"id"` + Online bool `json:"online"` + SinceMs int64 `json:"since_ms"` + // NotFound 为 true 时该项对应未知编号(协议用 not_found 表达,实现可汇总)。 + NotFound bool `json:"-"` +} + +// DirectoryItem 是目录一项。 +type DirectoryItem struct { + ID string `json:"id"` + Name string `json:"name"` + Online bool `json:"online"` + OnlineSinceMs *int64 `json:"online_since_ms"` + OfflineSinceMs *int64 `json:"offline_since_ms"` + TalkPasswordSet bool `json:"talk_password_set"` +} + +// Service 是在线与目录子系统契约(第 6.5 节)。 +type Service interface { + // Get 查询若干编号在线状态。 + Get(ctx context.Context, ids []string) ([]StatusItem, error) + // Directory 分页目录。 + Directory(ctx context.Context, req *protocol.DirectoryList) (items []DirectoryItem, nextCursor string, err error) + // Watch 覆盖本连接的上下线订阅;断线由 ClearWatch 清空。 + Watch(ctx context.Context, connID port.ConnID, endpointID string, req *protocol.PresenceWatch) error + // ClearWatch 在连接断开时清空订阅。 + ClearWatch(connID port.ConnID) + + // SetOnline 握手完成时标记在线并通知订阅者。 + SetOnline(ctx context.Context, endpointID string, connID port.ConnID, atMs int64) error + // SetOffline 当前连接断开时标记离线并通知订阅者。 + SetOffline(ctx context.Context, endpointID string, connID port.ConnID, atMs int64) error + // IsOnline 查询端是否有已握手连接。 + IsOnline(endpointID string) bool + // CurrentConn 返回端的当前连接代号;无则空。 + CurrentConn(endpointID string) (port.ConnID, bool) +} diff --git a/internal/app/presence/service_test.go b/internal/app/presence/service_test.go new file mode 100644 index 0000000..d6eedc7 --- /dev/null +++ b/internal/app/presence/service_test.go @@ -0,0 +1,23 @@ +package presence + +import ( + "context" + "testing" +) + +func TestStubGetOffline(t *testing.T) { + s := NewStub() + items, err := s.Get(context.Background(), []string{"a", "b"}) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0].Online || items[1].Online { + t.Fatalf("%+v", items) + } +} + +func TestStubIsOnlineFalse(t *testing.T) { + if NewStub().IsOnline("x") { + t.Fatal("expected offline") + } +} diff --git a/internal/app/presence/stub.go b/internal/app/presence/stub.go new file mode 100644 index 0000000..70362aa --- /dev/null +++ b/internal/app/presence/stub.go @@ -0,0 +1,41 @@ +package presence + +import ( + "context" + + "git.asio.asia/nixevol/NixMsg/internal/app/port" + "git.asio.asia/nixevol/NixMsg/internal/protocol" +) + +// Stub 是测试用假实现:全部视为离线。 +type Stub struct{} + +func NewStub() *Stub { return &Stub{} } + +func (s *Stub) Get(_ context.Context, ids []string) ([]StatusItem, error) { + out := make([]StatusItem, 0, len(ids)) + for _, id := range ids { + out = append(out, StatusItem{ID: id, Online: false, SinceMs: 0}) + } + return out, nil +} + +func (s *Stub) Directory(context.Context, *protocol.DirectoryList) ([]DirectoryItem, string, error) { + return nil, "", nil +} + +func (s *Stub) Watch(context.Context, port.ConnID, string, *protocol.PresenceWatch) error { + return nil +} + +func (s *Stub) ClearWatch(port.ConnID) {} + +func (s *Stub) SetOnline(context.Context, string, port.ConnID, int64) error { return nil } + +func (s *Stub) SetOffline(context.Context, string, port.ConnID, int64) error { return nil } + +func (s *Stub) IsOnline(string) bool { return false } + +func (s *Stub) CurrentConn(string) (port.ConnID, bool) { return "", false } + +var _ Service = (*Stub)(nil) diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..02b7a12 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,95 @@ +// Package auth 定义密码哈希池、会话令牌、API 令牌与登录锁定计数的接口。 +// 本包在 T0.4 只提供契约与测试假实现;真正的 argon2 池与令牌逻辑由平台 P 实现。 +package auth + +import ( + "context" + "errors" + "time" +) + +// ErrNotImplemented 表示假实现尚未提供业务能力。 +var ErrNotImplemented = errors.New("auth: not implemented") + +// PasswordKind 区分哈希用途(便于日志与限流,不影响算法)。 +type PasswordKind string + +const ( + PasswordLogin PasswordKind = "login" + PasswordTalk PasswordKind = "talk" + PasswordAdmin PasswordKind = "admin" +) + +// HashPool 是 argon2id 并发池(DEVELOPMENT 第 12 节)。 +type HashPool interface { + // Hash 计算 PHC 格式哈希。 + Hash(ctx context.Context, kind PasswordKind, password string) (phc string, err error) + // Verify 常量时间比较;ok 为 false 时 err 仍可为 nil(密码不匹配)。 + Verify(ctx context.Context, kind PasswordKind, password, phc string) (ok bool, err error) + // QueueLen 返回等待哈希的任务数(指标用)。 + QueueLen() int +} + +// SessionTokens 管理端会话令牌(nst_ 前缀,库中存 SHA-256)。 +type SessionTokens interface { + // Issue 生成新令牌明文,返回明文与 SHA-256 十六进制(或原始哈希字节由实现约定)。 + Issue(ctx context.Context) (token string, hash []byte, err error) + // HashToken 对已有令牌做 SHA-256(校验用,不走 argon2)。 + HashToken(token string) []byte + // LooksLikeSessionToken 判断密码字段是否以 nst_ 开头。 + LooksLikeSessionToken(credential string) bool +} + +// APITokenInfo 是列表项(不含令牌明文)。 +type APITokenInfo struct { + ID int64 + Name string + Enabled bool + CreatedAt time.Time + LastUsedAt *time.Time +} + +// APITokens 管理后台 API 令牌(nxm_ 前缀)。 +type APITokens interface { + Issue(ctx context.Context) (token string, hash []byte, err error) + HashToken(token string) []byte + LooksLikeAPIToken(credential string) bool +} + +// LockKind 区分锁定计数器类型。 +type LockKind string + +const ( + // LockLoginEndpointIP:编号 + IP,5 分钟内 10 次错 → 锁该组合 5 分钟。 + LockLoginEndpointIP LockKind = "login_endpoint_ip" + // LockLoginEndpoint:编号总数,1 小时内 50 次错 → 暂停该编号密码登录 1 小时。 + LockLoginEndpoint LockKind = "login_endpoint" + // LockTalkPair:发送方 + 对方对话密码。 + LockTalkPair LockKind = "talk_pair" + // LockTalkTarget:对方对话密码总数。 + LockTalkTarget LockKind = "talk_target" + // LockAdminIP:管理员登录 / 错误 API 令牌。 + LockAdminIP LockKind = "admin_ip" + // LockRegisterIP:注册安全码错误。 + LockRegisterIP LockKind = "register_ip" +) + +// LockKey 是一次锁定查询/计数的键。 +type LockKey struct { + Kind LockKind + EndpointID string // 端编号;管理员/注册场景可空 + PeerID string // 对话密码对方;可空 + IP string // 来源 IP;按编号总数等场景可空 +} + +// LoginLocks 是内存锁定计数器(重启清零)。 +type LoginLocks interface { + // Check 若当前已锁定返回 locked=true 与剩余时间。 + Check(key LockKey) (locked bool, retryAfter time.Duration) + // Fail 记录一次失败;若因此触发锁定,返回 locked=true。 + Fail(key LockKey) (locked bool, retryAfter time.Duration) + // ClearEndpoint 清除某端编号相关的登录锁定(两种都清),对应管理 unlock。 + ClearEndpoint(endpointID string) + // Clear 清除精确键。 + Clear(key LockKey) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..da8c4e9 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,59 @@ +package auth + +import ( + "context" + "testing" +) + +func TestStubHashPoolRoundTrip(t *testing.T) { + p := NewStubHashPool() + h, err := p.Hash(context.Background(), PasswordLogin, "secret") + if err != nil { + t.Fatal(err) + } + ok, err := p.Verify(context.Background(), PasswordLogin, "secret", h) + if err != nil || !ok { + t.Fatalf("verify: ok=%v err=%v", ok, err) + } + ok, _ = p.Verify(context.Background(), PasswordLogin, "wrong", h) + if ok { + t.Fatal("expected mismatch") + } +} + +func TestStubSessionTokenPrefix(t *testing.T) { + s := NewStubSessionTokens() + tok, hash, err := s.Issue(context.Background()) + if err != nil { + t.Fatal(err) + } + if !s.LooksLikeSessionToken(tok) { + t.Fatalf("token %q should look like session", tok) + } + if len(hash) != 32 { + t.Fatalf("hash len %d", len(hash)) + } +} + +func TestStubAPITokenPrefix(t *testing.T) { + s := NewStubAPITokens() + tok, _, err := s.Issue(context.Background()) + if err != nil { + t.Fatal(err) + } + if !s.LooksLikeAPIToken(tok) { + t.Fatalf("token %q should look like api", tok) + } +} + +func TestStubLoginLocksFailRecord(t *testing.T) { + l := NewStubLoginLocks() + locked, _ := l.Fail(LockKey{Kind: LockLoginEndpointIP, EndpointID: "a", IP: "1.2.3.4"}) + if locked { + t.Fatal("stub should not lock") + } + l.ClearEndpoint("a") + if len(l.Cleared) != 1 || l.Cleared[0] != "a" { + t.Fatalf("cleared=%v", l.Cleared) + } +} diff --git a/internal/auth/stub.go b/internal/auth/stub.go new file mode 100644 index 0000000..90eba90 --- /dev/null +++ b/internal/auth/stub.go @@ -0,0 +1,105 @@ +package auth + +import ( + "context" + "crypto/sha256" + "strings" + "sync" + "time" +) + +// StubHashPool 是测试用假哈希池:明文加前缀,不做 argon2。 +type StubHashPool struct { + mu sync.Mutex + queue int +} + +func NewStubHashPool() *StubHashPool { return &StubHashPool{} } + +func (p *StubHashPool) Hash(_ context.Context, _ PasswordKind, password string) (string, error) { + return "stub$" + password, nil +} + +func (p *StubHashPool) Verify(_ context.Context, _ PasswordKind, password, phc string) (bool, error) { + return phc == "stub$"+password, nil +} + +func (p *StubHashPool) QueueLen() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.queue +} + +// StubSessionTokens 假会话令牌。 +type StubSessionTokens struct{} + +func NewStubSessionTokens() *StubSessionTokens { return &StubSessionTokens{} } + +func (s *StubSessionTokens) Issue(_ context.Context) (string, []byte, error) { + tok := "nst_stub_session_token_000000000000" + sum := sha256.Sum256([]byte(tok)) + return tok, sum[:], nil +} + +func (s *StubSessionTokens) HashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +func (s *StubSessionTokens) LooksLikeSessionToken(credential string) bool { + return strings.HasPrefix(credential, "nst_") +} + +// StubAPITokens 假 API 令牌。 +type StubAPITokens struct{} + +func NewStubAPITokens() *StubAPITokens { return &StubAPITokens{} } + +func (s *StubAPITokens) Issue(_ context.Context) (string, []byte, error) { + tok := "nxm_stub_api_token_0000000000000000" + sum := sha256.Sum256([]byte(tok)) + return tok, sum[:], nil +} + +func (s *StubAPITokens) HashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +func (s *StubAPITokens) LooksLikeAPIToken(credential string) bool { + return strings.HasPrefix(credential, "nxm_") +} + +// StubLoginLocks 假锁定:永不锁定,记录调用便于测试。 +type StubLoginLocks struct { + mu sync.Mutex + Fails []LockKey + Cleared []string +} + +func NewStubLoginLocks() *StubLoginLocks { return &StubLoginLocks{} } + +func (l *StubLoginLocks) Check(LockKey) (bool, time.Duration) { return false, 0 } + +func (l *StubLoginLocks) Fail(key LockKey) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + l.Fails = append(l.Fails, key) + return false, 0 +} + +func (l *StubLoginLocks) ClearEndpoint(endpointID string) { + l.mu.Lock() + defer l.mu.Unlock() + l.Cleared = append(l.Cleared, endpointID) +} + +func (l *StubLoginLocks) Clear(LockKey) {} + +// 编译期检查:假实现满足接口。 +var ( + _ HashPool = (*StubHashPool)(nil) + _ SessionTokens = (*StubSessionTokens)(nil) + _ APITokens = (*StubAPITokens)(nil) + _ LoginLocks = (*StubLoginLocks)(nil) +)