s-blog

s-blog —— 个人内容网站

Vue 全栈个人站。前后端分离 + 后台管理系统,三端跑通 + Aurora 视觉。

Vue3 NestJS Prisma MySQL Docker Nginx

Demo ↗

s-blog —— 个人内容网站

s-blog

一个能让我自己每天想点开的个人站。

为什么自己做

之前用过 Hexo、Hugo、Notion publish、Vitepress —— 每个都好,但都不是"我的"。 模板站的本质是:你在别人的房间里贴海报。颜色 / 字号 / 间距 / 动效,最后总有几处别扭, 但你没法逐处去改,因为整套样式系统不是你的。

加上手里堆了 78 篇笔记,从 Gitbook 时代就在迁。每换一次平台就要重导一次, 一直没找到能稳稳住下来的容器。索性自己写一个 —— 顺便把"网站本身就是作品"这件事 做扎实。让别人翻代码翻得到东西,让自己改任何一个像素都不用绕过框架。

技术栈

选型
前台 web Vue 3.5 + <script setup> + Vite 6 + Pinia + Vue Router 4
后台 admin Vue 3 + Naive UI + md-editor-v3 + axios
后端 api NestJS 10 + Prisma 5 + MySQL 8
Markdown markdown-it + shiki + DOMPurify(保存时渲染)
鉴权 JWT httpOnly cookie(access 1h + refresh 7d)
存储 本地 multer / 七牛 OSS 直传,运行时可切
全文搜索 MySQL 8 FULLTEXT + ngram (token-size=2)
SEO 服务端 HTML + nginx UA 路由 + RSS / sitemap / robots
部署 Docker Compose + Nginx + Let’s Encrypt
CI GitHub Actions(typecheck + lint + vitest + build)

monorepo 用 pnpm workspaces,三个 app 一个共享类型包。turbo 没接, 现在的规模不需要。

一些写起来挣扎过的地方

Markdown 在保存时就渲染

最初想前端拿到 markdown 自己渲染。试了一下 bundle —— shiki 主题包几百 KB, markdown-it 加插件再几十 KB,全部塞进首屏 JS。一个以阅读为主的站,这是奢侈品。

最后的管线是后端 markdown-it → markdown-it-anchor → @shikijs/markdown-it (Aurora 主题) → DOMPurify (jsdom + 白名单) → 写入 contentHtml 字段。前端 v-html 直出, 不带 markdown 依赖。

代价是改主题 / 改渲染管线时所有历史文章要重渲一遍。专门写了个脚本:

pnpm --filter api run script:rerender-all

跑一次刷新所有 Post / Work / NowEntry 的 contentHtml。算是"写时贵一点点 / 读时便宜很多" 的取舍 —— 内容站读写比就该这样。

token 不放 localStorage —— 任何前端依赖被供应链投毒一次就被偷走。 两个 token 都装 httpOnly cookie,access 1h、refresh 7d、SameSite=Strict。

这套一开始有个挺隐蔽的坑:access 过期时如果同时打开多个 tab,并发 N 个请求 全部 401,全部触发 refresh,refresh 又会旋转 refresh token —— 后到的 N-1 个 refresh 全部失败,用户被踹出去登录。

解法是 single-flight:所有 401 共用同一个 in-flight refresh promise, refresh 成功后统一 retry。代码在 apps/admin/src/api/http.ts,写得最讲究的一段 axios 拦截器。

双存储 provider,运行时切换

dev 环境没人愿意配七牛 access key,本地先得能传。 生产又必须走 OSS(不能让一张图把 VPS 磁盘吃满)。

SiteConfig.storageProvider 字段决定当前用哪个 provider,运行时改不重启。 后端 generateUploadToken() 返回判别联合:

type UploadPlan = LocalUploadPlan | QiniuUploadPlan;

前端 qiniu-upload.ts 拿到后按 kind 分发:LOCAL 走 multipart 直传 NestJS, QINIU 拿 1h token 走 qiniu-js SDK 直传 OSS、回调 /api/assets/callback。 删除时反过来按 URL 前缀判断 provider,调对应接口。

部署前发现还没买七牛 bucket。切到 LOCAL 把示例内容传完,再切回 QINIU —— 旧的本地 URL 不变照样能访问,新图走 OSS。意外地舒服。

SEO 没用 puppeteer

Vue SPA 默认对爬虫不友好。常规两条路:

  • 重构成 Nuxt 上 SSR —— 等于重写整个前台
  • puppeteer 预渲染 —— chromium 镜像 ~150MB,CI 镜像体积全炸

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

人类浏览器           爬虫 (Baiduspider / Googlebot / 字节 / 华为 / etc)
    │                          │
    └─→ Vite SPA               └─→ 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
  • /robots.txt
  • /(bot UA)—— 站点首页 SEO HTML
  • /blog/:slug/notes/:slug/works/:slug(bot UA)—— 详情完整 SEO 页

中间踩了一个 nginx 启动崩溃 —— if 块里的 proxy_pass 不能带静态 URI, 要先 rewrite ... breakproxy_pass http://upstream;。这是 nginx 文档里 不显眼但很硬的规则。

附带做了 ICP / 公安备案的 footer 合规外链 —— ICP 链 beian.miit.gov.cn、 公安备案号正则提 14 位 recordcode 拼 beian.gov.cn 查询页。 工信部要求"备案号必须超链接到对应官方 URL",不是装饰。

七牛响应式图片

文章配一张 1600×1200 的截图,原图 ~600KB。在 4G 上首屏卡掉一秒。

七牛 imageMogr2 fop 自动转 WebP + 三档缩略图。markdown 渲染时 image 规则 改写成带 srcset 的 <img>

<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">

q=85 平衡画质和体积。一张图省 60–80% 流量。命中条件是 SiteConfig.qiniuDomain 正好等于 img.src 的 host —— LOCAL provider 不触发,因为 LOCAL 本来就不应该 作为生产存储。

MySQL ngram 而不是 ES

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

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

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

token-size=2 在 docker-compose 里配死。Prisma 的 @@fulltext 不会自动 生成 WITH PARSER ngram,要在 migration 里手写 raw SQL。

中文相关性算法弱是局限 —— 个人站搜索量本来就低,等真撞墙再换 Meilisearch。 现在的版本能搜「鉴权 jwt」「七牛 webp」这种短语,够日常翻笔记。

类似这种"dev 永远遇不到,部署立刻撞"的小坑还有几个,慢慢填。

数据 / 现状

  • 3 个 app(web / admin / api)+ 1 个共享类型包
  • 15 个 NestJS 业务模块
  • 78 篇历史笔记一次性脚本导入
  • 23 个 Vitest 单测(util + markdown service rule)
  • CI 全绿,typecheck + lint + test + build push/PR 触发
  • 代码层面准备度 ~95%,主要剩部署相关 (Sentry / Umami / 备份脚本) 和视觉收口

没做 / 暂时没做的

没做 原因
评论系统 V1 不要。真要做接 Giscus,不自己存
暗黑 / 浅色切换 Aurora 本身是深色,浅色版本要重新调色,工作量等同重做视觉
多语言 设计稿留了 i18n 开关,但每篇文章维护两版内容成本翻倍
前端 SSR SEO 需求已经被 nginx UA 路由 + SeoModule 满足
Newsletter 超出 V1 范围
AI 摘要 / AI alt 文本 想做。优先级排在 Sentry / Umami 后面
Redis 缓存 没有。MySQL query cache + Nginx 静态缓存头够用

写在这里防自己反复纠结 —— 每一项以后真要做的时候来翻这张表, 判断当时拒绝的理由是否还成立。

写完之后想说的

写之前估的是 6-8 周全职,最后跑了大概那么长。 卡得最久的不是 NestJS / Prisma 这些,是 Aurora 视觉细节 —— 留白、 字间距、hover 状态、动效节奏。这部分没办法外包给框架,眼睛得连续看几周。

测试覆盖率没刻意拉高 —— 一个人项目追覆盖率是负价值。23 个单测都集中在 真实出过问题的地方(reading time / slug / markdown image rule / XSS golden file)。

如果你也在想自己做一个个人站,建议是: 先把视觉稿定到自己满意,再写代码。 反过来很容易做着做着发现配色和原计划差太多, 要么妥协要么推倒重来。我用了 /office-hours + Claude Design 的 Aurora handoff 稿 作为视觉锚点,全程没偏离过。这个流程值。

链接

原文链接:https://www.ssssmy.net/works/s-blog