Files
pangolin/docs/superpowers/plans/2026-07-13-notifications.md
T
wangjia 0657a830d0
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
docs(plan): 系统通知机制实现计划(11 任务 TDD)
迁移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>
2026-07-13 13:30:28 +08:00

593 lines
32 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.
# 系统通知机制(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 副本 · 邮件队列。