迁移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>
32 KiB
系统通知机制(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
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.go、server/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.go的openDB/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=1PASS。 -
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
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,未登录 401apierr.ErrUnauthorized;出错 500apierr.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:
Noticeschema 增type(enum 六值)、link、unread(boolean);/notices响应对象增unread_count(required);新增/notices/readPOST(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.go、server/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.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 段之后):
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.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:
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 真实化:删
_Noticerecord 与静态数据;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 analyze0 新增;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 副本 · 邮件队列。