返回博客

用 Cloudflare Workers 和 D1 搭建无服务器联系表单

用 Cloudflare Workers 和 D1 搭建无服务器联系表单

2026年10月7日 — Cloudflare

发布于

2026年10月7日

阅读时长

2 分钟

分类

Cloudflare

标签

  • Cloudflare Workers
  • D1
  • Serverless
  • Forms
  • Turnstile

(摘要)用一个 Cloudflare Worker、D1 数据库、Turnstile 防垃圾验证和即时通知,给静态网站加上可用的联系表单。免费额度够用,附完整代码。

静态网站快、便宜、不容易出问题——直到你需要一个联系表单。常见的方案要么是按月付费的表单 SaaS,要么是一整套后端。其实还有更轻的选择:一个 Cloudflare Worker,负责校验提交内容、存进 D1(SQLite)数据库,然后通知你。在免费额度内,一个小企业网站基本永远不用为它付钱。

这就是本站咨询表单背后的方案。

架构

text
Browser (static page)
  └─ POST /api/inquiry  ──►  Cloudflare Worker
                               ├─ verify Turnstile token
                               ├─ validate fields
                               ├─ INSERT into D1
                               └─ notify (Telegram / email)

1. 创建项目和数据库

bash
npm create cloudflare@latest contact-worker -- --type=hello-world --lang=ts
cd contact-worker
npx wrangler d1 create contact-db

最后一条命令会输出一段绑定配置,把它加到 Wrangler 配置里:

wrangler.toml
name = "contact-worker"
main = "src/index.ts"
compatibility_date = "2026-10-01"

[[d1_databases]]
binding = "DB"
database_name = "contact-db"
database_id = "<the id wrangler printed>"

2. 建表迁移

bash
npx wrangler d1 migrations create contact-db init
migrations/0001_init.sql
CREATE TABLE inquiries (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  name       TEXT NOT NULL,
  email      TEXT NOT NULL,
  message    TEXT NOT NULL,
  budget     TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  ip_country TEXT
);
CREATE INDEX idx_inquiries_created ON inquiries (created_at);
bash
npx wrangler d1 migrations apply contact-db --local   # for wrangler dev
npx wrangler d1 migrations apply contact-db --remote  # production

3. Worker 代码

src/index.ts
interface Env {
  DB: D1Database
  TURNSTILE_SECRET: string
  TELEGRAM_TOKEN?: string
  TELEGRAM_CHAT_ID?: string
  ALLOWED_ORIGIN: string
}

const cors = (origin: string) => ({
  'Access-Control-Allow-Origin': origin,
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type',
})

const json = (body: unknown, status: number, origin: string) =>
  new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json', ...cors(origin) },
  })

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const origin = env.ALLOWED_ORIGIN
    // A 204 can't have a body, so the preflight gets its own response
    if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors(origin) })
    if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, origin)

    let data: Record<string, unknown>
    try {
      data = await request.json()
    } catch {
      return json({ error: 'Invalid JSON' }, 400, origin)
    }

    // 1. Spam check
    const ip = request.headers.get('CF-Connecting-IP') ?? ''
    if (!(await verifyTurnstile(String(data.token ?? ''), ip, env.TURNSTILE_SECRET))) {
      return json({ error: 'Verification failed' }, 403, origin)
    }

    // 2. Validation
    const name = String(data.name ?? '').trim()
    const email = String(data.email ?? '').trim()
    const message = String(data.message ?? '').trim()
    const budget = data.budget ? String(data.budget).slice(0, 50) : null
    if (!name || name.length > 100) return json({ error: 'Name is required' }, 422, origin)
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json({ error: 'Email is invalid' }, 422, origin)
    if (message.length < 10 || message.length > 5000) return json({ error: 'Message is too short or long' }, 422, origin)

    // 3. Store
    const country = (request as Request & { cf?: { country?: string } }).cf?.country ?? null
    await env.DB.prepare(
      'INSERT INTO inquiries (name, email, message, budget, ip_country) VALUES (?, ?, ?, ?, ?)',
    )
      .bind(name, email, message, budget, country)
      .run()

    // 4. Notify, without making the visitor wait for it
    if (env.TELEGRAM_TOKEN && env.TELEGRAM_CHAT_ID) {
      const text = `New enquiry from ${name} <${email}>\n\n${message.slice(0, 500)}`
      await fetch(`https://api.telegram.org/bot${env.TELEGRAM_TOKEN}/sendMessage`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT_ID, text }),
      }).catch(() => {})
    }

    return json({ ok: true }, 200, origin)
  },
}

async function verifyTurnstile(token: string, ip: string, secret: string) {
  if (!token) return false
  const body = new FormData()
  body.append('secret', secret)
  body.append('response', token)
  if (ip) body.append('remoteip', ip)
  const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body })
  const outcome = (await res.json()) as { success: boolean }
  return outcome.success
}

有三个细节值得注意:

  • 始终使用 prepare().bind()。 永远不要用字符串拼接 SQL;参数绑定从根本上杜绝了注入。
  • CORS 只允许你自己的域名,其他网站无法在浏览器里向你的接口提交数据。
  • 校验在服务端进行。 前端校验是为了用户方便,真正保护数据的是 Worker。

如果不想让响应等待通知发送完成,可以用 fetch 的第三个参数 ctx,调用 ctx.waitUntil(promise)。

4. 密钥和部署

bash
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID
npx wrangler deploy

ALLOWED_ORIGIN 不是密钥,放在 Wrangler 配置的 [vars] 里即可。本地开发时,密钥放在 .dev.vars 文件里,并且永远不要提交它。

5. 静态网站上的表单

引入 Turnstile 组件脚本和容器,然后以 JSON 形式提交表单:

html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact">
  <input name="name" required maxlength="100" />
  <input name="email" type="email" required />
  <textarea name="message" required minlength="10"></textarea>
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button>Send</button>
</form>
js
document.getElementById('contact').addEventListener('submit', async (event) => {
  event.preventDefault()
  const form = new FormData(event.currentTarget)
  const res = await fetch('https://contact-worker.example.workers.dev', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: form.get('name'),
      email: form.get('email'),
      message: form.get('message'),
      token: form.get('cf-turnstile-response'),
    }),
  })
  // show a success or error message based on res.ok
})

在 React 中,同样的逻辑写在一个客户端组件里,用 useState 管理状态——表单变复杂时可以换成 React Hook Form 加 Zod。

查看提交记录

bash
npx wrangler d1 execute contact-db --remote \
  --command "SELECT created_at, name, email, substr(message, 1, 80) FROM inquiries ORDER BY id DESC LIMIT 20"

想要更友好的方式,可以给 Worker 加一个需要 Authorization: Bearer <token> 请求头的 GET 路由,返回最近的记录 JSON,再在一个私有的管理页面读取。

加固清单

  • Turnstile 在服务端验证,而不只是在前端渲染
  • CORS 限制为你的域名
  • 每个字段都有长度限制
  • 每条查询都使用参数绑定
  • 在 Cloudflare 控制台为这个路由设置限流规则(WAF → 速率限制规则)
  • 密钥放在 wrangler secret 中,绝不放进代码仓库
  • 个人数据的处理方案:咨询记录保存多久,用户要求时如何删除

为什么比表单服务更好

数据归你所有;小企业的量级基本零成本;没有第三方脚本追踪你的访客;而且你可以用几行 TypeScript 扩展它——自动回复、同步到 CRM、加一个预约步骤。它和静态导出的网站是天然搭配,见 Next.js 静态导出完全指南。

订阅

通过邮件接收新文章

有新文章时发一封简短的邮件。不发垃圾邮件,随时可以退订。

动画网站的 Core Web Vitals 优化:保留动效,通过 LCP、INP 和 CLS
动画网站的 Core Web Vitals 优化:保留动效,通过 LCP、INP 和 CLS
下一篇 →