# 实施决策记录 > 本文档记录按 `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** | `pn 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` / `passwd` 子命令**不执行迁移、不做 user_version 守卫**(只有 `start`/`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 → start → 断言 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 常规增强,不构成功能偏离 | | D36 | 第 2 轮评审(review-round2.md)修复随附的实现决策:维护命令经 `store.OpenData` 打开(落实 D6,`--allow-newer` 相应从维护子命令移除);Origin 校验增加 scheme 比对(可信反代后采信 X-Forwarded-Proto);邻接查询纳入 pinned 排序键;上传解码前以 `DecodeConfig` 限制像素 ≤2²⁵;备份经 umask 收紧使创建即 0600;前端写操作后统一失效公共查询缓存 | review-round2.md P1-1/P1-5/P2-11/P2-12/P2-7/P2-15 | ## 6. 编辑器即时渲染改造(2026-09-11,方案 `docs/editor-live-rendering.md` v1.1) | # | 决策 | 依据 | | --- | --- | --- | | D37 | 采用 **CM6 装饰器即时渲染**(路线 A),不引入 ProseMirror/Tiptap 富文本模型;**零新增 npm 依赖** | 方案 §2.3 / E1;真相源保持 Markdown 原文,公开页渲染管线与 sanitize 防线完全不动 | | D38 | 全部装饰由**单一 ViewPlugin** 产出,不引入 StateField | 方案 E10 / §12.2 D1:实现中不存在真正的块级装饰(水平线用整行 inline-block 承接、闭围栏只隐藏内容),免去全文档遍历与块 widget 布局/选区风险;代价是闭围栏行留下 1 行空白作为代码块底部内边距 | | D39 | 模块合并 `markers.ts`/`inline.ts`/`blocks.ts` → `syntax.ts` + `decorations.ts` | 方案 §12.2 D2;三者职责同为「节点 → 装饰」,拆分只增加跳转成本;单测边界改为「识别」与「构建/展开」 | | D40 | reveal 以**编辑器聚焦**为前提(失焦整篇渲染);粒度 v1 取**行级** | 方案 E11 / E6:避免载入即显示首行 `#`;行级对 IME 最安全,span 级列入 §10.2 | | D41 | **图片与任务复选框不参与 reveal**:图片恒渲染(改 URL 走源码模式),复选框恒可点 | 方案 E12 / §12.2 D3-D4:与 Typora 一致;避免「段落内行内图片在编辑该行时永远显示源码」与「光标落到任务行后复选框变文本无法点击」 | | D42 | 行号/活动行高亮用 CSS(`.pn-cm-live .cm-gutters{display:none}`)隐藏,不改 `basicSetup` | 方案 §12.2 D5:不依赖 `@uiw/react-codemirror` 对 `basicSetup` 的运行时重配置行为 | | D43 | 排版参数抽为 `--md-*` CSS 变量,**阅读态 `.pn-note-body` 与编辑态 `.cm-md-*` 共同引用** | 方案 §4.6 / E9:编辑态视觉=阅读态视觉;实机核对公开页计算样式未变(15px/27px、标题衬线、段落 24px) | | D44 | 表格 v1 只做**源码等宽对齐 + 竖线淡化**(普通 55%、分隔行 38% 透明),不渲染 HTML | 方案 E5 / §10.1;`TableDelimiter` 在 lezer 中对分隔行是整段节点,故用单个带 `delimiter` 标记的 spec 表达 | | D45 | `AdminEdit`「预览」按钮改为**「源码模式 / 退出源码」**,删除分屏预览块、`MarkdownViewer` import 与 `.pn-ae-preview` 样式 | 方案 §4.7:逃生舱;CM 体积仍走 lazy chunk,`MarkdownViewer` 继续服务公开页 | | D46 | 真机输入法回归**列为发布前硬门槛未执行**;自动化仅覆盖合成组合事件(`compositionstart/end`)与 `insertText` 中文输入 | 方案 §7.1 / §12.4:自动化测不出真实 IME 行为,冻结逻辑(`view.composing` 时只映射不重建)已实现但需人工真机确认 | | D47 | 长文档必须**补解析**:装饰前用 `ensureSyntaxTree(state, 可见区间末端, 20ms)`,并在 ViewPlugin 增加「语法树对象换代 → 重建」触发 | P3 实测 `syntaxTree(state)` 对 3000 行/15.5 万字符文档首次只覆盖 3061 字符(lezer 惰性增量),不补解析则未解析区间显示裸 Markdown;后台解析推进的事务没有 doc/selection 变化,必须靠树对象换代触发重算。20ms 预算有界(整篇解析实测仅 15.4ms,未真正耗尽) | | D48 | 装饰采集范围 = `visibleRanges` + 选区**端点**行,而非选区跨越的全部行 | CM 只把选区端点所在的块渲染到视口之外(其余用 `cm-gap` 占位);全量取会让 `Cmd+A` 在长文档上退化为全文档装饰构建。仅取端点行即可让「凡渲染出来的行都有装饰」成立 | | D49 | `applyReveal` 改为「区间合并 + 二分」,替代线性扫描 | 整篇选区在 3000 行文档下产生 3000 个展开区间,原 O(装饰数×区间数) 最坏约数千万次比较;二分后最坏选区(Cmd+A)构建中位数 0.98ms。另加「二分/线性结果等价」单测守护 | | D50 | 新增 `@codemirror/commands` 为 **devDependency**(运行时零新增依赖不变) | 边界回归需要真实执行 `deleteCharBackward` 等编辑命令来验证文首退格/原子区间行为;该包本就在依赖树中(`@uiw/react-codemirror` 的依赖),不新增安装体积、不进入产物 | | D51 | 真机输入法回归形式化为**人工验收门槛(阻塞发布)**,见方案 §12.6 与 `acceptance.md` M5 | §7.1 早已声明其为硬门槛;自动化(合成 composition 事件 + `insertText`)只能证明冻结逻辑不崩、文本不乱序,无法替代真实 IME。门槛含 8 项用例、覆盖系统拼音与搜狗、要求执行人与环境签字;当前状态:**未执行** | | D52 | 编辑卡默认**吃满视口剩余高度**(`.pn-ae-grid: calc(100vh - 110px)`),长文改为编辑器内部滚动(`.pn-ae-body-wrap` 吃掉剩余高度,滚动由 `.cm-scroller` 承担) | 用户要求(2026-09-11)。原实现 `.pn-ae-body-wrap` 仅 `min-height: 420px` 且高度随内容撑开:空文档编辑区只有 420px,3000 行长文把页面撑到 8.4 万像素、只能整页滚动。实测:两种场景下编辑卡均 610px(1280×720),长文 `.cm-scroller` 内部滚动 85087px、页面保持一屏;≤900px 堆叠断点回落 `height: auto`。方案 §12.7 |