Files
pure-note/docs/decisions.md
T
wangairnan 9b0aff6f9f docs: 新增编辑器即时渲染方案、实施与 P3 加固记录
- editor-live-rendering.md(新):Typora 式单编辑面方案——路线选型、装饰层分层设计、
  widget 规格、安全与风险、测试方案、P0–P3 实施拆解;§12 为实施与加固记录:
  两个实测缺陷的根因与修复、3000 行性能实测数据、§12.6 真机输入法人工门槛(阻塞发布)、
  §12.7 编辑区高度
- design.md §8.2:编辑器段落由「分屏实时预览」改为即时渲染表述,链接本方案并标注发布门槛
- decisions.md:D37–D52(装饰器路线、单一 ViewPlugin、行级 reveal、长文档补解析、
  选区端点行采集、reveal 二分、@codemirror/commands 仅为 devDependency、人工门槛、编辑区高度)
- acceptance.md:新增 M5 里程碑与 M5-G 人工门槛行,补 v1.1/v1.2 浏览器复走记录与用例数

文档先行,代码实现见后续提交。
2026-09-11 01:02:36 +08:00

15 KiB
Raw Permalink Blame History

实施决策记录

本文档记录按 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