Files
aiaa-notification-server/docs/superpowers/plans/2026-08-15-safew-chats-list.md
T
ryan bd8a9ff96d feat: 增加 SafeW 已监控群列表接口
通过 getUpdates 将群写入 Redis,供创建/编辑渠道时选择 chat_id,避免前端重复传递 token。
2026-08-15 00:40:40 +08:00

1192 lines
31 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.
# SafeW Chat List Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add two admin APIs that return SafeW groups already seen via `getUpdates`, persisted in Redis, with a per-token background long-poller (`timeout=30`).
**Architecture:** Parse group chats from `getUpdates`. Redis Hash keyed by `sha256(token)` stores chats and offset. List handlers drain with `timeout=0` then return Redis. A lazy in-process Watcher long-polls `timeout=30`. Same token is serialized with a Redis lock.
**Tech Stack:** Go 1.22+, gin, go-redis, stdlib `net/http/httptest`. No new dependencies. No real bot tokens in repo.
## Global Constraints
- Endpoints: `POST /api/v1/channels/safew/chats`, `GET /api/v1/channels/:id/chats?q=`
- Admin Bearer auth (existing middleware)
- `id` is always a JSON string; never decode chat id as `float64`
- Default types: `group` and `supergroup` only
- Redis keys: `safew:chats:{sha256hex}`, `safew:offset:{sha256hex}`, `safew:poll:{sha256hex}` — token never in the key
- List path uses `getUpdates` `timeout=0`; Watcher uses `timeout=30`
- Empty list is `200` `{"data":[],"total":0}`
- Invalid token: HTTP `401` with SafeW `description`
- Do not commit unless the user explicitly asks
- Do not write the user's bot token into any file
---
## File Structure
| File | Responsibility |
|------|----------------|
| `internal/adapter/safew.go` | `SafewChat`, parse updates, `PollGroupChats`, `FilterSafewChats`, `SafewAuthError` |
| `internal/adapter/safew_chats_test.go` | Parse / filter / poll unit tests |
| `internal/cache/safew_chats.go` | Token hash, Redis merge/list/offset/lock |
| `internal/safew/store.go` | `ChatStore` interface + memory fake for tests |
| `internal/safew/watcher.go` | Per-token goroutine, `Ensure` / `Stop` / `Refresh` / `List` |
| `internal/safew/watcher_test.go` | Watcher + Refresh with fake store + fake poller |
| `internal/handler/channel.go` | Two HTTP handlers |
| `internal/handler/channel_chats_test.go` | POST missing token / fake list; GET type/token checks |
| `cmd/server/main.go` | Routes + Watcher wiring + shutdown Stop |
| `README.md` / `docs/httpie/curls.md` | API docs |
---
### Task 1: Parse updates and filter chats
**Files:**
- Modify: `internal/adapter/safew.go`
- Create: `internal/adapter/safew_chats_test.go`
**Interfaces:**
- Produces:
- `type SafewChat struct { ID string; Type string; Title string; Username *string }` with JSON tags `id,type,title,username`
- `func GroupsFromUpdates(raw []byte) ([]SafewChat, int64, error)``raw` is the full getUpdates JSON body (`{"ok":true,"result":[...]}`); returns unique groups and max `update_id` (0 if none)
- `func FilterSafewChats(chats []SafewChat, q string) []SafewChat`
- [ ] **Step 1: Write the failing tests**
Create `internal/adapter/safew_chats_test.go`:
```go
package adapter
import (
"encoding/json"
"testing"
)
func TestGroupsFromUpdatesKeepsGroupsDropsPrivate(t *testing.T) {
body := []byte(`{
"ok": true,
"result": [
{"update_id": 100000001, "message": {"chat": {"id": 10000778141, "type": "group", "title": "测试AI"}}},
{"update_id": 100000002, "message": {"chat": {"id": 11, "type": "private", "first_name": "u"}}},
{"update_id": 100000003, "my_chat_member": {"chat": {"id": 22, "type": "supergroup", "title": "SG", "username": "sg_name"}}}
]
}`)
chats, maxID, err := GroupsFromUpdates(body)
if err != nil {
t.Fatal(err)
}
if maxID != 100000003 {
t.Fatalf("maxID=%d", maxID)
}
if len(chats) != 2 {
t.Fatalf("len=%d want 2: %#v", len(chats), chats)
}
byID := map[string]SafewChat{}
for _, c := range chats {
byID[c.ID] = c
}
g := byID["10000778141"]
if g.Type != "group" || g.Title != "测试AI" {
t.Fatalf("group: %#v", g)
}
raw, _ := json.Marshal(g)
if !json.Valid(raw) || string(raw)[0] != '{' {
t.Fatal(string(raw))
}
if got := string(mustJSONField(t, raw, "id")); got != `"10000778141"` && false {
t.Fatal(got)
}
var m map[string]any
_ = json.Unmarshal(raw, &m)
if _, ok := m["id"].(string); !ok {
t.Fatalf("id JSON type = %T, want string", m["id"])
}
sg := byID["22"]
if sg.Username == nil || *sg.Username != "sg_name" {
t.Fatalf("username: %#v", sg)
}
}
func TestFilterSafewChats(t *testing.T) {
chats := []SafewChat{
{ID: "10000778141", Type: "group", Title: "测试AI"},
{ID: "99", Type: "group", Title: "ops"},
}
got := FilterSafewChats(chats, "测试")
if len(got) != 1 || got[0].ID != "10000778141" {
t.Fatalf("%#v", got)
}
got = FilterSafewChats(chats, "10000778141")
if len(got) != 1 || got[0].Title != "测试AI" {
t.Fatalf("%#v", got)
}
got = FilterSafewChats(chats, "")
if len(got) != 2 {
t.Fatalf("empty q should keep all, got %d", len(got))
}
}
func mustJSONField(t *testing.T, raw []byte, key string) string {
t.Helper()
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return string(m[key])
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/adapter/ -run 'TestGroupsFromUpdates|TestFilterSafewChats' -count=1`
Expected: FAIL `undefined: GroupsFromUpdates`
- [ ] **Step 3: Implement parse + filter**
Append to `internal/adapter/safew.go` (keep existing send code). Add imports: `strconv`.
```go
type SafewChat struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Username *string `json:"username"`
}
func GroupsFromUpdates(body []byte) ([]SafewChat, int64, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var wrap struct {
OK bool `json:"ok"`
Result []json.RawMessage `json:"result"`
}
if err := dec.Decode(&wrap); err != nil {
return nil, 0, fmt.Errorf("safew updates decode: %w", err)
}
seen := map[string]SafewChat{}
var maxID int64
for _, item := range wrap.Result {
id, chat, err := parseUpdateItem(item)
if err != nil {
return nil, 0, err
}
if id > maxID {
maxID = id
}
if chat == nil {
continue
}
if chat.Type != "group" && chat.Type != "supergroup" {
continue
}
seen[chat.ID] = *chat
}
out := make([]SafewChat, 0, len(seen))
for _, c := range seen {
out = append(out, c)
}
return out, maxID, nil
}
func parseUpdateItem(item json.RawMessage) (int64, *SafewChat, error) {
dec := json.NewDecoder(bytes.NewReader(item))
dec.UseNumber()
var u map[string]json.RawMessage
if err := dec.Decode(&u); err != nil {
return 0, nil, err
}
var updateID int64
if raw, ok := u["update_id"]; ok {
var n json.Number
if err := json.Unmarshal(raw, &n); err == nil {
updateID, _ = n.Int64()
}
}
for _, key := range []string{"message", "edited_message", "channel_post", "edited_channel_post", "my_chat_member", "chat_member"} {
raw, ok := u[key]
if !ok {
continue
}
chat := chatFromNested(raw)
if chat != nil {
return updateID, chat, nil
}
}
return updateID, nil, nil
}
func chatFromNested(raw json.RawMessage) *SafewChat {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var obj map[string]json.RawMessage
if err := dec.Decode(&obj); err != nil {
return nil
}
chatRaw, ok := obj["chat"]
if !ok {
return nil
}
dec = json.NewDecoder(bytes.NewReader(chatRaw))
dec.UseNumber()
var c struct {
ID json.Number `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Username *string `json:"username"`
}
if err := dec.Decode(&c); err != nil {
return nil
}
id := strings.TrimSpace(c.ID.String())
if id == "" {
return nil
}
return &SafewChat{ID: id, Type: c.Type, Title: c.Title, Username: c.Username}
}
func FilterSafewChats(chats []SafewChat, q string) []SafewChat {
q = strings.TrimSpace(strings.ToLower(q))
if q == "" {
return chats
}
var out []SafewChat
for _, c := range chats {
uname := ""
if c.Username != nil {
uname = *c.Username
}
if strings.Contains(strings.ToLower(c.Title), q) ||
strings.Contains(strings.ToLower(uname), q) ||
strings.Contains(strings.ToLower(c.ID), q) {
out = append(out, c)
}
}
return out
}
```
Unused `strconv` — do **not** add that import.
- [ ] **Step 4: Run tests and make sure they pass**
Run: `go test ./internal/adapter/ -count=1`
Expected: `PASS` (existing send tests + new tests)
---
### Task 2: PollGroupChats (httptest)
**Files:**
- Modify: `internal/adapter/safew.go`
- Modify: `internal/adapter/safew_chats_test.go`
**Interfaces:**
- Consumes: `GroupsFromUpdates`
- Produces:
- `type SafewAuthError struct { Description string }` with `Error() string`
- `func (s *SafeWSender) PollGroupChats(token string, offset int64, timeout int) (chats []SafewChat, nextOffset int64, err error)`
- `nextOffset` is `max(update_id)+1` when any updates; otherwise unchanged `offset`
- [ ] **Step 1: Write the failing poll tests**
Append to `internal/adapter/safew_chats_test.go`:
```go
func TestPollGroupChatsSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/bottok/getUpdates" {
t.Errorf("path=%s", r.URL.Path)
}
raw, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(raw, &body)
if body["timeout"] != float64(0) {
t.Errorf("timeout=%v", body["timeout"])
}
if body["offset"] != float64(5) {
t.Errorf("offset=%v", body["offset"])
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true,"result":[{"update_id":10,"message":{"chat":{"id":10000778141,"type":"group","title":"测试AI"}}}]}`))
}))
defer srv.Close()
s := &SafeWSender{apiBase: srv.URL}
chats, next, err := s.PollGroupChats("tok", 5, 0)
if err != nil {
t.Fatal(err)
}
if next != 11 {
t.Fatalf("next=%d want 11", next)
}
if len(chats) != 1 || chats[0].ID != "10000778141" {
t.Fatalf("%#v", chats)
}
}
func TestPollGroupChatsUnauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"ok":false,"error_code":401,"description":"BOT_TOKEN_INVALID"}`))
}))
defer srv.Close()
s := &SafeWSender{apiBase: srv.URL}
_, _, err := s.PollGroupChats("bad", 0, 0)
if err == nil {
t.Fatal("expected error")
}
ae, ok := err.(*SafewAuthError)
if !ok {
t.Fatalf("type %T %v", err, err)
}
if !strings.Contains(ae.Description, "BOT_TOKEN_INVALID") {
t.Fatalf("%q", ae.Description)
}
}
```
Add imports in that file: `io`, `net/http`, `net/http/httptest`, `strings`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./internal/adapter/ -run TestPollGroupChats -count=1`
Expected: FAIL `undefined: PollGroupChats`
- [ ] **Step 3: Implement poll**
In `internal/adapter/safew.go`, change `endpoint` to go through `methodURL`:
```go
func (s *SafeWSender) endpoint(token string) string {
return s.methodURL(token, "sendMessage")
}
func (s *SafeWSender) methodURL(token, method string) string {
base := s.apiBase
if base == "" {
base = safewAPIBase
}
return strings.TrimRight(base, "/") + "/bot" + token + "/" + method
}
type SafewAuthError struct {
Description string
}
func (e *SafewAuthError) Error() string {
if e.Description == "" {
return "safew unauthorized"
}
return e.Description
}
func (s *SafeWSender) PollGroupChats(token string, offset int64, timeout int) ([]SafewChat, int64, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, offset, fmt.Errorf("safew: token is required")
}
reqBody, _ := json.Marshal(map[string]any{
"timeout": timeout,
"offset": offset,
"limit": 100,
})
resp, err := http.Post(s.methodURL(token, "getUpdates"), "application/json", bytes.NewReader(reqBody))
if err != nil {
return nil, offset, fmt.Errorf("safew getUpdates: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, offset, fmt.Errorf("safew getUpdates read: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return nil, offset, &SafewAuthError{Description: safewErrorDescription(respBody)}
}
if resp.StatusCode >= 400 {
desc := safewErrorDescription(respBody)
if desc != "" {
return nil, offset, fmt.Errorf("safew getUpdates status %d: %s", resp.StatusCode, desc)
}
return nil, offset, fmt.Errorf("safew getUpdates status %d", resp.StatusCode)
}
var api safewAPIResponse
if err := json.Unmarshal(respBody, &api); err == nil && !api.OK {
if resp.StatusCode == 401 || strings.Contains(strings.ToLower(api.Description), "token") {
return nil, offset, &SafewAuthError{Description: api.Description}
}
return nil, offset, fmt.Errorf("safew: %s", api.Description)
}
chats, maxID, err := GroupsFromUpdates(respBody)
if err != nil {
return nil, offset, err
}
next := offset
if maxID > 0 {
next = maxID + 1
}
return chats, next, nil
}
```
Need `net/http` import in `safew.go` (already present).
- [ ] **Step 4: Run adapter tests**
Run: `go test ./internal/adapter/ -count=1`
Expected: `PASS`
---
### Task 3: Redis chat store + ChatStore interface
**Files:**
- Create: `internal/cache/safew_chats.go`
- Create: `internal/cache/safew_chats_test.go` (hash only)
- Create: `internal/safew/store.go`
**Interfaces:**
- Produces:
- `func TokenHash(token string) string` in package `cache` — sha256 hex of token
- `cache.Cache` methods: `MergeSafewChats`, `ListSafewChats`, `GetSafewOffset`, `SetSafewOffset`, `TrySafewPollLock`, `UnlockSafewPoll`
- `type safew.ChatStore interface` wrapping those operations by **token string** (hash inside Redis impl)
- [ ] **Step 1: Write hash test**
`internal/cache/safew_chats_test.go`:
```go
package cache
import (
"encoding/hex"
"testing"
)
func TestTokenHashStableAndNotPlainToken(t *testing.T) {
h := TokenHash("secret-token")
if h == "secret-token" || h == "" {
t.Fatalf("hash=%q", h)
}
if _, err := hex.DecodeString(h); err != nil {
t.Fatalf("not hex: %v", err)
}
if TokenHash("secret-token") != h {
t.Fatal("not stable")
}
if TokenHash("other") == h {
t.Fatal("collision")
}
}
```
- [ ] **Step 2: Run to verify fail**
Run: `go test ./internal/cache/ -run TestTokenHash -count=1`
Expected: FAIL `undefined: TokenHash`
- [ ] **Step 3: Implement hash + Redis + interface**
`internal/cache/safew_chats.go`:
```go
package cache
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"aiaa-notification-service/internal/adapter"
"github.com/redis/go-redis/v9"
)
func TokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func safewChatsKey(hash string) string { return "safew:chats:" + hash }
func safewOffsetKey(hash string) string { return "safew:offset:" + hash }
func safewPollKey(hash string) string { return "safew:poll:" + hash }
func (c *Cache) MergeSafewChats(ctx context.Context, token string, chats []adapter.SafewChat) error {
if c == nil || c.rdb == nil {
return fmt.Errorf("redis unavailable")
}
if len(chats) == 0 {
return nil
}
hash := TokenHash(token)
vals := make([]any, 0, len(chats)*2)
for _, ch := range chats {
b, err := json.Marshal(ch)
if err != nil {
return err
}
vals = append(vals, ch.ID, b)
}
return c.rdb.HSet(ctx, safewChatsKey(hash), vals...).Err()
}
func (c *Cache) ListSafewChats(ctx context.Context, token string) ([]adapter.SafewChat, error) {
if c == nil || c.rdb == nil {
return nil, fmt.Errorf("redis unavailable")
}
m, err := c.rdb.HGetAll(ctx, safewChatsKey(TokenHash(token))).Result()
if err != nil {
return nil, err
}
out := make([]adapter.SafewChat, 0, len(m))
for _, raw := range m {
var ch adapter.SafewChat
if err := json.Unmarshal([]byte(raw), &ch); err != nil {
return nil, err
}
out = append(out, ch)
}
return out, nil
}
func (c *Cache) GetSafewOffset(ctx context.Context, token string) (int64, error) {
if c == nil || c.rdb == nil {
return 0, fmt.Errorf("redis unavailable")
}
n, err := c.rdb.Get(ctx, safewOffsetKey(TokenHash(token))).Int64()
if err == redis.Nil {
return 0, nil
}
return n, err
}
func (c *Cache) SetSafewOffset(ctx context.Context, token string, offset int64) error {
if c == nil || c.rdb == nil {
return fmt.Errorf("redis unavailable")
}
return c.rdb.Set(ctx, safewOffsetKey(TokenHash(token)), offset, 0).Err()
}
func (c *Cache) TrySafewPollLock(ctx context.Context, token string, ttl time.Duration) (bool, error) {
if c == nil || c.rdb == nil {
return false, fmt.Errorf("redis unavailable")
}
ok, err := c.rdb.SetNX(ctx, safewPollKey(TokenHash(token)), "1", ttl).Result()
return ok, err
}
func (c *Cache) UnlockSafewPoll(ctx context.Context, token string) error {
if c == nil || c.rdb == nil {
return nil
}
return c.rdb.Del(ctx, safewPollKey(TokenHash(token))).Err()
}
```
`internal/safew/store.go`:
```go
package safew
import (
"context"
"sync"
"time"
"aiaa-notification-service/internal/adapter"
)
type ChatStore interface {
MergeSafewChats(ctx context.Context, token string, chats []adapter.SafewChat) error
ListSafewChats(ctx context.Context, token string) ([]adapter.SafewChat, error)
GetSafewOffset(ctx context.Context, token string) (int64, error)
SetSafewOffset(ctx context.Context, token string, offset int64) error
TrySafewPollLock(ctx context.Context, token string, ttl time.Duration) (bool, error)
UnlockSafewPoll(ctx context.Context, token string) error
}
type MemStore struct {
mu sync.Mutex
chats map[string]map[string]adapter.SafewChat
offset map[string]int64
locks map[string]bool
}
func NewMemStore() *MemStore {
return &MemStore{
chats: map[string]map[string]adapter.SafewChat{},
offset: map[string]int64{},
locks: map[string]bool{},
}
}
func (m *MemStore) MergeSafewChats(_ context.Context, token string, chats []adapter.SafewChat) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.chats[token] == nil {
m.chats[token] = map[string]adapter.SafewChat{}
}
for _, c := range chats {
m.chats[token][c.ID] = c
}
return nil
}
func (m *MemStore) ListSafewChats(_ context.Context, token string) ([]adapter.SafewChat, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []adapter.SafewChat
for _, c := range m.chats[token] {
out = append(out, c)
}
if out == nil {
out = []adapter.SafewChat{}
}
return out, nil
}
func (m *MemStore) GetSafewOffset(_ context.Context, token string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.offset[token], nil
}
func (m *MemStore) SetSafewOffset(_ context.Context, token string, offset int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.offset[token] = offset
return nil
}
func (m *MemStore) TrySafewPollLock(_ context.Context, token string, _ time.Duration) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.locks[token] {
return false, nil
}
m.locks[token] = true
return true, nil
}
func (m *MemStore) UnlockSafewPoll(_ context.Context, token string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.locks, token)
return nil
}
```
MemStore keys by raw token (tests only). Redis impl hashes. That is OK: production always uses `cache.Cache`.
- [ ] **Step 4: Run tests**
Run: `go test ./internal/cache/ ./internal/safew/ -count=1`
Expected: cache hash test PASS; safew package may have no tests yet (`?` or PASS)
---
### Task 4: Watcher + Refresh + List
**Files:**
- Create: `internal/safew/watcher.go`
- Create: `internal/safew/watcher_test.go`
**Interfaces:**
- Consumes: `ChatStore`, `PollGroupChats` via `type Poller func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error)`
- Produces:
- `type Watcher struct`
- `func NewWatcher(store ChatStore, poll Poller) *Watcher`
- `func (w *Watcher) Ensure(token string)`
- `func (w *Watcher) Stop()`
- `func (w *Watcher) Refresh(ctx context.Context, token string) error` — timeout=0 drain
- `func (w *Watcher) List(ctx context.Context, token, q string) ([]adapter.SafewChat, error)`
- [ ] **Step 1: Write failing watcher tests**
```go
package safew
import (
"context"
"errors"
"testing"
"time"
"aiaa-notification-service/internal/adapter"
)
func TestRefreshMergesAndAdvancesOffset(t *testing.T) {
st := NewMemStore()
poll := func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error) {
if timeout != 0 {
t.Fatalf("timeout=%d", timeout)
}
if offset != 0 {
t.Fatalf("offset=%d", offset)
}
return []adapter.SafewChat{{ID: "10000778141", Type: "group", Title: "测试AI"}}, 11, nil
}
w := NewWatcher(st, poll)
ctx := context.Background()
if err := w.Refresh(ctx, "tok"); err != nil {
t.Fatal(err)
}
list, err := w.List(ctx, "tok", "测试")
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].ID != "10000778141" {
t.Fatalf("%#v", list)
}
off, _ := st.GetSafewOffset(ctx, "tok")
if off != 11 {
t.Fatalf("offset=%d", off)
}
}
func TestRefreshAuthError(t *testing.T) {
st := NewMemStore()
w := NewWatcher(st, func(string, int64, int) ([]adapter.SafewChat, int64, error) {
return nil, 0, &adapter.SafewAuthError{Description: "BOT_TOKEN_INVALID"}
})
err := w.Refresh(context.Background(), "bad")
if err == nil {
t.Fatal("expected auth error")
}
if _, ok := err.(*adapter.SafewAuthError); !ok {
t.Fatalf("%T", err)
}
}
func TestEnsurePollsInBackground(t *testing.T) {
st := NewMemStore()
got := make(chan int, 1)
w := NewWatcher(st, func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error) {
if timeout != 30 {
return nil, offset, errors.New("not background")
}
select {
case got <- timeout:
default:
}
return []adapter.SafewChat{{ID: "1", Type: "group", Title: "g"}}, offset + 1, nil
})
w.bgIdle = 10 * time.Millisecond
w.Ensure("tok")
select {
case <-got:
case <-time.After(2 * time.Second):
t.Fatal("background poll not called")
}
w.Stop()
}
```
- [ ] **Step 2: Run to verify fail**
Run: `go test ./internal/safew/ -count=1`
Expected: FAIL `undefined: NewWatcher`
- [ ] **Step 3: Implement Watcher**
`internal/safew/watcher.go`:
```go
package safew
import (
"context"
"log/slog"
"sync"
"time"
"aiaa-notification-service/internal/adapter"
)
type Poller func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error)
type Watcher struct {
store ChatStore
poll Poller
bgIdle time.Duration
mu sync.Mutex
running map[string]context.CancelFunc
stopped bool
}
func NewWatcher(store ChatStore, poll Poller) *Watcher {
return &Watcher{
store: store,
poll: poll,
bgIdle: time.Second,
running: map[string]context.CancelFunc{},
}
}
func (w *Watcher) Ensure(token string) {
if token == "" || w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if w.stopped {
return
}
if _, ok := w.running[token]; ok {
return
}
ctx, cancel := context.WithCancel(context.Background())
w.running[token] = cancel
go w.loop(ctx, token)
}
func (w *Watcher) Stop() {
w.mu.Lock()
w.stopped = true
for _, cancel := range w.running {
cancel()
}
w.running = map[string]context.CancelFunc{}
w.mu.Unlock()
}
func (w *Watcher) Refresh(ctx context.Context, token string) error {
return w.pollOnce(ctx, token, 0)
}
func (w *Watcher) List(ctx context.Context, token, q string) ([]adapter.SafewChat, error) {
chats, err := w.store.ListSafewChats(ctx, token)
if err != nil {
return nil, err
}
return adapter.FilterSafewChats(chats, q), nil
}
func (w *Watcher) loop(ctx context.Context, token string) {
for {
if err := w.pollOnce(ctx, token, 30); err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("safew watcher poll", "error", err)
}
idle := w.bgIdle
if idle <= 0 {
idle = time.Second
}
select {
case <-ctx.Done():
return
case <-time.After(idle):
}
}
}
func (w *Watcher) pollOnce(ctx context.Context, token string, timeout int) error {
ok, err := w.store.TrySafewPollLock(ctx, token, 35*time.Second)
if err != nil {
return err
}
if !ok {
return nil
}
defer func() { _ = w.store.UnlockSafewPoll(ctx, token) }()
offset, err := w.store.GetSafewOffset(ctx, token)
if err != nil {
return err
}
chats, next, err := w.poll(token, offset, timeout)
if err != nil {
return err
}
if err := w.store.MergeSafewChats(ctx, token, chats); err != nil {
return err
}
if next != offset {
return w.store.SetSafewOffset(ctx, token, next)
}
return nil
}
```
- [ ] **Step 4: Run safew tests**
Run: `go test ./internal/safew/ -count=1`
Expected: `PASS`
If `TestEnsurePollsInBackground` is flaky, increase wait or call `loop` once via exported test hook — do not lengthen beyond 2s.
---
### Task 5: HTTP handlers, routes, docs
**Files:**
- Modify: `internal/handler/channel.go`
- Create: `internal/handler/channel_chats_test.go`
- Modify: `cmd/server/main.go`
- Modify: `README.md`
- Modify: `docs/httpie/curls.md`
**Interfaces:**
- Consumes: `*safew.Watcher` (`Ensure`, `Refresh`, `List`)
- Produces: the two admin endpoints
- [ ] **Step 1: Write handler tests**
`internal/handler/channel_chats_test.go`:
```go
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"aiaa-notification-service/internal/adapter"
"aiaa-notification-service/internal/model"
"aiaa-notification-service/internal/safew"
"github.com/gin-gonic/gin"
)
func TestListSafewChatsPOSTMissingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ChannelHandler{chats: safew.NewWatcher(safew.NewMemStore(), func(string, int64, int) ([]adapter.SafewChat, int64, error) {
return nil, 0, nil
})}
r := gin.New()
r.POST("/api/v1/channels/safew/chats", h.ListSafewChats)
req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/safew/chats", bytes.NewReader([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
}
}
func TestListSafewChatsPOSTOk(t *testing.T) {
gin.SetMode(gin.TestMode)
st := safew.NewMemStore()
_ = st.MergeSafewChats(nil, "tok", []adapter.SafewChat{{ID: "10000778141", Type: "group", Title: "测试AI"}})
h := &ChannelHandler{chats: safew.NewWatcher(st, func(string, int64, int) ([]adapter.SafewChat, int64, error) {
return nil, 0, nil
})}
r := gin.New()
r.POST("/api/v1/channels/safew/chats", h.ListSafewChats)
req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/safew/chats", bytes.NewReader([]byte(`{"token":"tok","q":"测试"}`)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data []adapter.SafewChat `json:"data"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Total != 1 || resp.Data[0].ID != "10000778141" {
t.Fatalf("%#v", resp)
}
}
func TestSafewTokenFromChannel(t *testing.T) {
raw := json.RawMessage(`{"token":"abc","chat_id":"1"}`)
ch := &model.Channel{Type: "safew", Config: &raw}
tok, err := safewTokenFromChannel(ch)
if err != nil || tok != "abc" {
t.Fatalf("%q %v", tok, err)
}
ch.Type = "bark"
if _, err := safewTokenFromChannel(ch); err == nil {
t.Fatal("expected not safew")
}
}
```
`MergeSafewChats(nil, ...)` — MemStore ignores ctx, OK. If compile requires context, use `context.Background()`.
- [ ] **Step 2: Run to verify fail**
Run: `go test ./internal/handler/ -count=1`
Expected: FAIL `undefined: ListSafewChats` or `unknown field chats`
- [ ] **Step 3: Implement handlers and wire main**
Update `ChannelHandler`:
```go
type ChannelHandler struct {
store *store.Store
cache *cache.Cache
chats *safew.Watcher
}
func NewChannelHandler(s *store.Store, c *cache.Cache, w *safew.Watcher) *ChannelHandler {
return &ChannelHandler{store: s, cache: c, chats: w}
}
```
Add handlers (same file):
```go
type listSafewChatsReq struct {
Token string `json:"token"`
Q string `json:"q"`
}
func (h *ChannelHandler) ListSafewChats(c *gin.Context) {
var req listSafewChatsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.respondSafewChats(c, strings.TrimSpace(req.Token), req.Q)
}
func (h *ChannelHandler) ListChannelSafewChats(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
ch, err := h.store.GetChannel(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
token, err := safewTokenFromChannel(ch)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.respondSafewChats(c, token, c.Query("q"))
}
func (h *ChannelHandler) respondSafewChats(c *gin.Context, token, q string) {
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "safew token is required"})
return
}
if h.chats == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "safew chat list unavailable"})
return
}
h.chats.Ensure(token)
if err := h.chats.Refresh(c.Request.Context(), token); err != nil {
if _, ok := err.(*adapter.SafewAuthError); ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
slog.Warn("safew refresh", "error", err)
}
list, err := h.chats.List(c.Request.Context(), token, q)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []adapter.SafewChat{}
}
c.JSON(http.StatusOK, gin.H{"data": list, "total": len(list)})
}
func safewTokenFromChannel(ch *model.Channel) (string, error) {
if ch.Type != "safew" {
return "", fmt.Errorf("channel is not safew")
}
if ch.Config == nil {
return "", fmt.Errorf("safew token is required")
}
var cfg struct {
Token string `json:"token"`
}
if err := json.Unmarshal(*ch.Config, &cfg); err != nil {
return "", fmt.Errorf("safew token is required")
}
token := strings.TrimSpace(cfg.Token)
if token == "" {
return "", fmt.Errorf("safew token is required")
}
return token, nil
}
```
Add imports in `channel.go`: `fmt`, `log/slog`, `strings`, `adapter`, `safew`.
**Every** existing `NewChannelHandler(st, redisCache)` call must become `NewChannelHandler(st, redisCache, watcher)`. In `cmd/server/main.go`:
After Redis is connected:
```go
poller := func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error) {
return (&adapter.SafeWSender{}).PollGroupChats(token, offset, timeout)
}
var watcherStore safew.ChatStore
if redisCache != nil {
watcherStore = redisCache
} else {
watcherStore = safew.NewMemStore()
slog.Warn("safew chats using in-memory store")
}
safewWatcher := safew.NewWatcher(watcherStore, poller)
defer safewWatcher.Stop()
channelH := handler.NewChannelHandler(st, redisCache, safewWatcher)
```
Register routes **before** `GET /channels/:id`:
```go
admin.POST("/channels/safew/chats", channelH.ListSafewChats)
admin.GET("/channels/:id/chats", channelH.ListChannelSafewChats)
admin.POST("/channels", channelH.Create)
admin.GET("/channels", channelH.List)
admin.GET("/channels/:id", channelH.Get)
```
On shutdown, `safewWatcher.Stop()` via defer is enough.
Grep for `NewChannelHandler` and update all call sites.
README: after SafeW config block, before `#### GET /api/v1/channels`, insert the two endpoints with the JSON examples from the spec.
`docs/httpie/curls.md`: after 创建 ChannelSafeW, add:
```bash
curl -X POST 'http://localhost:8080/api/v1/channels/safew/chats' \
-H 'Authorization: Bearer admin-sk-change-me' \
-H 'Content-Type: application/json' \
-d '{"token":"<bot token>","q":"测试AI"}'
curl -X GET 'http://localhost:8080/api/v1/channels/1/chats?q=' \
-H 'Authorization: Bearer admin-sk-change-me'
```
- [ ] **Step 4: Run all internal tests**
Run: `go test ./internal/... -count=1`
Expected: `PASS`
Also: `go build ./cmd/server`
Expected: success