From 0657a830d06c8ee4698613f3d4bc1c39ac8a1210 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 13:30:28 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E7=B3=BB=E7=BB=9F=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=9C=BA=E5=88=B6=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= =?UTF-8?q?(11=20=E4=BB=BB=E5=8A=A1=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移000026 → notices.Store → handler+openapi → 四事件钩子(同事务) → Service邮件兜底 → nodectl notice → main装配+发版联动 → 原型统一 → client api/provider → 通知页+红点+l10n → 文档。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-13-notifications.md | 592 ++++++++++++++++++ 1 file changed, 592 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-notifications.md diff --git a/docs/superpowers/plans/2026-07-13-notifications.md b/docs/superpowers/plans/2026-07-13-notifications.md new file mode 100644 index 0000000..4cd3818 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-notifications.md @@ -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 ?) +// 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: 红→实现→绿**(照签名;邮件主题 `【穿山甲】 / `,正文 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 +``` + +- [ ] **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 items; final int unreadCount; } +class NoticesApi { + NoticesApi(this._c); + Future fetch(); // GET /v1/notices + Future markRead(); // POST /v1/notices/read +} +final noticesApiProvider = Provider((ref) => NoticesApi(ref.watch(apiClientProvider))); +final noticesProvider = AsyncNotifierProvider(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 副本 · 邮件队列。