# 实施决策记录 > 本文档记录按 `docs/design.md`(v1.1)实施过程中的全部自行决策与偏差说明。 > 原则:不偏离设计主线;凡设计未明确或环境受限处,选择最小侵入方案并在此留档。 > 日期:2026-09-08 ## 1. 环境与依赖 | # | 决策 | 依据 | | --- | --- | --- | | D1 | Go 工具链使用 **1.26.6**(设计写 1.27.1) | 本机已装版本。所用特性(1.22+ ServeMux 方法/通配符路由、slog、go:embed)全部满足,无升级必要 | | D2 | 依赖版本按设计锁定值解析:modernc.org/sqlite v1.58.0、goldmark v1.8.6、bluemonday v1.0.27、x/crypto v0.56.0、x/time v0.15.0 | 设计附录 A「实际开发时以 go mod tidy 解析到的版本为准」——解析结果与设计核实值完全一致 | | D3 | 新增第 6 个直接依赖 **golang.org/x/term v0.45.0** | `pure-note init` 交互式口令输入需要关闭回显。设计 §2.2 预算「直接依赖 ≤6(Go 侧 5 个)」,6 个在预算内;`PN_ADMIN_PASSWORD` 环境变量仍是非交互主通道 | | D4 | 前端 TypeScript 用 **5.9**(设计允许 7.x 或回退 5.9) | 直接采用设计声明的保守回退路径,规避第三方类型兼容风险(CodeMirror/react-markdown 生态) | | D5 | 前端其余依赖与设计一致:react 19.2.8、vite 8.2.2、tailwind 4.3.3、react-router 8.3.1、@tanstack/react-query 5.102.8、react-markdown 10.1.0 等,npm 解析 0 漏洞 | `npm install` 实测 | ## 2. 后端行为细化 | # | 决策 | 依据 | | --- | --- | --- | | D6 | `backup` / `gc` 子命令**不执行迁移、不做 user_version 守卫**(只有 `serve`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 | | D7 | `init` 在口令已存在时**拒绝并提示走管理界面改密**(无 `--force`) | 防止误操作覆盖口令;单管理员场景下改密有专门界面(§7.1 POST /api/admin/password) | | D8 | `GET /api/site` 公开端点(返回 site_title/site_desc/page_size 白名单三键) | 设计 §8.1 首页要显示「站点标题」,但 §7.1 公共 API 无设置出口;`/api/admin/settings` 需要会话。新增白名单只读端点,**永不含 admin_password_hash**(测试守护) | | D9 | `GET /api/notes/{slug}` 响应含 `prev`/`next`(相邻公开笔记 slug+title) | §8.1 详情页要求「上一篇/下一篇」;放在详情响应里避免前端额外请求与分页边界问题。排序与公开列表一致(updated_at DESC, id DESC) | | D10 | RSS `description` = 全文服务端渲染(goldmark→bluemonday),条目上限 50;`pubDate` 用 RFC 822(RFC1123Z) | §7.5 要求服务端渲染;全量 description 是博客 RSS 常规做法。注意从 `ListPublicNotesFull` 取(列表查询不含全文,曾因此修复空 description 缺陷) | | D11 | 管理列表 `GET /api/admin/notes` 不分页,一次返回全部正常笔记 | 设计未定义该端点分页参数;§11 边界 1 万篇内无压力。公开列表 `GET /api/notes` 保持分页 | | D12 | **Origin/Referer 校验严格模式**:非 GET/HEAD/OPTIONS 请求若 Origin 与 Referer 均缺失也拒绝(403) | 浏览器跨站/同站 fetch 都会带 Origin;缺失意味着非浏览器伪造。设计 T2「所有非 GET 请求」从严解释。测试覆盖 login/logout/admin 三处 | | D13 | 登录限流 fail-only 实现:**预检**(桶空 → 429+Retry-After,跳过 Argon2)+ **失败时消耗**两维度令牌;per-IP 10次/5min + per-账号 5次/10min;改密复用同一组限流器 | §7.2/§7.3。「预检不消费」保证成功登录不计费;429 时 Retry-After 取两维度较大值 | | D14 | 全局限流参数取 20 req/s、burst 40/桶上限 4096、TTL 10 分钟 | 设计只说「宽松令牌桶」,此参数对个人博客足够宽松 | | D15 | `image_refs` 重建按设计正则 `/api/images/(\d+)` 全量扫描去重;悬空引用(图片不存在)忽略 | §6.2 原文 | | D16 | 图片上传双重校验:客户端 Content-Type 白名单 → 魔数(PNG/JPEG/WebP/GIF,SVG 拒绝)→ PNG/JPEG/GIF 再过 stdlib 解码(拦截截断流);WebP 仅魔数(stdlib 不支持) | §7.4「不信任客户端声明」。多一层解码校验属纵深,不改变接口语义 | | D17 | 摘要为空时服务端自动生成:Markdown→HTML→剥标签→压空白→截 200 字符 | §6.1 summary「可空则截取正文」 | | D18 | slug 生成:ASCII 部分小写连字符化;**结果为空才退化为 post-YYYYMMDD**;唯一性查重**含回收站**,冲突自动 -2/-3;POST 创建忽略客户端 slug 一律自动生成;PUT 允许手改(格式校验 + 排除自身查重,冲突 409 + `field:"slug"`) | §8.2。创建路径「自动生成 + 自解冲突」,手改冲突走 409——与「首次保存即定稿」一致:新建后前端立即拿到真实 slug | | D19 | 回收站中的笔记对所有公开出口(含管理员经 `/api/notes/{slug}`)一律 404,仅 `/api/admin/trash` 可见 | §13 判定规则「回收站仅经 /api/admin/trash」(管理员私有预览只适用于未删除的私有笔记,§1.3) | | D20 | 设置 PUT 用 `DisallowUnknownFields` 严格白名单:未知键 400(含试图写 admin_password_hash) | §9.3「PUT 无法写入非白名单键」 | | D21 | 改密成功**不失效当前会话** | §7.3-7 明示(单管理员仅本会话) | ## 3. 前端 | # | 决策 | 依据 | | --- | --- | --- | | D22 | **未使用 shadcn CLI / Radix 原语**:按 shadcn「复制式组件」理念手写所需的最小组件集(按钮/输入/开关/模态/布局),样式走 Tailwind 类 | 无头环境下 CLI 交互链路风险高、收益低;设计定位是「复制式组件零运行时」,手写与 CLI 产物等价且依赖面更小。设计「按需叠加 Radix 原语」——本项目需求内为零需要 | | D23 | index.html 内嵌 Go template 占位符 `{{.Title}}` 等;vite 原样保留,服务端渲染时经 html/template 自动转义;字段由服务端保证非空(站点默认回退) | §8.3-4。副作用:`vite dev` 直连时标签页标题显示占位符原文,纯开发态外观问题 | | D24 | rehype-sanitize 在 GitHub 默认 schema 上扩展:`input` 补 `['checked', true]`(默认 schema 已含 type=checkbox/disabled)、`code`/`span` 追加 `hljs*` className 白名单;`a` 组件强制 `target=_blank rel="nofollow noopener noreferrer"` | §8.2 渲染管线。schema 快照测试守护「禁 script/iframe/style/事件属性」 | | D25 | 新建笔记**首次手动保存**时才 POST 创建(自动保存仅对已存在笔记生效),避免半空草稿泛滥 | §8.2 自动保存防抖 2s 的安全解释;「草稿即私有」不受影响 | | D26 | 编辑器工具栏提供加粗/斜体/链接/代码/表格插入;图片粘贴/拖拽上传后插入 `![name](/api/images/{id})` | §8.2 | ## 4. 测试与验收 | # | 决策 | 依据 | | --- | --- | --- | | D27 | §13 各测试组全部落为 Go 集成测试(httptest + 临时目录真实 SQLite):可见性矩阵(主体 × 状态 × 出口表驱动)、迁移守卫、登录/改密/会话轮换、CSRF、上传、回收站+gc、slug、设置白名单;webui 用 fstest.MapFS 单测 meta 转义/缓存头/fallback | 设计「其他测试组」要求 httptest + 临时目录真实 SQLite | | D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → serve → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 | | D29 | 测试注入限流器:`httpapi.NewWithLimiters` 允许测试替换高容量桶;登录限流测试单独用真实参数构造器 | 避免全局限流 429 干扰矩阵测试,同时保留限流本身的专项测试(两全) | | D30 | 夹具图片用「合法 PNG + IEND 后差异化尾部」绕开 sha256 去重合并——去重本身另用同字节上传断言 | 实测发现同字节图片被去重合并为同一行(正确行为,§14 已预告「去重会合并」),矩阵需要四张不同图 | ## 5. 其他 | # | 决策 | 依据 | | --- | --- | --- | | D31 | CSP v1 采用设计原样(style-src 含 'unsafe-inline',CodeMirror style-mod 所需);nonce 硬化按设计留 M3+ 待办,未实施 | §9.2/§14 | | D32 | 安全头(含 HSTS)对全部响应统一下发 | §9.2 为全局中间件;HTTP 开发模式下 HSTS 无副作用 | | D33 | webui 资产缓存:`assets/`(内容 hash 命名)immutable 一年;其他静态文件 1h;index.html no-cache;缺失资产 404 不回退 HTML | §8.3-5 + 防止把 JS 404 伪装成 SPA 页面造成误判 | | D34 | 无内嵌 index.html(M0 占位)时 HTML 路径返回 503 占位说明页 | 部署缺产物属于配置错误,503 比 200 空页更诚实 | | D35 | `robots.txt` 额外 `Disallow: /admin` | §7.1 仅要求「允许全部 + sitemap 指向」;Disallow /admin 是 SEO 常规增强,不构成功能偏离 |