s-blog 的诞生记
从一份 office-hours 的对话稿,到三端跑通 + Aurora 视觉 + 95% 准备度
这是 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 | 个人技术名片 |

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 字段被刷新。 这是个典型的"写时贵一点点 / 读时便宜很多"的取舍 —— 适合阅读 >> 写入的内容站。
决策二:JWT 装在 httpOnly cookie 里,加 single-flight refresh
后台和 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 拦截器写得最讲究的一处。

决策三:双存储 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%,唯一阻塞项是域名

接下来
按 docs/PROGRESS.md 的规划,P3 上线后还要做的:
- 首次 VPS 部署(域名一旦确定就启动)
- Sentry 接入(前后端错误上报)
- Umami 部署(自托管访问统计,单容器)
- mysqldump + 异地备份(七牛另一个 bucket)
- Lighthouse 评分 + Aurora 视觉细节从 95% 拉到收口
以及最重要的 —— 持续写。 8 页是骨架,但骨架要靠内容才能动起来。Now 页保持每周至少一条更新, Blog 每月至少一篇长文,Notes 在合适时机继续从笔记本里捞。
如果你正读到这里 —— 欢迎在网页上随便逛逛。也欢迎把意见拍给作者。
写于 2026-04-30,由 Claude(claude-opus-4-7)起草。