# SafeW 群列表接口设计 **Date:** 2026-08-15 **Status:** Approved ## Goal 为 safew 渠道提供两个管理接口,返回该 bot token **已经监控到的群**。SafeW 没有 `getChats`;用 `getUpdates` 收集群聊,写入 Redis,列表接口读 Redis。 ## Non-goals - 列出 private / channel - 在请求路径上 `timeout=30` 阻塞 UI - 把 bot token 明文写入 Redis key 或提交到仓库 - 远程 e2e 真实 SafeW 轮询 - 新增 MySQL 表 ## Endpoints Admin Bearer 鉴权,与现有 `/api/v1/channels` 相同。 ### `POST /api/v1/channels/safew/chats` 创建渠道时选群。Body: ```json { "token": "", "q": "测试AI" } ``` | 字段 | 必填 | 说明 | |------|------|------| | `token` | 是 | SafeW Bot Token | | `q` | 否 | 按群名 / username / chat_id 子串过滤(不区分大小写) | 路由必须注册在 `GET /channels/:id` 之前,避免 `safew` 被当成 id。 ### `GET /api/v1/channels/:id/chats?q=` 编辑已有渠道。用库里 `channel.config.token`,前端不再传 token。 | 情况 | 响应 | |------|------| | channel 不存在 | 404 `{"error":"channel not found"}` | | `type != safew` | 400 `{"error":"channel is not safew"}` | | config 无 token | 400 `{"error":"safew token is required"}` | ### 成功响应(两个入口相同) ```json { "data": [ { "id": "10000778141", "type": "group", "title": "测试AI", "username": null } ], "total": 1 } ``` - `id` 永远是 JSON 字符串(SafeW 的数字 id 可能超过 JS 安全整数) - `username` 没有则为 `null` - 默认只含 `group` / `supergroup` - 空列表:`200`,`{"data":[],"total":0}`(bot 尚未在任何群收到更新时正常) Token 无效:`401`,`{"error":""}`。 ## Architecture ``` POST/GET chats → 解析 token → Watcher.Ensure(token) // 懒启动该 token 的后台长轮询 → Poll getUpdates timeout=0 // 抽干积压,不阻塞 → 合并群到 Redis → 按 q 过滤,返回 data/total Watcher (per token, in-process) loop: getUpdates timeout=30 合并群到 Redis 更新 offset ``` 同一 token 对 SafeW `getUpdates` 必须串行(Redis 锁),避免并发把 `offset` 冲掉。 ## SafeW getUpdates - URL:`POST https://api.safew.bot/bot{token}/getUpdates` - Body:`{"timeout":<0|30>,"offset":,"limit":100}` - 处理完一批后:`offset = max(update_id)+1` 写入 Redis - 从每条 update 的 `message` / `edited_message` / `my_chat_member` / `chat_member` / `channel_post` 等字段里取 `chat`;只保留 `type` 为 `group` 或 `supergroup` - `chat.id` 用 `json.Number` / raw 转成十进制字符串,禁止 `float64` 群只有 bot **收到过该群的更新**(加群、有人说话等)才会出现。 ## Redis Key 用 `sha256(token)` 的 hex,不把 token 放进 key。 | Key | 类型 | 内容 | TTL | |-----|------|------|-----| | `safew:chats:{hash}` | Hash | field=`chat_id`,value=`{"id","type","title","username"}` | 无 | | `safew:offset:{hash}` | String | 已确认的最大 `update_id` | 无 | | `safew:poll:{hash}` | String | `getUpdates` 互斥锁,短 TTL(约 35s) | 有 | 进程重启后群记录仍在;Watcher 从存着的 offset 继续。 ## Components | 文件 | 职责 | |------|------| | `internal/adapter/safew.go` | `getUpdates` HTTP、解析 chat、id 转字符串 | | `internal/cache/safew_chats.go` | chats / offset / lock | | `internal/safew/watcher.go` | 每 token 一条 goroutine,`timeout=30` | | `internal/handler/channel.go` | 两个 HTTP handler | | `cmd/server/main.go` | 注册路由、注入 Watcher、shutdown 时 Stop | | `README.md` / `docs/httpie/curls.md` | 接口文档 | ## Error handling | 情况 | 行为 | |------|------| | SafeW 401 / token 无效 | 列表接口 401;Watcher 打日志,该轮结束,下次再试 | | 网络错误、`getUpdates` 5xx | 列表仍返回 Redis 已有群;Watcher sleep 后重试 | | 抢不到 poll 锁 | 跳过本次拉取,直接返回 Redis | | JSON 非法 / 缺 token | 400 | ## Tests 不引入真实 token。 1. **解析**:update 含 group + private → 只留下 group;`id` 为字符串 `"10000778141"`。 2. **过滤**:`q=测试` 命中 title;`q=10000778141` 命中 id。 3. **getUpdates mock**:httptest 返回一批 update,offset 前进。 4. **handler**:非 safew channel → 400;缺 token → 400。 Watcher 用可注入的 poll 函数 + 假 cache,不断言真实 30s 阻塞。 ## Success criteria - 两个接口返回已监控群,`id` 为字符串 - 默认只有群;`q` 可过滤 - 后台 `timeout=30` 持续写入 Redis;列表接口不卡 30 秒 - 仓库与 Redis key 中不出现明文 token