Last updated on
Series: 站点手记

本站架构手记:Astro 静态站 + 在线后台 + 一次 AI 协作改造


这篇尽量写细一点,把 fanhexuan.com 从前台到后台、从构建到部署的实现都摊开讲清楚,包括最近一次由 Claude(Claude Code)协作完成的改造。既是给自己留的”架构备忘”,也方便以后接手维护或迁移。

一、整体架构与请求路径

本站是一个 Astro 静态博客:所有页面在构建期生成纯静态 HTML,运行期没有服务端渲染。一次访问的路径大致是:

浏览器


Nginx (443, TLS)
  ├─ /                → 直接返回 /var/www/fanhexuan.com/html 下的静态文件
  └─ /admin/*         → 反向代理到 127.0.0.1:4322 (本机的 Express 后台)

这么分有几个好处:前台是纯静态,访问快、好缓存、攻击面极小;后台只监听 127.0.0.1不直接暴露公网,由 Nginx 在 /admin 前面挡一层。两个部分职责清晰:

部分技术目录
前台Astro(Content Collections + 动态路由)astro-blog/,构建产物 dist/
后台Node / Express(systemd 服务)fanhexuan-admin/server.mjs
编辑器Milkdown Crepe(esbuild 打包的 bundle)单独构建后部署进后台的 public/

目录关系简化如下:

astro-blog/
├─ src/
│  ├─ content/blog/*.md        # 文章(Markdown + frontmatter)
│  ├─ content.config.ts        # Content Collection 的 zod schema
│  ├─ pages/
│  │  ├─ index.astro           # 首页
│  │  ├─ blog/[...slug].astro  # 文章详情路由
│  │  └─ series/[slug].astro   # 系列页(动态生成)
│  ├─ layouts/BlogPost.astro   # 文章页模板:目录 / 加密解锁 / 文件卡片
│  ├─ components/              # Header / Footer / BaseHead ...
│  └─ styles/global.css        # 全站设计 token(配色 / 字体 / 阴影)
└─ scripts/protect-posts.mjs   # 构建后加密带密码的文章

二、内容模型:Content Collections 与 frontmatter

文章是一堆 .md 文件,元数据写在 frontmatter 里,由 Astro 的 Content Collections 用 zod 校验类型。content.config.ts 的 schema 是这样的:

const blog = defineCollection({
  loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string(),
      pubDate: z.coerce.date(),          // 字符串自动转 Date
      updatedDate: z.coerce.date().optional(),
      series: z.string().optional(),     // 归入某系列
      password: z.string().optional(),   // 有则触发加密
      heroImage: z.optional(image()),
    }),
});

对应一篇文章的开头:

---
title: "文章标题"
description: "一句话摘要,用于列表与 SEO"
pubDate: "2026-06-12"
series: "站点手记"
password: "******"   # 可选;写了就会被加密
---

好处是任何不合规的 frontmatter(比如日期格式错、缺 title)在构建期就报错,不会带着坏数据上线。

三、系列:一个动态路由搞定

系列页不需要手动维护。src/pages/series/[slug].astrogetStaticPaths() 扫描所有文章里出现过的 series,给每个去重后的系列生成一页:

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  const map = new Map();           // series -> 该系列的文章
  for (const post of posts) {
    const s = post.data.series;
    if (!s) continue;
    if (!map.has(s)) map.set(s, []);
    map.get(s).push(post);
  }
  return [...map].map(([series, posts]) => ({
    params: { slug: series.toLowerCase().replaceAll(' ', '-') },
    props: { series, posts },
  }));
}

这里有个容易踩的一致性坑:slug 的推导规则 series.toLowerCase().replaceAll(' ', '-') 必须在所有地方保持一模一样——首页、blog/indexBlogPost.astro 里指向系列的链接都用同一套,否则链接和页面就对不上、404。

早期这些系列页是一个系列一个硬编码文件cty.astrodji-mini-se.astro…),结果新增系列(尤其是中文名)没有对应文件就 404。改成上面这个动态路由后,新系列——哪怕是中文的,比如本文所属的「站点手记」——会自动生成页面,中文 slug 构建成同名目录、URL 编码后也能正常解析。

四、加密文章:构建期加密 + 前端解密

有些研究笔记不想完全公开,于是做了一套”软加密”:纯静态站没有后端,所以加密发生在构建期,解密发生在浏览器

构建期:protect-posts.mjs

构建命令是 astro build && node scripts/protect-posts.mjs。后面这个脚本会遍历文章,凡是 frontmatter 带 password 的,就:

  1. 读取已经构建好的 dist/blog/<slug>/index.html
  2. 取出正文里用注释标记包起来的那段 HTML:<!-- protected-content-start -->...<!-- protected-content-end -->
  3. AES-GCM-256 加密这段 HTML,密钥由 PBKDF2-SHA-256(210,000 次迭代) 从密码派生;
  4. 把密文连同算法参数写到 dist/protected/<slug>.json
  5. 最后把 HTML 里那段明文删掉——上线的页面里不含正文。

核心加密逻辑(Node 的 webcrypto):

const iterations = 210_000;

const encrypt = async (html, password) => {
  const salt = webcrypto.getRandomValues(new Uint8Array(16));
  const iv   = webcrypto.getRandomValues(new Uint8Array(12));
  const material = await webcrypto.subtle.importKey(
    'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
  const key = await webcrypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
    material, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
  const ciphertext = new Uint8Array(await webcrypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, new TextEncoder().encode(html)));
  return { v: 1, alg: 'AES-GCM', kdf: 'PBKDF2-SHA-256',
           iterations, salt: toBase64(salt), iv: toBase64(iv),
           ciphertext: toBase64(ciphertext) };
};

产物 protected/<slug>.json 就是 { v, alg, kdf, iterations, salt, iv, ciphertext },全部 base64。

浏览器端:输入密码后解密

文章页(BlogPost.astro)里有一段脚本:访问者输密码 → fetch 对应的 /protected/<slug>.json → 用 Web Crypto 重新派生密钥并解密 → 把明文注入页面:

const deriveKey = async (password, salt, iterations) => {
  const material = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
    material, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
};

// 提交密码时:
const payload = await fetch(`/protected/${postId}.json`).then((r) => r.json());
const key = await deriveKey(password, decodeBase64(payload.salt), payload.iterations);
const plain = await crypto.subtle.decrypt(
  { name: 'AES-GCM', iv: decodeBase64(payload.iv) }, key,
  decodeBase64(payload.ciphertext));
container.innerHTML = new TextDecoder().decode(plain);   // 解密后注入正文

安全边界要说清楚:密文是公开可下载的,理论上可以拿着它离线暴力破解密码。所以这套机制本质是”挡住顺手翻看的人”,强度取决于密码本身;PBKDF2 的 21 万次迭代提高了每次猜测的成本,但它不是用来对抗有决心、且密码很弱的攻击者的。对个人博客的”半私密”笔记来说够用,心里有数即可。

五、/admin 在线后台

后台是一个手写的 Express 服务server.mjs),由 systemd 托管(fanhexuan-admin.service),只听 127.0.0.1:4322

鉴权

  • 密码用 PBKDF2-SHA-256 存成 pbkdf2-sha256$<迭代次数>$<盐>$<哈希> 的格式,校验时用 crypto.timingSafeEqual定长比较,避免计时侧信道。
  • 登录成功后在内存里建会话(Map),下发一个 httpOnly + Secure + SameSite=LaxPath=/admin 的 Cookie;会话有滑动过期(每次访问续期)。
  • 所有非 GET 的接口都要校验 X-CSRF-Token,token 在登录时随会话一起发。

API 一览

GET    /admin/api/status            会话状态 + CSRF token
POST   /admin/api/login | logout    登录 / 注销
GET    /admin/api/posts             文章列表(含 frontmatter 摘要)
GET    /admin/api/posts/:slug       读单篇
POST   /admin/api/posts             新建
PUT    /admin/api/posts/:slug       更新(可改 slug = 重命名)
DELETE /admin/api/posts/:slug       删除
GET    /admin/api/series            已有系列(含一组默认名)
POST   /admin/api/uploads/image     传图片
POST   /admin/api/uploads/file      传附件
POST   /admin/api/publish           构建并发布

文章 CRUD 与一个”重命名陷阱”

PUT /admin/api/posts/:slug 在 body 里的 slug 和 URL 里的不一致时,会重命名文件——先写新文件,再 fs.unlink 旧文件:

await backupPostFile(oldFile, 'update');                 // 先备份
await fs.writeFile(newFile, serializePost(fm, body));     // 写新
if (post.slug !== currentSlug) await fs.unlink(oldFile);  // slug 变了才删旧

这个设计本身没问题,但前端如果在某个时刻 currentSlug 记错了,就可能把一篇文章覆盖/删掉。早期就因为一个启动竞态(“新建文章”点击后,后台自动加载最新文章把 currentSlug 又改了)真的误删过一篇。两道防线解决:

  1. 改前必备份:每次更新/删除前把旧文件复制到 .admin-backups/<时间戳>-<原因>-<slug>.md,误删能字节级恢复;
  2. hasUserNavigated 守卫:用户一旦主动打开/新建文章就置位,启动期的自动加载不再覆盖用户的操作。

上传与”即时镜像”

图片传到 public/images/uploads/,附件传到 public/files/uploads/,按 YYYY-MM 分月份目录,文件名是 <时间戳>-<随机>-<安全化的原名><扩展名>。关键一步是镜像:上传后立刻把文件也复制一份到 Nginx 发布目录,这样还没点发布时图片/附件路径也不会失效:

const finalName = `${Date.now()}-${rand}-${safeName}`;
await fs.writeFile(path.join(targetDir, finalName), file.buffer);
await mirrorPublicFile(filePath);   // 复制到 /var/www/.../html 同名路径 + chown www-data

mirrorPublicFile 里用 fs.realpath 做了越界校验(拒绝写到 public / 发布根目录之外),防止路径穿越。

发布

POST /admin/api/publish 就是 spawn('npm', ['run', 'build']),构建完把 dist/ 拷到发布目录;deployDist 同样用 realpath 校验,拒绝往非预期目录写。

编辑器

编辑器是 Milkdown Crepe(基于 ProseMirror 的所见即所得,语雀那种手感),从早期卡顿的 Vditor 迁移而来。它的源码单独用 esbuild 打成一个 IIFE bundle,对外暴露 window.CrepeEditor 的命令式 API(mount / load / getMarkdown / insertLink / ...),后台的 app.js(手写、无需打包)调用它。server.mjs 对编辑器是无感的——它只负责把 getMarkdown() 的结果当正文存盘。侧边栏是按系列分组的可折叠树,右侧是扫描标题自动生成的大纲。

六、构建与部署:两条流水线

前台/内容改动走这条:

# 在服务器上
cd astro-blog
npm run build                       # astro build && node scripts/protect-posts.mjs
rsync -a --delete dist/ /var/www/fanhexuan.com/html/
chown -R www-data:www-data /var/www/fanhexuan.com/html

编辑器改动走另一条(editor/deploy.sh):esbuild 打包 → scp 把 bundle / 样式 / 字体平铺进后台的 public/systemctl restart fanhexuan-admin.service → curl 冒烟检查。后台路由是 app.use('/admin/assets', static('./public/')),所以文件是平铺public/ 里、没有 assets/ 子目录——这点后面会再提到(它曾经坑了部署脚本一次)。

七、最近一次改造(AI 协作)

最近请 Claude(Claude Code)一起做了三件事,并顺手补了一些工程化的细节。贯穿始终的一个原则是:底层 Markdown 保持干净,花活全部放在”渲染期”做

7.1 任意文件拖拽上传

之前编辑器只认图片,拖个 Python 脚本进去没反应。改动在 main.js 里把上传器从”只处理图片”扩展成”处理任意文件”:图片仍然作为图片节点插入,其他文件上传后插入成一个普通 Markdown 链接 [name](url)

async function fileUploader(files, schema) {
  const out = [];
  for (const file of files) {
    if (file.type.includes('image')) {
      const src = await handlers.onUploadImage(file);      // → 图片节点
      out.push(schema.nodes.image.createAndFill({ src, alt: file.name }));
    } else {
      const url = await handlers.onUploadFile(file);       // → 链接节点
      out.push(schema.text(file.name, [schema.marks.link.create({ href: url })]));
    }
  }
  return out;
}

为什么是”普通链接”而不是自定义语法?因为这样 Markdown 能在编辑器和 Astro 之间无损往返——下次再打开文章,它还是个标准链接,不会因为编辑器不认识自定义语法而被吃掉。后台 app.jsonUploadFile 接到 /admin/api/uploads/file;服务端把允许的扩展名从 8 种放宽到约 60 种(脚本 / 代码 / 文本 / 压缩包 / 文档)。这是安全的:Nginx 把这些文件当静态文件返回,从不执行。

7.2 文件卡片 + 页内预览(前台)

光是一行链接太”呆”。前台用一段自包含的 <script is:inline> 把指向 /files/uploads/独占一行的链接”升级”成文件卡片:图标、文件名、大小、下载按钮;文本/代码类可以在页面内展开预览,PDF 直接内嵌:

root.querySelectorAll('a[href*="/files/uploads/"]').forEach((link) => {
  // 只升级"整行就是一个附件链接"的情况;句中链接不动
  const block = link.closest('p, li');
  if (block.textContent.trim() !== link.textContent.trim()) return;
  // ...构建卡片:HEAD 拿 content-length 显示大小;按扩展名判类型
  // 文本/代码:点击展开 → fetch 文本写进 <pre><code>(用 textContent,天然防 XSS,上限 200KB)
  // PDF:内嵌 <iframe>
});

几个细节:大小用一次 HEAD 请求拿 content-length;预览内容用 code.textContent = text 写入(不解析 HTML,天然防 XSS),超过 200KB 截断并提示下载;加密文章解密后注入的内容也要增强,所以用一个 MutationObserver 盯着 #protected-content,等密文解开、HTML 注入后再跑一遍。

7.3 编辑器内也显示成卡片(ProseMirror 装饰)

前台有卡片了,但编辑器里还是一行字。解决办法是给 Milkdown 加一个 ProseMirror 装饰(decoration)插件:扫描”唯一子节点是一个指向 /files/uploads/ 的链接文本”的段落,给它挂一个节点装饰:

props: {
  decorations(state) {
    const decos = [];
    state.doc.descendants((node, pos) => {
      if (node.type.name !== 'paragraph' || node.childCount !== 1) return;
      const link = node.firstChild.marks.find((m) => m.type.name === 'link');
      const href = link?.attrs?.href;
      if (!href?.includes('/files/uploads/')) return false;
      decos.push(Decoration.node(pos, pos + node.nodeSize, {
        class: 'milk-attach',
        'data-icon': iconFor(name),     // 🐍 / 📕 / 🗜️ ...
        'data-size': sizeCache.get(href) || '',
      }));
      return false;
    });
    return DecorationSet.create(state.doc, decos);
  },
}

它是纯视图层的——只加 class 和 data-* 属性,底层 Markdown 一个字都不改,所以保存/往返、“脏未保存”判定都不受影响。卡片样式用 CSS 的 ::before { content: attr(data-icon) }::after { content: attr(data-size) } 渲染。文件大小同样异步 HEAD 拿到后写进缓存,再 dispatch 一个 meta 事务让插件重算一次装饰、把大小补上。

7.4 首页重设计

把首页从”默认模板感”改成统一、克制的学术风

  • 配色:默认电光蓝 #2337ff → 深 teal #0f766e(和后台一致);偏蓝的灰统一成中性灰;强调色只在链接 / 激活态 / 标签上克制使用,全站 token 收在 global.css:root
  • 字体:去掉网页字体,改用系统字体栈(含 PingFang SC / Microsoft YaHei 等中文字体)。两个理由:原来的 Atkinson 不含中文字形,中文本来就在 fallback;而且服务器在国内,用 Google Fonts 这类外链字体可能拖垮构建——系统栈零加载、零风险、中英文一致。
  • 结构:Hero 区结构化(头像 + 研究方向 + 名字 + 联系按钮);新增 Research Series 卡片网格,把”系列”从每行重复的元信息提升为首页主导航;文章列表用 teal 标签 pill 替代到处重复的蓝色 “Series:“。

八、工程化与踩过的坑

  • 先校验后上线:改完先在服务器上构建,把 dist/ 拉回本地起一个静态服务、用 headless 浏览器截图确认(桌面 + 移动、首页/系列/列表/文章都看一遍),满意了才 rsync 到生产——生产不经历半成品状态
  • 改前必备份:动 server.mjs / 模板前先打时间戳备份(.design-backups/<时间戳>/*.bak-<时间戳>),误操作可秒回滚。
  • 部署脚本的老 bug:编辑器的 deploy.sh 里有一行 ls public/assets,但文件其实是平铺public/、根本没有 assets/ 子目录;在 set -e 下这一行直接让脚本在”重启服务”之前就退出——表现就是”看似部署了、其实没重启”。删掉那行多余的 ls 就好了。
  • 纯链接往返原则:附件不引入任何自定义 Markdown 语法,卡片和预览全部放在渲染期/视图层做,源文件始终是干净的标准 Markdown。这条原则让”编辑器里 / 前台 / 加密注入”三处可以各自增强,互不打架。

结语

整套东西的取舍可以一句话概括:能在构建期做的就不放到运行期,能放在渲染期/视图层的就不污染源数据。静态前台 + 本机后台 + 构建期加密,既保持了静态站的简单与安全,又通过一个手写后台拿到了”在线写作 / 上传 / 一键发布”的体验。

这篇会随站点继续更新。如果你也在用 Astro 搭个人站,希望这份手记能省下你几次踩坑的时间。