返回部落格

用 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 靜態匯出完全指南。

電子報

用 Email 訂閱新文章

有新文章時寄一封簡短的信給你。不發垃圾信,隨時可以取消訂閱。

動畫網站的 Core Web Vitals 優化:保留動效,透過 LCP、INP 和 CLS
動畫網站的 Core Web Vitals 優化:保留動效,透過 LCP、INP 和 CLS
下一篇