feat: 增加 SafeW 已监控群列表接口

通过 getUpdates 将群写入 Redis,供创建/编辑渠道时选择 chat_id,避免前端重复传递 token。
This commit is contained in:
2026-08-15 00:40:40 +08:00
parent 0d0cd0c510
commit bd8a9ff96d
14 changed files with 2265 additions and 4 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
# 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": "<bot 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":"<SafeW description>"}`
## 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":<last+1>,"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 返回一批 updateoffset 前进。
4. **handler**:非 safew channel → 400;缺 token → 400。
Watcher 用可注入的 poll 函数 + 假 cache,不断言真实 30s 阻塞。
## Success criteria
- 两个接口返回已监控群,`id` 为字符串
- 默认只有群;`q` 可过滤
- 后台 `timeout=30` 持续写入 Redis;列表接口不卡 30 秒
- 仓库与 Redis key 中不出现明文 token