静态网站快、便宜、不容易出问题——直到你需要一个联系表单。常见的方案要么是按月付费的表单 SaaS,要么是一整套后端。其实还有更轻的选择:一个 Cloudflare Worker,负责校验提交内容、存进 D1(SQLite)数据库,然后通知你。在免费额度内,一个小企业网站基本永远不用为它付钱。
这就是本站咨询表单背后的方案。
架构
Browser (static page)
└─ POST /api/inquiry ──► Cloudflare Worker
├─ verify Turnstile token
├─ validate fields
├─ INSERT into D1
└─ notify (Telegram / email)1. 创建项目和数据库
npm create cloudflare@latest contact-worker -- --type=hello-world --lang=ts
cd contact-worker
npx wrangler d1 create contact-db最后一条命令会输出一段绑定配置,把它加到 Wrangler 配置里:
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. 建表迁移
npx wrangler d1 migrations create contact-db initCREATE 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);npx wrangler d1 migrations apply contact-db --local # for wrangler dev
npx wrangler d1 migrations apply contact-db --remote # production3. Worker 代码
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. 密钥和部署
npx wrangler secret put TURNSTILE_SECRET
npx wrangler secret put TELEGRAM_TOKEN
npx wrangler secret put TELEGRAM_CHAT_ID
npx wrangler deployALLOWED_ORIGIN 不是密钥,放在 Wrangler 配置的 [vars] 里即可。本地开发时,密钥放在 .dev.vars 文件里,并且永远不要提交它。
5. 静态网站上的表单
引入 Turnstile 组件脚本和容器,然后以 JSON 形式提交表单:
<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>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。
查看提交记录
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 静态导出完全指南。

