做小 SaaS 最痛苦的往往不是业务逻辑,而是那些绕不开的“地基”:注册登录、支付回调、管理后台。买服务器、配 HTTPS、设计用户表、处理订单状态、写一个能看数据的后台……这些工作叠加起来,足够把一个晚上拖成一周。
后来我把这套东西整体迁移到 Cloudflare 免费额度上跑,发现效率提升非常明显:前端用 Pages 托管,API 和鉴权交给 Workers,数据存储用 D1 和 KV,登录走 OAuth,支付通过第三方支付网关完成。整套链路不需要自己维护服务器,也没有复杂的运维负担。本文就把这套“一个晚上上线一个能收钱的 SaaS”的完整方案拆解出来,覆盖登录认证、支付接入、后台管理、部署上线的全过程。
如果你是独立开发者、正在做 side project,或者想快速验证一个 SaaS 想法,这篇文章会比较适合你。
1. 背景与核心概念
1.1 一个能收钱的 SaaS 最少需要什么
抛开具体业务,一个能在线收费的 SaaS 至少需要四个部分:
- 登录认证:识别用户身份,让用户有自己的账号空间。
- 支付能力:创建订单、拉起支付、接收支付结果回调。
- 业务后台:查看用户、订单、收入,甚至给用户配置权限。
- 数据库:持久化用户信息、订单记录、订阅状态。
很多人在开始时容易犯一个错误:想先把所有功能都做好,再考虑收费。但现实是,一个 MVP 应该优先把“用户注册 → 选择套餐 → 完成支付 → 自动开通权益”这条闭环跑通。业务功能可以后加,但这条闭环越早验证越好。
1.2 IaaS、PaaS、SaaS 到底有什么区别
这三个概念经常一起出现,但含义差别很大:
| 类别 | 用户管理范围 | 典型示例 | 适用场景 |
|---|---|---|---|
| IaaS | 虚拟机、网络、存储等基础设施都要自己管 | 云服务器 | 需要完全掌控底层环境,团队有运维能力 |
| PaaS | 只管应用代码和少量配置,运行时由平台提供 | Cloudflare Workers | 希望快速部署应用,不想维护基础设施 |
| SaaS | 直接使用软件能力,连部署都不用关心 | 在线文档、CRM | 面向业务人员或作为最终产品交付 |
本文要搭建的 SaaS 是最终产品,而 Cloudflare Workers 其实更接近 PaaS——它把运行环境、负载均衡、HTTPS 等底层问题都屏蔽掉了,开发者只需要关心业务代码怎么写。
1.3 为什么选择 Cloudflare 免费额度
选择 Cloudflare 免费额度作为落地环境,主要看中几点:
- 不用买服务器,不用装 Nginx,不用管证书到期。
- Pages 可以托管前端静态资源,自带 CDN。
- Workers 可以运行后端逻辑,处理接口请求。
- D1 提供 SQLite 兼容的数据库,适合存储用户和订单。
- KV 适合存会话状态、临时凭证这类键值数据。
- 免费有额度和限制,但支撑一个小型 MVP 通常足够。
需要特别说明的是,Cloudflare 的免费套餐内容会随官方政策调整,本文不是报价单,具体额度请以 Cloudflare 官方最新页面为准。
2. 环境准备与版本说明
2.1 需要准备的账号
实操之前,先准备好下面几类账号:
- Cloudflare 账号,用于创建 Workers、Pages、D1、KV。
- GitHub 账号,本文的登录示例以 GitHub OAuth 为例。
- 支付服务商账号,用于创建支付应用、获取密钥、配置回调地址。
支付部分需要特别强调合规性:SaaS 要在线收款,必须对接合法合规、有支付牌照或有正规资质的支付机构,例如支付宝、微信支付、Stripe,或者通过成熟的技术服务商接入。本文的代码会把这部分抽象成统一接口,你可以按自己所在地区和业务场景选择合适的服务商。
2.2 本地开发环境
本地需要安装以下工具:
- Node.js 18 或更高版本,具体以 Cloudflare 官方对 Workers 的要求为准。
- npm 或 pnpm,用于管理依赖。
- git,用于代码版本管理。
- Wrangler CLI,Cloudflare 官方命令行工具。
Wrangler 可以使用 npm 全局安装,也可以在项目里通过npx调用。本文统一使用npx wrangler的方式,避免版本混乱。
2.3 版本说明
由于 Cloudflare 产品迭代很快,文章示例中的命令和配置可能不是最新格式。安装时请留意终端提示和官方文档,以当前稳定版本为准。重点理解每个命令和参数的含义,版本差异并不会影响整体思路。
3. 整体架构设计
3.1 架构总览
先看整体结构:
浏览器 | |--- Cloudflare Pages:前端静态页面 |--- Cloudflare Workers:API、登录、支付回调、后台接口 | |--- D1:用户表、订单表、订阅表 |--- KV:会话状态、临时凭证 |--- GitHub OAuth:第三方登录 |--- 支付网关:创建预支付单、接收支付结果回调这个架构的核心思路是:静态资源与动态接口分离。页面资源走 Pages,面向用户的接口和内部回调走 Workers。
3.2 为什么把前端和 API 分开
Pages 和 Workers 是 Cloudflare 免费额度里两个互补的产品。
Pages 适合托管纯静态文件,构建一次之后由 Cloudflare CDN 分发到全球边缘节点,用户访问快,也没有服务器成本。Workers 则运行在边缘环境,可以处理动态逻辑,连接数据库、调用外部 API、读取请求头、设置 Cookie 都支持。
把两者分开,还有一个好处:前端页面可以随时重新构建,不影响 API 接口;后端逻辑改动时也不用重新上传整个前端资源。
3.3 项目目录规划
下面是一个推荐的项目结构:
my-saas/ ├── wrangler.toml # Workers 配置 ├── package.json ├── schema.sql # D1 数据库建表脚本 ├── src/ │ ├── index.js # Worker 入口,路由分发 │ ├── auth.js # 登录、回调、会话管理 │ ├── pay.js # 下单、支付回调、验签 │ ├── admin.js # 后台接口 │ └── db.js # D1 数据库封装 └── frontend/ ├── index.html # 官网或落地页 ├── dashboard.html # 用户控制台 └── admin.html # 管理后台业务复杂之后可以继续拆分成更多模块,但 MVP 阶段这个结构已经足够了。
4. 登录认证模块
4.1 登录方案选择
登录是所有 SaaS 的第一步。常见方案有三种:
- 自己实现账号密码注册登录。
- 使用 OAuth 第三方登录,例如 GitHub、Google、微信等。
- 使用现成认证服务,例如 Auth.js、Userbase 等。
自己做账号体系最灵活,但要处理密码加密、找回密码、邮箱验证、会话过期等一系列问题。对于“一个晚上上线”的目标来说,使用 OAuth 登录效率最高,用户也省去了注册成本。
本文以 GitHub OAuth 为例说明,换成其他 OAuth Provider 的原理是一样的。
4.2 初始化 Workers 项目
先创建项目:
npm create cloudflare@latest my-saas cd my-saas npm install创建过程中,CLI 会询问你要使用什么模板。可以选择 “Hello World” 模板,后续直接把src/index.js替换成自己的代码。
创建 KV 命名空间和 D1 数据库:
npx wrangler kv namespace create SESSION npx wrangler d1 create my-saas这两条命令执行后会输出对应的id,需要填到wrangler.toml中。
4.3 配置 wrangler.toml
wrangler.toml是 Worker 的核心配置文件:
name = "my-saas" main = "src/index.js" compatibility_date = "2024-11-01" [[kv_namespaces]] binding = "SESSION" id = "这里填 KV 命名空间 ID" [[d1_databases]] binding = "DB" database_name = "my-saas" database_id = "这里填 D1 数据库 ID" [vars] APP_NAME = "My SaaS" FRONTEND_ORIGIN = "https://my-saas.pages.dev"这里的SESSION和DB是绑定名称,会在代码中通过env.SESSION、env.DB访问。变量名可以根据喜好修改,但代码中的引用要保持一致。
4.4 登录与回调实现
在src/auth.js中实现登录逻辑。
第一步,发起登录,生成state参数并存入 KV:
// src/auth.js export async function handleLogin(request, env) { const state = crypto.randomUUID(); const authUrl = new URL("https://github.com/login/oauth/authorize"); authUrl.searchParams.set("client_id", env.GITHUB_CLIENT_ID); authUrl.searchParams.set("redirect_uri", env.REDIRECT_URI); authUrl.searchParams.set("scope", "read:user user:email"); authUrl.searchParams.set("state", state); await env.SESSION.put(`oauth:${state}`, "pending", { expirationTtl: 600 }); return Response.redirect(authUrl.toString(), 302); }这里的state参数非常重要,它可以防止 CSRF 攻击。用户带着回调地址返回时,服务端必须校验这个state是否是自己之前生成的,否则攻击者可能诱导用户登录攻击者账户。
第二步,处理回调,用授权码换取用户信息:
export async function handleCallback(request, env) { const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const expected = await env.SESSION.get(`oauth:${state}`); if (!code || !state || !expected) { return new Response("登录失败,请重新发起", { status: 400 }); } const tokenRes = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, client_secret: env.GITHUB_CLIENT_SECRET, code, }), }); const tokenData = await tokenRes.json(); if (!tokenData.access_token) { return new Response("获取 access_token 失败", { status: 400 }); } const userRes = await fetch("https://api.github.com/user", { headers: { "Authorization": `Bearer ${tokenData.access_token}`, "User-Agent": "my-saas-tutorial", }, }); const user = await userRes.json(); const sessionId = crypto.randomUUID(); const sessionData = { userId: String(user.id), login: user.login, email: user.email || "", }; await env.SESSION.put(`session:${sessionId}`, JSON.stringify(sessionData), { expirationTtl: 60 * 60 * 24 * 7, }); const response = new Response(null, { status: 302 }); response.headers.set("Location", "/dashboard"); response.headers.set( "Set-Cookie", `session=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800` ); return response; }这段代码有几个要点:
- 授权码
code是一次性的,用完之后立即换access_token。 - 会话 ID 通过 Cookie 返回给浏览器,
HttpOnly防止 JavaScript 读取,Secure确保只在 HTTPS 下传输。 - 会话数据放在 KV 中,设置了 7 天过期时间。
- 用户信息并不直接放在 Cookie 里,Cookie 里只有会话 ID,用户数据始终从服务端获取。
4.5 会话校验
业务接口需要知道当前请求是哪个用户,所以封装一个获取当前用户的方法:
export async function getCurrentUser(request, env) { const cookieHeader = request.headers.get("Cookie") || ""; const match = cookieHeader.match(/(?:^|;\s*)session=([^;]+)/); if (!match) return null; const raw = await env.SESSION.get(`session:${match[1]}`); if (!raw) return null; try { return JSON.parse(raw); } catch (e) { return null; } }在需要登录的接口中,先调用getCurrentUser,如果返回null就返回 401,让前端跳转登录页。
5. 支付能力接入
5.1 支付网关选型
登录完成之后,下一步是“能收钱”。这一步涉及的是资金和交易,比登录更需要谨慎。
无论选择哪家支付服务商,流程基本是一样的:
- 用户在页面点击购买。
- 后端创建本地订单,状态为
pending。 - 后端调用支付网关的创建预支付接口,拿到支付参数。
- 前端用支付参数跳转到收银台或拉起支付工具。
- 用户在支付机构完成支付。
- 支付机构通过异步通知(Webhook)把支付结果推到你的回调地址。
- 后端校验签名和金额,更新订单状态,开通用户权益。
关键原则是:支付成功与否,必须以支付机构的服务端回调为准,不能只凭前端提示或页面跳转判断。
5.2 创建订单接口
在src/pay.js中实现下单逻辑:
// src/pay.js const PRICE_MAP = { pro: 1990, premium: 4990, }; export async function createOrder(request, env) { const user = await getCurrentUser(request, env); if (!user) { return new Response("未登录", { status: 401 }); } const body = await request.json(); const plan = body.plan; const amount = PRICE_MAP[plan]; if (!amount) { return new Response("套餐不存在", { status: 400 }); } const orderId = crypto.randomUUID().replace(/-/g, ""); await env.DB.prepare( "INSERT INTO orders (id, user_id, plan, amount, status, created_at) VALUES (?, ?, ?, ?, 'pending', ?)" ) .bind(orderId, user.userId, plan, amount, Date.now()) .run(); const payParams = await gatewayCreate({ orderId, amount, subject: `订阅 ${plan} 套餐`, }); return new Response( JSON.stringify({ orderId, payParams }), { headers: { "Content-Type": "application/json" } } ); }注意几个细节:
amount以最小货币单位存储,例如分或美分,避免浮点数精度问题。- 价格表必须在服务端定义,不能从前端传入价格,否则用户可以直接改包名和价格。
orderId用 UUID 去掉横杠,作为本地订单号,同时传给支付网关。
gatewayCreate是示意函数,不同的支付机构参数差异很大,需要按具体服务商的 API 实现。核心是返回给前端一个可用于拉起支付的payParams,可能是一个跳转 URL,也可能是一串签名后的参数。
5.3 支付回调处理
支付回调是整个支付链路里最需要小心的接口:
export async function handlePaymentNotify(request, env) { const body = await request.text(); const signOk = verifyGatewaySign(request, body, env); if (!signOk) { return new Response("签名校验失败", { status: 400 }); } const result = parseGatewayNotify(body); if (result.tradeStatus !== "SUCCESS") { return new Response("success"); } const order = await env.DB.prepare( "SELECT * FROM orders WHERE id = ?" ) .bind(result.orderId) .first(); if (!order || order.status !== "pending") { return new Response("success"); } if (Number(order.amount) !== Number(result.amount)) { return new Response("success"); } await env.DB.prepare( "UPDATE orders SET status = 'paid', paid_at = ? WHERE id = ?" ) .bind(Date.now(), result.orderId) .run(); await enablePlanForUser(env, order.user_id, order.plan); return new Response("success"); }回调处理的逻辑可以总结为四步:
- 验签,确认通知确实来自支付机构。
- 查订单,确认存在且未支付。
- 对金额,防止回调金额与本地订单不一致。
- 更新状态并开通权益,必须做幂等处理,因为支付机构可能会重复推送通知。
enablePlanForUser负责把套餐写入subscriptions表,并记录过期时间。这里同样要保证多次执行不会重复扣权益或重复延期。
5.4 前端拉起支付
前端只需要创建一个订单,然后根据返回的payParams跳转:
<button id="buyPro">升级 Pro</button> <script> document.querySelector("#buyPro").addEventListener("click", async () => { const res = await fetch("/api/pay/create-order", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ plan: "pro" }), }); const data = await res.json(); if (!data.payParams) { alert("创建订单失败"); return; } window.location.href = data.payParams.payUrl; }); </script>如果你的页面是纯静态 HTML,这里可以直接使用fetch,不需要引入任何 SDK。
6. 管理后台搭建
6.1 后台界面方案
管理后台不需要一开始就做得非常花哨。MVP 阶段可以直接用 HTML 页面 + 接口访问,登录之后查看订单列表和收入统计。
更复杂的场景可以上 React、Vue,但这会引入前端工程化成本。先把核心接口做好,后台页面后续随时可以替换。
6.2 D1 数据库表设计
在schema.sql中创建三张表:
-- schema.sql CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, login TEXT NOT NULL, email TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, plan TEXT NOT NULL, amount INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, paid_at INTEGER ); CREATE TABLE IF NOT EXISTS subscriptions ( user_id TEXT PRIMARY KEY, plan TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);users表存用户信息,orders表存每一笔订单,subscriptions表存当前生效的套餐。
给orders表的user_id和status建索引,可以加快按用户查订单和按状态筛选的效率。
6.3 后台接口实现
后台接口重点是权限控制。在src/admin.js中,每次请求都要先判断当前用户是否为管理员:
const ADMIN_LOGIN = "your-admin-login"; export async function handleAdmin(request, env) { const user = await getCurrentUser(request, env); if (!user || user.login !== ADMIN_LOGIN) { return new Response("无权访问", { status: 403 }); } const url = new URL(request.url); if (url.pathname === "/api/admin/stats") { const row = await env.DB.prepare( "SELECT COUNT(*) AS total_orders, " + "SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS revenue " + "FROM orders" ).first(); return new Response(JSON.stringify(row), { headers: { "Content-Type": "application/json" }, }); } if (url.pathname === "/api/admin/orders") { const { results } = await env.DB.prepare( "SELECT * FROM orders ORDER BY created_at DESC LIMIT 100" ).all(); return new Response(JSON.stringify(results), { headers: { "Content-Type": "application/json" }, }); } return new Response("Not Found", { status: 404 }); }管理员判断最简单的方式是维护一个管理员名单。生产环境可以把管理员名单放到 KV 或 D1 的配置表中,避免每次改代码。
后台页面用fetch调用这些接口即可:
fetch("/api/admin/stats") .then(r => r.json()) .then(data => { document.querySelector("#revenue").textContent = data.revenue; });6.4 路由入口整合
在src/index.js中把所有路由统一管理:
import { handleLogin, handleCallback, getCurrentUser } from "./auth"; import { createOrder, handlePaymentNotify } from "./pay"; import { handleAdmin } from "./admin"; export default { async fetch(request, env) { const url = new URL(request.url); const path = url.pathname; if (path === "/api/login") { return handleLogin(request, env); } if (path === "/api/auth/callback") { return handleCallback(request, env); } if (path === "/api/me") { const user = await getCurrentUser(request, env); if (!user) return new Response("未登录", { status: 401 }); return new Response(JSON.stringify(user), { headers: { "Content-Type": "application/json" }, }); } if (path === "/api/pay/create-order") { return createOrder(request, env); } if (path === "/api/pay/notify") { return handlePaymentNotify(request, env); } if (path.startsWith("/api/admin")) { return handleAdmin(request, env); } return new Response("服务正常", { status: 200 }); }, };路由集中管理的好处是,新增接口不需要改动入口文件以外的结构,整个项目的调用关系一目了然。
7. 部署到 Cloudflare 免费额度
7.1 初始化 D1 数据库
本地编写好schema.sql之后,需要把表结构同步到远端数据库:
npx wrangler d1 execute my-saas --remote --file=./schema.sql这里的my-saas需要替换成你在wrangler.toml中配置的database_name。
执行成功后,可以登录 Cloudflare 控制台,在 Workers 的 D1 管理页面看到三张表。
7.2 配置密钥
不要把GITHUB_CLIENT_SECRET、支付密钥这类敏感信息直接写在wrangler.toml或代码里。正确方式是通过 wrangler 的 secret 功能注入:
npx wrangler secret put GITHUB_CLIENT_ID npx wrangler secret put GITHUB_CLIENT_SECRET npx wrangler secret put PAYMENT_SECRET执行后终端会提示输入对应值,输入后回车即可。
本地开发时,可以在项目根目录创建.dev.vars文件存放这些变量:
GITHUB_CLIENT_ID=your_dev_value GITHUB_CLIENT_SECRET=your_dev_secret PAYMENT_SECRET=your_dev_secret注意把.dev.vars加入.gitignore,避免误提交。
7.3 部署 Worker
一切就绪后,运行:
npx wrangler deploy部署成功后,终端会输出一个workers.dev域名,这就是你的 API 地址。
7.4 部署前端
前端如果是纯静态页面,可以构建后上传到 Pages:
cd frontend npm run build npx wrangler pages deploy dist --project-name=my-saas-frontend如果你的前端项目没有构建步骤,直接把 HTML 文件放在一个目录下,用npx wrangler pages deploy 该目录 --project-name=my-saas-frontend即可。
7.5 配置自定义域名和同源访问
免费额度下,Pages 和 Workers 默认域名是不同的,如果前端页面直接调用 Worker 接口,会面临跨域问题。有两种解决方式:
第一种,是给 Worker 绑定自定义域名。假设你的 API 域名是api.example.com,前端是app.example.com,在 Cloudflare 控制台给 Worker 添加自定义域名后,前端接口地址就变成同协议、不同子域。这样做比开启 CORS 更干净,也方便后续管理。
第二种,是在 Worker 代码里手动设置 CORS 响应头。需要把允许来源白名单写死,不能直接用*,因为携带 Cookie 的请求要求明确指定来源。
function withCors(response) { const newRes = new Response(response.body, response); newRes.headers.set("Access-Control-