实现计划阅读版 · 2026-07-13 · Spec ③ · 11 任务 TDD · 状态 待执行
配套设计文档 系统通知机制设计。执行真相源(含 checkbox,驱动 subagent-driven-development/executing-plans):docs/superpowers/plans/2026-07-13-notifications.md。
notices 表(user_id NULL=广播/非空=定向)+ users.notices_read_at 已读水位;新 server/internal/notices 包(store/service/handler)替换现有 GET /v1/notices 空壳;生产三路(nodectl 手工、reward/pay 事务内钩子、发版脚本);客户端 noticesProvider 驱动铃铛与列表,原型先行统一两端通知视图。important|feature|news|reward|version|promo(DB CHECK/ENUM、openapi、客户端 pill、原型登记四处同集)。published_at > users.notices_read_at 即未读;不做逐条已读。important 且显式 --email;notices.email_sent_at 幂等;发送失败只记日志不阻塞发布。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。context.pangolin/var(--token)),文案经 AppText,红线词禁用。cd server && go test ./...;cd client && flutter analyze && flutter test;bash server/run_sqlite_test.sh。# 后端(新建) server/migrations/{mysql,sqlite}/000026_notices.{up,down}.sql server/internal/notices/{store,service,handler}.go # 后端(修改) server/internal/reward/service.go # OnRegister×2/OnFirstPaidTx×2/ClaimTelegram 接 InsertNoticeTx 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 · state/notices_provider.dart client/lib/screens/notifications_page.dart # 真实化 client/lib/widgets/content_top_bar.dart + screens/account_page.dart # 红点接 provider client/lib/l10n/app_text.dart + strings_*.dart ×6
新建 notices 表(六值 CHECK/ENUM、user_id NULL=广播、双语标题/正文、link/published_at/expires_at/revoked_at/email_sent_at、索引 (user_id,published_at))+ users 加 notices_read_at。sqlite/mysql 各一对 up/down,四个文件。
run_sqlite_test.sh + 迁移 up→down→up 全量测试通过(断言若因新迁移顶偏,按 000024/000025 既有修法更新计数)。notices.Store — 数据访问层新建 notices 包的纯数据访问层:广播∪定向合并查询(过滤 revoked/expired,倒序 limit)+ 已读水位判定;事务内插通知(事件钩子用,不自开事务)+ 自管连接的广播插入(nodectl/发版用)。
关键方法:ListForUser · MarkRead · InsertNoticeTx(事务内定向)· InsertBroadcast · Revoke · ListAdmin · MarkEmailSent · ListActiveUserEmails
InsertNoticeTx 随外部事务回滚则零孤儿。新建 Handler.List(GET /v1/notices,未登录 401)+ Handler.MarkRead(POST /v1/notices/read);删 httpapi/account.go 的 ListNotices 空壳(若编译顺序需要可延到 Task 7);openapi Notice schema 增 type/link/unread,响应增 unread_count,新增 /notices/read。
type/unread/unread_count);read 后 unread_count 清零;未登录 401;openapi 校验通过;go build ./... 绿。reward/pay 各自声明小接口 Noticer(照 Granter/Rewarder 惯例避免 import cycle),四接缝在既有事务内、Grant 成功之后追加 InsertNoticeTx:OnRegister(邀请注册双方)、OnFirstPaidTx(首充双方)、ClaimTelegram(TG 任务)、pay webhook.settle(购买开通)。插入失败视为该事务失败(随之回滚),非吞错。
type='reward' 定向通知;Grant 失败回滚时通知同回滚(零孤儿);双语文案模板落对天数。notices.Service — 发布/撤回 + 邮件兜底校验(type∈六值、标题非空、Email=true 仅 important 可用)→ Publish:InsertBroadcast → 写 audit(notice_publish)→ Email 时逐发 active 用户(失败仅 log)→ MarkEmailSent 幂等。RevokeByID:Revoke + audit(notice_revoke)。
notice add --type … --title-zh … --title-en … [--body-zh] [--body-en] [--link] [--expires] [--email] / notice list [--all] [--limit] / notice revoke <id>。add 组 PublishInput 调 Service.Publish(SMTP mailer 复用 auth 配置或包内极简 net/smtp 实现;无配置且 --email 明确报错拒发)。
go build ./... && go test ./cmd/nodectl/ 绿。装配 noticesStore/noticesHandler,注入 rewardSvc.SetNoticer/payWebhook.SetNoticer;路由 protected 组替换空壳:GET /notices、新增 POST /notices/read。scripts/ci/release-client.sh 在 version.yaml 推送成功后追加 nodectl notice add --type version(失败不阻断发版)。
go build ./... && go vet ./cmd/server/ && go test ./... -count=1 全绿;shellcheck release-client.sh 通过。统一形态为桌面式「类型 pill + 标题 + 相对时间 + 未读点」:改造 ui-mobile 通知子屏对齐桌面 .notif-row 结构(放弃 icon 区分);两端各补三类新示例行(reward/version/promo),i18n 字典补对应示例文案(zh/en),两端占位内容统一为同一组;点击行展开正文的简单交互示意。
node design/prototype/serve.mjs 起两文件均 200;新增行无裸 hex,全走 token。新建 NoticesApi(fetch() 拉 GET /v1/notices、markRead() 打 POST /v1/notices/read)+ NoticeItem/NoticesData 数据类(fromJson 安全默认,缺字段不 crash);Riverpod noticesProvider(AsyncNotifierProvider):未登录 null、已登录延迟 2s 首拉(照 update_provider 模式),refresh()/markAllRead() 本地态同步。
notices_api_test.dart 用 MockClient 断言 fetch 解析(2 条/unread_count/类型/双语字段)+ markRead 打对路径;flutter analyze 新文件 0 issue。l10n 加 notifTypeReward/Version/Promo(六语实现)。notifications_page 删静态数据,ref.watch(noticesProvider) 三态(loading/空/error);行=类型 pill + 双语标题 + 相对时间 + 未读点,点击展开正文,version 行「去更新」接现有更新流程,进页调 markAllRead()。content_top_bar/account_page 的 NotificationBell(hasUnread:true) 硬编码 → 接 unreadCount>0 真值;回前台刷新接壳层 lifecycle。
flutter analyze 0 新增;flutter test 全绿(通知页 golden 若有则重录)。即本文档:按项目「设计/计划双产物」规范,把 docs/superpowers/plans/2026-07-13-notifications.md(执行真相源,含 checkbox)生成同内容 HTML 阅读版,登记进 docs/index.html「实现计划 / Plans」分类,并把 notifications-design.html 顶部「配套实现计划」链接改为直链本文档。
.md 保持执行真相源不变(供 subagent-driven-development 继续驱动 Task 1–10)。cd server && go test ./... 全绿;nodectl notice add --type news … 后 GET /v1/notices(带 JWT)可见、红点计数对;revoke 后消失。--email → active 用户收信一轮,email_sent_at 置位,重复 add 是新公告不误判。本页为阅读版,不含逐步 TDD 代码细节;完整测试代码/实现片段见执行真相源 docs/superpowers/plans/2026-07-13-notifications.md。