Files
pangolin/docs/notifications-plan.html
T

227 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &gt; 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&lt;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 &amp;&amp; go test ./...</code>;<code>cd client &amp;&amp; flutter analyze &amp;&amp; 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 17)</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 &lt;id&gt;</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 ./... &amp;&amp; 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 ./... &amp;&amp; go vet ./cmd/server/ &amp;&amp; go test ./... -count=1</code> 全绿;shellcheck release-client.sh 通过。</li>
</ul>
</div>
<h2>原型与客户端(Task 810)</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&gt;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 110)。</li>
</ul>
</div>
<h2>验收(端到端)</h2>
<ul>
<li>后端:<code>cd server &amp;&amp; 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>