docs(plan): 系统通知机制实现计划(11 任务 TDD)
ci-pangolin / Lint — shellcheck (pull_request) Successful in 12s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 24s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 39s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 18s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 32s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 3s
ci-pangolin / Go — build + test (pull_request) Failing after 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 5m7s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 12s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 24s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 39s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 18s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 32s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 3s
ci-pangolin / Go — build + test (pull_request) Failing after 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 5m7s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
迁移000026 → notices.Store → handler+openapi → 四事件钩子(同事务) → Service邮件兜底 → nodectl notice → main装配+发版联动 → 原型统一 → client api/provider → 通知页+红点+l10n → 文档。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
# 系统通知机制(Spec ③)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:** App 内统一通知收件箱(铃铛红点 + 六类通知列表),含个人到账通知(同事务零孤儿)、nodectl 发布、发版联动与 important 邮件兜底。
|
||||
|
||||
**Architecture:** 单 `notices` 表(`user_id` NULL=广播/非空=定向)+ `users.notices_read_at` 已读水位;新 `server/internal/notices` 包(store/service/handler)替换现有 `GET /v1/notices` 空壳;生产三路(nodectl 手工、reward/pay 事务内钩子、发版脚本);客户端 noticesProvider 驱动铃铛与列表,**原型先行**统一两端通知视图。
|
||||
|
||||
**Tech Stack:** Go(chi+裸 SQL 双方言)、SMTP(复用 auth mailer 配置)、Flutter/Riverpod、shared 设计原型。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 类型集**六值恒定**:`important|feature|news|reward|version|promo`(DB CHECK/ENUM、openapi、客户端 pill、原型登记四处同集)。
|
||||
- 内容仅 **zh/en** 双语字段;客户端 ja/ko/ru/es 界面取 en 内容。
|
||||
- 已读=**水位**:`published_at > users.notices_read_at` 即未读;**不做逐条已读**。
|
||||
- 事件到账通知必须与发放**同事务**(传入 tx,不自开;失败随事务回滚)。
|
||||
- 邮件**仅** `important` 且显式 `--email`;`notices.email_sent_at` 幂等;发送失败只记日志不阻塞发布。
|
||||
- 多 DB:迁移 `server/migrations/{mysql,sqlite}/` 两套一一对应,**下一编号 000026**;裸 SQL;时间 Go 端 `time.Now().UTC()` 传 `?`,禁 NOW()/UTC_TIMESTAMP()。
|
||||
- `GET /v1/notices` 响应契约(客户端按此解析):`{"notices":[{id,type,title_zh,title_en,body_zh,body_en,link,published_at,unread}],"unread_count":N}`;列表=广播∪我的定向,过滤 `revoked_at IS NOT NULL`/`expires_at<now`,`published_at` 倒序 **limit 50**。
|
||||
- 客户端改 UI 前**原型先行**(两端通知视图统一为「类型 pill+未读点」桌面式,补 reward/version/promo 示例),原型与代码同批次落地。
|
||||
- 颜色走 token(`context.pangolin`/var(--token)),文案经 AppText,红线词禁用。
|
||||
- 命令:`cd server && go test ./...`;`cd client && flutter analyze && flutter test`;`bash server/run_sqlite_test.sh`。
|
||||
- ⚠️ harness 可能拦 Write/Edit(worktree 隔离守卫):被拦用 Bash heredoc/python 就地改,`git diff` 自查。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `server/migrations/{mysql,sqlite}/000026_notices.{up,down}.sql` — 新表+列(**新建**)
|
||||
- `server/internal/notices/store.go` — 数据访问:List/UnreadCount/MarkRead/InsertNoticeTx/InsertBroadcast/Revoke/ListAdmin/MarkEmailSent(**新建**)
|
||||
- `server/internal/notices/service.go` — Publish(含邮件兜底)/Revoke + EmailSender 接口(**新建**)
|
||||
- `server/internal/notices/handler.go` — GET /v1/notices + POST /v1/notices/read(**新建**)
|
||||
- `server/internal/notices/*_test.go` — sqlite 实库测试(**新建**)
|
||||
- `server/internal/reward/service.go` — 三处发奖后插到账通知(经 Noticer 接口)(**改**)
|
||||
- `server/internal/pay/webhook.go` — settle 插「购买开通」通知(**改**)
|
||||
- `server/cmd/nodectl/main.go` + `notice.go` — notice add/list/revoke 子命令(**改/新建**)
|
||||
- `server/cmd/server/main.go` — 装配 notices + 注入 reward/pay + 路由替换空壳(**改**)
|
||||
- `server/internal/httpapi/account.go` — 删 ListNotices 空壳(**改**)
|
||||
- `server/api/openapi.yaml` — Notice schema 扩展 + /notices/read(**改**)
|
||||
- `scripts/ci/release-client.sh` — 发版联动插 version 公告(**改**)
|
||||
- `design/prototype/screens/{ui-desktop,ui-mobile}.html` — 通知视图统一+新类型示例(**改**)
|
||||
- `client/lib/services/notices_api.dart` + `client/lib/state/notices_provider.dart`(**新建**)
|
||||
- `client/lib/screens/notifications_page.dart` — 真实化(**改**)
|
||||
- `client/lib/widgets/content_top_bar.dart` + `client/lib/screens/account_page.dart` — 红点接 provider(**改**)
|
||||
- `client/lib/l10n/app_text.dart` + `strings_*.dart`×6 — 三新类型标签(**改**)
|
||||
- `docs/notifications-plan.html` + `docs/index.html` — 阅读版+登记(**新建/改**)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 迁移 000026 — notices 表 + users.notices_read_at
|
||||
|
||||
**Files:**
|
||||
- Create: `server/migrations/sqlite/000026_notices.{up,down}.sql`、`server/migrations/mysql/000026_notices.{up,down}.sql`
|
||||
- Test: 现有迁移测试(`server/internal/store/sqlite_migrate_test.go` 若表数/版本断言需更新则更新;`codes_lib_migrate_test.go` 的 Steps(-1) 边界数同理——000024/000025 两次都顶偏过,已知模式)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 表 `notices`;列 `users.notices_read_at`。
|
||||
|
||||
- [ ] **Step 1: sqlite up** `server/migrations/sqlite/000026_notices.up.sql`
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN notices_read_at DATETIME;
|
||||
|
||||
CREATE TABLE notices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL CHECK (type IN ('important','feature','news','reward','version','promo')),
|
||||
user_id INTEGER, -- NULL=全员广播;非空=定向该用户
|
||||
title_zh TEXT NOT NULL,
|
||||
title_en TEXT NOT NULL,
|
||||
body_zh TEXT,
|
||||
body_en TEXT,
|
||||
link TEXT,
|
||||
published_at DATETIME NOT NULL,
|
||||
expires_at DATETIME,
|
||||
revoked_at DATETIME,
|
||||
email_sent_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX ix_notices_user_pub ON notices(user_id, published_at);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: sqlite down** `server/migrations/sqlite/000026_notices.down.sql`
|
||||
|
||||
```sql
|
||||
DROP TABLE notices;
|
||||
ALTER TABLE users DROP COLUMN notices_read_at;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: mysql up** `server/migrations/mysql/000026_notices.up.sql`
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN notices_read_at DATETIME(6) NULL;
|
||||
|
||||
CREATE TABLE notices (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
type ENUM('important','feature','news','reward','version','promo') NOT NULL,
|
||||
user_id BIGINT UNSIGNED NULL,
|
||||
title_zh TEXT NOT NULL,
|
||||
title_en TEXT NOT NULL,
|
||||
body_zh TEXT NULL,
|
||||
body_en TEXT NULL,
|
||||
link TEXT NULL,
|
||||
published_at DATETIME(6) NOT NULL,
|
||||
expires_at DATETIME(6) NULL,
|
||||
revoked_at DATETIME(6) NULL,
|
||||
email_sent_at DATETIME(6) NULL,
|
||||
INDEX ix_notices_user_pub (user_id, published_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: mysql down**
|
||||
|
||||
```sql
|
||||
DROP TABLE notices;
|
||||
ALTER TABLE users DROP COLUMN notices_read_at;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑迁移测试**
|
||||
|
||||
Run: `cd server && bash run_sqlite_test.sh && go test ./internal/store/ -run Migrate -v -count=1`
|
||||
Expected: PASS(断言若因新迁移顶偏,按 000024/000025 两次的既有修法更新计数)。
|
||||
|
||||
- [ ] **Step 6: Commit** `feat(server/migrate): 000026 notices 表 + users.notices_read_at 已读水位`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: notices.Store — 数据访问层
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/notices/store.go`、`server/internal/notices/store_sqlite_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces(后续任务按此消费,签名固定):
|
||||
|
||||
```go
|
||||
package notices
|
||||
|
||||
type Notice struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
UserID *int64 `json:"-"`
|
||||
TitleZH string `json:"title_zh"`
|
||||
TitleEN string `json:"title_en"`
|
||||
BodyZH string `json:"body_zh,omitempty"`
|
||||
BodyEN string `json:"body_en,omitempty"`
|
||||
Link string `json:"link,omitempty"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Unread bool `json:"unread"`
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store
|
||||
// 列表:广播(user_id IS NULL)∪ 定向(user_id=uid),过滤 revoked/expired,倒序 limit;
|
||||
// 同时读 users.notices_read_at 计算每条 Unread 与 unreadCount。
|
||||
func (s *Store) ListForUser(ctx context.Context, uid int64, now time.Time, limit int) (items []Notice, unreadCount int, err error)
|
||||
func (s *Store) MarkRead(ctx context.Context, uid int64, at time.Time) error
|
||||
// 事务内插定向通知(事件钩子用;不自开事务)
|
||||
func (s *Store) InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error
|
||||
// 广播插入(nodectl/发版用;自管连接)返回 id
|
||||
func (s *Store) InsertBroadcast(ctx context.Context, typ, titleZH, titleEN, bodyZH, bodyEN, link string, publishedAt time.Time, expiresAt *time.Time) (int64, error)
|
||||
func (s *Store) Revoke(ctx context.Context, id int64, at time.Time) error // 置 revoked_at;不存在返回 sql.ErrNoRows
|
||||
func (s *Store) ListAdmin(ctx context.Context, includeInactive bool, limit int) ([]AdminRow, error) // nodectl list 用:id/type/标题/发布时间/状态
|
||||
func (s *Store) MarkEmailSent(ctx context.Context, id int64, at time.Time) error
|
||||
func (s *Store) ListActiveUserEmails(ctx context.Context) ([]string, error) // 邮件兜底:SELECT email FROM users WHERE status='active'
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 失败测试** `store_sqlite_test.go`(建库 helper 照 `server/internal/reward/store_sqlite_test.go` 的 `openDB`/`seedU` 模式抄一份到本包,函数名 `openDB`/`seedU` 同名即可——新包不共享测试 helper)
|
||||
|
||||
```go
|
||||
package notices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/config"
|
||||
"github.com/wangjia/pangolin/server/internal/store"
|
||||
)
|
||||
|
||||
func openDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
|
||||
if err != nil { t.Fatal(err) }
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := store.MigrateUp(db, "sqlite"); err != nil { t.Fatal(err) }
|
||||
_ = store.ApplyCodesLibMigrations(context.Background(), db, "sqlite")
|
||||
return db
|
||||
}
|
||||
|
||||
func seedU(t *testing.T, db *sql.DB, id int64, uuid string) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at)
|
||||
VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForUser_MergeFilterUnread(t *testing.T) {
|
||||
db := openDB(t); seedU(t, db, 1, "u1"); seedU(t, db, 2, "u2")
|
||||
st := NewStore(db)
|
||||
now := time.Now().UTC()
|
||||
// 广播1条 + 我的定向1条 + 他人定向1条 + 已撤回1条 + 已过期1条
|
||||
if _, err := st.InsertBroadcast(context.Background(), "news", "b1", "b1", "", "", "", now.Add(-3*time.Hour), nil); err != nil { t.Fatal(err) }
|
||||
tx, _ := db.Begin()
|
||||
if err := st.InsertNoticeTx(context.Background(), tx, 1, "reward", "mine", "mine", "", "", "", now.Add(-2*time.Hour)); err != nil { t.Fatal(err) }
|
||||
if err := st.InsertNoticeTx(context.Background(), tx, 2, "reward", "other", "other", "", "", "", now.Add(-1*time.Hour)); err != nil { t.Fatal(err) }
|
||||
_ = tx.Commit()
|
||||
rid, _ := st.InsertBroadcast(context.Background(), "promo", "revoked", "revoked", "", "", "", now, nil)
|
||||
_ = st.Revoke(context.Background(), rid, now)
|
||||
exp := now.Add(-time.Minute)
|
||||
_, _ = st.InsertBroadcast(context.Background(), "promo", "expired", "expired", "", "", "", now.Add(-4*time.Hour), &exp)
|
||||
|
||||
items, unread, err := st.ListForUser(context.Background(), 1, now, 50)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if len(items) != 2 { t.Fatalf("items=%d want 2(广播+我的;他人/撤回/过期均排除): %+v", len(items), items) }
|
||||
if items[0].TitleZH != "mine" || items[1].TitleZH != "b1" { t.Fatalf("排序应 published_at 倒序: %+v", items) }
|
||||
if unread != 2 { t.Fatalf("水位为空→全未读, unread=%d", unread) }
|
||||
|
||||
// 置水位到 -2.5h:广播(-3h)已读、定向(-2h)未读
|
||||
if err := st.MarkRead(context.Background(), 1, now.Add(-150*time.Minute)); err != nil { t.Fatal(err) }
|
||||
items, unread, _ = st.ListForUser(context.Background(), 1, now, 50)
|
||||
if unread != 1 || !items[0].Unread || items[1].Unread { t.Fatalf("水位判定错: unread=%d items=%+v", unread, items) }
|
||||
}
|
||||
|
||||
func TestInsertNoticeTx_RollsBackWithTx(t *testing.T) {
|
||||
db := openDB(t); seedU(t, db, 1, "u1")
|
||||
st := NewStore(db)
|
||||
tx, _ := db.Begin()
|
||||
if err := st.InsertNoticeTx(context.Background(), tx, 1, "reward", "t", "t", "", "", "", time.Now().UTC()); err != nil { t.Fatal(err) }
|
||||
_ = tx.Rollback()
|
||||
var n int
|
||||
_ = db.QueryRow(`SELECT COUNT(*) FROM notices`).Scan(&n)
|
||||
if n != 0 { t.Fatalf("回滚后应零孤儿通知, got %d", n) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑确认失败** — `cd server && go test ./internal/notices/ -v` → FAIL(包不存在)。
|
||||
|
||||
- [ ] **Step 3: 实现 store.go**(要点;完整落地由实现者按签名写)
|
||||
|
||||
```go
|
||||
// ListForUser 核心 SQL(unread 在 Go 端比较水位,水位一次性读出):
|
||||
// SELECT id,type,user_id,title_zh,title_en,COALESCE(body_zh,''),COALESCE(body_en,''),
|
||||
// COALESCE(link,''),published_at
|
||||
// FROM notices
|
||||
// WHERE (user_id IS NULL OR user_id = ?)
|
||||
// AND revoked_at IS NULL
|
||||
// AND (expires_at IS NULL OR expires_at > ?)
|
||||
// ORDER BY published_at DESC LIMIT ?
|
||||
// 水位: SELECT notices_read_at FROM users WHERE id=? (NULL→零值时间,全未读)
|
||||
// unreadCount = len(items 中 published_at > 水位);Unread 同判据。
|
||||
// MarkRead: UPDATE users SET notices_read_at=? WHERE id=?
|
||||
// InsertNoticeTx / InsertBroadcast: 普通 INSERT(broadcast user_id 传 NULL);
|
||||
// Revoke: UPDATE notices SET revoked_at=? WHERE id=? AND revoked_at IS NULL;RowsAffected==0 → sql.ErrNoRows
|
||||
// ListAdmin: 全字段倒序,includeInactive=false 时同 ListForUser 的 revoked/expired 过滤
|
||||
// MarkEmailSent: UPDATE notices SET email_sent_at=? WHERE id=? AND email_sent_at IS NULL
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑确认通过** — `go test ./internal/notices/ -v -count=1` PASS。
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(server/notices): Store — 广播∪定向合并查询/已读水位/事务内插通知`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: notices HTTP handler + openapi 扩展 + 替换空壳
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/notices/handler.go`、`server/internal/notices/handler_test.go`
|
||||
- Modify: `server/internal/httpapi/account.go`(删 ListNotices 空壳与其路由引用注释)、`server/api/openapi.yaml`
|
||||
- (main.go 挂载在 Task 7 统一装配;本任务 handler 自测即可)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `func NewHandler(st *Store) *Handler`;`(h *Handler) List(w,r)`;`(h *Handler) MarkRead(w,r)`。响应契约见 Global Constraints。
|
||||
|
||||
- [ ] **Step 1: 失败测试** `handler_test.go`
|
||||
|
||||
```go
|
||||
package notices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
func TestListAndMarkRead(t *testing.T) {
|
||||
db := openDB(t); seedU(t, db, 1, "u1")
|
||||
st := NewStore(db)
|
||||
_, _ = st.InsertBroadcast(context.Background(), "news", "hello", "hello", "", "", "", time.Now().UTC(), nil)
|
||||
h := NewHandler(st)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/notices", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), codes.CtxKeyUserID, int64(1)))
|
||||
w := httptest.NewRecorder()
|
||||
h.List(w, req)
|
||||
if w.Code != 200 { t.Fatalf("code=%d body=%s", w.Code, w.Body) }
|
||||
var got struct {
|
||||
Notices []map[string]any `json:"notices"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if len(got.Notices) != 1 || got.UnreadCount != 1 { t.Fatalf("got %+v", got) }
|
||||
if got.Notices[0]["type"] != "news" || got.Notices[0]["unread"] != true { t.Fatalf("字段契约: %+v", got.Notices[0]) }
|
||||
|
||||
// read → 再查 unread 清零
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/v1/notices/read", nil)
|
||||
req2 = req2.WithContext(context.WithValue(req2.Context(), codes.CtxKeyUserID, int64(1)))
|
||||
w2 := httptest.NewRecorder()
|
||||
h.MarkRead(w2, req2)
|
||||
if w2.Code != 200 { t.Fatalf("read code=%d", w2.Code) }
|
||||
w3 := httptest.NewRecorder()
|
||||
h.List(w3, req)
|
||||
_ = json.Unmarshal(w3.Body.Bytes(), &got)
|
||||
if got.UnreadCount != 0 { t.Fatalf("read 后 unread_count=%d", got.UnreadCount) }
|
||||
}
|
||||
|
||||
func TestListUnauthorized(t *testing.T) {
|
||||
db := openDB(t)
|
||||
h := NewHandler(NewStore(db))
|
||||
w := httptest.NewRecorder()
|
||||
h.List(w, httptest.NewRequest(http.MethodGet, "/v1/notices", nil))
|
||||
if w.Code != http.StatusUnauthorized { t.Fatalf("code=%d want 401", w.Code) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑确认失败**。
|
||||
|
||||
- [ ] **Step 3: 实现 handler.go**(uid 取 `auth.UserIDFromContext`,未登录 401 `apierr.ErrUnauthorized`;出错 500 `apierr.ErrInternal`;成功 `json.NewEncoder` 直接编码——照 reward/handler.go 惯例;limit 固定 50;MarkRead 成功回 `{"ok":true}`)。
|
||||
|
||||
- [ ] **Step 4: 删空壳**:`server/internal/httpapi/account.go` 删 `ListNotices` 方法(main.go 引用在 Task 7 一并换成 notices handler,本步后 `go build ./...` 若 main 编译失败,可临时保留空壳到 Task 7 再删——**实现者按编译通过为准选择删除时机,报告注明**)。
|
||||
|
||||
- [ ] **Step 5: openapi.yaml**:`Notice` schema 增 `type`(enum 六值)、`link`、`unread`(boolean);`/notices` 响应对象增 `unread_count`(required);新增 `/notices/read` POST(200 `{ok:true}`)。跑仓库 openapi 校验(CI 用的同款:`python3 -m openapi_spec_validator server/api/openapi.yaml` 或项目脚本,grep ci.yml 确认命令)。
|
||||
|
||||
- [ ] **Step 6: 跑绿** — `go test ./internal/notices/ -count=1` + `go build ./...`。
|
||||
|
||||
- [ ] **Step 7: Commit** `feat(server/notices): GET /v1/notices(合并+unread_count)+ POST /read + openapi 扩展`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 事件钩子 — 四处到账通知(同事务)
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/reward/service.go`(OnRegister×2、OnFirstPaidTx×2、ClaimTelegram)
|
||||
- Modify: `server/internal/pay/webhook.go`(settle 购买开通)
|
||||
- Test: `server/internal/reward/notices_hook_sqlite_test.go`(新)+ `server/internal/pay/webhook_notice_sqlite_test.go`(新)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `notices.Store.InsertNoticeTx`(Task 2)。
|
||||
- Produces: 消费方各自定义小接口(照 Granter/Rewarder 惯例,避免 import cycle):
|
||||
|
||||
```go
|
||||
// reward/service.go 与 pay/webhook.go 各自声明:
|
||||
type Noticer interface {
|
||||
InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error
|
||||
}
|
||||
// reward.Service 加字段 noticer Noticer + SetNoticer(n Noticer)
|
||||
// pay.WebhookHandler 加字段 noticer Noticer + SetNoticer(n Noticer)
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 双语文案模板**(reward/service.go 包级私有函数;硬编码双语,非 l10n——服务端数据):
|
||||
|
||||
```go
|
||||
// noticeTitle 按事件渲染双语标题(reward 类通知无正文,一行标题即可)。
|
||||
func rewardNoticeTitles(kind string, days int) (zh, en string) {
|
||||
switch kind {
|
||||
case "invite_reg":
|
||||
return fmt.Sprintf("邀请奖励 +%d 天已到账", days), fmt.Sprintf("Invite reward +%d days credited", days)
|
||||
case "invite_paid":
|
||||
return fmt.Sprintf("好友首购奖励 +%d 天已到账", days), fmt.Sprintf("Friend's first purchase: +%d days credited", days)
|
||||
case "task_tg":
|
||||
return fmt.Sprintf("任务奖励 +%d 天已到账", days), fmt.Sprintf("Task reward +%d days credited", days)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 失败测试(reward 侧)** `notices_hook_sqlite_test.go` —— 复用本包 `openDB/seedU/newSvc` 既有 helper:
|
||||
|
||||
```go
|
||||
func TestOnRegister_InsertsRewardNotices(t *testing.T) {
|
||||
db := openDB(t); seedU(t, db, 1, "inviter"); seedU(t, db, 2, "invitee")
|
||||
s := newSvc(t, db)
|
||||
s.SetNoticer(noticesStoreAdapter(t, db)) // 直接用 notices.NewStore(db)(import cycle 无:reward→notices 单向;若成环则测试内定义适配器)
|
||||
code, _ := s.EnsureCode(context.Background(), 1)
|
||||
s.OnRegister(context.Background(), 2, code, "dev-2")
|
||||
var n int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id IN (1,2)`).Scan(&n)
|
||||
if n != 2 { t.Fatalf("注册段应双方各一条到账通知, got %d", n) }
|
||||
}
|
||||
|
||||
func TestClaimTelegram_NoticeRollsBackWithGrantFailure(t *testing.T) {
|
||||
// 用 failing granter(注入一个 Grant 返回错误的 fake)驱动 ClaimTelegram 失败,
|
||||
// 断言 notices 零行——同事务回滚验证。fake granter 照本包 fakeChecker 模式写。
|
||||
}
|
||||
```
|
||||
|
||||
(pay 侧同法:`TestSettle_InsertsPurchaseNotice` —— 复用 `newWebhookRig`,settle 成功后断言 `notices` 有一条 `type='reward'` 定向 user 的「已开通」通知;标题模板放 pay 包:`fmt.Sprintf("已开通 Pro · %d 天", days)` / `fmt.Sprintf("Pro activated · %d days", days)`。)
|
||||
|
||||
- [ ] **Step 3: 跑确认失败**(SetNoticer 未定义)。
|
||||
|
||||
- [ ] **Step 4: 实现**:reward 三处、pay 一处,均在**既有事务内、Grant 成功之后**追加 `if s.noticer != nil { _ = 判断 }`——注意:**到账通知插入失败应视为该事务失败**(return err,与设计「同 COMMIT」一致),不是吞错。四处 kind/days:OnRegister→invite_reg×RegDays(双方);OnFirstPaidTx→invite_paid×PaidDays(双方);ClaimTelegram→task_tg×TgDays;settle→购买模板×item.Days。noticer 为 nil 时跳过(装配前兼容)。
|
||||
|
||||
- [ ] **Step 5: 跑绿** — `go test ./internal/reward/ ./internal/pay/ ./internal/notices/ -count=1`。
|
||||
|
||||
- [ ] **Step 6: Commit** `feat(server): 到账通知四接缝(邀请注册/首充/TG/购买开通)——与发放同事务零孤儿`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: notices.Service — 发布/撤回 + 邮件兜底
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/notices/service.go`、`server/internal/notices/service_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
|
||||
```go
|
||||
type EmailSender interface { Send(ctx context.Context, to, subject, body string) error }
|
||||
|
||||
type PublishInput struct {
|
||||
Type, TitleZH, TitleEN, BodyZH, BodyEN, Link string
|
||||
ExpiresAt *time.Time
|
||||
Email bool // 仅 important 允许 true
|
||||
}
|
||||
func NewService(st *Store, mailer EmailSender, audit AuditWriter) *Service
|
||||
// AuditWriter interface { WriteAuditLog(ctx, tx *sql.Tx, actor, action, target, meta string) error } —
|
||||
// 若 codes.Store 的审计方法非事务版不匹配,则 Service 直接持 *sql.DB 自写 INSERT INTO audit_log(actor,action,target,meta,at);
|
||||
// 以实现最简为准,报告注明选择。
|
||||
func (s *Service) Publish(ctx context.Context, in PublishInput) (int64, error)
|
||||
// 校验:type∈六值;TitleZH/EN 非空;Email==true 时 type 必须 important(否则报错)。
|
||||
// InsertBroadcast → audit(notice_publish) → Email 时:ListActiveUserEmails 逐发(失败仅 log),
|
||||
// 全部尝试后 MarkEmailSent。
|
||||
func (s *Service) RevokeByID(ctx context.Context, id int64) error // Revoke + audit(notice_revoke)
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 失败测试**(fake EmailSender 记录收件人;audit 用真 audit_log 表断言):校验用例(type 非法/标题缺/email 配非 important 均报错);Publish 成功落库+audit 行;Email=true → fake 收到全部 active 用户邮箱、email_sent_at 置位;**再次 Publish 是新公告**(幂等的是单公告不重发,不是全局);Revoke 后 ListForUser 不可见 + audit 行。
|
||||
|
||||
- [ ] **Step 2-4: 红→实现→绿**(照签名;邮件主题 `【穿山甲】<TitleZH> / <TitleEN>`,正文 zh+en 合排 + 「在 App 内查看详情」)。
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(server/notices): Service — 发布校验/审计/important 邮件兜底(幂等)`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: nodectl notice 子命令
|
||||
|
||||
**Files:**
|
||||
- Create: `server/cmd/nodectl/notice.go`
|
||||
- Modify: `server/cmd/nodectl/main.go`(switch 加 `case "notice"` + usage)
|
||||
- Test: `server/cmd/nodectl/notice_test.go`(参数解析/校验层单测;DB 交互经 Service 已测)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `notices.NewStore/NewService`(Task 2/5);nodectl 既有 `openDeps`(DB_DSN 直连)模式。
|
||||
- Produces: CLI:
|
||||
|
||||
```
|
||||
nodectl notice add --type <六值> --title-zh … --title-en … [--body-zh …] [--body-en …] \
|
||||
[--link …] [--expires 2026-08-01] [--email]
|
||||
nodectl notice list [--all] [--limit 20]
|
||||
nodectl notice revoke <id>
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 失败测试**:`parseNoticeAddFlags`(抽成可测纯函数:flag 集→PublishInput+错误)——type 非法/缺标题/--email 非 important/--expires 格式(YYYY-MM-DD→当日 23:59:59 UTC)各红。
|
||||
- [ ] **Step 2-4: 红→实现→绿**:`notice.go` 内 `cmdNotice(args)` 分发 add/list/revoke;add 组 PublishInput 调 Service.Publish(mailer:从 SMTP_* env 构造——**从 auth.SMTPMailer 复用配置结构**,若其不导出通用 Send,则 notices 包内实现极简 smtp 发送器(net/smtp,同 env:SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS/SMTP_FROM——先 grep main.go auth mailer 装配处核对确切 env 名);无 SMTP 配置且 --email → 明确报错拒发)。list 输出表格(id/type/标题zh/发布时间/状态[active|revoked|expired]);revoke 调 RevokeByID。main.go switch 与 usage 更新。
|
||||
- [ ] **Step 5:** `go build ./... && go test ./cmd/nodectl/ -count=1` 绿。
|
||||
- [ ] **Step 6: Commit** `feat(nodectl): notice add/list/revoke 子命令(校验+审计+可选邮件)`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: main.go 装配 + 发版联动
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/cmd/server/main.go`、`server/internal/httpapi/account.go`(空壳删除若 Task 3 未删)、`scripts/ci/release-client.sh`
|
||||
|
||||
- [ ] **Step 1: 装配**(codesSvc/rewardSvc 段之后):
|
||||
|
||||
```go
|
||||
noticesStore := notices.NewStore(sqlDB)
|
||||
noticesHandler := notices.NewHandler(noticesStore)
|
||||
rewardSvc.SetNoticer(noticesStore)
|
||||
// payWebhook 存在时(PAY_BASE_URL 块内):
|
||||
payWebhook.SetNoticer(noticesStore)
|
||||
```
|
||||
|
||||
路由(protected 组):`protected.Get("/notices", noticesHandler.List)`(**替换** `accountAPI.ListNotices`)+ `protected.Post("/notices/read", noticesHandler.MarkRead)`;删 account.go 空壳。
|
||||
|
||||
- [ ] **Step 2: 发版联动** `scripts/ci/release-client.sh`:在 version.yaml 推送成功后追加(失败不阻断发版,`|| echo warn`):
|
||||
|
||||
```bash
|
||||
$SSH "root@${DEPLOY_HOST}" "DB_DSN=/var/lib/pangolin/pangolin.db DB_DRIVER=sqlite \
|
||||
/usr/local/bin/pangolin-nodectl notice add --type version \
|
||||
--title-zh \"v${VERSION} 已发布\" --title-en \"v${VERSION} released\" \
|
||||
--body-zh \"新版本已可更新,打开设置检查更新。\" --body-en \"A new version is available — check for updates in Settings.\"" \
|
||||
|| echo "==> release-client: version notice 插入失败(不阻断发版)"
|
||||
```
|
||||
|
||||
(nodectl 二进制部署名以 deploy-server.sh 实际安装名为准——现装的是 `pangolin-migrate` 等,`pangolin-nodectl` 若未部署,补进 compile-backend.sh/deploy-server.sh 的产物清单;实现者核对后调整,报告注明。)
|
||||
|
||||
- [ ] **Step 3:** `go build ./... && go vet ./cmd/server/ && go test ./... -count=1` 全绿;shellcheck release-client.sh(CI 有 shellcheck 闸)。
|
||||
- [ ] **Step 4: Commit** `feat(server): 装配 notices(路由替换空壳+reward/pay 注入)+ 发版联动插 version 公告`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 原型先行 — 两端通知视图统一 + 新类型示例
|
||||
|
||||
**Files:**
|
||||
- Modify: `design/prototype/screens/ui-desktop.html`、`design/prototype/screens/ui-mobile.html`
|
||||
|
||||
- [ ] **Step 1:** 统一形态为桌面式「类型 pill + 标题 + 相对时间 + 未读点」:**ui-mobile 的通知子屏改造**(现为 icon-box 行)对齐桌面 `.notif-row` 结构(可在 mobile 页内加等价 CSS,复用 token;放弃 icon 区分)。
|
||||
- [ ] **Step 2:** 两端各补三类新示例行:`reward`(「邀请奖励 +3 天已到账」·个人)、`version`(「v1.0.73 已发布」+「去更新」次要动作)、`promo`(「¥6 月付优惠上线」);i18n 字典补 `nt.tReward/tVersion/tPromo` 与示例文案(zh/en);两端占位文案**统一为同一组**(现状两端内容不同步,一并对齐)。
|
||||
- [ ] **Step 3:** 点击行展开正文的简单交互示意(桌面:`.notif-row.is-open .notif-body-full{display:block}` 级别即可,一条示例带正文)。
|
||||
- [ ] **Step 4:** `node design/prototype/serve.mjs 5185` 起→两文件 200→杀;新增行无裸 hex。
|
||||
- [ ] **Step 5: Commit** `design(prototype): 通知视图两端统一(pill式)+ reward/version/promo 示例与展开态`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 客户端 notices api + provider
|
||||
|
||||
**Files:**
|
||||
- Create: `client/lib/services/notices_api.dart`、`client/lib/state/notices_provider.dart`
|
||||
- Test: `client/test/unit/notices_api_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
|
||||
```dart
|
||||
class NoticeItem {
|
||||
final int id; final String type, titleZh, titleEn, bodyZh, bodyEn, link;
|
||||
final DateTime? publishedAt; final bool unread;
|
||||
// fromJson 安全默认(缺字段不 crash);title(AppLang) => lang==zh ? titleZh : titleEn(en 兜底)
|
||||
}
|
||||
class NoticesData { final List<NoticeItem> items; final int unreadCount; }
|
||||
class NoticesApi {
|
||||
NoticesApi(this._c);
|
||||
Future<NoticesData> fetch(); // GET /v1/notices
|
||||
Future<void> markRead(); // POST /v1/notices/read
|
||||
}
|
||||
final noticesApiProvider = Provider<NoticesApi>((ref) => NoticesApi(ref.watch(apiClientProvider)));
|
||||
final noticesProvider = AsyncNotifierProvider<NoticesNotifier, NoticesData?>(NoticesNotifier.new);
|
||||
// NoticesNotifier.build: 未登录 null;已登录延迟 2s 首拉(照 update_provider 的 Timer 模式);
|
||||
// refresh();markAllRead()(调 api.markRead 后本地把 unreadCount 置 0、items unread 置 false)。
|
||||
// 回前台重拉:WidgetsBindingObserver 在页面/壳层触发 refresh —— 由 Task 10 的消费方调,provider 只暴露 refresh。
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 失败测试**(MockClient 走 ApiClient,照 invite_api_test 模式):fetch 解析(2 条、unread_count、类型/双语字段、缺字段安全);markRead 打 POST 路径。
|
||||
- [ ] **Step 2-4: 红→实现→绿**;`flutter analyze` 新文件 0 issue。
|
||||
- [ ] **Step 5: Commit** `feat(client): notices api + provider(未读计数/markAllRead/延迟首拉)`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 通知页真实化 + 铃铛红点接 provider + l10n
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/lib/screens/notifications_page.dart`、`client/lib/widgets/content_top_bar.dart`、`client/lib/screens/account_page.dart`、`client/lib/l10n/app_text.dart` + `strings_{zh,en,ja,ko,ru,es}.dart`
|
||||
- Test: `client/test/widget/notifications_page_test.dart`(新)
|
||||
|
||||
- [ ] **Step 1: l10n**:`app_text.dart` 加 `notifTypeReward/notifTypeVersion/notifTypePromo` 抽象 getter,6 个 strings 文件各加实现(zh:到账/版本/活动;en:Credited/Update/Promo;ja/ko/ru/es 真实翻译,照现有三标签语气)。
|
||||
- [ ] **Step 2: 失败 widget 测试**:override noticesProvider 注入固定 NoticesData(3 条含一条 unread reward),断言:类型 pill 文案在、未读点在、进入页面触发 markAllRead(fake notifier 记录调用)。
|
||||
- [ ] **Step 3: notifications_page 真实化**:删 `_Notice` record 与静态数据;`ref.watch(noticesProvider)` 三态(loading 圈/空→notifEmpty/error→loadFailedRetry);行=pill(type→六标签映射)+ title(按 t.lang 取 zh/en)+ `relativeTime(now-publishedAt)` + 未读点;点击行展开 body(纯文本渲染,空 body 则不展开);version 类行尾「去更新」按钮→触发现有更新流程(`update_provider` 的检查/或打开设置更新入口——以现有 update 消费点最小接法为准);`initState`/首帧后调 `markAllRead()`。
|
||||
- [ ] **Step 4: 红点接真值**:`content_top_bar.dart` 的 `NotificationBell(hasUnread: true)` 与 `account_page.dart` 同款处 → `hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0`(两处都是 Consumer 上下文,确认 ref 可达;content_top_bar 若非 Consumer 则由调用方壳传入)。回前台刷新:壳层(desktop/mobile/tablet 任一公共点,首选 `main.dart` 既有 lifecycle 监听处若有,否则 notifications 页 didChangeAppLifecycleState)调 `noticesProvider.notifier.refresh()`——取最小侵入点,报告注明落点。
|
||||
- [ ] **Step 5:** `flutter analyze` 0 新增;`flutter test`(新 widget 测试 + 全量非 golden);通知页 golden 若有(desktop/tablet 套件含 notifications 屏则重录+抽查),SubScaffold/PageBody 复用不新造。
|
||||
- [ ] **Step 6: Commit** `feat(client/notices): 通知页真实化+铃铛红点接 provider+进页清读+三类型标签六语`
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 计划 HTML 阅读版 + 索引登记 + 设计文档互链
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/notifications-plan.html`(家族样式,11 任务卡分后端/客户端/文档组)
|
||||
- Modify: `docs/index.html`(实现计划分类加条目)、`docs/notifications-design.html`(顶部 sub 的「配套实现计划」改为直链 notifications-plan.html)
|
||||
|
||||
- [ ] **Step 1:** 生成阅读版+登记+互链;**Step 2: Commit** `docs: 通知机制实现计划 HTML 阅读版 + 索引登记 + 设计互链`
|
||||
|
||||
---
|
||||
|
||||
## 验收(端到端)
|
||||
|
||||
- 后端:`cd server && go test ./...` 全绿;`nodectl notice add --type news …` 后 `GET /v1/notices`(带 JWT)可见、红点计数对;`revoke` 后消失。
|
||||
- 事件:TG 领奖/邀请注册/首充/购买各触发一次 → 对应用户列表出现 reward 通知,与权益同事务。
|
||||
- 邮件:important + --email → active 用户收信一轮,email_sent_at 置位,重复 add 是新公告不误判。
|
||||
- 客户端:铃铛红点=unread_count 驱动;进通知页红点清零(另一设备登录同账号也清);version 行可跳更新;六语标签;空态正常。
|
||||
- 部署:迁移 000026 + server + nodectl 上 pangolin1;发一条真实 news 公告真机验证。
|
||||
|
||||
## 不在本轮(YAGNI)
|
||||
|
||||
APNs/FCM 推送 · 逐条已读 · 通知偏好开关 · 管理后台页 · 六语内容 · 静态镜像 JSON 副本 · 邮件队列。
|
||||
Reference in New Issue
Block a user