227 lines
19 KiB
HTML
227 lines
19 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>系统通知机制 — 实现计划</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||
p{margin:10px 0}
|
||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||
.kw{color:#c68bd6}.str{color:#b6d47a}.num{color:#e0b06a}.cm{color:#6b7280}.fn{color:#5fb0c9}
|
||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||
.card.root{border-left:3px solid var(--accent)}
|
||
.card h3{margin-top:0}
|
||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||
td code{font-size:.85em}
|
||
ul,ol{padding-left:22px;margin:10px 0}
|
||
li{margin:5px 0}
|
||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||
.small{color:var(--fg2);font-size:13px}
|
||
a{color:var(--accent2)}
|
||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||
b{color:#fff}
|
||
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||
@media(max-width:680px){.cols{grid-template-columns:1fr}}
|
||
.task-num{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;background:var(--panel2);color:var(--accent2);font-weight:700;font-size:13px;margin-right:10px;flex:none}
|
||
.task-head{display:flex;align-items:center;margin-bottom:4px}
|
||
.task-head h3{margin:0;color:var(--fg)}
|
||
.files{font-family:var(--mono);font-size:12.5px;color:var(--fg2);margin:8px 0}
|
||
.files b{color:#f0d9c4;font-weight:400}
|
||
.accept{margin:6px 0 0}
|
||
.accept li{color:var(--fg2)}
|
||
.accept li b{color:var(--fg)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<a class="back" href="index.html">← 文档索引</a>
|
||
<h1>系统通知机制</h1>
|
||
<p class="sub">实现计划阅读版 · 2026-07-13 · Spec ③ · 11 任务 TDD · 状态 <span class="tag info">待执行</span></p>
|
||
<p class="small">配套设计文档 <a href="notifications-design.html">系统通知机制设计</a>。执行真相源(含 checkbox,驱动 <code>subagent-driven-development</code>/<code>executing-plans</code>):<code>docs/superpowers/plans/2026-07-13-notifications.md</code>。</p>
|
||
|
||
<div class="lead">
|
||
<b>Goal</b>:App 内统一通知收件箱(铃铛红点 + 六类通知列表),含个人到账通知(同事务零孤儿)、nodectl 发布、发版联动与 important 邮件兜底。<br><br>
|
||
<b>Architecture</b>:单 <code>notices</code> 表(<code>user_id</code> NULL=广播/非空=定向)+ <code>users.notices_read_at</code> 已读水位;新 <code>server/internal/notices</code> 包(store/service/handler)替换现有 <code>GET /v1/notices</code> 空壳;生产三路(nodectl 手工、reward/pay 事务内钩子、发版脚本);客户端 noticesProvider 驱动铃铛与列表,<b>原型先行</b>统一两端通知视图。<br><br>
|
||
<b>Tech Stack</b>:Go(chi+裸 SQL 双方言)、SMTP(复用 auth mailer 配置)、Flutter/Riverpod、shared 设计原型。
|
||
</div>
|
||
|
||
<h2>Global Constraints</h2>
|
||
<ul>
|
||
<li>类型集<b>六值恒定</b>:<code>important|feature|news|reward|version|promo</code>(DB CHECK/ENUM、openapi、客户端 pill、原型登记四处同集)。</li>
|
||
<li>内容仅 <b>zh/en</b> 双语字段;客户端 ja/ko/ru/es 界面取 en 内容。</li>
|
||
<li>已读=<b>水位</b>:<code>published_at > users.notices_read_at</code> 即未读;<b>不做逐条已读</b>。</li>
|
||
<li>事件到账通知必须与发放<b>同事务</b>(传入 tx,不自开;失败随事务回滚)。</li>
|
||
<li>邮件<b>仅</b> <code>important</code> 且显式 <code>--email</code>;<code>notices.email_sent_at</code> 幂等;发送失败只记日志不阻塞发布。</li>
|
||
<li>多 DB:迁移 <code>server/migrations/{mysql,sqlite}/</code> 两套一一对应,<b>下一编号 000026</b>;裸 SQL;时间 Go 端 <code>time.Now().UTC()</code> 传 <code>?</code>,禁 NOW()/UTC_TIMESTAMP()。</li>
|
||
<li><code>GET /v1/notices</code> 响应契约(客户端按此解析):<code>{"notices":[{id,type,title_zh,title_en,body_zh,body_en,link,published_at,unread}],"unread_count":N}</code>;列表=广播∪我的定向,过滤 <code>revoked_at IS NOT NULL</code>/<code>expires_at<now</code>,<code>published_at</code> 倒序 <b>limit 50</b>。</li>
|
||
<li>客户端改 UI 前<b>原型先行</b>(两端通知视图统一为「类型 pill+未读点」桌面式,补 reward/version/promo 示例),原型与代码同批次落地。</li>
|
||
<li>颜色走 token(<code>context.pangolin</code>/var(--token)),文案经 AppText,红线词禁用。</li>
|
||
<li>命令:<code>cd server && go test ./...</code>;<code>cd client && flutter analyze && flutter test</code>;<code>bash server/run_sqlite_test.sh</code>。</li>
|
||
</ul>
|
||
|
||
<h3>File Structure(总览)</h3>
|
||
<pre><span class="cm"># 后端(新建)</span>
|
||
server/migrations/{mysql,sqlite}/000026_notices.{up,down}.sql
|
||
server/internal/notices/{store,service,handler}.go
|
||
<span class="cm"># 后端(修改)</span>
|
||
server/internal/reward/service.go <span class="cm"># OnRegister×2/OnFirstPaidTx×2/ClaimTelegram 接 InsertNoticeTx</span>
|
||
server/internal/pay/webhook.go <span class="cm"># settle 插「购买开通」通知</span>
|
||
server/cmd/nodectl/main.go + notice.go <span class="cm"># notice add/list/revoke 子命令</span>
|
||
server/cmd/server/main.go <span class="cm"># 装配 notices + 注入 reward/pay + 路由替换空壳</span>
|
||
server/internal/httpapi/account.go <span class="cm"># 删 ListNotices 空壳</span>
|
||
server/api/openapi.yaml <span class="cm"># Notice schema 扩展 + /notices/read</span>
|
||
scripts/ci/release-client.sh <span class="cm"># 发版联动插 version 公告</span>
|
||
<span class="cm"># 客户端/原型(新建/修改)</span>
|
||
design/prototype/screens/{ui-desktop,ui-mobile}.html <span class="cm"># 通知视图统一+新类型示例</span>
|
||
client/lib/services/notices_api.dart · state/notices_provider.dart
|
||
client/lib/screens/notifications_page.dart <span class="cm"># 真实化</span>
|
||
client/lib/widgets/content_top_bar.dart + screens/account_page.dart <span class="cm"># 红点接 provider</span>
|
||
client/lib/l10n/app_text.dart + strings_*.dart ×6</pre>
|
||
|
||
<h2>后端(Task 1–7)</h2>
|
||
|
||
<div class="card root">
|
||
<div class="task-head"><span class="task-num">1</span><h3>迁移 000026 — notices 表 + users.notices_read_at</h3></div>
|
||
<p>新建 <code>notices</code> 表(六值 CHECK/ENUM、<code>user_id</code> NULL=广播、双语标题/正文、<code>link/published_at/expires_at/revoked_at/email_sent_at</code>、索引 <code>(user_id,published_at)</code>)+ <code>users</code> 加 <code>notices_read_at</code>。sqlite/mysql 各一对 up/down,四个文件。</p>
|
||
<div class="files"><b>新建</b> server/migrations/{sqlite,mysql}/000026_notices.{up,down}.sql</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:<code>run_sqlite_test.sh</code> + 迁移 up→down→up 全量测试通过(断言若因新迁移顶偏,按 000024/000025 既有修法更新计数)。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">2</span><h3><code>notices.Store</code> — 数据访问层</h3></div>
|
||
<p>新建 <code>notices</code> 包的纯数据访问层:广播∪定向合并查询(过滤 revoked/expired,倒序 limit)+ 已读水位判定;事务内插通知(事件钩子用,不自开事务)+ 自管连接的广播插入(nodectl/发版用)。</p>
|
||
<div class="files"><b>新建</b> server/internal/notices/{store.go, store_sqlite_test.go}</div>
|
||
<p><b>关键方法</b>:<code>ListForUser</code> · <code>MarkRead</code> · <code>InsertNoticeTx</code>(事务内定向)· <code>InsertBroadcast</code> · <code>Revoke</code> · <code>ListAdmin</code> · <code>MarkEmailSent</code> · <code>ListActiveUserEmails</code></p>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:广播+我的定向合并可见、他人定向/撤回/过期均排除;水位判定正确(空水位全未读);<code>InsertNoticeTx</code> 随外部事务回滚则零孤儿。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">3</span><h3>notices HTTP handler + openapi 扩展 + 替换空壳</h3></div>
|
||
<p>新建 <code>Handler.List</code>(<code>GET /v1/notices</code>,未登录 401)+ <code>Handler.MarkRead</code>(<code>POST /v1/notices/read</code>);删 <code>httpapi/account.go</code> 的 <code>ListNotices</code> 空壳(若编译顺序需要可延到 Task 7);openapi <code>Notice</code> schema 增 <code>type/link/unread</code>,响应增 <code>unread_count</code>,新增 <code>/notices/read</code>。</p>
|
||
<div class="files"><b>新建</b> server/internal/notices/{handler.go, handler_test.go} · <b>改</b> server/internal/httpapi/account.go、server/api/openapi.yaml</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:list 返回契约字段齐全(<code>type/unread/unread_count</code>);read 后 unread_count 清零;未登录 401;openapi 校验通过;<code>go build ./...</code> 绿。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">4</span><h3>事件钩子 — 四处到账通知(同事务)</h3></div>
|
||
<p>reward/pay 各自声明小接口 <code>Noticer</code>(照 Granter/Rewarder 惯例避免 import cycle),四接缝在既有事务内、Grant 成功之后追加 <code>InsertNoticeTx</code>:<code>OnRegister</code>(邀请注册双方)、<code>OnFirstPaidTx</code>(首充双方)、<code>ClaimTelegram</code>(TG 任务)、pay <code>webhook.settle</code>(购买开通)。插入失败视为该事务失败(随之回滚),非吞错。</p>
|
||
<div class="files"><b>改</b> server/internal/reward/service.go、server/internal/pay/webhook.go · <b>新建</b> notices_hook_sqlite_test.go、webhook_notice_sqlite_test.go</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:四事件各自触发一条 <code>type='reward'</code> 定向通知;Grant 失败回滚时通知同回滚(零孤儿);双语文案模板落对天数。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">5</span><h3><code>notices.Service</code> — 发布/撤回 + 邮件兜底</h3></div>
|
||
<p>校验(type∈六值、标题非空、<code>Email=true</code> 仅 important 可用)→ <code>Publish</code>:<code>InsertBroadcast</code> → 写 audit(<code>notice_publish</code>)→ Email 时逐发 active 用户(失败仅 log)→ <code>MarkEmailSent</code> 幂等。<code>RevokeByID</code>:Revoke + audit(<code>notice_revoke</code>)。</p>
|
||
<div class="files"><b>新建</b> server/internal/notices/{service.go, service_test.go}</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:非法 type/缺标题/email 配非 important 均报错;成功发布落库+audit;email=true 全员发送、email_sent_at 置位、二次 Publish 是新公告不误判幂等;Revoke 后列表不可见+audit 行。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">6</span><h3>nodectl notice 子命令</h3></div>
|
||
<p><code>notice add --type … --title-zh … --title-en … [--body-zh] [--body-en] [--link] [--expires] [--email]</code> / <code>notice list [--all] [--limit]</code> / <code>notice revoke <id></code>。add 组 <code>PublishInput</code> 调 <code>Service.Publish</code>(SMTP mailer 复用 auth 配置或包内极简 net/smtp 实现;无配置且 <code>--email</code> 明确报错拒发)。</p>
|
||
<div class="files"><b>新建</b> server/cmd/nodectl/{notice.go, notice_test.go} · <b>改</b> server/cmd/nodectl/main.go</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:参数解析层单测覆盖 type 非法/缺标题/email 非 important/expires 格式各红;<code>go build ./... && go test ./cmd/nodectl/</code> 绿。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">7</span><h3>main.go 装配 + 发版联动</h3></div>
|
||
<p>装配 <code>noticesStore/noticesHandler</code>,注入 <code>rewardSvc.SetNoticer</code>/<code>payWebhook.SetNoticer</code>;路由 <code>protected</code> 组替换空壳:<code>GET /notices</code>、新增 <code>POST /notices/read</code>。<code>scripts/ci/release-client.sh</code> 在 version.yaml 推送成功后追加 <code>nodectl notice add --type version</code>(失败不阻断发版)。</p>
|
||
<div class="files"><b>改</b> server/cmd/server/main.go、server/internal/httpapi/account.go、scripts/ci/release-client.sh</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:<code>go build ./... && go vet ./cmd/server/ && go test ./... -count=1</code> 全绿;shellcheck release-client.sh 通过。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<h2>原型与客户端(Task 8–10)</h2>
|
||
|
||
<div class="card root">
|
||
<div class="task-head"><span class="task-num">8</span><h3>原型先行 — 两端通知视图统一 + 新类型示例</h3></div>
|
||
<p>统一形态为桌面式「类型 pill + 标题 + 相对时间 + 未读点」:改造 ui-mobile 通知子屏对齐桌面 <code>.notif-row</code> 结构(放弃 icon 区分);两端各补三类新示例行(reward/version/promo),i18n 字典补对应示例文案(zh/en),两端占位内容统一为同一组;点击行展开正文的简单交互示意。</p>
|
||
<div class="files"><b>改</b> design/prototype/screens/{ui-desktop,ui-mobile}.html</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:<code>node design/prototype/serve.mjs</code> 起两文件均 200;新增行无裸 hex,全走 token。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">9</span><h3>客户端 notices api + provider</h3></div>
|
||
<p>新建 <code>NoticesApi</code>(<code>fetch()</code> 拉 <code>GET /v1/notices</code>、<code>markRead()</code> 打 <code>POST /v1/notices/read</code>)+ <code>NoticeItem/NoticesData</code> 数据类(<code>fromJson</code> 安全默认,缺字段不 crash);Riverpod <code>noticesProvider</code>(<code>AsyncNotifierProvider</code>):未登录 null、已登录延迟 2s 首拉(照 update_provider 模式),<code>refresh()</code>/<code>markAllRead()</code> 本地态同步。</p>
|
||
<div class="files"><b>新建</b> client/lib/services/notices_api.dart · state/notices_provider.dart · test/unit/notices_api_test.dart</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:<code>notices_api_test.dart</code> 用 MockClient 断言 fetch 解析(2 条/unread_count/类型/双语字段)+ markRead 打对路径;<code>flutter analyze</code> 新文件 0 issue。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">10</span><h3>通知页真实化 + 铃铛红点接 provider + l10n</h3></div>
|
||
<p>l10n 加 <code>notifTypeReward/Version/Promo</code>(六语实现)。<code>notifications_page</code> 删静态数据,<code>ref.watch(noticesProvider)</code> 三态(loading/空/error);行=类型 pill + 双语标题 + 相对时间 + 未读点,点击展开正文,version 行「去更新」接现有更新流程,进页调 <code>markAllRead()</code>。<code>content_top_bar</code>/<code>account_page</code> 的 <code>NotificationBell(hasUnread:true)</code> 硬编码 → 接 <code>unreadCount>0</code> 真值;回前台刷新接壳层 lifecycle。</p>
|
||
<div class="files"><b>改</b> 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 · <b>新建</b> test/widget/notifications_page_test.dart</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:widget 测试断言类型 pill 文案在/未读点在/进页触发 markAllRead;<code>flutter analyze</code> 0 新增;<code>flutter test</code> 全绿(通知页 golden 若有则重录)。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<h2>文档(Task 11)</h2>
|
||
|
||
<div class="card">
|
||
<div class="task-head"><span class="task-num">11</span><h3>计划 HTML 阅读版 + 索引登记 + 设计文档互链</h3></div>
|
||
<p>即本文档:按项目「设计/计划双产物」规范,把 <code>docs/superpowers/plans/2026-07-13-notifications.md</code>(执行真相源,含 checkbox)生成同内容 HTML 阅读版,登记进 <code>docs/index.html</code>「实现计划 / Plans」分类,并把 <code>notifications-design.html</code> 顶部「配套实现计划」链接改为直链本文档。</p>
|
||
<div class="files"><b>新建</b> docs/notifications-plan.html · <b>改</b> docs/index.html、docs/notifications-design.html</div>
|
||
<ul class="accept">
|
||
<li><b>验收</b>:三文件登记完整、互链可点;<code>.md</code> 保持执行真相源不变(供 subagent-driven-development 继续驱动 Task 1–10)。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<h2>验收(端到端)</h2>
|
||
<ul>
|
||
<li>后端:<code>cd server && go test ./...</code> 全绿;<code>nodectl notice add --type news …</code> 后 <code>GET /v1/notices</code>(带 JWT)可见、红点计数对;<code>revoke</code> 后消失。</li>
|
||
<li>事件:TG 领奖/邀请注册/首充/购买各触发一次 → 对应用户列表出现 reward 通知,与权益同事务。</li>
|
||
<li>邮件:important + <code>--email</code> → active 用户收信一轮,<code>email_sent_at</code> 置位,重复 add 是新公告不误判。</li>
|
||
<li>客户端:铃铛红点=unread_count 驱动;进通知页红点清零(另一设备登录同账号也清);version 行可跳更新;六语标签;空态正常。</li>
|
||
<li>部署:迁移 000026 + server + nodectl 上 pangolin1;发一条真实 news 公告真机验证。</li>
|
||
</ul>
|
||
|
||
<h2>不在本轮(YAGNI)</h2>
|
||
<ul>
|
||
<li><span class="tag bad">不做</span> APNs/FCM 推送、逐条已读、通知偏好开关、管理后台页、六语内容、静态镜像 JSON 副本、邮件队列。</li>
|
||
</ul>
|
||
|
||
<p class="small" style="margin-top:32px">本页为阅读版,不含逐步 TDD 代码细节;完整测试代码/实现片段见执行真相源 <code>docs/superpowers/plans/2026-07-13-notifications.md</code>。</p>
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|