Astro + Cloudflare Pages 全栈实战:D1、R2、Turnstile 完整接入指南

本文基于真实项目 thetakumi 整理,该项目为 Cloudflare Monorepo,包含一个 Astro v5 SSR 博客(部署到 Cloudflare Pages)与一个 docs-rag Durable Object Worker(手动 wrangler deploy)。


一、通过 GitHub 关联 Cloudflare Pages 实现自动发布

1.1 项目结构

cloudflare/                  ← monorepo 根(git 仓库)
├── src/                     ← docs-rag Worker 源码
├── wrangler.jsonc           ← Worker 配置
├── blog/                    ← Astro 博客子项目
│   ├── src/
│   ├── migrations/
│   └── wrangler.jsonc       ← Pages 配置(含 D1、R2 绑定)
└── .gitignore

两个子项目共用同一 git 仓库,各自有独立的 package.jsonwrangler.jsonc不使用 npm workspaces,保持最简结构。

1.2 Cloudflare Pages Dashboard 配置

dash.cloudflare.comWorkers & PagesCreatePagesConnect to Git

配置项
Project name thetakumi
Production branch main
Root directory blog
Build command npm run build
Build output directory dist
NODE_VERSION(环境变量) 20

⚠️ Root directory 是关键:Cloudflare Pages 的 root directory 指定后,所有构建命令都在该目录下执行,相当于 cd blog && npm run build。Monorepo 中只发布子目录时必须填写。

1.3 自动发布流程

flowchart LR
  A[git push origin main] --> B[GitHub Webhook 触发]
  B --> C[Cloudflare Pages 拉取 blog/ 子目录]
  C --> D["npm run build -> dist"]
  D --> E[全球 CDN 部署]
  E --> F[thetakumi.com 更新]

推送后约 30–60 秒即可在 Pages Dashboard → Deployments 看到新的构建记录。

1.4 加密环境变量(Secrets)

以下变量必须在 Pages Dashboard → Settings → Environment variables 中设置为加密变量,绝不能写入 wrangler.jsonc

JWT_SECRET              # JWT 签名密钥
TURNSTILE_SECRET_KEY    # Cloudflare Turnstile 服务端密钥
CONTACT_EMAIL_SECRET    # 邮件中继 Worker 共享密钥

本地开发时,在 blog/.dev.vars(已被 .gitignore 忽略)中填写:

JWT_SECRET=any-local-secret
TURNSTILE_SECRET_KEY=<from-Cloudflare-Turnstile-dashboard>
CONTACT_EMAIL_URL=https://api.thetakumi.com
CONTACT_EMAIL_SECRET=<same-as-worker-secret>

1.5 自定义域名

Pages 创建后默认得到 thetakumi.pages.dev。绑定自定义域:

  1. Pages 项目 → Custom DomainsSet up a custom domain
  2. 填入 thetakumi.com(Cloudflare DNS 自动创建 CNAME flattening)
  3. 等待 2–5 分钟,状态变为 Active

二、Cloudflare D1 数据库

D1 是 Cloudflare 提供的 SQLite 兼容边缘数据库,零冷启动,支持 SQL 事务。

2.1 创建 D1 数据库

cd blog
npx wrangler d1 create thetakumi-blog

输出中会给出 database_id,复制到 wrangler.jsonc

// blog/wrangler.jsonc
{
  "name": "thetakumi",
  "pages_build_output_dir": "./dist",
  "compatibility_date": "2025-01-01",
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "thetakumi-blog",
      "database_id": "<your-database-id>",
      "migrations_dir": "./migrations"
    }
  ]
}
  • binding 是代码中访问数据库的变量名,即 env.DB
  • migrations_dir 指定 migration SQL 文件目录

2.2 编写 Migration

blog/migrations/0001_init.sql 中定义表结构:

CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  slug TEXT NOT NULL UNIQUE,
  description TEXT NOT NULL,
  content TEXT NOT NULL,
  tags TEXT DEFAULT '[]',      -- JSON 字符串数组,如 '["astro","cloudflare"]'
  published INTEGER DEFAULT 1, -- 0 或 1(SQLite 无 BOOLEAN 类型)
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

注意:D1 / SQLite 没有原生布尔类型,用 INTEGER 0/1 代替;数组用 JSON 字符串存储。

2.3 应用 Migration

# 应用到本地(开发调试)
npx wrangler d1 migrations apply thetakumi-blog --local

# 应用到远程(生产数据库)
npx wrangler d1 migrations apply thetakumi-blog

2.4 在 Astro 页面中访问 D1

Cloudflare Pages + @astrojs/cloudflare adapter 通过 locals.runtime.env 暴露所有绑定:

// blog/src/env.d.ts
type D1Database = import('@cloudflare/workers-types').D1Database;
type R2Bucket   = import('@cloudflare/workers-types').R2Bucket;

interface Env {
  DB: D1Database;
  IMAGES_BUCKET: R2Bucket;
  IMAGES_BASE_URL: string;
  CONTACT_EMAIL_URL: string;
  JWT_SECRET: string;
  TURNSTILE_SECRET_KEY: string;
  CONTACT_EMAIL_SECRET: string;
}

declare namespace App {
  interface Locals extends Runtime<Env> {
    userId?: number;
  }
}

.astro 页面中:

// blog/src/pages/index.astro
---
import { getPosts } from '../lib/db';
const env = Astro.locals.runtime?.env as Env;
const posts = await getPosts(env.DB, true); // publishedOnly = true
---

在 API Route 中:

// blog/src/pages/api/posts/index.ts
import type { APIRoute } from 'astro';
import { createPost } from '../../../lib/db';

export const POST: APIRoute = async ({ request, locals }) => {
  const env = locals.runtime?.env as Env;
  const data = await request.json();
  const post = await createPost(env.DB, data);
  return new Response(JSON.stringify(post), {
    headers: { 'Content-Type': 'application/json' },
  });
};

2.5 D1 查询封装模式

将所有数据库操作集中在 src/lib/db.ts,不在页面中直接写 SQL:

// blog/src/lib/db.ts(节选关键模式)

// 1. 原始行类型与业务类型分离
interface RawPost {
  tags: string;      // JSON 字符串
  published: number; // 0 或 1
  // ...其他字段
}

// 2. 统一转换函数
function parsePost(row: RawPost): Post {
  return {
    ...row,
    tags: JSON.parse(row.tags || '[]'),
    published: row.published === 1,
  };
}

// 3. 标签搜索:用双引号精确匹配,避免前缀误匹配
export async function getPostsByTag(db: D1Database, tag: string) {
  const { results } = await db
    .prepare(`SELECT * FROM posts WHERE published = 1 AND tags LIKE ?`)
    .bind(`%"${tag}"%`) // 注意:双引号包裹,匹配 JSON 数组中的完整元素
    .all<RawPost>();
  return results.map(parsePost);
}

// 4. 动态 UPDATE:只更新传入的字段
export async function updatePost(db: D1Database, id: number, data: Partial<CreatePostInput>) {
  const sets: string[] = [];
  const values: unknown[] = [];
  if (data.title !== undefined) { sets.push('title = ?'); values.push(data.title); }
  // ...其他字段
  sets.push('updated_at = CURRENT_TIMESTAMP');
  values.push(id);
  const result = await db
    .prepare(`UPDATE posts SET ${sets.join(', ')} WHERE id = ? RETURNING *`)
    .bind(...values)
    .first<RawPost>();
  if (!result) throw new Error('Post not found');
  return parsePost(result);
}

2.6 本地开发查看 D1 数据

# 执行任意 SQL
npx wrangler d1 execute thetakumi-blog --local --command="SELECT * FROM posts"

# 导入 SQL 文件
npx wrangler d1 execute thetakumi-blog --local --file=./seeds/data.sql

三、Cloudflare R2 对象存储

R2 是 S3 兼容的对象存储,零出站流量费(egress free),适合存储图片、附件等静态资源。

3.1 创建 R2 Bucket

npx wrangler r2 bucket create thetakumi-images

3.2 绑定到 Pages 项目

// blog/wrangler.jsonc
{
  "r2_buckets": [
    {
      "binding": "IMAGES_BUCKET",
      "bucket_name": "thetakumi-images"
    }
  ],
  "vars": {
    "IMAGES_BASE_URL": "https://images.thetakumi.com"
  }
}

IMAGES_BASE_URL 是 R2 Bucket 的自定义域名(在 Cloudflare Dashboard → R2 → Bucket → Settings → Custom Domains 中绑定)。

3.3 图片上传 API 实现

// blog/src/pages/api/upload.ts
import type { APIRoute } from 'astro';

export const prerender = false;

const MAX_SIZE = 10 * 1024 * 1024; // 10 MB

function buildKey(filename: string): string {
  const now = new Date();
  const yyyy = now.getFullYear();
  const mm = String(now.getMonth() + 1).padStart(2, '0');
  const ext = filename.split('.').pop()?.toLowerCase() ?? 'bin';
  const base = filename
    .replace(/\.[^.]+$/, '')
    .replace(/[^a-z0-9_-]/gi, '_')
    .slice(0, 40);
  // 对象键格式:2026/06/my_image_1719388800000.jpg
  return `${yyyy}/${mm}/${base}_${Date.now()}.${ext}`;
}

export const POST: APIRoute = async ({ request, locals }) => {
  const env = locals.runtime?.env as Env;

  // 检查 R2 绑定是否存在
  if (!env?.IMAGES_BUCKET) {
    return new Response(JSON.stringify({ error: 'R2 未配置' }), { status: 503 });
  }

  const formData = await request.formData();
  const file = formData.get('file');

  if (!(file instanceof File)) {
    return new Response(JSON.stringify({ error: '缺少文件字段 file' }), { status: 400 });
  }
  if (!file.type.startsWith('image/')) {
    return new Response(JSON.stringify({ error: '只支持图片文件' }), { status: 415 });
  }
  if (file.size > MAX_SIZE) {
    return new Response(JSON.stringify({ error: '文件超过 10 MB 限制' }), { status: 413 });
  }

  const key = buildKey(file.name);
  const arrayBuffer = await file.arrayBuffer();

  // 写入 R2,同时设置 Content-Type
  await env.IMAGES_BUCKET.put(key, arrayBuffer, {
    httpMetadata: { contentType: file.type },
  });

  // 返回可公开访问的 CDN URL
  const url = `${env.IMAGES_BASE_URL}/${key}`;
  return new Response(JSON.stringify({ url }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
};

3.4 前端上传调用示例

async function uploadImage(file: File): Promise<string> {
  const form = new FormData();
  form.append('file', file);

  const res = await fetch('/api/upload', { method: 'POST', body: form });
  if (!res.ok) throw new Error('上传失败');

  const { url } = await res.json();
  return url; // "https://images.thetakumi.com/2026/06/photo_1719388800000.jpg"
}

3.5 R2 公开访问配置

默认 R2 Bucket 是私有的。要让上传的图片可公开访问,有两种方式:

方式一(推荐):绑定自定义域名

R2 → Bucket → Settings → Custom Domains → Add Domain,填入 images.thetakumi.com,Cloudflare 自动配置 DNS 和 SSL。

方式二:开启 R2.dev 公共访问

R2 → Bucket → Settings → Public Access → Allow Access,得到 pub-xxxx.r2.dev 域名(不推荐用于生产)。


四、Cloudflare Turnstile 人机验证

Turnstile 是 Cloudflare 的 CAPTCHA 替代方案,无图形验证码,对用户体验友好,且对爬虫有效拦截。

4.1 获取密钥

  1. dash.cloudflare.comTurnstileAdd site
  2. 填入域名 thetakumi.com,Widget type 选 Managed
  3. 复制 Site Key(公开,写在前端)和 Secret Key(保密,写在 Pages 加密变量中)

4.2 前端嵌入

在表单页面中嵌入 Turnstile Widget:

<!-- blog/src/pages/contact.astro -->
<form method="POST" action="/api/contact">
  <!-- 其他表单字段 -->

  <!-- Turnstile Widget:data-sitekey 填入 Site Key -->
  <div class="cf-turnstile"
       data-sitekey="<your-turnstile-site-key>"
       data-theme="light">
  </div>

  <button type="submit">发送</button>
</form>

<!-- 在 </body> 之前异步加载 Turnstile JS -->
<script is:inline
  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
  async defer>
</script>

Turnstile 会自动在 Widget 容器中渲染,并在表单提交时自动注入隐藏字段 cf-turnstile-response,值为验证 token。

4.3 服务端验证

// blog/src/pages/api/contact.ts
import type { APIRoute } from 'astro';

async function verifyTurnstile(
  token: string,
  secret: string,
  ip: string
): Promise<boolean> {
  const res = await fetch(
    'https://challenges.cloudflare.com/turnstile/v0/siteverify',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        secret,           // TURNSTILE_SECRET_KEY
        response: token,  // 前端提交的 cf-turnstile-response
        remoteip: ip,     // 用户真实 IP(Cloudflare 自动注入 CF-Connecting-IP)
      }),
    }
  );
  const data = await res.json<{ success: boolean }>();
  return data.success === true;
}

export const POST: APIRoute = async ({ request, redirect, locals }) => {
  const env = locals.runtime?.env as Env;
  const form = await request.formData();

  const tsToken = form.get('cf-turnstile-response')?.toString() ?? '';
  const ip = request.headers.get('CF-Connecting-IP') ?? '';

  // 1. 先验证 Turnstile,不通过直接拒绝
  const tsOk = await verifyTurnstile(tsToken, env.TURNSTILE_SECRET_KEY, ip);
  if (!tsOk) return redirect('/contact?error=captcha');

  // 2. 验证通过后再处理业务逻辑
  const name    = form.get('name')?.toString().trim() ?? '';
  const email   = form.get('email')?.toString().trim() ?? '';
  const message = form.get('message')?.toString().trim() ?? '';
  if (!name || !email || !message) return redirect('/contact?error=server');

  // 3. 转发给邮件中继 Worker
  try {
    const res = await fetch(`${env.CONTACT_EMAIL_URL}/api/contact-email`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Contact-Secret': env.CONTACT_EMAIL_SECRET,
      },
      body: JSON.stringify({ name, email, message }),
    });
    if (!res.ok) return redirect('/contact?error=server');
  } catch {
    return redirect('/contact?error=server');
  }

  return redirect('/contact?sent=1');
};

4.4 本地开发绕过

本地开发时 localhost 域名下 Turnstile 会使用测试模式,使用以下测试密钥:

类型 行为
Site Key(始终通过) 1x00000000000000000000AA 验证码始终通过
Site Key(始终阻断) 2x00000000000000000000AB 验证码始终失败
Secret Key(测试用) 1x0000000000000000000000000000000AA 服务端始终返回 success=true

blog/.dev.vars 中设置测试 Secret Key,前端 data-sitekey 替换为测试 Site Key 即可本地正常开发。


五、架构总结

flowchart TD
  User["访客浏览器"] --> CF["Cloudflare Edge"]
  CF --> Pages["Cloudflare Pages - Astro SSR"]
  Pages --> D1["D1 SQLite - 文章/用户数据"]
  Pages --> R2["R2 对象存储 - 图片资源"]
  Pages --> Turnstile["Turnstile 验证 - 防垃圾表单"]
  Pages --> Worker["docs-rag Worker - 邮件中继/AI Chat"]

  Dev["开发者 git push"] --> GitHub["GitHub main branch"]
  GitHub --> Build["Pages 自动构建 blog/ 子目录"]
  Build --> CF
功能 Cloudflare 产品 费用
静态/SSR 托管 Pages 免费(100k req/day)
关系数据存储 D1 免费(5 GB / 25M 读/天)
图片/文件存储 R2 免费(10 GB / 1M 操作/月)
人机验证 Turnstile 免费
自定义域名 SSL Cloudflare DNS 免费

整个博客的基础架构可以在 Cloudflare 免费套餐内运行,适合个人博客、文档站等中小流量场景。


六、常用 Wrangler 命令速查

# D1
npx wrangler d1 migrations apply thetakumi-blog          # 应用 migration 到生产
npx wrangler d1 migrations apply thetakumi-blog --local  # 应用到本地
npx wrangler d1 execute thetakumi-blog --command="SELECT * FROM posts" --local

# R2
npx wrangler r2 object list thetakumi-images             # 列出对象
npx wrangler r2 object put thetakumi-images/test.jpg --file=./test.jpg

# Pages 本地开发(含绑定)
cd blog && npm run dev  # http://localhost:4321

# Pages Secret
npx wrangler pages secret put JWT_SECRET
npx wrangler pages secret put TURNSTILE_SECRET_KEY

# Worker Secret(docs-rag)
npx wrangler secret put CONTACT_EMAIL_SECRET