news 2026/9/2 18:38:49

基于Cloudflare免费额度快速搭建可收款SaaS完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Cloudflare免费额度快速搭建可收款SaaS完整指南

做小 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"

这里的SESSIONDB是绑定名称,会在代码中通过env.SESSIONenv.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 支付网关选型

登录完成之后,下一步是“能收钱”。这一步涉及的是资金和交易,比登录更需要谨慎。

无论选择哪家支付服务商,流程基本是一样的:

  1. 用户在页面点击购买。
  2. 后端创建本地订单,状态为pending
  3. 后端调用支付网关的创建预支付接口,拿到支付参数。
  4. 前端用支付参数跳转到收银台或拉起支付工具。
  5. 用户在支付机构完成支付。
  6. 支付机构通过异步通知(Webhook)把支付结果推到你的回调地址。
  7. 后端校验签名和金额,更新订单状态,开通用户权益。

关键原则是:支付成功与否,必须以支付机构的服务端回调为准,不能只凭前端提示或页面跳转判断。

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"); }

回调处理的逻辑可以总结为四步:

  1. 验签,确认通知确实来自支付机构。
  2. 查订单,确认存在且未支付。
  3. 对金额,防止回调金额与本地订单不一致。
  4. 更新状态并开通权益,必须做幂等处理,因为支付机构可能会重复推送通知。

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_idstatus建索引,可以加快按用户查订单和按状态筛选的效率。

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-
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 18:33:34

问卷调查模拟数据实战:从解压检查到数据分析与生成

简介&#xff1a;问卷调查模拟数据2.rar是一套聚焦问卷调查场景的完整项目资源&#xff0c;面向数据分析、JSP/Java Web开发及问卷系统学习者。压缩包共含1388个文件&#xff0c;整体约19.2MB&#xff0c;文件类型覆盖jsp、js、css、html等前端资源&#xff0c;class、jar、sql…

作者头像 李华
网站建设 2026/9/2 18:32:44

OpenAI 用数万台 Mac 训练操作电脑的 AI 智能体

先给结论&#xff1a;这条消息的核心不是“OpenAI 买了几万台 Mac”&#xff0c;而是“OpenAI 准备用大量真实 Mac 设备来训练能操作电脑的 AI 智能体”。这说明智能体训练的重心正在从纯文本对话、API 调用&#xff0c;转向真正接管图形界面里的鼠标和键盘。买的是 Mac mini 还…

作者头像 李华
网站建设 2026/9/2 18:32:35

从黄仁勋的“低期望值”哲学看技术团队的务实工程思维

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/2 18:27:41

AI十年之路:在拥挤的赛道中寻找有效路径与差异化价值

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/2 18:24:27

纯Go实现Pydantic规则引擎:monty-go让多语言数据校验保持一致

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/2 18:24:16

从分层架构到Spring Boot实战:构建清晰可维护的代码结构

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华