在实际业务里,能收钱的 SaaS 比纯粹的管理后台多出来的不只是支付按钮,而是订单、回调、验签、用户体系、后台管理这一整条闭环。很多人想用 Cloudflare 免费额度做一个自己的小产品,但一打开文档就看到 Workers、Pages、D1、R2、KV 一堆名词,真正动手时又不知道从哪起头。本文以一个开源的 SaaS starter 为参照,把登录、支付、后台管理、部署验证这条链路完整跑一遍。项目跑在 Cloudflare 的免费层,使用 Workers 处理接口、D1 存数据库、Pages 托管前端,注册登录是真实可用的,支付流程会先用沙箱和模拟网关跑通,替换正式商户配置后再接入真实收款。
读完这篇文章,你能得到两样东西:第一,一套可复制的工程结构和部署命令,不用从空白文档开始猜;第二,一张支付和回调的排错清单,真正上线时遇到问题知道按什么顺序查。整个项目不需要自购服务器,也不需要单独配置 Nginx,适合做 MVP、内部工具和低频的独立产品起步。
1. 先拆需求:能收钱的 SaaS 最少需要哪些模块
1.1 从“购买”这条动作倒推模块
一个 SaaS 要“收钱”,核心是用户从看到商品到订单完成支付,再到后台核对收入。这个闭环需要四个部分:
- 用户体系:注册、登录、会话、退出。没有用户体系,订单无法归属到具体的人。
- 商品或套餐:至少有一个价格固定的商品,才能生成订单。
- 订单与支付:创建订单、跳转支付、支付回调、验签、更新订单状态。
- 后台管理:查看订单、用户和商品状态,处理异常订单。
这种设计不是过度设计。哪怕只卖一个会员,也需要知道是谁买的、买的是什么、支付平台是否确认到账、后续该给谁开通权限。很多初学者直接写一个“点击购买,跳转微信支付”的前端页面,结果没有订单表,也没有回调接口,支付完不知道该给谁开通,最后只能放弃。
正确做法是先确定数据表和状态机。订单表的status字段是 SaaS 收款系统的核心,它决定了用户付完钱后能触发什么动作。哪怕是模拟支付,也要按真实支付流程设计状态:创建订单时是pending,支付平台通知后变成paid,超时用户没付则变成closed。
1.2 为什么选择 Cloudflare 免费额度做快速启动
Cloudflare 提供 Workers、Pages、D1、R2、KV、Turnstile 等产品,免费额度对于小型 SaaS 起步足够。常见好处:
- 不需要自己买服务器和配置 Nginx。
- Workers 天然运行在边缘节点,不需要关心 CDN 和负载均衡。
- D1 是 SQLite 兼容数据库,使用门槛低,本地可以直接开发。
- R2 可以用来存头像、导出文件等对象,免费用户不需要担心出口流量费。
- Turnstile 可以替代传统验证码,避免机器人刷注册。
- 部署通过 Git 集成,push 代码后自动发布,适合“一个晚上上线”这种节奏。
免费额度虽然不错,但不能当成无限制资源。Workers 免费层每天约 10 万次请求,D1 有 5GB 存储,R2 免费 10GB 存储,Turnstile 免费。具体数值可能随服务商政策调整,要以 Cloudflare 官方页面为准。对这个限制有预期,才知道生产环境什么时候需要升级到付费计划。
1.3 学习环境和生产环境要分开
初学者容易犯的错是直接用真实支付商户号在本地调试。正确做法是:
- 学习环境:使用本地 mock 支付网关,或支付宝沙箱,验证订单状态流转。
- 测试环境:部署到 Cloudflare Preview 链接,使用沙箱配置,验证回调地址。
- 生产环境:替换正式商户号、密钥、回调 URL,开启日志和监控。
这样不会因回调到 localhost 失败而中断调试,也不会误收真实款项导致对账事故。支付回调只能在公网可达的 HTTPS 地址上收到,生产域名必须先完成 DNS、证书和支付平台回调地址配置,再去联调。
注意:支付沙箱只能验证流程,不能产生真实交易。正式收款前必须确认商户号、密钥、回调地址已经切换到真实渠道,并经过小额测试。
2. 搭建项目骨架与本地环境
2.1 环境准备
开发这个项目需要 Node.js 18 以上、Git、Cloudflare 账号和 Wrangler CLI。安装完 Node.js 后,先确认基础环境:
node -v npm -v npx wrangler loginnpx wrangler login会在浏览器打开 Cloudflare 授权页,登录成功后本地 CLI 会保存临时凭证。这一步完成后,后续部署和数据库操作才能使用你的账号权限。
2.2 初始化 Worker 与前端项目
推荐把项目拆成api和web两个目录,一个负责接口,一个负责页面。常见的初始化命令如下,具体参数以你使用的 CLI 版本为准:
mkdir cf-saas-starter && cd cf-saas-starter npm create cloudflare@latest api npm create vite@latest web -- --template vue然后在 Worker 目录安装依赖:
cd api npm install hono @hono/zod-validatorHono 是一个适合边缘运行的 Web 框架,API 风格简单,中间件和路由能力足够支撑 SaaS 后端。也可以不用框架,直接用原生 Workers 的fetch事件路由,但那样写容易乱。项目里用 Hono 管理接口,看起来更清晰。
2.3 配置 wrangler.toml
Worker 的配置集中在wrangler.toml。下面是一个最小配置文件,绑定 D1 数据库和 R2 存储:
name = "cf-saas-starter" main = "src/index.ts" compatibility_date = "2025-01-01" [[d1_databases]] binding = "DB" database_name = "cf-saas-db" database_id = "你的-database-id" [[r2_buckets]] binding = "FILES" bucket_name = "cf-saas-files" [env.production] vars = { APP_URL = "https://你的域名" }每个绑定都有自己的作用:
DB:在 Worker 代码里通过c.env.DB访问 D1 数据库。FILES:在 Worker 代码里通过c.env.FILES访问 R2 文件存储。APP_URL:用来拼接支付回调地址和前端跳转链接。- 支付密钥和 Turnstile Secret 不要写在
wrangler.toml,使用wrangler secret put设置。
2.4 本地开发启动
在api目录下执行:
npm run devWrangler 会在本地启动 Worker,并输出访问地址。同时准备一个最基础的数据库迁移文件,在db/schema.sql中创建表,然后执行:
npx wrangler d1 execute cf-saas-db --local --file=db/schema.sql检查点:浏览器访问http://localhost:8787/api/health,返回 JSON:
{ "ok": true }本地开发最常见的坑是:本地 D1 数据和远程 D1 数据不自动同步。改 Schema 后要记得对本地环境重复执行迁移,远程另有一套数据库文件。
2.5 为什么要把 API 和前端分开
前后端分离便于使用 Vite 热更新,也便于把静态站点部署到 Pages,把 API 部署到 Workers。如果项目很小,也可以把 API 放在 Pages Functions 里,前后端共用一套部署流程。但从扩展性考虑,API 独立成 Worker 更好:后续可以增加 Worker 定时任务、队列,或者给 API 单独做限流。
项目结构保持简单:
cf-saas-starter/ api/ # Cloudflare Workers / Hono web/ # Vue3 + Vite 前台与后台 db/ # D1 迁移 SQL wrangler.toml # Workers 配置3. 实现注册登录与认证体系
3.1 用户表与会话表设计
在db/schema.sql中创建用户表、会话表和商品订单表。先看用户和会话部分:
CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, name TEXT, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) );这里的会话表不是必须的。如果使用 JWT,可以不用存会话。但保留会话表有一个实际好处:后台可以踢人、可以清理过期 token、可以在用户修改密码后立即让旧会话失效。对于 SaaS 后台管理,这个能力比无状态 JWT 更可控。
密码哈希不要使用明文。项目中哈希要放在服务端做,不要在浏览器端提交明文密码后又把哈希交给服务器。密码哈希算法要兼容 Worker 运行时,不能直接依赖原生 Node 模块。骨架里默认提供hashPassword和verifyPassword两个函数,生产环境建议换成 Argon2id 或交给专业的认证服务处理。
3.2 注册接口
注册接口的职责是:接收邮箱密码、校验验证码、检查邮箱是否重复、写入用户、创建会话。核心代码结构如下:
import { Hono } from 'hono' const app = new Hono() app.post('/api/auth/register', async (c) => { const { email, password, name, turnstileToken } = await c.req.json() if (!email || !password) { return c.json({ error: 'email and password required' }, 400) } const turnstileResult = await verifyTurnstile(turnstileToken) if (!turnstileResult) { return c.json({ error: 'turnstile verify failed' }, 400) } const exists = await c.env.DB.prepare('SELECT id FROM users WHERE email = ?') .bind(email) .first() if (exists) { return c.json({ error: 'email already exists' }, 409) } const passwordHash = await hashPassword(password) const userId = crypto.randomUUID() await c.env.DB.prepare( 'INSERT INTO users (id, email, password_hash, name) VALUES (?, ?, ?, ?)' ) .bind(userId, email, passwordHash, name || '') .run() const session = await createSession(c.env.DB, userId) return c.json({ token: session.token, user: { id: userId, email } }, 201) })每一步都有明确目的:
- 先校验 Turnstile,避免机器人灌库。
- 再查用户是否存在,及时返回错误。
- 再哈希密码,避免数据库泄露后明文暴露。
- 最后创建会话,前端拿到 token 后就可以直接进入登录状态。
3.3 登录接口
登录接口与注册类似,但逻辑是从数据库中取出用户,验证密码,然后创建新会话:
app.post('/api/auth/login', async (c) => { const { email, password, turnstileToken } = await c.req.json() const user = await c.env.DB.prepare( 'SELECT id, email, password_hash, role FROM users WHERE email = ?' ) .bind(email) .first() if (!user) { return c.json({ error: 'invalid email or password' }, 401) } const valid = await verifyPassword(user.password_hash, password) if (!valid) { return c.json({ error: 'invalid email or password' }, 401) } const session = await createSession(c.env.DB, user.id) return c.json({ token: session.token, user: { id: user.id, email: user.email, role: user.role } }) })登录失败的提示不要区分“邮箱不存在”和“密码错误”,否则攻击者可以批量探测有效邮箱。
3.4 使用 Turnstile 防止机器人注册
Turnstile 的用法是:前端加载脚本,渲染组件,用户通过后拿到一个 token;提交注册或登录表单时带上这个 token;后端调用siteverify接口验证。
服务端验证示例:
async function verifyTurnstile(token: string) { const secret = c.env.TURNSTILE_SECRET const resp = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body: `secret=${secret}&response=${encodeURIComponent(token)}`, headers: { 'content-type': 'application/x-www-form-urlencoded' } }) const data = await resp.json() return data.success === true }注意,前端拿到的 token 是一次性的,验证通过后不能重复使用。如果登录也加了 Turnstile,要防止用户每次登录都弹挑战,可以根据业务风险决定是注册强制、登录弱化还是后台接口强制。
3.5 登录页与前端状态存储
前端方面,Vue 项目可以使用 Pinia 管理用户状态。登录成功后把 token 存起来,在请求拦截器里带上:
import axios from 'axios' const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE }) api.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })这里有两个要注意的地方:
- 把 token 放在
localStorage对小程序项目来说实现最快,但有 XSS 风险;生产环境优先考虑 httpOnly Cookie,并处理好 CSRF。 - 不要只在登录页存一份用户信息,还要在刷新页面后通过
/api/auth/me接口恢复用户状态,不然刷新后就变成了未登录。
4. 接入支付:从本地模拟到真实沙箱
4.1 支付流程拆解
支付不只是“调一个接口返回跳转链接”,而是需要处理四个环节:
- 下单:用户选择商品,服务端创建订单,状态为
pending。 - 发起支付:服务端调用支付平台接口,返回跳转链接或二维码。
- 回调通知:支付平台异步通知服务端,服务端验签并更新订单。
- 结果查询:前端跳转回业务页面后,调用查询接口确认订单最终状态。
这个链路里最重要的是“服务端确认到账”,而不是“客户端说支付成功”。前端可以跳转支付,但订单状态只能由回调或服务端主动查询来修改。
4.2 订单表与订单状态机
订单表结构如下:
CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, product_id TEXT NOT NULL, amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'CNY', status TEXT NOT NULL DEFAULT 'pending', provider TEXT, transaction_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), paid_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS products ( id TEXT PRIMARY KEY, title TEXT NOT NULL, price INTEGER NOT NULL, active INTEGER NOT NULL DEFAULT 1 );商品价格使用整数存储,单位是“分”。这样能避免浮点数精度问题。比如价格为 29 元,存2900。后续如果要支持折扣、满减、退款,整数分都是最稳妥的基础。
订单状态建议包含:
| 状态 | 含义 | 触发方式 |
|---|---|---|
pending | 已下单,未支付 | 创建订单时 |
paid | 支付成功 | 支付回调或主动查询 |
closed | 超时关闭 | 定时任务或手动操作 |
refunded | 已退款 | 退款流程 |
4.3 先实现一个模拟支付网关
为了方便本地开发和演示,项目默认提供一个 mock 支付接口。它模拟“用户点击支付后,支付平台回调业务系统”的过程:
app.post('/api/payments/mock/charge', async (c) => { const { orderId } = await c.req.json() const order = await c.env.DB.prepare( 'SELECT id, amount, status FROM orders WHERE id = ?' ) .bind(orderId) .first() if (!order || order.status !== 'pending') { return c.json({ error: 'order not payable' }, 400) } await c.env.DB.prepare( "UPDATE orders SET status = 'paid', paid_at = datetime('now'), provider = 'mock' WHERE id = ?" ) .bind(orderId) .run() return c.json({ ok: true, orderId: order.id }) })mock 网关的价值是先把整条链路跑通,不依赖外部商户号。等模拟流程稳定后,再替换成真实沙箱。
4.4 支付宝沙箱或微信支付 Native 的最小逻辑
真实支付平台的 SDK 未必能在 Cloudflare Worker 里直接运行,因为有些 SDK 依赖 Node.js 原生 API。常见做法是使用支付平台的 HTTP API,在服务端拼接参数并签名。
以支付宝电脑网站支付为例,创建订单后,服务端需要构造这样的参数结构:
{ "app_id": "2021000000000000", "method": "alipay.trade.page.pay", "charset": "utf-8", "sign_type": "RSA2", "timestamp": "2025-01-01 12:00:00", "version": "1.0", "biz_content": "{\"out_trade_no\":\"ORDER_ID\",\"total_amount\":\"29.00\",\"subject\":\"Pro 月度会员\",\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}" }然后服务端按支付宝规则生成 RSA2 签名,把所有参数拼到https://openapi.alipay.com/gateway.do上,用户浏览器跳转到这个 URL 即可看到支付页面。订单金额total_amount要从数据库读取,不能使用前端传入的金额。
微信支付 Native 的流程类似:生成预支付单,得到code_url,后端把code_url转成二维码,用户扫码支付。回调内容不同,但核心验证思路一致:验签、核对金额、核对商户号、处理幂等。
4.5 回调处理和验签
支付回调接口是整个收款系统的关键入口。支付宝异步通知示例接口:
app.post('/api/payments/alipay/notify', async (c) => { const body = await c.req.parseBody() if (!verifyAlipaySign(body)) { return c.text('fail') } const outTradeNo = String(body.out_trade_no) const tradeStatus = String(body.trade_status) const totalAmount = String(body.total_amount) if (tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED') { const amountInCents = Math.round(parseFloat(totalAmount) * 100) const order = await c.env.DB.prepare( 'SELECT id, amount, status FROM orders WHERE id = ?' ) .bind(outTradeNo) .first() if (order && order.status === 'pending' && order.amount === amountInCents) { await c.env.DB.prepare( "UPDATE orders SET status = 'paid', transaction_id = ?, paid_at = datetime('now') WHERE id = ?" ) .bind(String(body.trade_no), order.id) .run() } } return c.text('success') })这里最容易被忽略的是“验签”和“金额核对”。如果接口不验签,任何人都可以伪造回调,把订单改成已支付。如果不核对金额,用户付了 1 分钱,你可能会给他开通全额会员。
支付宝回调要求业务系统返回纯文本success,否则会重复通知。要做成幂等:已经paid的订单再次收到回调,直接返回成功,不再重复处理。
注意:真实支付环境必须把回调接口当作公网接口对待,做好验签、签名算法升级和日志记录。不要为了快速跑通而关闭验签。
4.6 为什么不能直接在客户端调用支付接口
有一个常见错误写法:前端把商品金额传给/api/payments/create,后端直接用这个金额生成支付单。攻击者可以改成 0.01 元甚至负数账单。正确做法是前端只传productId,后端从商品表读取价格,再创建订单。订单金额一旦创建,回调时还要再次与支付平台通知的金额比对,确保没被中间人篡改。
5. 后台管理系统:订单、用户、配置
5.1 后台技术选型
前端后台使用 Vue3 + Vite + Pinia + Vue Router,UI 组件库可以用 Element Plus 或 Naive UI。后台页面和用户前台可以放在同一个web工程里,通过路由区分/admin目录,也可以在构建时拆成两个入口。
推荐使用同一个工程,但路由层严格区分。因为后台复用登录状态和 API 请求封装,避免维护两套登录逻辑。页面结构大致为:
web/src/ views/ Home.vue Login.vue Products.vue admin/ AdminLayout.vue AdminOrders.vue AdminUsers.vue AdminSummary.vue5.2 接口鉴权
后台接口和普通业务接口要隔离。Hono 中可以使用路由级中间件:
app.use('/api/admin/*', async (c, next) => { const token = c.req.header('Authorization')?.replace('Bearer ', '') if (!token) { return c.json({ error: 'unauthorized' }, 401) } const session = await getSessionByToken(c.env.DB, token) if (!session || session.expires_at < new Date().toISOString()) { return c.json({ error: 'session expired' }, 401) } if (session.role !== 'admin') { return c.json({ error: 'forbidden' }, 403) } c.set('userId', session.user_id) await next() })角色字段来自用户表role。最小系统里可以只用admin和user两种角色,后续如果需要更细粒度权限,再加权限表或 RBAC 表。
5.3 订单列表和统计
后台需要的基础接口包括:
GET /api/admin/orders?status=paid&page=1:分页查询订单。GET /api/admin/summary:统计总订单数、总收入、今日支付数。POST /api/admin/orders/:id/close:手动关闭超时订单。
订单列表的 SQL 要关联用户表,显示用户邮箱:
SELECT o.id, o.amount, o.status, o.created_at, u.email FROM orders o LEFT JOIN users u ON u.id = o.user_id ORDER BY o.created_at DESC LIMIT 20 OFFSET ?;后台页面不要把所有数据一次查出来,必须分页。免费额度下如果请求量一大,全表查询会消耗大量 D1 读行数,也影响响应速度。
5.4 配置项放在哪里
一个 SaaS 项目的配置有三种存放位置,适合的场景不同:
| 存放位置 | 适合内容 | 注意事项 |
|---|---|---|
| 环境变量 | 支付密钥、Turnstile Secret、数据库 ID | 敏感信息,禁止提交到 Git |
| D1 数据库 | 商品价格、渠道开关、页面文案 | 适合后台可修改,需要迁移 |
| KV 缓存 | 频繁读取且变化较慢的配置 | 可以减少数据库读,但要注意过期时间 |
后台管理商品价格时,不要把价格直接写死在代码里。商品表已经设计了price字段,后台维护商品数据,前台通过接口读取商品列表。
5.5 后台权限隔离
再提醒一次:后台路由如果只做前端隐藏,用户手动访问/admin/orders仍然会拿到数据。判断权限必须以服务端为准,前端隐藏菜单只是体验优化。数据库里每个订单也要按用户维度控制,不能让普通用户通过拼接接口参数查看别人的订单。
6. 部署到 Cloudflare 并验证收款链路
6.1 创建远程数据库并执行迁移
本地调试通过后,创建远程 D1 数据库:
npx wrangler d1 create cf-saas-db创建成功后,CLI 会输出database_id,把它填到wrangler.toml。然后执行迁移:
npx wrangler d1 execute cf-saas-db --remote --file=db/schema.sql这里的--remote表示操作线上数据库。如果不加,命令只操作本地数据库,线上环境不会变化。迁移后可以验证表是否存在:
npx wrangler d1 execute cf-saas-db --remote --command "SELECT name FROM sqlite_master WHERE type='table'"6.2 部署 Worker 和设置密钥
执行部署:
npx wrangler deploy首次部署后,Cloudflare 会返回一个*.workers.dev域名。这个域名可以直接用来测试回调,但生产环境建议绑定自定义域名。支付平台回调地址通常要求 HTTPS,workers.dev自带 HTTPS,只是看起来不够正规。
设置机密变量:
npx wrangler secret put TURNSTILE_SECRET npx wrangler secret put PAYMENT_PRIVATE_KEYwrangler secret设置的变量不会出现在代码仓库,也不会出现在页面源代码中,适合保存密钥。
6.3 部署前端到 Pages
在 Cloudflare Dashboard 中创建 Pages 项目,连接 Git 仓库。前端构建配置为:
- 构建命令:
npm run build - 输出目录:
dist - 环境变量:
VITE_API_BASE=https://你的-worker域名
前端部署后,需要把前端域名回填到 Worker 的CORS_ALLOW_ORIGIN环境变量里,否则浏览器跨域请求会被拦截。
6.4 完整验证清单
验证不能只看页面能不能打开,要按用户路径完整走一遍:
- 访问前端首页,商品列表正常展示。
- 注册新账号,邮箱未重复,密码不过于简单。
- 登录后,进入个人中心。
- 选择商品,创建订单,订单状态为
pending。 - 使用模拟支付或沙箱支付,完成支付。
- 查看回调日志,订单状态变为
paid。 - 后台以 admin 登录,订单列表能看到这笔订单。
表格化验证清单:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 打开前端首页 | 商品列表加载 |
| 2 | 注册账号 | 注册成功,返回 token |
| 3 | 登录账号 | 进入个人中心 |
| 4 | 创建订单 | 订单状态为 pending |
| 5 | 模拟支付 | 订单状态变为 paid |
| 6 | 回调通知 | 服务端日志出现 notify 记录 |
| 7 | 后台查看 | 后台订单列表展示该订单 |
6.5 正式收款前必须替换的配置
模拟链路跑通后,正式收款不能直接用 mock 和沙箱。逐项替换:
- 支付网关:从 mock / 沙箱换成真实商户渠道。
- 商户号:换成自己的支付宝或微信商户号。
- 回调域名:在支付平台后台配置为线上域名。
- 密钥:重新生成支付私钥、公钥和应用密钥,不要沿用公开示例。
- 金额单位:确认所有价格字段都使用分为单位。
- DNS 与备案:如果绑定自定义域名,要按运营地区要求完成解析和备案。
注意:在真实支付配置下,每一笔测试支付都会产生真实资金。上线前建议用 1 元或最低金额测试一次即可,避免产生大量测试订单。
7. 常见问题与排错链路
7.1 登录成功但请求带 token 仍返回 401
现象:登录返回了 token,但调业务接口时提示未认证。
排查顺序:
- 检查请求头是否真的带上了
Authorization: Bearer <token>。 - 检查 token 是否在 session 表中存在。
- 检查 token 是否过期。
- 检查中间件是不是写错了路由匹配规则,比如保护了登录接口本身。
常见原因是在前端 Axios 拦截器里没有读取最新 token,或者从 Pinia 中取值时用了错误 key。建议打开浏览器控制台 Network 面板,直接看请求头。
7.2 支付回调验签失败
支付回调验签失败时,先看日志里是否记录了原始回调参数和验签结果。可能原因:
- 私钥和公钥不匹配。
- 签名原串拼接顺序不对。
- 参数包含中文或特殊字符,编码不一致。
- 时间戳过期,支付平台拒绝请求。
- 使用了浏览器插件或代理篡改请求。
用支付平台提供的官方验签工具验证同一份回调数据,先确认“数据是否真实”。如果官方工具也验签失败,说明回调参数或密钥配置有问题;如果官方工具能成功,说明代码里签名验证逻辑有问题。
7.3 D1 表不存在或数据库写入失败
现象:查询接口报错,日志提示no such table: orders。
可能原因:
- 远程 D1 没有执行迁移。
wrangler.toml里的database_id不正确。- 迁移执行了,但是对本地库执行的,不是远程库。
- SQL 里没有使用
CREATE TABLE IF NOT EXISTS,重复执行时报错。
检查方式:
npx wrangler d1 execute cf-saas-db --remote --command "SELECT name FROM sqlite_master WHERE type='table'"如果命令报错,先确认数据库名称和database_id是否匹配。生产环境的迁移最好写成独立迁移文件,统一在发布流程中执行,不要手工改线上表。
7.4 Cloudflare 免费额度超限
现象:请求返回1020或1015,或者 Cloudflare Dashboard 显示使用量接近上限。
排查步骤:
- 进入 Cloudflare Dashboard,查看 Workers 请求量、D1 读行数、KV 读写次数。
- 查看有没有异常循环请求或定时任务频繁触发。
- 检查前端是否有自动轮询接口,轮询频率是否过高。
- 对不需要高频请求的接口,增加客户端缓存。
免费额度适合低流量 MVP,不能支撑大规模生产环境。如果产品开始有真实用户,要提前评估付费计划,或者把高频接口拆分到更合适的存储。
7.5 CORS 报错
前后端分离部署时,最常见报错是:
Access to XMLHttpRequest at 'https://api.example.com' from origin 'https://web.example.com' has been blocked by CORS policy原因是 Worker 没有配置允许来源。Hono 可以使用cors中间件:
import { cors } from 'hono/cors' app.use('/api/*', cors({ origin: c.env.ALLOWED_ORIGIN || 'http://localhost:5173' }))生产环境不要把origin设置为*,否则任何网站都可以调用你的接口,造成越权和刷单风险。只放行自己的前端域名。
7.6 排错速查表
| 问题现象 | 常见原因 | 检查方式 | 解决建议 |
|---|---|---|---|
| 登录 401 | token 未传递或过期 | 查看请求头、session 表 | 修正拦截器或延长会话 |
| 回调失败 | 回调地址不可达或验签失败 | 查看回调日志、公网测试 | 配置公网 HTTPS 地址 |
| 订单未变 paid | 回调未处理或金额不一致 | 查询订单和回调记录 | 核对金额、检查幂等 |
| D1 表缺失 | 迁移未执行 | 远程查询表名单 | 执行迁移命令 |
| CORS 报错 | 未配置白名单 | 浏览器 Network 面板 | 添加 CORS 中间件 |
| 免费额度超限 | 请求量过大 | Dashboard 用量 | 优化缓存或升级计划 |
8. 生产环境最佳实践与后续扩展
8.1 安全底线
支付类 SaaS 的安全底线,不是“功能做得多炫”,而是“别人能不能绕过你的流程获取利益”。以下几点必须做到:
- 金额只信任服务端,前端只传
productId,不传最终价格。 - 支付回调必须验签,必须核对订单号和金额。
- 用户密码必须哈希存储,不使用 MD5、SHA1。
- 后台接口必须做角色校验,不能只靠前端隐藏路由。
- 密钥只放环境变量,严禁写进
wrangler.toml并提交到 Git。 - 与支付平台通信统一使用 HTTPS,不跳过证书校验。
8.2 数据备份与迁移
D1 是托管数据库,但也要有备份意识。上线前要确认 Cloudflare 的备份和快照功能是否满足需求。如果没有自动备份,可以写一个定时 Worker,每天把关键表导出到 R2。
迁移流程要固定:先在本地执行迁移,再在 Preview 环境验证,最后对远程生产库执行。不要让开发者直接登录线上库手工改数据,否则出问题后很难追溯。
8.3 日志、监控与告警
Workers 控制台可以查看实时日志,但免费层日志保留时间有限。支付接口必须单独记录日志,至少包含:
- 原始回调参数。
- 验签结果。
- 订单号、订单金额、回调金额。
- 幂等处理结果。
后续可以增加告警:订单变成了paid但没有关联用户,或者同一订单收到多次回调,都值得关注。
8.4 这个开源骨架可以继续扩展的方向
最小闭环跑通之后,可以在同一套架构上继续扩展:
- 接入 GitHub、Google、微信扫码登录。
- 增加订阅制与周期扣款,不只是单次购买。
- 增加邮件模板,支付成功后自动发送凭证。
- 增加订单超时定时任务,自动关闭
pending订单。 - 增加多租户,让不同企业有独立空间和独立用户。
- 使用队列处理支付成功后的开通权限、发送通知等延迟任务。
真正重要的是先把“用户下单 -> 支付回调 -> 后台确认”这条链路吃透。开发者的价值不在于把登录和支付按钮堆出来,而在于理解订单状态为什么只能由服务端推进,回调验签为什么不能跳过,后台权限为什么必须从接口层拦截。把这个最小闭环跑通以后,再讨论增长、营销和复杂业务逻辑,才不会让项目停留在页面展示阶段。