[{"data":1,"prerenderedAt":21},["ShallowReactive",2],{"post-tech-stack":3},{"data":4},{"id":5,"title":6,"slug":7,"summary":8,"coverUrl":9,"status":10,"publishedAt":11,"createdAt":12,"updatedAt":13,"readingMinutes":14,"tags":15,"author":9,"contentMd":19,"contentHtml":20},28,"本站技术栈：Nuxt 4 + TypeScript + SQLite","tech-stack","一个只跑一个进程的个人博客是怎么搭起来的：Nuxt 4 负责页面与 SSR，Nitro 提供 API，SQLite 与 Drizzle 保存数据，Zod 守住输入边界，Vitest 与 Playwright 负责验证。",null,"published","2026-09-26T12:31:52.425Z","2026-09-26T12:31:52.000Z","2026-09-26T14:12:19.421Z",6,[16],{"id":17,"name":18,"slug":18},12,"网站开发","这个博客的目标很朴素：能舒服地写、能被搜索引擎完整收录、三年后的自己还看得懂代码。它不需要扛高并发，也不打算拆成前后端两个项目，于是整套系统被收在一个 Nuxt 应用里。这篇记录它实际使用的技术栈，以及每一项选择背后的取舍。\n\n# 一句话架构\n\n```text\nBrowser\n  │\nNginx \u002F Caddy              HTTPS、反向代理、静态资源\n  │\nNuxt 4（Nitro \u002F Node.js）    页面 SSR + API + 业务规则\n  │\nSQLite（WAL）+ uploads\u002F     业务数据与图片\n```\n\n页面、后台和 API 在同一个应用内，共享同一套 Zod Schema 与 TypeScript 类型。部署时只有一个 Node 进程、一个 SQLite 文件，没有 Redis，没有消息队列，也没有第二个需要单独运维的数据库实例。\n\n# 这套栈支撑了什么\n\n| 区域 | 能力 |\n| --- | --- |\n| 前台 | 首页、文章列表与详情（SSR）、标签、归档、追番、足迹、Sitemap |\n| 写作 | 后台登录、Markdown 编辑器（实时预览、插图、导入 `.md`）、草稿 \u002F 发布 \u002F 归档、图片库 |\n| 附加 | 追番排名、旅行足迹、Giscus 评论 |\n| SEO | 每页 title \u002F description \u002F canonical \u002F Open Graph，文章页带 BlogPosting 结构化数据 |\n\n# 技术选型一览\n\n下表是 `pnpm-lock.yaml` 里实际锁定的版本：\n\n| 层级 | 技术 | 版本 | 职责 |\n| --- | --- | --- | --- |\n| 应用框架 | Nuxt（Vue 3） | 4.5.2 \u002F 3.5.41 | 页面、路由、SSR、SEO |\n| 语言 | TypeScript | 5.9.3 | 前后端统一类型 |\n| 服务端 | Nitro on Node.js | Node 24 | API、服务端逻辑、生产运行时 |\n| 样式 | Tailwind CSS | 4.3.3 | 布局、响应式与设计令牌 |\n| 数据库 | SQLite | — | 业务数据持久化 |\n| ORM | Drizzle ORM | 0.44.7 | 类型安全的查询与 Migration |\n| 数据库驱动 | better-sqlite3 | 12.11.1 | Node 侧同步 SQLite 连接 |\n| 输入校验 | Zod | 4.4.3 | API、表单与环境变量校验 |\n| Markdown | marked + isomorphic-dompurify | 16.4.2 \u002F 2.36.0 | 渲染与 HTML 清洗 |\n| 密码哈希 | bcryptjs | 3.0.3 | 管理员密码 |\n| 日志 | Pino | 9.14.0 | 结构化运行日志 |\n| 测试 | Vitest + Playwright | 3.2.7 \u002F 1.63.0 | 单元、集成与端到端测试 |\n| 静态检查 | ESLint + vue-tsc | 9.39.5 \u002F 3.3.11 | 代码规范与类型检查 |\n| 包管理 | pnpm | 11.22.0 | 依赖锁定与脚本管理 |\n\n刻意没有引入的东西同样是选型的一部分：没有 Redis（进程内限流足够），没有 Elasticsearch（文章量级用 SQLite 查询足够），没有 GraphQL（内部接口用不上），也没有把实验性的 `node:sqlite` 当作生产驱动。\n\n# 为什么是模块化单体\n\n- 一次 `git clone`、一次 `pnpm install`、一条 `pnpm dev` 就能跑起整个站点。\n- 前后端共享类型：`shared\u002Fschemas` 里的 Zod Schema 同时给 API 校验和前端表单使用，改字段时类型检查会直接报错。\n- 没有跨服务鉴权、CORS 和分布式事务的问题。\n- 个人博客的写入者就是作者本人，SQLite 的单写入限制在这里不构成瓶颈。\n\n只有当多实例部署、写并发明显升高或跨机器异步任务出现时，才需要迁移 PostgreSQL 或拆分服务；在那之前领域层保持独立，迁移成本是可控的。\n\n# 分层与依赖方向\n\n```text\nPage \u002F Component → API → Domain Service → Repository → Database\n                            │\n                            └→ shared schemas \u002F types\n```\n\n```text\napp\u002F        页面、组件、布局、composables、中间件\nserver\u002F\n  api\u002F      HTTP 适配：Zod 校验 → 领域服务 → 统一响应\n  domains\u002F  领域服务与 Repository（auth \u002F posts \u002F media \u002F anime-rankings \u002F feeds）\n  db\u002F       Drizzle Schema、连接、Migration\n  routes\u002F   sitemap.xml\n  utils\u002F    响应包装、错误转换、限流、路径与日志\nshared\u002F     前后端共享的常量、Schema、类型与工具函数\ntests\u002F      unit \u002F integration \u002F e2e\ndata\u002F       SQLite 与上传文件（不提交到 Git）\n```\n\n几条硬规则：Vue 组件不碰数据库；API 路由只负责校验、鉴权和响应转换，不堆业务逻辑；所有 Drizzle 查询收在 Repository 里；底层模块不反向依赖页面。\n\n# 数据层：SQLite + Drizzle\n\n- Schema 用 Drizzle 的 SQLite 方言声明，字段类型直接推导出 TypeScript 类型。\n- 结构变更全部走 Migration：`pnpm db:generate` 生成 SQL，`pnpm db:migrate` 执行，产物提交进仓库。\n- 连接初始化固定四行 PRAGMA：`journal_mode = WAL`、`foreign_keys = ON`、`busy_timeout = 5000`、`synchronous = NORMAL`。\n- 开发、测试、生产使用不同的数据库路径；集成测试跑在临时库上，不会污染开发数据。\n- 时间统一按毫秒时间戳存储，外键关系显式声明并按需级联。\n\n目前的数据表：`users`、`sessions`、`posts`、`tags`、`post_tags`、`media`、`settings`、`anime_rankings`。\n\n# 输入边界与统一响应\n\n所有外部输入都要过 Zod：请求体、路径参数、查询参数、表单和环境变量。API 只返回两种形状：\n\n```json\n{ \"data\": {}, \"meta\": { \"page\": 1, \"pageSize\": 10, \"total\": 42, \"totalPages\": 5 } }\n```\n\n```json\n{ \"error\": { \"code\": \"POST_NOT_FOUND\", \"message\": \"文章不存在\" } }\n```\n\n错误码集中在 `shared\u002Fconstants` 里维护，客户端可以稳定判断；响应里不会出现堆栈、SQL 或服务器路径。列表接口一律分页，单页上限 50 条。\n\n# 内容管线：Markdown 怎么变成页面\n\n文章以 Markdown 原文存入数据库，渲染结果同时缓存下来：\n\n```text\nMarkdown 原文 → marked（GFM）→ DOMPurify 白名单清洗 → 缓存 HTML → 页面直接输出\n```\n\n- 渲染与清洗都只在服务端完成，客户端不承担安全职责。\n- 白名单只放行正文需要的标签，`script`、`iframe`、`style` 与内联事件一律丢弃。\n- 外链自动补 `target=\"_blank\"` 与 `rel=\"noopener noreferrer nofollow\"`，图片自动补 `loading=\"lazy\"`。\n- 正文里的标题整体降一级，保证一个页面只有一个 `h1`，也就是文章标题。\n- 后台编辑器的实时预览复用同一套渲染实现，不会出现「预览好看、发布错乱」。\n\n# 安全基线\n\n- Session 存在数据库里，只保存 Token 的 SHA-256 摘要；Cookie 为 `HttpOnly` + `SameSite=Lax`，生产环境自动加 `Secure`。\n- 写操作使用双提交 Cookie 校验 CSRF（`x-csrf-token`）。\n- 密码用 bcrypt（cost 12）哈希；登录与上传接口有进程内限流。\n- 上传校验 MIME、扩展名与大小，文件名随机化，并且只能通过 `\u002Fapi\u002Fuploads\u002F:name` 读取，`data\u002Fuploads` 目录不对外暴露。\n- 评论交给 Giscus 托管，正文页不承担评论系统的维护成本。\n\n# 测试与质量门禁\n\n| 层次 | 工具 | 覆盖内容 |\n| --- | --- | --- |\n| 单元测试 | Vitest | Schema、纯函数与领域规则（slug 生成、日期、地理投影等） |\n| 集成测试 | Vitest + 临时 SQLite | Repository、Session、API 契约与 Sitemap |\n| 端到端 | Playwright | 登录、写文章、发布、上传、公开阅读，含桌面与移动视口截图 |\n| 静态检查 | ESLint + vue-tsc | 代码规范与严格类型（`strict: true`） |\n\n日常提交前的顺序是 `pnpm lint` → `pnpm typecheck` → `pnpm test` → `pnpm build`，涉及关键流程时再跑 `pnpm test:e2e`。\n\n# 部署与备份\n\n```bash\npnpm build\nnode .output\u002Fserver\u002Findex.mjs\n```\n\n- 只运行一个 Node 写入进程，SQLite 与上传目录挂载到持久化磁盘。\n- 启动前执行 `pnpm db:migrate`；`pnpm db:backup` 通过 SQLite 在线备份 API 生成一致性快照。\n- 建议保留 7 份每日备份与 4 份每周备份，并且至少一份放到异机或对象存储。\n- 由 Nginx 或 Caddy 提供 HTTPS 与安全响应头。\n\n# 两个顺带实现的小功能\n\n- **追番排名**：条目元数据来自 Bangumi 公开 API，后台可搜索、增删改与调序，前台按分档展示。\n- **旅行足迹**：省级 GeoJSON 存放在本地，用自己写的投影与路径生成逻辑渲染成 SVG，点击省份记录足迹，全程不依赖第三方地图 SDK。\n\n# 什么时候会换掉这套栈\n\n当站点需要多实例部署、写入并发明显升高，或者出现跨机器的异步任务时，会先把数据层迁到 PostgreSQL——领域层与 Repository 的边界就是为此留的——再考虑拆分服务。在那之前，保持单一进程、单一数据库文件，是维护成本最低的形态。","\u003Cp>这个博客的目标很朴素：能舒服地写、能被搜索引擎完整收录、三年后的自己还看得懂代码。它不需要扛高并发，也不打算拆成前后端两个项目，于是整套系统被收在一个 Nuxt 应用里。这篇记录它实际使用的技术栈，以及每一项选择背后的取舍。\u003C\u002Fp>\n\u003Ch2>一句话架构\u003C\u002Fh2>\n\u003Cpre>\u003Ccode class=\"language-text\">Browser\n  │\nNginx \u002F Caddy              HTTPS、反向代理、静态资源\n  │\nNuxt 4（Nitro \u002F Node.js）    页面 SSR + API + 业务规则\n  │\nSQLite（WAL）+ uploads\u002F     业务数据与图片\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>页面、后台和 API 在同一个应用内，共享同一套 Zod Schema 与 TypeScript 类型。部署时只有一个 Node 进程、一个 SQLite 文件，没有 Redis，没有消息队列，也没有第二个需要单独运维的数据库实例。\u003C\u002Fp>\n\u003Ch2>这套栈支撑了什么\u003C\u002Fh2>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>区域\u003C\u002Fth>\n\u003Cth>能力\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\u003Ctr>\n\u003Ctd>前台\u003C\u002Ftd>\n\u003Ctd>首页、文章列表与详情（SSR）、标签、归档、追番、足迹、Sitemap\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>写作\u003C\u002Ftd>\n\u003Ctd>后台登录、Markdown 编辑器（实时预览、插图、导入 \u003Ccode>.md\u003C\u002Fcode>）、草稿 \u002F 发布 \u002F 归档、图片库\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>附加\u003C\u002Ftd>\n\u003Ctd>追番排名、旅行足迹、Giscus 评论\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>SEO\u003C\u002Ftd>\n\u003Ctd>每页 title \u002F description \u002F canonical \u002F Open Graph，文章页带 BlogPosting 结构化数据\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\n\u003Ch2>技术选型一览\u003C\u002Fh2>\n\u003Cp>下表是 \u003Ccode>pnpm-lock.yaml\u003C\u002Fcode> 里实际锁定的版本：\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>层级\u003C\u002Fth>\n\u003Cth>技术\u003C\u002Fth>\n\u003Cth>版本\u003C\u002Fth>\n\u003Cth>职责\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\u003Ctr>\n\u003Ctd>应用框架\u003C\u002Ftd>\n\u003Ctd>Nuxt（Vue 3）\u003C\u002Ftd>\n\u003Ctd>4.5.2 \u002F 3.5.41\u003C\u002Ftd>\n\u003Ctd>页面、路由、SSR、SEO\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>语言\u003C\u002Ftd>\n\u003Ctd>TypeScript\u003C\u002Ftd>\n\u003Ctd>5.9.3\u003C\u002Ftd>\n\u003Ctd>前后端统一类型\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>服务端\u003C\u002Ftd>\n\u003Ctd>Nitro on Node.js\u003C\u002Ftd>\n\u003Ctd>Node 24\u003C\u002Ftd>\n\u003Ctd>API、服务端逻辑、生产运行时\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>样式\u003C\u002Ftd>\n\u003Ctd>Tailwind CSS\u003C\u002Ftd>\n\u003Ctd>4.3.3\u003C\u002Ftd>\n\u003Ctd>布局、响应式与设计令牌\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>数据库\u003C\u002Ftd>\n\u003Ctd>SQLite\u003C\u002Ftd>\n\u003Ctd>—\u003C\u002Ftd>\n\u003Ctd>业务数据持久化\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>ORM\u003C\u002Ftd>\n\u003Ctd>Drizzle ORM\u003C\u002Ftd>\n\u003Ctd>0.44.7\u003C\u002Ftd>\n\u003Ctd>类型安全的查询与 Migration\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>数据库驱动\u003C\u002Ftd>\n\u003Ctd>better-sqlite3\u003C\u002Ftd>\n\u003Ctd>12.11.1\u003C\u002Ftd>\n\u003Ctd>Node 侧同步 SQLite 连接\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>输入校验\u003C\u002Ftd>\n\u003Ctd>Zod\u003C\u002Ftd>\n\u003Ctd>4.4.3\u003C\u002Ftd>\n\u003Ctd>API、表单与环境变量校验\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Markdown\u003C\u002Ftd>\n\u003Ctd>marked + isomorphic-dompurify\u003C\u002Ftd>\n\u003Ctd>16.4.2 \u002F 2.36.0\u003C\u002Ftd>\n\u003Ctd>渲染与 HTML 清洗\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>密码哈希\u003C\u002Ftd>\n\u003Ctd>bcryptjs\u003C\u002Ftd>\n\u003Ctd>3.0.3\u003C\u002Ftd>\n\u003Ctd>管理员密码\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>日志\u003C\u002Ftd>\n\u003Ctd>Pino\u003C\u002Ftd>\n\u003Ctd>9.14.0\u003C\u002Ftd>\n\u003Ctd>结构化运行日志\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>测试\u003C\u002Ftd>\n\u003Ctd>Vitest + Playwright\u003C\u002Ftd>\n\u003Ctd>3.2.7 \u002F 1.63.0\u003C\u002Ftd>\n\u003Ctd>单元、集成与端到端测试\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>静态检查\u003C\u002Ftd>\n\u003Ctd>ESLint + vue-tsc\u003C\u002Ftd>\n\u003Ctd>9.39.5 \u002F 3.3.11\u003C\u002Ftd>\n\u003Ctd>代码规范与类型检查\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>包管理\u003C\u002Ftd>\n\u003Ctd>pnpm\u003C\u002Ftd>\n\u003Ctd>11.22.0\u003C\u002Ftd>\n\u003Ctd>依赖锁定与脚本管理\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\n\u003Cp>刻意没有引入的东西同样是选型的一部分：没有 Redis（进程内限流足够），没有 Elasticsearch（文章量级用 SQLite 查询足够），没有 GraphQL（内部接口用不上），也没有把实验性的 \u003Ccode>node:sqlite\u003C\u002Fcode> 当作生产驱动。\u003C\u002Fp>\n\u003Ch2>为什么是模块化单体\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>一次 \u003Ccode>git clone\u003C\u002Fcode>、一次 \u003Ccode>pnpm install\u003C\u002Fcode>、一条 \u003Ccode>pnpm dev\u003C\u002Fcode> 就能跑起整个站点。\u003C\u002Fli>\n\u003Cli>前后端共享类型：\u003Ccode>shared\u002Fschemas\u003C\u002Fcode> 里的 Zod Schema 同时给 API 校验和前端表单使用，改字段时类型检查会直接报错。\u003C\u002Fli>\n\u003Cli>没有跨服务鉴权、CORS 和分布式事务的问题。\u003C\u002Fli>\n\u003Cli>个人博客的写入者就是作者本人，SQLite 的单写入限制在这里不构成瓶颈。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>只有当多实例部署、写并发明显升高或跨机器异步任务出现时，才需要迁移 PostgreSQL 或拆分服务；在那之前领域层保持独立，迁移成本是可控的。\u003C\u002Fp>\n\u003Ch2>分层与依赖方向\u003C\u002Fh2>\n\u003Cpre>\u003Ccode class=\"language-text\">Page \u002F Component → API → Domain Service → Repository → Database\n                            │\n                            └→ shared schemas \u002F types\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cpre>\u003Ccode class=\"language-text\">app\u002F        页面、组件、布局、composables、中间件\nserver\u002F\n  api\u002F      HTTP 适配：Zod 校验 → 领域服务 → 统一响应\n  domains\u002F  领域服务与 Repository（auth \u002F posts \u002F media \u002F anime-rankings \u002F feeds）\n  db\u002F       Drizzle Schema、连接、Migration\n  routes\u002F   sitemap.xml\n  utils\u002F    响应包装、错误转换、限流、路径与日志\nshared\u002F     前后端共享的常量、Schema、类型与工具函数\ntests\u002F      unit \u002F integration \u002F e2e\ndata\u002F       SQLite 与上传文件（不提交到 Git）\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>几条硬规则：Vue 组件不碰数据库；API 路由只负责校验、鉴权和响应转换，不堆业务逻辑；所有 Drizzle 查询收在 Repository 里；底层模块不反向依赖页面。\u003C\u002Fp>\n\u003Ch2>数据层：SQLite + Drizzle\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>Schema 用 Drizzle 的 SQLite 方言声明，字段类型直接推导出 TypeScript 类型。\u003C\u002Fli>\n\u003Cli>结构变更全部走 Migration：\u003Ccode>pnpm db:generate\u003C\u002Fcode> 生成 SQL，\u003Ccode>pnpm db:migrate\u003C\u002Fcode> 执行，产物提交进仓库。\u003C\u002Fli>\n\u003Cli>连接初始化固定四行 PRAGMA：\u003Ccode>journal_mode = WAL\u003C\u002Fcode>、\u003Ccode>foreign_keys = ON\u003C\u002Fcode>、\u003Ccode>busy_timeout = 5000\u003C\u002Fcode>、\u003Ccode>synchronous = NORMAL\u003C\u002Fcode>。\u003C\u002Fli>\n\u003Cli>开发、测试、生产使用不同的数据库路径；集成测试跑在临时库上，不会污染开发数据。\u003C\u002Fli>\n\u003Cli>时间统一按毫秒时间戳存储，外键关系显式声明并按需级联。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>目前的数据表：\u003Ccode>users\u003C\u002Fcode>、\u003Ccode>sessions\u003C\u002Fcode>、\u003Ccode>posts\u003C\u002Fcode>、\u003Ccode>tags\u003C\u002Fcode>、\u003Ccode>post_tags\u003C\u002Fcode>、\u003Ccode>media\u003C\u002Fcode>、\u003Ccode>settings\u003C\u002Fcode>、\u003Ccode>anime_rankings\u003C\u002Fcode>。\u003C\u002Fp>\n\u003Ch2>输入边界与统一响应\u003C\u002Fh2>\n\u003Cp>所有外部输入都要过 Zod：请求体、路径参数、查询参数、表单和环境变量。API 只返回两种形状：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{ \"data\": {}, \"meta\": { \"page\": 1, \"pageSize\": 10, \"total\": 42, \"totalPages\": 5 } }\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cpre>\u003Ccode class=\"language-json\">{ \"error\": { \"code\": \"POST_NOT_FOUND\", \"message\": \"文章不存在\" } }\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>错误码集中在 \u003Ccode>shared\u002Fconstants\u003C\u002Fcode> 里维护，客户端可以稳定判断；响应里不会出现堆栈、SQL 或服务器路径。列表接口一律分页，单页上限 50 条。\u003C\u002Fp>\n\u003Ch2>内容管线：Markdown 怎么变成页面\u003C\u002Fh2>\n\u003Cp>文章以 Markdown 原文存入数据库，渲染结果同时缓存下来：\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">Markdown 原文 → marked（GFM）→ DOMPurify 白名单清洗 → 缓存 HTML → 页面直接输出\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cul>\n\u003Cli>渲染与清洗都只在服务端完成，客户端不承担安全职责。\u003C\u002Fli>\n\u003Cli>白名单只放行正文需要的标签，\u003Ccode>script\u003C\u002Fcode>、\u003Ccode>iframe\u003C\u002Fcode>、\u003Ccode>style\u003C\u002Fcode> 与内联事件一律丢弃。\u003C\u002Fli>\n\u003Cli>外链自动补 \u003Ccode>target=\"_blank\"\u003C\u002Fcode> 与 \u003Ccode>rel=\"noopener noreferrer nofollow\"\u003C\u002Fcode>，图片自动补 \u003Ccode>loading=\"lazy\"\u003C\u002Fcode>。\u003C\u002Fli>\n\u003Cli>正文里的标题整体降一级，保证一个页面只有一个 \u003Ccode>h1\u003C\u002Fcode>，也就是文章标题。\u003C\u002Fli>\n\u003Cli>后台编辑器的实时预览复用同一套渲染实现，不会出现「预览好看、发布错乱」。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>安全基线\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>Session 存在数据库里，只保存 Token 的 SHA-256 摘要；Cookie 为 \u003Ccode>HttpOnly\u003C\u002Fcode> + \u003Ccode>SameSite=Lax\u003C\u002Fcode>，生产环境自动加 \u003Ccode>Secure\u003C\u002Fcode>。\u003C\u002Fli>\n\u003Cli>写操作使用双提交 Cookie 校验 CSRF（\u003Ccode>x-csrf-token\u003C\u002Fcode>）。\u003C\u002Fli>\n\u003Cli>密码用 bcrypt（cost 12）哈希；登录与上传接口有进程内限流。\u003C\u002Fli>\n\u003Cli>上传校验 MIME、扩展名与大小，文件名随机化，并且只能通过 \u003Ccode>\u002Fapi\u002Fuploads\u002F:name\u003C\u002Fcode> 读取，\u003Ccode>data\u002Fuploads\u003C\u002Fcode> 目录不对外暴露。\u003C\u002Fli>\n\u003Cli>评论交给 Giscus 托管，正文页不承担评论系统的维护成本。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>测试与质量门禁\u003C\u002Fh2>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>层次\u003C\u002Fth>\n\u003Cth>工具\u003C\u002Fth>\n\u003Cth>覆盖内容\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\u003Ctr>\n\u003Ctd>单元测试\u003C\u002Ftd>\n\u003Ctd>Vitest\u003C\u002Ftd>\n\u003Ctd>Schema、纯函数与领域规则（slug 生成、日期、地理投影等）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>集成测试\u003C\u002Ftd>\n\u003Ctd>Vitest + 临时 SQLite\u003C\u002Ftd>\n\u003Ctd>Repository、Session、API 契约与 Sitemap\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>端到端\u003C\u002Ftd>\n\u003Ctd>Playwright\u003C\u002Ftd>\n\u003Ctd>登录、写文章、发布、上传、公开阅读，含桌面与移动视口截图\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>静态检查\u003C\u002Ftd>\n\u003Ctd>ESLint + vue-tsc\u003C\u002Ftd>\n\u003Ctd>代码规范与严格类型（\u003Ccode>strict: true\u003C\u002Fcode>）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\n\u003Cp>日常提交前的顺序是 \u003Ccode>pnpm lint\u003C\u002Fcode> → \u003Ccode>pnpm typecheck\u003C\u002Fcode> → \u003Ccode>pnpm test\u003C\u002Fcode> → \u003Ccode>pnpm build\u003C\u002Fcode>，涉及关键流程时再跑 \u003Ccode>pnpm test:e2e\u003C\u002Fcode>。\u003C\u002Fp>\n\u003Ch2>部署与备份\u003C\u002Fh2>\n\u003Cpre>\u003Ccode class=\"language-bash\">pnpm build\nnode .output\u002Fserver\u002Findex.mjs\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cul>\n\u003Cli>只运行一个 Node 写入进程，SQLite 与上传目录挂载到持久化磁盘。\u003C\u002Fli>\n\u003Cli>启动前执行 \u003Ccode>pnpm db:migrate\u003C\u002Fcode>；\u003Ccode>pnpm db:backup\u003C\u002Fcode> 通过 SQLite 在线备份 API 生成一致性快照。\u003C\u002Fli>\n\u003Cli>建议保留 7 份每日备份与 4 份每周备份，并且至少一份放到异机或对象存储。\u003C\u002Fli>\n\u003Cli>由 Nginx 或 Caddy 提供 HTTPS 与安全响应头。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>两个顺带实现的小功能\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>\u003Cstrong>追番排名\u003C\u002Fstrong>：条目元数据来自 Bangumi 公开 API，后台可搜索、增删改与调序，前台按分档展示。\u003C\u002Fli>\n\u003Cli>\u003Cstrong>旅行足迹\u003C\u002Fstrong>：省级 GeoJSON 存放在本地，用自己写的投影与路径生成逻辑渲染成 SVG，点击省份记录足迹，全程不依赖第三方地图 SDK。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>什么时候会换掉这套栈\u003C\u002Fh2>\n\u003Cp>当站点需要多实例部署、写入并发明显升高，或者出现跨机器的异步任务时，会先把数据层迁到 PostgreSQL——领域层与 Repository 的边界就是为此留的——再考虑拆分服务。在那之前，保持单一进程、单一数据库文件，是维护成本最低的形态。\u003C\u002Fp>\n",1790434626634]