YeagerZhao
首页博客项目旅程关于
返回

我是怎么搭建 YeagerZhao Blog 的:Next.js 15 + Notion + 阿里云 OSS 完整架构

2026 · 06 · 23
Vibe Coding个人网站
目录我是怎么搭建 YeagerZhao Blog 的:Next.js 15 + Notion + 阿里云 OSS 完整架构1. 项目定位2. 总体架构3. 技术栈4. 目录结构5. 路由设计6. 内容数据建模:四个 Notion 数据库7. 服务端数据层8. 内容发布流水线:从本地 markdown 到 Notion9. 图片处理流水线:sharp + 盲水印 + OSS 三档实时派生10. NotionBlocks:自己写的 Notion → React 渲染器11. 视觉系统:Tailwind v4 + CSS 变量12. UI 分层:views + components + effects13. 国际化与中英文路由14. 缓存策略15. API Routes16. SEO 与站点索引17. Docker + Nginx + 阿里云 ECS 部署18. 日常工作流19. 关键取舍20. 局限与下一步21. 总结

我是怎么搭建 YeagerZhao Blog 的:Next.js 15 + Notion + 阿里云 OSS 完整架构

这是一份给我自己以后维护用,也希望同样在做个人站的朋友能照着抄的架构梳理。不光列技术栈,而是从目录树、路由、数据层、内容发布流水线、图片处理、缓存、国际化、视觉系统一路写到 Docker + Nginx 部署。目标是把 YeagerZhao_Blog 这个项目讲成一篇别人能读懂、自己以后也能照着维护的架构文。

首页
首页

1. 项目定位

YeagerZhao_Blog 是我的个人网站,线上地址 https://yeagerzhao.com。它把 Notion 当作 Headless CMS,用 Next.js 15 负责页面渲染、SEO、API 网关和缓存,用阿里云 OSS 存照片,用 Docker + Nginx 部署在阿里云 ECS 上。

要解决的核心问题就四个:

  • 写作要轻:内容直接在 Notion 里写,不需要每次改一段文字都提 PR、跑 CI。
  • 访问要快:访客看到的是独立站点(域名 + 视觉自定义),不是 Notion 原生页面那种灰白模板。
  • 设计要自由:字体、配色、动效、文章排版全在代码里掌控。
  • 照片要有版权保护:每张相册图本地先压成 ~8MP master、嵌一个 DWT-DCT-SVD 频域盲水印,再上 OSS;浏览器看到的图都是经过实时压缩派生的小图,原图只在点击放大时下发。

一句话概括:Notion 负责内容生产,Next.js 负责内容发布,OSS 负责图片分发,Nginx + Docker 负责稳定运行。

2. 总体架构

整个系统画成一张图大概是这样:

架构总览
架构总览
用户浏览器
    |
    | HTTPS
    v
Nginx on 阿里云 ECS
    |
    | /_next/static 长缓存;其他反代 127.0.0.1:3000
    v
Docker 容器:yeagerzhao-blog
    |
    | Next.js 15 standalone server
    | ├─ RSC 渲染(home / blog / product / journey / about)
    | ├─ unstable_cache + 内存层(1h TTL)
    | ├─ API Routes:/api/revalidate、/api/notion-image
    | └─ generateStaticParams 预渲染所有 slug
    v
        ┌─────────────────────────┬──────────────────────────┐
        v                         v                          v
   Notion API                  OSS(图床)              本地 SKILLS 脚本
   ├─ content DB              ├─ 相册 master            ├─ publish-content.mjs
   │  (Blog+Product)        │  (8MP + 盲水印)       ├─ publish-journey.mjs
   ├─ journey DB              └─ ?x-oss-process=...     ├─ rewatermark-album.mjs
   ├─ journey_photo DB           thumb/preview/full     └─ publish-about.mjs
   └─ about DB                   边缘节点实时派生

它没有传统意义上的"独立后端"。Next.js 自己同时承担四件事:

  1. 页面服务:home、blog 列表/详情、product 列表/详情、journey 列表/相册详情、about——全部 RSC。
  2. 数据网关:/api/notion-image 给 Notion 临时 S3 链接做代理,/api/revalidate 给 Notion / 脚本一个推送入口。
  3. 内容适配层:把 Notion 的 4 张数据库转成站点用的 TypeScript 类型。
  4. 静态资源代理:图片本身的 CDN 由 OSS 承担,Next.js 只服务自家的 _next/static 和 public/。

这种"内容/资产分家"的设计对个人站非常合适:文章和图片更新频率不一样,缓存策略也不一样,没必要塞到同一个 Node 进程里硬扛。

3. 技术栈

层级技术版本作用
应用框架Next.js15.5App Router、RSC、ISR、API Routes、standalone 输出
UI 运行时React19.1RSC + 客户端 hydration
类型系统TypeScript5BlogPost / Product / JourneyItem / AboutSection 等核心类型
样式系统Tailwind CSSv4CSS-first:@theme inline + CSS 变量
内容源Notion—4 个数据库:content / journey / journey_photo / about
Notion SDK@notionhq/client5.22v5 dataSources API + 自实现的兼容 shim
国际化next-intl4.9/zh、/en 路由 + UI 文案
图片上传ali-oss6.23本地脚本写入 OSS bucket
图片压缩sharpdevmozjpeg q=88、4:2:0 色度抽样、按 MP 预算缩放
盲水印invisible-watermark(Python)conda envDWT-DCT-SVD 频域水印
字体@fontsource-variable/*5.2Noto Sans/Serif SC + Newsreader + Spline Mono
部署Docker—多阶段 standalone 镜像
入口代理Nginx—HTTPS、反代、_next/static 长缓存

值得专门点名的几个"不在了"的依赖:之前我用过 react-notion-x 渲染正文,后来发现它打包过大、还要拽一堆它自己的 CSS 而且都得在客户端跑,于是改成 RSC 服务端自己渲染 Notion blocks,体积一下小了很多——这部分后面有专门一节讲。同样下线的还有自己写的 use-cached-fetch.ts 三层缓存:Next.js 15 的 unstable_cache + tags 已经能做到我想要的效果,没必要再叠一层客户端的状态机。早期还引入过 next-themes 做明暗切换,后来发现单一配色已经能撑住整套视觉系统,就把这个依赖一并删了。

4. 目录结构

按 Next.js App Router 的约定组织,但明确把"服务端数据层(server)"和"展示层(ui)"分开:

YeagerZhao_Blog/
├── messages/                       # next-intl 翻译
│   ├── zh.json
│   └── en.json
├── public/                         # 静态资源(favicon、图片)
├── src/
│   ├── app/                        # Next.js App Router
│   │   ├── [locale]/
│   │   │   ├── layout.tsx          # locale-scoped layout(字体、Header/Footer)
│   │   │   ├── page.tsx            # 首页
│   │   │   ├── blog/page.tsx       # 博客列表
│   │   │   ├── blog/[slug]/page.tsx
│   │   │   ├── product/page.tsx    # 产品列表
│   │   │   ├── product/[slug]/page.tsx
│   │   │   ├── journey/page.tsx    # 旅行/相册列表
│   │   │   ├── journey/[slug]/page.tsx
│   │   │   ├── about/page.tsx      # 关于页
│   │   │   ├── loading.tsx
│   │   │   └── not-found.tsx
│   │   ├── api/
│   │   │   ├── notion-image/route.ts
│   │   │   ├── posts/route.ts
│   │   │   ├── products/route.ts
│   │   │   └── revalidate/route.ts # 按需重新生成(脚本/Notion 推送用)
│   │   ├── layout.tsx              # 顶层根 layout
│   │   ├── page.tsx                # 根 / 重定向到 /zh
│   │   ├── robots.ts
│   │   └── sitemap.ts
│   ├── server/                     # 服务端逻辑(只在 Node 端运行)
│   │   ├── cache/
│   │   ├── images/                 # Notion 图片代理 handler
│   │   └── notion/
│   │       ├── client.ts           # 单例 Notion client
│   │       ├── content.queries.ts  # Blog + Product 统一查询
│   │       ├── blog.queries.ts     # 薄包装
│   │       ├── product.queries.ts  # 薄包装
│   │       ├── journey.queries.ts  # 相册查询
│   │       ├── about.queries.ts
│   │       ├── page-content.ts     # 递归拉 Notion blocks(深度 4)
│   │       └── mappers.ts          # property → TypeScript 类型
│   ├── ui/                         # 展示层(client / server 都可能)
│   │   ├── components/             # 原子组件(Button、Card、Tag、Badge、Panel)
│   │   ├── effects/                # SilkHero 之类的视觉效果
│   │   ├── styles/                 # 全局 CSS + Tailwind v4 变量
│   │   ├── tokens/                 # CSS 变量定义
│   │   └── views/                  # 页面级视图组件
│   │       ├── home/HomeView.tsx
│   │       ├── blog/BlogIndexView.tsx
│   │       ├── product/ProductIndexView.tsx
│   │       ├── journey/JourneyView.tsx
│   │       ├── journey/JourneyAlbumView.tsx
│   │       ├── about/AboutView.tsx
│   │       └── article/
│   │           ├── ArticleDetailView.tsx
│   │           ├── ArticleToc.tsx
│   │           └── NotionBlocks.tsx   # 自己写的 Notion 渲染器
│   ├── i18n/
│   ├── lib/
│   │   ├── oss-image.ts            # ossThumb(url, {preset}) 派生三档 URL
│   │   ├── embed-video.ts          # B站 / YouTube 嵌入 URL 解析
│   │   └── constants.ts
│   └── types/
├── SKILLS/                         # 内容发布工具链
│   ├── publish-blog-product/
│   ├── publish-journey-album/
│   ├── publish-journey-video/
│   ├── publish-about/
│   ├── operations/                 # revalidate-site / sync-notion / setup-schema
│   └── _shared/content-tools.mjs   # Notion 客户端 + SDK v5 兼容 shim
├── Dockerfile
├── deploy.sh
├── middleware.ts
└── next.config.ts

几个最关键的文件:

文件职责
src/server/notion/content.queries.tsBlog + Product 的统一查询(按 ContentType 字段过滤)
src/server/notion/journey.queries.ts相册 + 照片的查询 + 关联
src/server/notion/page-content.ts递归拉 Notion 页面的 blocks 树(深度 4)
src/ui/views/article/NotionBlocks.tsx自己写的 Notion blocks → React 渲染器(替代 react-notion-x)
src/ui/views/article/ArticleToc.tsx客户端 TOC:scroll-spy + 平滑滚动
src/ui/views/journey/JourneyAlbumView.tsx相册详情:轮播、键盘导航、lightbox
src/lib/oss-image.tsOSS 实时图片处理的 URL 构造器
SKILLS/publish-journey-album/publish-journey.mjs相册首发:sharp + 盲水印 + OSS PUT + Notion 写入
SKILLS/publish-journey-album/rewatermark-album.mjs调流水线参数后重新水印已发布相册(不动 Notion)
SKILLS/publish-journey-album/watermark.pyPython 盲水印 encode/decode CLI
Dockerfile多阶段 standalone 构建
middleware.tsnext-intl 语言路由

5. 路由设计

src/app/[locale]/ 下八条页面路由:

/zh                              首页
/en
/zh/blog        /zh/blog/[slug]   博客列表 + 详情
/en/blog        /en/blog/[slug]
/zh/product     /zh/product/[slug] 产品列表 + 详情
/en/product     /en/product/[slug]
/zh/journey     /zh/journey/[slug] 相册列表 + 详情
/en/journey     /en/journey/[slug]
/zh/about                          关于页
/en/about

src/i18n/routing.ts:

export const routing = defineRouting({
  locales: ["zh", "en"],
  defaultLocale: "zh",
});

middleware.ts 把 next-intl 接入:

import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";

export default createMiddleware(routing);

export const config = {
  matcher: ["/(zh|en)/:path*", "/((?!_next|_vercel|api|.*\\..*).*)"],
};

所有详情路由都用 generateStaticParams() 预生成,对每个 slug 同时输出 zh + en 两条静态路径。访问时如果 ISR 缓存还新就直接给静态 HTML,过期则后台重新生成。

6. 内容数据建模:四个 Notion 数据库

我用了四个 Notion 数据库,而不是更多。设计的取舍是:高度同构的内容尽量挤进同一张表(用 ContentType 区分),高度异构的(比如照片)才单独建表。

数据库env 变量角色
contentNOTION_CONTENT_DATABASE_IDBlog + Product 共用(ContentType 字段区分)
journeyNOTION_JOURNEY_DATABASE_ID相册 / Vlog / 混剪 / 游记的元数据
journey_photoNOTION_JOURNEY_PHOTO_DATABASE_ID照片表,通过 Journey relation 关联到 journey 表
aboutNOTION_ABOUT_DATABASE_ID关于页:profile / timeline / highlight 三类小节(SectionType 字段区分)

content 数据库

字段(节选):

字段类型说明
Titletitle文章/项目标题
ContentTypeselect (blog / product)同一张表区分两种内容
Slugrich_textURL slug,zh/en 共用
Languageselect (zh / en)语言版本
Summaryrich_text摘要
Datedate发布日期
Tagsmulti_select标签
Publishedcheckbox是否上线
Featuredcheckbox是否精选
CoverImagefiles封面图(脚本通过 Notion file_upload 上传)
ProductUrlurl仅 product 用

主键概念是 ContentType + Slug + Language 三元组——同一篇文章的 zh 和 en 是两行,但共享 slug;同一个项目作为 product 出现 和 同一篇文章作为 blog 出现也可以共用同一个 slug(这次这篇就同时发为 blog 和 product 两条)。

Notion content 数据库
Notion content 数据库

journey + journey_photo 表的拆分

相册和照片做成了两张表 + 一个 relation,理由:

  • 一篇相册(journey)有标题、地点、日期、感受、封面这些"列表卡片"需要的字段
  • 一篇相册有 N 张照片(journey_photo),每张照片只需要 ImageUrl + Order + 关联到哪篇相册
  • 用 relation 联系,列表页只查 journey 表,详情页才把 photos 拉出来

journey 表字段(节选):Title / Slug / Language / Type(album/vlog/anime-edit/travel-note)/ Date / Place / Summary / Feeling / CoverImageUrl / VideoUrl / Published。

journey_photo 表字段:Name / Journey(relation)/ ImageUrl / Order / Published。

about 表的单表多 section 设计

关于页比较杂:有 profile(头像 + 标语)、有 timeline(求学/工作经历)、有 highlights(亮点项目/作品)。如果分三张表会很碎,所以用一张表 SectionType 字段区分:

SectionType: select (profile / timeline / highlight)

代码侧 getAboutProfile() / getAboutTimeline() / getAboutHighlights() 各自传不同的过滤条件。

7. 服务端数据层

数据层全部放在 src/server/notion/,只在服务端运行(不会打到客户端 bundle)。这层有三件正经事要做:

7.1 兼容 Notion SDK v5 的 dataSources API

@notionhq/client v5 把数据库查询从 notion.databases.query() 改成了 notion.dataSources.query()(参数名也从 database_id 改成 data_source_id)。为了让代码同时兼容 v4 / v5,SKILLS/_shared/content-tools.mjs 里有一个小 shim:

export async function queryNotionDataSource(notion, args) {
  if (notion.databases?.query) return notion.databases.query(args);          // v4 老接口
  if (notion.dataSources?.query) {                                            // v5 新接口
    const { database_id, ...rest } = args;
    return notion.dataSources.query({ data_source_id: database_id, ...rest });
  }
  throw new Error("Installed Notion SDK does not support database/data source queries");
}

这个 shim 在网站端的 src/server/notion/ 里也有同名实现,所有查询都走它。日后哪天 v6 又改一次,只改这一处。

7.2 把 Notion property 翻译成 TypeScript 类型

src/server/notion/mappers.ts 写了一组 extractBlogPost(page)、extractJourneyItem(page) 这样的 mapper。它们做三件事:

  • 处理 Notion 的 rich_text 数组 → string(取 plain_text 拼起来)
  • 把 select / multi_select 的 name 拿出来
  • 用合理的默认值兜底(缺字段时不报错,给个空串/空数组)

输出就是 src/types/ 里定义的强类型:BlogPost、Product、JourneyItem、JourneyPhoto、AboutProfile 等等。

7.3 用 unstable_cache 包装查询

每个查询函数外面套一层 Next.js 的 unstable_cache,配上 cache tag:

import { unstable_cache } from "next/cache";

export const getBlogPosts = (locale: Locale) =>
  unstable_cache(
    async () => fetchFromNotion(...),
    ["blog-posts", locale],
    { revalidate: 3600, tags: ["notion", "content", "blog-posts"] }
  )();

ISR 用同一个 tag 体系:

  • /api/revalidate 收到推送 → revalidateTag("blog-posts") → 下次访问就重新拉 Notion
  • 也可以一次性 revalidateTag("notion") 把所有缓存的 Notion 查询全部失效(部署后我都是直接走这条粗暴路径)

8. 内容发布流水线:从本地 markdown 到 Notion

我刻意不让博客的"写作"步骤依赖 Notion 原生编辑器——Notion 网页端写 markdown 太憋屈,而且不好做本地预览和 git 版本控制。所以发布流水线是:

本地 D:\...\YeagerZhao\content\blog\<slug>\
├── manifest.json        # 元数据
├── draft.zh.md          # 中文正文
├── draft.en.md          # 英文正文
└── images/              # 配图
    └── 01-xxx.png       # 正文 ![](images/01-xxx.png) 引用的图

    ↓ npm run publish:content -- --dir "..."

SKILLS/publish-blog-product/publish-content.mjs
├── 解析 markdown → Notion blocks(自实现解析器)
├── 本地图片 → uploadNotionFile() → file_upload API
├── 在 Notion content 数据库写入 zh + en 两行(Published=false)
└── 用 ContentType + Slug + Language 三元组防重

    ↓ 我去 Notion 把 Published 改 true

    ↓ npm run revalidate:site

    ↓ Next.js revalidateTag("notion") → ISR 重新拉

网站 /zh/blog/<slug> 上线

manifest.json schema(脚本会校验):

{
  "type": "blog",
  "slug": "yeagerzhao-blog-architecture",
  "date": "2026-06-22",
  "tags": ["Vibe Coding", "个人网站"],
  "featured": true,
  "cover": "images/00-cover.png",
  "zh": { "title": "...", "summary": "..." },
  "en": { "title": "...", "summary": "..." }
}

整套 SKILL 的好处是:

  • 写作本地化:在 VSCode 里写 markdown,git 提交,发布脚本只是"上传到 Notion + 触发 revalidate"
  • 图片本地化:![](images/xxx.png) 引用本地图,脚本帮我上传到 Notion 的 file_upload API,并在 block 里写好 URL
  • 双语并行:一次 publish 同时写 zh + en 两行,slug 共用
  • 幂等:再跑一遍同 slug 会报错;要更新 properties 加 --overwrite,要换正文就在 Notion 里手动 archive 旧记录再重跑

9. 图片处理流水线:sharp + 盲水印 + OSS 三档实时派生

这部分是整个项目最值得细讲的——既是我做得最仔细的部分,也是从其他个人站很少见到的玩法。

9.1 三件要解决的事

目标怎么解决
① 保护隐私(剥 EXIF GPS / 相机型号)sharp .rotate() 把方向烧进像素后默认丢弃所有 metadata
② 控制 master 体积(目标 ≤ 1.5MB)sharp 缩放到 ~8 megapixels + JPEG q=88 mozjpeg 4:2:0
③ 防盗图(被盗了能溯源)Python invisible-watermark 在 DWT-DCT-SVD 频域嵌入 "YeagerZhao" 字符串
④ 浏览器只下载需要的尺寸OSS 上只存 1 张 master,三档(thumb / preview / full)由 ?x-oss-process=... 实时派生

9.2 本地:8 MP 预算的缩放策略

最早我用"长边封顶 3500px"的方式压缩,结果碰到一张 13223×2360 的全景图——长边砍到 3500 后短边只剩 625px,肉眼看一眼就糊得不行。换成按面积预算之后再没出过问题:

// SKILLS/publish-journey-album/publish-journey.mjs
const TARGET_MASTER_MP = 8_000_000;

async function processPhoto(srcPath) {
  const meta = await sharp(srcPath).rotate().metadata();
  const sourceMP = meta.width * meta.height;
  const scale = Math.min(1, Math.sqrt(TARGET_MASTER_MP / sourceMP));
  const newW = Math.round(meta.width * scale);
  const newH = Math.round(meta.height * scale);

  return sharp(srcPath)
    .rotate()                                  // 按 EXIF 旋转
    .resize(newW, newH)                        // 8MP 预算
    .jpeg({ quality: 88, mozjpeg: true, chromaSubsampling: "4:2:0" })
    .toBuffer();                               // 默认丢弃 EXIF/XMP/IPTC
}

为什么是 8MP:4K 屏幕原生像素 8.3MP,正好够看;再大浏览器一定会缩,是浪费。

对比真实数据:

相册源图之前(长边 3500)现在(8MP)
普通 3:2 风光(24MP)11MB3500×2334(8.2MP)800KB3464×2310(8.0MP)798KB
全景 5.6:1 (31MP)19MB3500×625(2.2MP)381KB6695×1195(8.0MP)1294KB

全景图短边从 625 升到 1195,差不多翻倍;master 体积只多了 900KB——非常划算。

9.3 本地:盲水印嵌入

sharp 的输出写到临时 JPEG 后调 watermark.py:

execFileSync(PYTHON_EXE, [
  WATERMARK_PY, "encode",
  "--input", tmpIn, "--output", tmpOut,
  "--text", "YeagerZhao",
  "--method", "dwtDctSvd",            // SVD 变体比纯 dwtDct 更扛得住 JPEG 压缩
  "--quality", "80",                  // 输出 JPEG q=80 → 终态 master
]);

为什么选 dwtDctSvd:实测下 dwtDct + q=85 已经丢字(解码 0/10 bytes),换成 dwtDctSvd 后 q=80 仍能解出 10/10 bytes——也就是即使我对方拿到这张图再做一轮 JPEG 压缩,"YeagerZhao" 这个字符串依然能用频域解码工具完整恢复出来。

Python 环境靠 conda 隔离,依赖只两个:

conda create -n yeagerzhao python=3.11 -y
conda activate yeagerzhao
pip install invisible-watermark opencv-python

Node 这边通过 PYTHON_EXE 环境变量指向 conda 的 python.exe,避免依赖系统 PATH。

9.4 OSS:master 一份,三档实时派生

OSS bucket 里每张图都只存 master,key 是 yeagerzhao/journey/<slug>/photos/NN-<原名>.jpg。浏览器要看不同尺寸的图,由前端在 URL 后面加 ?x-oss-process=image/resize,p_X/format,webp/quality,q_Y,OSS 边缘节点现场缩放、转 webp、调质量再返回。

src/lib/oss-image.ts 里的预设:

const PRESETS = {
  // 列表 / 网格封面 — 25% of source, q=70 → ~50KB
  thumb:   { percent: 25, quality: 70 },
  // 相册内单图(轮播) — 40% of source, q=78 → ~200KB
  preview: { percent: 40, quality: 78 },
  // lightbox:直接返回 master 本体,不做任何处理
};

export function ossThumb(url, { preset }) {
  if (preset === "full") return url;               // master 不动
  if (!isOssUrl(url)) return url;                  // 非 OSS(B站/YouTube 缩略图等)原样返回
  const { percent, quality } = PRESETS[preset];
  const sep = url.includes("?") ? "&" : "?";
  return `${url}${sep}x-oss-process=image/resize,p_${percent}/format,webp/quality,q_${quality}`;
}

p_N 是按源图百分比缩,不是按固定宽度——保证全景图、竖图、方图都按自身比例缩,不会变形。format,webp 让 OSS 边缘转 webp,比 JPEG 再省 ~30%。

图片处理流水线
图片处理流水线

9.5 重新水印已发布相册

我的图片流水线是迭代出来的——从最早不压缩、到长边封顶、到 8MP 预算、从 dwtDct 到 dwtDctSvd——每次调整都需要对已发布的相册做批处理。所以单独写了一个 rewatermark-album.mjs:

# 单个相册
node SKILLS/publish-journey-album/rewatermark-album.mjs --dir "<原始素材目录>"

# 批量
for d in "E:/Media/Photo/24Select/"*/ ; do
  node SKILLS/publish-journey-album/rewatermark-album.mjs --dir "$d"
done

关键设计:OSS key 用同样的 <slug>/photos/NN-<原名>.jpg 规则重建,PUT 覆盖既有对象——所以 Notion 里的 ImageUrl / CoverImageUrl 不变,不需要重写 Notion,也不需要给 Notion 缓存失效,只需 npm run revalidate:site 让 CDN 拉新版即可。

这次我用它一口气把 7 个相册的 87 张照片全部重新打了水印,耗时大概 5 分钟。

9.6 验证:水印是否还在

curl -s -o /tmp/test.jpg "<master URL>"
python SKILLS/publish-journey-album/watermark.py decode \
  --input /tmp/test.jpg --method dwtDctSvd --length 80 \
  --expect YeagerZhao
# 期望最后一行:match: 10/10 bytes vs expected 'YeagerZhao'

--length 80 是因为 encode 时重复了 8 次 "YeagerZhao"(80 字节)来增强容错——只要前 10 字节解出来就 OK,后面 70 字节用 majority vote 兜底。每次发布之后我都会随机抽几张跑这条命令验一遍。

10. NotionBlocks:自己写的 Notion → React 渲染器

最早我用 react-notion-x。后来两个问题让我放弃了它:

  1. 包大:它把整个 notion react 渲染器全打到客户端,加上 prismjs、katex、mermaid 这些重组件,一个博客详情页 client bundle 涨到 200KB+
  2. 强制 client component:它的 <NotionRenderer> 必须在客户端跑,整篇文章的渲染都被迫从 RSC 退到 client——首屏 HTML 里没有正文,对 SEO 和首屏可读性都是浪费

后来换成自己写的 NotionBlocks.tsx:

// 服务端组件 — Notion blocks 直接渲染成 HTML
export function NotionBlocks({ blocks }: { blocks: NotionBlock[] }) {
  return <>{blocks.map((b) => renderBlock(b))}</>;
}

function renderBlock(block: NotionBlock) {
  switch (block.type) {
    case "paragraph":  return <p>{renderRichText(block.paragraph.rich_text)}</p>;
    case "heading_1":  return <h1>{renderRichText(...)}</h1>;
    case "heading_2":  return <h2>{renderRichText(...)}</h2>;
    case "heading_3":  return <h3>{renderRichText(...)}</h3>;
    case "code":       return <CodeBlock language={block.code.language}>{...}</CodeBlock>;
    case "image":      return <Image src={proxyUrl(block.image)} alt="..." />;
    case "quote":      return <blockquote>{renderRichText(...)}</blockquote>;
    case "bulleted_list_item": /* 折叠相邻 item 成 <ul> */
    case "callout":    return <Callout icon={...}>{...}</Callout>;
    // ...
  }
}

renderRichText 处理 Notion 的 annotations(bold/italic/code/strikethrough/underline/color + link)。代码块的语法高亮直接走 hljs(CSS only),不拉运行时 JS。

结果:

  • 博客详情页 client bundle 减了大概 180KB
  • 不用再 override 任何 .notion-* class,正文样式直接复用全站的字体/间距/链接样式
  • 整篇 RSC,首屏 HTML 直接带正文出去(SEO 友好)

文章侧栏 TOC(仅这个是 client)

ArticleToc.tsx 是详情页里唯一保留的 client component——它需要 scroll-spy 和点击平滑滚动:

"use client";
function ArticleToc({ headings }) {
  const [active, setActive] = useState(headings[0]?.id);

  useEffect(() => {
    const io = new IntersectionObserver((entries) => {
      const visible = entries.find((e) => e.isIntersecting);
      if (visible) setActive(visible.target.id);
    }, { rootMargin: "-50% 0px -50% 0px" });
    headings.forEach((h) => {
      const el = document.getElementById(h.id);
      if (el) io.observe(el);
    });
    return () => io.disconnect();
  }, [headings]);
  // render ...
}

headings 是服务端从 NotionBlocks 解析时同步抽出的——客户端不二次解析正文。

11. 视觉系统:Tailwind v4 + CSS 变量

这站只有一套配色——没做明暗切换。理由很简单:单一配色已经能撑住整套视觉系统(深蓝底色 + 月光色文字 + 金色点缀),再做切换反而要为暗/亮两套色卡都调对比度、调链接色、调代码块底色,工作量翻倍但读者收益有限。所以早期引入的 next-themes 也跟着拿掉了(见第 3 节"不在了的依赖")。

Tailwind v4 的 CSS-first 写法让"主题变量"和"utility class"用同一套机制:

/* src/ui/styles/globals.css */
@import "tailwindcss";

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-accent: var(--c-gold);
  --font-sans: var(--font-noto-sans-sc), "Microsoft YaHei", system-ui, sans-serif;
  --font-serif: var(--font-newsreader), serif;
  --font-mono: var(--font-spline-sans-mono), monospace;
}

:root {
  /* 一组深夜蓝 + 月光色 + 金点缀 */
  --background: #0a0f1a;
  --foreground: #ecede4;
  --c-navy: #0e2740;
  --c-steel: #3e6b89;
  --c-olive: #7a8b6a;
  --c-gold: #c8a85b;
  --text-strong: #f5f1de;
  --text-muted: #8d96a8;
}

字体策略:

  • 英文标题:Newsreader(衬线,有书卷气)
  • 中文:Noto Sans/Serif SC(fontsource-variable 加载,按字重切片)
  • 代码:Spline Sans Mono
  • 中文系统字体(兜底):微软雅黑 / 苹方

背景不是图片,而是 CSS 生成的两层效果:

  • body::before:SVG turbulence 噪点,模拟纸张颗粒
  • body::after:多组 radial-gradient,模拟水彩色块

这让页面有质感,但不需要加载任何额外背景图。

还有一个小设计:全站默认禁用文本选择 + 长按下载,只有文章正文和产品详情页才重新允许选择。列表和导航更像一个"应用界面",正文阅读又不影响复制引用。相册的照片更进一步——onContextMenu 和 onDragStart 都阻止默认行为,让右键另存为这条路径走不通。

12. UI 分层:views + components + effects

src/ui/
├── views/         # 页面级视图(每个 page.tsx 引用一个 View)
├── components/    # 原子 + 复合组件(Card / Button / Tag / Badge / Panel / Reveal / Timeline)
├── effects/       # 装饰性视觉效果(SilkHero 之类)
├── styles/        # 全局 CSS
└── tokens/        # CSS 变量定义

把 views/ 和 components/ 分开是为了让 page.tsx 保持极简——它只做"取数 + 把数据递给 View"两件事,比如:

// src/app/[locale]/blog/page.tsx
export default async function Page({ params }) {
  const { locale } = await params;
  const posts = await getBlogPosts(locale);
  return <BlogIndexView lang={locale} posts={posts} />;
}

View 内部再 compose <Panel>、<Card>、<Tag>、<SectionBlock> 之类的小积木。Server / Client 边界原则:

类型代表组件特点
Server所有 page.tsx、HomeView、BlogIndexView、NotionBlocks在服务端取数据并输出 HTML
ClientSiteNav、LanguageSwitcher、ArticleToc、JourneyAlbumView、Reveal需要 hooks、事件、滚动监听或浏览器状态

简单原则:能在服务端完成的尽量在服务端完成;只有交互、滚动和浏览器状态才放到客户端。

13. 国际化与中英文路由

国际化两层:

第一层是 UI 文案——messages/zh.json 和 messages/en.json 存所有导航、按钮、页面标题、空状态文字。组件里:

const t = useTranslations("common");
t("back");  // → "返回" / "Back"

第二层是路由——/zh 和 /en 是两套语言入口,src/i18n/navigation.ts 导出的 Link 会自动处理 locale-aware navigation:

import { Link } from "@/i18n/navigation";

<Link href="/blog/my-post">阅读</Link>
// 在 /zh 下渲染成 /zh/blog/my-post,在 /en 下渲染成 /en/blog/my-post

内容侧的语言策略:每篇文章在 Notion 里都有 zh 和 en 两行,getBlogPosts(locale) 会按 Language 字段过滤。所以 /zh/blog/my-post 和 /en/blog/my-post 真的是两份内容,不是 UI 翻译 + 同一份正文。

14. 缓存策略

不像旧版那种"三层缓存"的复杂事,现在简化到了两层 + ISR:

┌─────────────────────────────────────┐
│  Layer A:unstable_cache + tags     │  Next.js 15 内置
│  ├─ revalidate: 3600(1h ISR)      │
│  └─ tags: ["notion", "content", "journey", ...]
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│  Layer B:进程内 Map<key, {data, ts}>│  自己写的轻量层
│  ├─ TTL: 1h                          │
│  └─ 单 Node 进程内有效(容器重启清空)│
└─────────────────────────────────────┘
              ↓
          Notion API

Layer A 是 Next.js 自家的 unstable_cache()——既负责 RSC 拿数据的缓存,也负责 ISR 的页面缓存(同一组 tag)。

Layer B 是个简单的 Map,主要是在 Layer A 还没"暖"起来之前(容器刚启动),减少多个并发 RSC 调用同一个 Notion query 的次数。

需要让缓存失效时只有一条路:POST /api/revalidate 带上 Authorization: Bearer $REVALIDATE_SECRET,handler 内部调 revalidateTag("notion") 把所有 tag 含 "notion" 的缓存一起失效。SKILLS/operations/revalidate-site.mjs 就是这条命令的脚本封装:

npm run revalidate:site
# → POST $NEXT_PUBLIC_SITE_URL/api/revalidate
# → { "revalidated": true, "tags": ["notion", "content", "blog-posts", ...] }

15. API Routes

只有三个:

路径作用
/api/notion-image?url=...&blockId=...代理 Notion 文件 S3 临时 URL(避免过期 / 跨域 / 缓存不可控)
/api/revalidate按需失效 Next.js 缓存(脚本/Notion webhook 用)
/api/posts、/api/products列表数据 JSON(保留给可能的客户端拉取,但当前所有 view 都是 RSC)

/api/notion-image 设缓存:

"Cache-Control": "public, max-age=86400, s-maxage=604800"

浏览器缓存 1 天,CDN/共享缓存 7 天。

/api/revalidate 用 Authorization: Bearer 校验:

export async function POST(req: Request) {
  const auth = req.headers.get("authorization");
  if (auth !== `Bearer ${process.env.REVALIDATE_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  const tags = ["notion", "content", "blog-posts", "products", "journey", "about"];
  tags.forEach((tag) => revalidateTag(tag));
  return Response.json({ revalidated: true, tags, revalidatedAt: new Date() });
}

16. SEO 与站点索引

Next.js 服务端生成页面 + RSC,SEO 基础天然到位。详情页用 generateMetadata() 从 Notion 元数据生成:

export async function generateMetadata({ params }) {
  const { locale, slug } = await params;
  const post = await getBlogPostBySlug(slug, locale);
  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      type: "article",
      publishedTime: post.date,
      tags: post.tags,
      images: post.coverImage ? [{ url: post.coverImage }] : undefined,
    },
  };
}

metadataBase 设成 https://yeagerzhao.com,所有相对 URL 都能正确转成绝对 URL。

另外有:

文件作用
src/app/sitemap.ts自动遍历四个数据库生成 sitemap
src/app/robots.ts生成 robots.txt

17. Docker + Nginx + 阿里云 ECS 部署

Dockerfile 是经典的多阶段 standalone:

node:20-alpine (base)
    |
    v
deps:npm ci
    |
    v
builder:注入 NOTION_* env → npm run build
    |
    v
runner:复制 .next/standalone + .next/static + public/
       USER nextjs
       EXPOSE 3000
       CMD ["node", "server.js"]

构建出来的镜像只有 ~200MB,可以跑在最便宜的阿里云 ECS(2 核 2G)上。

端口绑定:

docker run -d \
  --name yeagerzhao-blog \
  --restart always \
  -p 127.0.0.1:3000:3000 \      # 注意是 127.0.0.1,外网不能直接打到容器
  --env-file .env.local \
  yeagerzhao-blog

Nginx 的职责:

  • 监听 80/443,做 HTTPS 终止(证书走 certbot)
  • 把请求反代到 127.0.0.1:3000
  • 给 /_next/static/ 长缓存

典型配置:

server {
    listen 443 ssl http2;
    server_name yeagerzhao.com www.yeagerzhao.com;

    location /_next/static/ {
        proxy_pass http://127.0.0.1:3000;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location /favicon.ico {
        proxy_pass http://127.0.0.1:3000;
        add_header Cache-Control "public, max-age=86400";
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

deploy.sh 把"装 Docker、装 Nginx、build、docker run、生成 Nginx 配置"一键化,新装 ECS 上 5 分钟就能跑起来。

部署架构
部署架构

18. 日常工作流

写一篇新博客

# 1. 本地新建目录
mkdir -p "D:\...\YeagerZhao\content\blog\<slug>"
# 在里面新建 manifest.json + draft.zh.md + draft.en.md + images/

# 2. dry-run 看 markdown 解析成多少个 Notion blocks
npm run publish:content -- --dir "...<slug>" --dry-run

# 3. 真发布(写 Notion,Published=false)
npm run publish:content -- --dir "...<slug>"

# 4. 去 Notion 审一遍,把 Published 改 true

# 5. 让网站拉新版
npm run revalidate:site

发布一个相册

# 1. 本地准备 D:\...\<相册文件夹>\
#    ├── manifest.json    # type/slug/date/title/summary/feeling/place
#    ├── cover.jpg        # 可选
#    └── xxx.jpg ...

# 2. 看上传计划
npm run publish:journey -- --dir "..." --dry-run

# 3. 真发布(自动压缩 + 盲水印 + OSS + Notion)
npm run publish:journey -- --dir "..."

# 4. Notion 改 Published=true,然后 revalidate
node SKILLS/publish-journey-album/mark-journey-published.mjs --slug ...
npm run revalidate:site

部署代码改动

# 本地
git add . && git commit -m "..." && git push

# 服务器
ssh yeagerzhao.com
cd /opt/blog
git pull
docker build \
  --build-arg NOTION_API_KEY=... \
  --build-arg NOTION_CONTENT_DATABASE_ID=... \
  --build-arg NOTION_JOURNEY_DATABASE_ID=... \
  --build-arg NOTION_JOURNEY_PHOTO_DATABASE_ID=... \
  --build-arg NOTION_ABOUT_DATABASE_ID=... \
  -t yeagerzhao-blog .
docker stop yeagerzhao-blog && docker rm yeagerzhao-blog
docker run -d --name yeagerzhao-blog --restart always \
  -p 127.0.0.1:3000:3000 --env-file .env.local yeagerzhao-blog

内容更新不需要重新部署——直接走 publish 脚本 + revalidate。

19. 关键取舍

为什么不用 Vercel 直接托管?

Vercel 对 Next.js 是最省心的,但这个站要服务国内访问,自托管 ECS + Nginx 比 Vercel 国内访问稳定得多,证书也好控。代价是自己得维护 Docker + 服务器。这个权衡对个人站来说划算。

为什么 Blog 和 Product 共用一张 content 表?

这次发布这篇文章的时候我就给它同时打了 Blog 和 Product 两个 type——同一篇内容在 /blog/<slug> 和 /product/<slug> 两条路径都能进。如果分两张表,要嘛重复维护,要嘛得加一层 join。共用一张表 + ContentType 字段,逻辑简单很多。

为什么相册要单独走 OSS,而不是也塞进 Notion?

Notion 的图片字段会返回临时 S3 URL(30 分钟过期),完全不适合做相册——浏览器一打开就全部 404。OSS 是公开 URL + 长缓存 + 实时图片处理,正好补这个空。Notion 在相册流程里只存 OSS 的 master URL(一段不会变的字符串),不再充当"图床"。

为什么要做盲水印?

被盗图能有手段维权。明水印能被一秒裁掉或者 cv2 inpaint 掉;DWT-DCT-SVD 频域水印不破坏视觉、扛 JPEG 再压缩、扛缩放、扛轻度裁切——只要对方没大改图,"YeagerZhao" 这个字符串能完整解出来。这对一个个人摄影 + 写作站来说,0 成本但有威慑力。

为什么自己写 NotionBlocks 而不是用 react-notion-x?

体积 + RSC 两个综合考虑(第 10 节展开过)。代价是 markdown 里不能用 Notion 的全部 block 类型(database view、synced block、AI block 这些直接 ignore),但我自己博客里基本不会用这些。

为什么不复制源图到本地 archive?

之前 publish 脚本会把每个相册的源图复制一份到 archive/journey/<slug>-<timestamp>/,9 个相册下来占了 1.1GB。这玩意儿本质是源图的冗余副本——我自己 E:\ 里已经有原始素材,archive 反而碍事。所以现在 publish 不再复制,需要重新水印就直接指向 E:\ 里的原始目录跑 rewatermark-album.mjs。

20. 局限与下一步

当前架构还不完美的地方:

  • on-demand revalidation 没接 Notion webhook:现在改完 Notion 还需要手动 npm run revalidate:site。理想是 Notion 改完自动推到 /api/revalidate。Notion 自己的 webhook 不太成熟,可能要走 Zapier 或自建 polling
  • Layer B 内存缓存进程级:容器重启后第一次访问要打 Notion API,可以考虑迁到 Redis 或 SQLite 持久化
  • 没有搜索 / 标签页 / 归档页:当前博客列表只能按时间倒序,没有按 tag 过滤、没有按年归档。加这些不难,但要先想清楚 URL 结构
  • 盲水印只挡得住"截屏 + 重压缩"那一类:如果对方做了重度修图(大幅裁切、color grading、CLAHE)水印也会丢。再保守一点可以考虑加一道像素级水印 hash(perceptual hashing)做最后一道兜底
  • 没有评论 / 订阅:目前只能微信扫码或邮件联系,未来要做评论的话可能走 giscus 这种 GitHub Issues 方案

下一步可能做的:

  • 给图片代理 /api/notion-image 增加来源白名单,避免被白嫖成开放代理
  • 给文章详情加阅读进度条 + 估算阅读时间
  • 为博客正文做"中英文互链"——当前同一篇文章 zh/en 是独立两行,但页面右上角的语言切换是基于路径硬切,不是基于 slug 的智能匹配
  • 增加 GitHub Actions 自动部署,省掉手动 SSH

21. 总结

YeagerZhao_Blog 的核心设计哲学是:每一层只做自己最擅长的事情。

  • Notion 做内容创作(写作体验 + 协作 + 移动端)
  • 本地 markdown + SKILLS 脚本 做内容工程(git 版本控制 + 可重放发布)
  • sharp + invisible-watermark + OSS 做图片资产(压缩 + 防盗 + CDN)
  • Next.js 15 做页面生成(RSC + ISR + i18n + SEO)
  • Tailwind v4 + CSS 变量 做视觉系统(单一配色 + 字体策略)
  • Docker + Nginx + 阿里云 ECS 做运行环境(封装 + 反代 + HTTPS)

最终得到的是一个维护成本低、访问速度快、视觉可定制、内容更新轻量、照片有版权保护的个人站点。它不是最复杂的架构,但它非常适合个人博客:足够现代,足够快,也足够可控。