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

32 KiB
Raw Blame History

系统通知机制(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}.sqlserver/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

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
DROP TABLE notices;
ALTER TABLE users DROP COLUMN notices_read_at;
  • Step 3: mysql up server/migrations/mysql/000026_notices.up.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
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.goserver/internal/notices/store_sqlite_test.go

Interfaces:

  • Produces(后续任务按此消费,签名固定):
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.goopenDB/seedU 模式抄一份到本包,函数名 openDB/seedU 同名即可——新包不共享测试 helper)
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(要点;完整落地由实现者按签名写)

// 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.goserver/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

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.goListNotices 方法(main.go 引用在 Task 7 一并换成 notices handler,本步后 go build ./... 若 main 编译失败,可临时保留空壳到 Task 7 再删——实现者按编译通过为准选择删除时机,报告注明)。

  • Step 5: openapi.yaml:Notice schema 增 type(enum 六值)、linkunread(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):
// 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——服务端数据):
// 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:
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.goserver/internal/notices/service_test.go

Interfaces:

  • Produces:
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.gocmdNotice(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.goserver/internal/httpapi/account.go(空壳删除若 Task 3 未删)、scripts/ci/release-client.sh

  • Step 1: 装配(codesSvc/rewardSvc 段之后):

	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):
$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.htmldesign/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.dartclient/lib/state/notices_provider.dart
  • Test: client/test/unit/notices_api_test.dart

Interfaces:

  • Produces:
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.dartclient/lib/widgets/content_top_bar.dartclient/lib/screens/account_page.dartclient/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.dartnotifTypeReward/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.dartNotificationBell(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 副本 · 邮件队列。