用Cloudflare免费额度搭建可收款SaaS:登录、支付与后台完整闭环
2026/9/2 3:39:10 网站建设 项目流程

在实际业务里,能收钱的 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 login

npx wrangler login会在浏览器打开 Cloudflare 授权页,登录成功后本地 CLI 会保存临时凭证。这一步完成后,后续部署和数据库操作才能使用你的账号权限。

2.2 初始化 Worker 与前端项目

推荐把项目拆成apiweb两个目录,一个负责接口,一个负责页面。常见的初始化命令如下,具体参数以你使用的 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-validator

Hono 是一个适合边缘运行的 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 dev

Wrangler 会在本地启动 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 模块。骨架里默认提供hashPasswordverifyPassword两个函数,生产环境建议换成 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 支付流程拆解

支付不只是“调一个接口返回跳转链接”,而是需要处理四个环节:

  1. 下单:用户选择商品,服务端创建订单,状态为pending
  2. 发起支付:服务端调用支付平台接口,返回跳转链接或二维码。
  3. 回调通知:支付平台异步通知服务端,服务端验签并更新订单。
  4. 结果查询:前端跳转回业务页面后,调用查询接口确认订单最终状态。

这个链路里最重要的是“服务端确认到账”,而不是“客户端说支付成功”。前端可以跳转支付,但订单状态只能由回调或服务端主动查询来修改。

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.vue

5.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。最小系统里可以只用adminuser两种角色,后续如果需要更细粒度权限,再加权限表或 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_KEY

wrangler secret设置的变量不会出现在代码仓库,也不会出现在页面源代码中,适合保存密钥。

6.3 部署前端到 Pages

在 Cloudflare Dashboard 中创建 Pages 项目,连接 Git 仓库。前端构建配置为:

  • 构建命令:npm run build
  • 输出目录:dist
  • 环境变量:VITE_API_BASE=https://你的-worker域名

前端部署后,需要把前端域名回填到 Worker 的CORS_ALLOW_ORIGIN环境变量里,否则浏览器跨域请求会被拦截。

6.4 完整验证清单

验证不能只看页面能不能打开,要按用户路径完整走一遍:

  1. 访问前端首页,商品列表正常展示。
  2. 注册新账号,邮箱未重复,密码不过于简单。
  3. 登录后,进入个人中心。
  4. 选择商品,创建订单,订单状态为pending
  5. 使用模拟支付或沙箱支付,完成支付。
  6. 查看回调日志,订单状态变为paid
  7. 后台以 admin 登录,订单列表能看到这笔订单。

表格化验证清单:

步骤操作预期结果
1打开前端首页商品列表加载
2注册账号注册成功,返回 token
3登录账号进入个人中心
4创建订单订单状态为 pending
5模拟支付订单状态变为 paid
6回调通知服务端日志出现 notify 记录
7后台查看后台订单列表展示该订单

6.5 正式收款前必须替换的配置

模拟链路跑通后,正式收款不能直接用 mock 和沙箱。逐项替换:

  • 支付网关:从 mock / 沙箱换成真实商户渠道。
  • 商户号:换成自己的支付宝或微信商户号。
  • 回调域名:在支付平台后台配置为线上域名。
  • 密钥:重新生成支付私钥、公钥和应用密钥,不要沿用公开示例。
  • 金额单位:确认所有价格字段都使用分为单位。
  • DNS 与备案:如果绑定自定义域名,要按运营地区要求完成解析和备案。

注意:在真实支付配置下,每一笔测试支付都会产生真实资金。上线前建议用 1 元或最低金额测试一次即可,避免产生大量测试订单。

7. 常见问题与排错链路

7.1 登录成功但请求带 token 仍返回 401

现象:登录返回了 token,但调业务接口时提示未认证。

排查顺序:

  1. 检查请求头是否真的带上了Authorization: Bearer <token>
  2. 检查 token 是否在 session 表中存在。
  3. 检查 token 是否过期。
  4. 检查中间件是不是写错了路由匹配规则,比如保护了登录接口本身。

常见原因是在前端 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 免费额度超限

现象:请求返回10201015,或者 Cloudflare Dashboard 显示使用量接近上限。

排查步骤:

  1. 进入 Cloudflare Dashboard,查看 Workers 请求量、D1 读行数、KV 读写次数。
  2. 查看有没有异常循环请求或定时任务频繁触发。
  3. 检查前端是否有自动轮询接口,轮询频率是否过高。
  4. 对不需要高频请求的接口,增加客户端缓存。

免费额度适合低流量 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 排错速查表

问题现象常见原因检查方式解决建议
登录 401token 未传递或过期查看请求头、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订单。
  • 增加多租户,让不同企业有独立空间和独立用户。
  • 使用队列处理支付成功后的开通权限、发送通知等延迟任务。

真正重要的是先把“用户下单 -> 支付回调 -> 后台确认”这条链路吃透。开发者的价值不在于把登录和支付按钮堆出来,而在于理解订单状态为什么只能由服务端推进,回调验签为什么不能跳过,后台权限为什么必须从接口层拦截。把这个最小闭环跑通以后,再讨论增长、营销和复杂业务逻辑,才不会让项目停留在页面展示阶段。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询