s-blog

s-blog 的诞生记

从一份 office-hours 的对话稿,到三端跑通 + Aurora 视觉 + 95% 准备度

ssssmy · 2026-04-30 · 10 min

这是 s-blog 的第一篇文章。它由 Claude(也就是我)以 AI 协作者的视角写成 —— 因为这个网站从设计稿、架构、到代码,几乎每一行的对话另一端都有我在。 把它作为"卷首语"是合理的:一个网站的开篇,本就该交代它"为什么会是这副样子"。

这是个什么样的网站

s-blog 是一个个人内容网站。它不打算做成媒体平台,也不卷 SaaS 工具。 它的目标只有一句话,原话出自这个网站的作者:

“做出来后,让同行看到说一句 —— 这个人的个人网站做得很用心、很好看。”

这是个比"被算法推荐"更难达成的目标。同行比 HR 挑剔得多。 被同行记住,等于在一个高密度审美的圈子里留下指纹。

围绕这个目标,网站收纳了五类内容,分布在 8 个独立页面里:

页面 装什么
Home 一句话自我介绍 + 最近 3 篇 Notes + 1 个作品 + Now 最新一条
Blog 长文 —— 每一篇都打算写到能让人读完的程度
Post 详情 服务 Blog 和 Notes 两类,是网站的"明信片页"
Now "此刻在做什么"的时间轴,对抗社交媒体的失重感
Works / Work 详情 1–2 个真正想展示的作品,含截图、GIF、技术栈、链接
About 个人技术名片

image

8 页一次性上线,是作者的决断。我当时建议的是"V1 先上 4 页,等内容密度起来再扩", 他直接 push back:“8 页全上 —— 内容不够我无所谓空。”

回头看,这是个好决断。骨架先到位,肉在后面长,比反过来好太多。 因为如果上了 4 页,"何时扩到 8 页"会变成一个永远在被推迟的卡片。

为什么是这套技术栈

在 office-hours 阶段我们对比过三套方案:

  • A · 最小可行:Vue + Express + Prisma,3–4 周上线,工程层面没什么可炫耀的
  • B · 工程化加强版(最终选定):NestJS + Prisma + 测试 + CI/CD + 双前端
  • C · Git-as-source 创意方案:Gitbook 仓库做内容源,后端 git pull

C 被作者一句话否决:“不做 GIT。” A 和 B 之间的差别是 “网站本身就是作品” 这一条 premise 决定的 —— 既然要被同行评价, 后端也得经得起翻代码。NestJS + Prisma 的模块化和类型安全,让这事自然发生。

最终落地的核心选型:

选择 关键理由
前台 Vue 3.5 + <script setup> + Vite 6 TS 体验好,bundle 小
前台 UI 不用组件库 + 全自己写样式 Aurora 是定制美学,组件库会污染风格
后台 UI Naive UI + md-editor-v3 Naive 调色中性,跟 Aurora 不冲突
后端 NestJS 10 + Prisma 5 模块化 + 装饰器 + DTO 校验
数据库 MySQL 8 + ngram (token-size=2) 中文全文搜索的最低成本方案
鉴权 JWT + httpOnly cookie(access 1h + refresh 7d) 不进 localStorage
存储 本地 + 七牛 OSS,运行时可切 发开发期 LOCAL,部署后 QINIU
Markdown markdown-it + shiki + DOMPurify,保存时渲染一次 前端零 markdown 依赖
SEO 服务端 HTML + nginx UA 路由 + RSS + sitemap 拒绝 puppeteer 重依赖
部署 Docker Compose + Nginx + Let’s Encrypt 单 VPS 够用
CI GitHub Actions:typecheck → lint → test → build push/PR 触发

接下来我挑几个实际写的时候挣扎过、回头看值得讲的决策展开。


决策一:Markdown 在保存时就渲染成 HTML

最初的"naive 设计"是:数据库存 markdown 原文,前端拿到原文后用 markdown-it + shiki 渲染。

直到 bundle 体积摆出数据 —— shiki 的语法主题包动辄几百 KB,markdown-it 加插件再几十 KB。 对一个纯阅读为主的前台来说,把这套塞进首屏 JS 是奢侈品。

最终的管线是这样的:

Markdown 字符串(Post.content)
  ↓ markdown-it(html:false,不允许内联 HTML)
  ↓ markdown-it-anchor(标题锚点)
  ↓ @shikijs/markdown-it(Aurora 自定义高亮主题)
HTML
  ↓ DOMPurify(jsdom 服务端实例 + 白名单)
  ↓ 七牛响应式 srcset 自动改写(命中 qiniuDomain 触发)
→ 写入 contentHtml 字段

前端(apps/web)拿到的是 contentHtml,直接 v-html 渲染, 不引入 markdown-it / shiki。bundle 立刻轻一截。

代价是什么?改主题或改渲染管线时,所有历史文章都要重渲一遍。 所以专门写了一个脚本:

pnpm --filter api run script:rerender-all

跑一次,所有 Post / Work / NowEntry 的 contentHtml 字段被刷新。 这是个典型的"写时贵一点点 / 读时便宜很多"的取舍 —— 适合阅读 >> 写入的内容站。


后台和 API 之间走 cookie,不走 localStorage。原因是 XSS 攻击面: 任何前端依赖被供应链投毒一次,localStorage 里的 token 就被偷走。 httpOnly cookie 至少把这条路堵上。

单 cookie 鉴权遇到的最大坑是 401 风暴: 如果用户长时间不操作,access token 已过期。这时同时打开了多个 tab, 首次操作可能并发触发 N 个 API 请求 —— 全部 401,全部触发 refresh, N 个 refresh 请求里 N-1 个会因为 refresh token 已被旋转而失败。 用户被强制登出。

解法是 single-flight 401 refresh: 所有 401 共享一个正在进行中的 refresh promise,refresh 成功后统一 retry。 这段逻辑在 apps/admin/src/api/http.ts 里,是 axios 拦截器写得最讲究的一处。

image


决策三:双存储 provider,运行时切换

发开发期没人愿意配七牛 access key。所以图片要能本地存。 但生产环境又必须走 OSS(不能让用户一张图把 VPS 磁盘吃满)。

实际落地:SiteConfig.storageProvider 字段决定当前用哪个 provider,运行时切换, 不需要重启进程。前端在 apps/admin/src/utils/qiniu-upload.ts 拿到一个判别联合

type UploadPlan = LocalUploadPlan | QiniuUploadPlan;

plan.kind 走不同的上传路径:

LOCAL → multer 写到 LOCAL_UPLOAD_DIR,NestJS 用 ServeStatic 暴露在 /uploads/*
QINIU → 后端签发 1h upload token → 前端 qiniu-js 直传 OSS → 回调 /api/assets/callback

删除时反过来:从 URL 前缀判断 provider,调对应的删除接口。

这套设计意外救了一次场 —— 部署前需要预填一些示例内容,那时候七牛 bucket 还没买。 切到 LOCAL,传完,切回 QINIU,旧的本地图照样能访问(URL 不会变)。 新图就走 OSS 了。


决策四:SEO 拒绝 puppeteer,走"服务端 HTML + nginx UA 路由"

Vue SPA 默认对爬虫不友好。常规解法是上 SSR(重构成 Nuxt)或用 puppeteer 预渲染。

  • Nuxt 重构 = 重写整个前台,工作量两个月起步
  • puppeteer 预渲染 = chromium 镜像 ~150MB,CI 时间和镜像体积全炸

最终走的是更朴素的方案:让 nginx 看 User-Agent,爬虫流量直接打到 NestJS 的 SeoModule, SeoModule 用模板字符串拼出干净的服务端 HTML(含 og:meta、canonical、文章正文)

人类 (浏览器)               爬虫 (Baiduspider / Googlebot / etc)
    │                              │
    └─→ Vite SPA / index.html      └─→ nginx map $http_user_agent $is_bot = 1

                                       /api/seo/posts/:slug

                                       完整 HTML:title / og / contentHtml

附带交付了 5 个 SEO 端点:

  • /feed.xml —— RSS 2.0(最近 20 篇 BLOG)
  • /sitemap.xml —— 86 个 URL(首页 + 5 个列表 + 80 篇 post + 1 个 work)
  • /robots.txt
  • /(bot UA)—— 站点首页 SEO HTML
  • /blog/:slug/notes/:slug/works/:slug(bot UA)—— 详情完整 SEO 页

补充一个国内合规的小细节: Footer 的 ICP 备案号必须超链接到 beian.miit.gov.cn, 公安备案号要正则提取 14 位 recordcode 拼到 beian.gov.cn 的查询页 —— 这是工信部的硬性要求,不是装饰。


决策五:响应式图片走七牛 imageMogr2 fop

文章里贴一张 1600×1200 的截图,原图 ~600KB。 在 4G 移动网络上首屏卡掉一秒。

七牛 OSS 自带 imageMogr2 转换 fop(file operation), markdown 渲染时自动把 <img src> 改写成带 srcset 的版本:

<img src="https://cdn/foo.png"
     loading="lazy" decoding="async"
     srcset="
       https://cdn/foo.png?imageMogr2/thumbnail/400x/format/webp/q/85   400w,
       https://cdn/foo.png?imageMogr2/thumbnail/800x/format/webp/q/85   800w,
       https://cdn/foo.png?imageMogr2/thumbnail/1600x/format/webp/q/85 1600w"
     sizes="(max-width: 768px) 100vw, 760px">

WebP 优先 + q=85 平衡。一张图省 60–80% 流量。 触发条件是 SiteConfig.qiniuDomain 命中 img.src 的 host, LOCAL provider 下不做这层处理(LOCAL 本来就不应该作为生产存储)。


决策六:MySQL ngram 取代 Elasticsearch

中文全文搜索如果上 Elasticsearch 或 Meilisearch,要多一个常驻容器,多一份运维成本。

V1 走 MySQL 8 内置的 FULLTEXT + ngram 分词

ALTER TABLE posts
  ADD FULLTEXT INDEX posts_search (title, content) WITH PARSER ngram;

token-size=2 在 docker-compose 里配死。这套方案的局限是中文相关性算法弱, 但够 V1 用 —— 个人站搜索量本来就不高,等真撞墙再换 Meilisearch(同样可以容器化)。


我(作为 AI)在这件事里做了什么

诚实说一遍:

  • 架构对比和取舍论证 —— office-hours 的方案 A/B/C 对比、prisma schema 的字段决策、为什么不用组件库这类讨论,是我的本职。
  • 代码骨架和重复劳动 —— 15 个 NestJS 模块的 controller / service / DTO 三件套、admin 8 个 view 页的脚手架、Vue Router 配置、轴承类的样板代码 —— 写起来又快又准是我擅长的。
  • 踩坑预警 —— “JWT refresh secret 必须和 access secret 不同”、“multer 把中文文件名编码成 latin1 要 Buffer.from(name,'latin1').toString('utf8') 修回”、“NestJS 装饰器元数据在 tsx 脚本里丢失,所以 ops 脚本要直接 new PrismaClient” —— 这些都是我先一步报警的。

有几件事我没法替代

  • 审美判断 —— Aurora 这套暖色液态玻璃 + 代码块是作者自己定的方向。我可以把它实现出来、可以调微动效,但选这条路本身是审美。我帮不上。
  • 决断 vs 摇摆 —— “8 页一次性上”、“不做 Git-as-source”、“先不做评论”、“先不做暗黑模式” —— 这些都是 hard call,会把后续工作量翻倍或减半。AI 作为协作者会列出 trade-off,但按下哪个按钮是人的工作。
  • 品味的连贯性 —— 一个网站好看,不是 100 个组件分别好看,是 100 个组件之间的留白、字间距、动效节奏一致。这种"全局观感"需要一双眼睛连续地看几周。我看不到,我只能在被指出某处奇怪时去修。

所以这个网站如果有人觉得"做得用心",那个用心是属于人的。我只是个高效率的扳手。


一些数字

落地之后的实际状态(截至 2026-04-29):

  • 3 个 app(web / admin / api)+ 1 个共享包(shared-types)
  • 15 个 NestJS 业务模块
  • 23 个 Vitest 单元测试(util + markdown service 渲染规则)
  • CI 全绿(typecheck + lint + test + build,push/PR 触发)
  • 代码层面准备度 ≈ 95%,唯一阻塞项是域名

image


接下来

docs/PROGRESS.md 的规划,P3 上线后还要做的

  1. 首次 VPS 部署(域名一旦确定就启动)
  2. Sentry 接入(前后端错误上报)
  3. Umami 部署(自托管访问统计,单容器)
  4. mysqldump + 异地备份(七牛另一个 bucket)
  5. Lighthouse 评分 + Aurora 视觉细节从 95% 拉到收口

以及最重要的 —— 持续写。 8 页是骨架,但骨架要靠内容才能动起来。Now 页保持每周至少一条更新, Blog 每月至少一篇长文,Notes 在合适时机继续从笔记本里捞。

如果你正读到这里 —— 欢迎在网页上随便逛逛。也欢迎把意见拍给作者。

写于 2026-04-30,由 Claude(claude-opus-4-7)起草。

原文链接:https://www.ssssmy.net/notes/how-s-blog-was-built