为什么要改

原来的博客很简单:Astro 5 + Vue 3,内容从 Notion API 拉取,构建成静态页面部署到 Cloudflare Pages。跑了一段时间后发现几个痛点:

  1. **Notion 作为 CMS 太重了。** 每次构建都要调 Notion API,速度慢不说,偶尔还会超时。Notion 的 Block 结构转 HTML 也是一堆边界情况。
  2. **没有后台管理界面。** 改个错别字都要去 Notion 改,等构建,再部署。
  3. **内容和代码耦合。** Markdown 文件直接放在仓库里,想加个"草稿"功能都得改构建逻辑。
  4. **没法做定时发布。** 静态站点没有运行时,想定时发一篇文章只能手动操作。

核心需求其实就一句话:我需要一个轻量级的、部署在 Cloudflare 上的 Headless CMS,同时保留 Astro 作为前端渲染层。

最终架构

改造后变成三个包的 Monorepo:

blog-cf/
├── apps/web/          # Astro 前端(SSG,Cloudflare Pages)
├── apps/console/      # React 管理后台(Vite SPA)
└── packages/api/      # Hono API(Cloudflare Worker)

数据流向很清晰:

  • **写入方向**:Console → API → D1 数据库 / R2 存储
  • **读取方向**:Astro 构建时调 API 拿文章 → 生成静态 HTML → 部署到 Pages
  • **开发时**:Astro dev server 通过 Vite proxy 调本地 Worker API,实现热更新

技术选型

组件选型理由
API 框架Hono专为 Edge Runtime 设计,体积小,中间件生态好
数据库Cloudflare D1SQLite 方言,和 Worker 原生集成,免费额度够用
文件存储Cloudflare R2S3 兼容,无出口流量费
管理后台React + Vite纯 SPA,构建后直接嵌入 Pages
认证Cloudflare Access零信任方案,不用自己写登录逻辑
输入校验Zod后端 schema 校验,类型安全

整套方案全部跑在 Cloudflare 上,没有传统服务器,冷启动快,成本接近零。

数据库设计

D1 就两张核心表加一张配置表:

articles 表 — 文章内容。content_json 存编辑器的 ProseMirror 文档结构(用于回填编辑器),content_html 存渲染后的 HTML(前端直接用)。这种双存储的好处是前端不需要在运行时解析 JSON。

categories 表 — 分类元数据。之前分类的图标和描述是硬编码在前端常量里的,现在存到数据库,可以在后台直接管理。

settings 表 — Key-Value 结构的运行时配置,比如站点标题、默认作者、阅读速度、评论系统类型等。不需要重新部署就能改。

一个设计决策:为什么不用外键

文章的 categories 字段存的是 JSON 字符串数组(如 '["技术折腾","前端"]'),而不是用关联表做多对多。原因是:

  1. D1 的 SQLite 不支持 `JSON_EACH` 等高级 JSON 函数,关联查询会很难写
  2. 分类数量很少(不到 20 个),不需要关系型的那套范式
  3. 读多写少的场景,反范式化反而更简单

API 设计的几个要点

字段按需加载

API 不是一股脑把所有字段都返回。列表接口只返回元数据(标题、标签、字数等),content_jsoncontent_html 只在编辑和详情接口才返回。这对前端性能影响很大,特别是文章多了之后。

const LIST_COLUMNS = `id, slug, title, description, cover_image,
  tags, categories, author, publish_date, word_count, reading_time,
  status, type, created_at, updated_at`;

配置集中读取

一开始每个需要读设置的地方都单独查一次 DB,后来改成了 loadSettings(db) 一次性把所有设置读出来缓存到一个 Map 里。一个请求内多处使用同一个 Settings 对象就行了。

export async function loadSettings(db: D1Database): Promise<Settings> {
  const { results } = await db.prepare('SELECT key, value FROM settings').all();
  const map = new Map();
  for (const row of results) map.set(row.key, row.value);
  return {
    get(key, fallback) { return map.get(key) ?? fallback ?? DEFAULTS[key] ?? '' },
    getNum(key, fallback) { /* ... */ },
  };
}

Slug 处理

踩了一个坑:一开始 slug 校验强制小写(sanitizeSlug 里有 .toLowerCase()),导致导入旧文章时把 personal-email-on-ESL-class 变成了 personal-email-on-esl-class,URL 就对不上了。后来改成保留原始大小写,只做安全字符过滤。

管理后台

Console 是一个 React SPA,UI 风格参考了 PlayStation 的设计语言——深色背景、圆角卡片、克制的配色。没有用任何 UI 框架,全部手写 CSS。

主要功能:

  • **概览面板**:文章总数、字数统计、分类分布、最近动态
  • **文章管理**:列表、搜索(调 API + 300ms 防抖)、按状态筛选、新建/编辑/发布
  • **分类管理**:CRUD,包括图标和描述
  • **媒体库**:R2 文件浏览、上传、按月分组
  • **设置**:站点信息、内容配置、评论系统、上传限制、部署触发

搜索是后端全文搜索,匹配标题、描述、标签、分类、作者五个字段。前端做了防抖,不会每按一个键就发请求。

遇到的坑

CORS 和 Cross-Origin-Resource-Policy

开发时 Console 跑在 5007 端口,API 跑在 8007 端口。编辑器里的图片加载不出来,控制台报 Cross-Origin-Resource-Policy: same-origin 错误。

这是 Hono 的 secureHeaders 中间件默认行为。一开始想直接关掉这个策略,后来想想不对——生产环境是同源的(都在 ropean.org 下),问题只出在开发环境的跨端口场景。

最终方案:用 Vite 的 proxy 把 /api 代理到 Worker,这样开发时也是同源请求,和生产环境行为一致。

// vite.config.ts
server: {
  proxy: {
    '/api': { target: 'http://localhost:8007' },
  }
}

D1 的 wrangler dev 怪癖

wrangler dev 上传的文件,R2 对象的 httpMetadata.contentType 有时候会变成 application/octet-stream,即使上传时明确指定了 image/png。这导致媒体库里图片数量统计不对。

解决方法是写了一个 inferContentType 函数,如果存储的 MIME 类型不可靠,就根据文件扩展名推断:

function inferContentType(key: string, stored: string | undefined): string {
  if (stored && stored !== 'application/octet-stream') return stored;
  const ext = key.split('.').pop()?.toLowerCase() ?? '';
  return EXT_TO_MIME[ext] ?? stored ?? 'application/octet-stream';
}

Astro 开发模式 vs 生产模式

Astro 在生产模式(SSG)下用 getStaticPaths 预生成所有文章页面,但在开发模式下是动态渲染。改了 API 的返回结构后(列表不再包含 content_html),开发模式下文章详情页变成了空白——因为 getAllArticles() 返回的列表数据没有内容字段了。

修复方式是让开发模式下的 [...slug].astro 始终单独调用 fetchArticleBySlugFromD1(slug) 获取完整数据,而不是复用列表数据。

Deploy Hook:连接 Worker 和 Pages 的关键

整个架构里最关键的胶水是 Cloudflare Pages Deploy Hook

静态站点有个本质问题:数据库里文章改了,但 Pages 上的 HTML 还是旧的。解决办法就是 Deploy Hook——Cloudflare Pages 提供一个 Webhook URL,POST 一下就会触发重新构建。

在系统里,Deploy Hook 出现在两个地方:

1. 手动触发

Console 的设置页有一个 Deploy Hook URL 配置项。API 提供了一个专门的触发端点:

// POST /api/admin/deploy — 触发 CF Pages Deploy Hook
deploy.post('/admin/deploy', async (c) => {
  if (!c.env.DEPLOY_HOOK_URL) {
    return c.json({ error: 'Deploy hook not configured' }, 503);
  }
  const res = await fetch(c.env.DEPLOY_HOOK_URL, { method: 'POST' });
  if (!res.ok) {
    return c.json({ error: 'Deploy hook failed', status: res.status }, 502);
  }
  return c.json({ success: true, message: '部署已触发' });
});

在后台发布文章后,手动点一下"部署"就行了。Pages 构建通常 1-2 分钟完成。

2. 定时发布自动触发

Worker 注册了一个 scheduled handler(Cron Trigger,默认每 15 分钟执行一次),用来处理计划发布的文章:

async function handleScheduled(env: Env) {
  const now = new Date().toISOString();
  const { results } = await env.DB.prepare(
    `SELECT id FROM articles
     WHERE status = 'scheduled' AND scheduled_at <= ?`
  ).bind(now).all();

if (results.length === 0) return;

// 批量把到期文章改为 published for (const { id } of results) { await env.DB.prepare( UPDATE articles SET status = 'published', ... ).bind(...).run(); }

// 有文章状态变了,触发 Pages 重新构建 if (env.DEPLOY_HOOK_URL) { await fetch(env.DEPLOY_HOOK_URL, { method: 'POST' }).catch(() => {}); } } `

这样就实现了定时发布:写好文章设一个 scheduled_at 时间,到点了 Cron 自动改状态并触发构建,不需要人工干预。

为什么这个设计有效

传统做法是用 SSR,请求到了现查数据库。但 SSR 意味着 Worker 每次请求都要查 D1、渲染模板——成本和延迟都上去了。

用 Deploy Hook 的方案是:写入时触发构建,读取时走 CDN 静态文件。 写入频率很低(一天最多几篇文章),但读取频率可以很高——全部走 Cloudflare 的边缘缓存,响应速度极快,成本几乎为零。

部署流程

# 本地开发:四个服务并行启动
pnpm dev

# 部署 API(Worker) pnpm ship:api

# 部署前端(Console 构建 → 嵌入 Web → 部署 Pages) pnpm ship `

pnpm ship 做的事情:先构建 Console SPA,把产物复制到 Astro 的 public/console 目录,再构建 Astro,最后 wrangler pages deploy 整个 dist。这样 Console 就作为 Pages 的一个子路径(/console/)部署了,认证由 Cloudflare Access 统一处理。

DEPLOY_HOOK_URL 作为 Worker Secret 配置(wrangler secret put DEPLOY_HOOK_URL),不硬编码在代码里。Settings 表里也存了一份,方便在后台界面查看和管理。

回头看

这次改造最大的收获不是技术方案本身,而是一个认知:Cloudflare 的免费套餐已经足够撑起一个完整的全栈应用。 D1、R2、Workers、Pages、Access——全部免费额度内搞定,不需要任何传统云服务器。

如果再做一次,可能会考虑的改进:

  1. **编辑器**:目前用的是比较基础的 Textarea + Markdown 预览,如果投入更多可以换成 TipTap 或类似的富文本编辑器
  2. **版本历史**:目前文章修改是直接覆盖的,没有历史版本。可以加一个简单的版本表
  3. **多语言**:设置里虽然有语言选项(中文/英文),但前端还没有做 i18n 支持
  4. **搜索优化**:D1 的 LIKE 查询在数据量大了之后可能会慢,可以考虑接入 Cloudflare 的 Vectorize 做语义搜索

总的来说,这是一个"刚好够用"的方案。不追求大而全,但每个部分都是实际需要的。