
壁纸销售这个赛道说大不大说小不小。之前我一直在用 WordPress 卖壁纸主题包每个月服务器费用、CDN、数据库加一起要烧掉二三百块而且高峰期经常被浏览器插件一抓就挂。后来把整站迁移到了 Next.js Supabase Cloudflare R2 这套组合上跑了大半年月成本压到了接近 0 美元稳定性反而比之前强了不少。这篇文章我把从选型、建表、存储、鉴权到支付闭环的完整过程拆开讲一遍中间踩过的坑也会一并列出来尤其是 Supabase 连接被重置那个问题折腾了我整整一个周末。先说清楚这套方案的适用场景。如果你打算做的是壁纸、模板、字库、素材包这类文件小、数量多、下载频繁的数字商品站点这套技术栈非常合适。但如果你是做视频课程、大型软件安装包这种动辄几个 GB 的交付物R2 的免费额度就不够看了得重新考虑存储策略。另外我默认你不会把整套代码当作开箱即用的 SaaS 产品而是能看懂 TypeScript 和 SQL 的基本逻辑遇到问题能自己改。下面所有代码都是我在生产环境验证过的直接抄没问题但建议你先理解每一段在干什么。1. 项目背景与方案选型1.1 做壁纸销售平台的核心需求我最初的业务很简单卖高清壁纸包按主题打包比如极简风格 100 张 4K 壁纸、赛博朋克风手机壁纸合辑。用户完成支付后可以下载一个 ZIP 包里面包含所有原图。这个模式有几个非常具体的技术诉求。第一是图片存储和带宽开销。一套 4K 壁纸包动辄 200 到 500 MB如果放在普通主机上用户下载几次就把月流量配额打穿了。之前用 WordPress 独服每个月光流量费就占了大头。所以存储对象必须用云厂商的 Object Storage最好是免费额度大的那种而且出口流量不要钱。第二是支付和用户系统。壁纸销售不复杂不需要会员体系、积分、购物车这类重型功能但要能区分已购买和未购买要能在用户下载的时候校验订单状态。Supabase 内置了 Auth 和 PostgreSQL 数据库正好覆盖这部分需求省去了自己搭建后端服务的工作量。第三是前端体验和 SEO。壁纸站的主要流量来自搜索引擎用户搜索4K 极简壁纸、暗色壁纸合集这类词进来。Next.js 天然支持 SSR 和静态生成对 SEO 非常友好而且免费部署在 Vercel 上就够用。核心需求理清楚之后选型就简单了。这套方案本质上是用免费层服务拼出一个足够支撑小规模商业化需求的架构。Next.js 负责页面和 API 层Supabase 负责认证和关系数据R2 负责文件存储和分发三者加起来只要流量不突破免费额度成本就是零。1.2 技术栈选型为什么是 Next.js Supabase Cloudflare R2先说 Next.js。选择它倒不是因为这两年它热度高而是因为它确实契合这种内容型电商站点的开发模式。壁纸站的页面结构非常规律首页展示壁纸包列表、详情页展示图片预览和购买按钮、下载页校验权限。这些页面有的需要静态生成首页、分类页有的需要服务端渲染详情页里的实时库存状态有的需要完全动态下载接口。Next.js 的 App Router 里一个路由可以同时声明generateStaticParams做静态化也可以用export const dynamic force-dynamic强制动态渲染这个灵活性在别的前端框架里很难找到同等的表达方式。再说 Supabase。其实最初我也考虑过直接用 Firebase但壁纸站需要存储的是图片元数据 订单记录 用户下载历史这种关系型数据尤其是订单和用户的关联查询Firebase 的 Firestore 用起来会很别扭。Supabase 直接给的是 PostgreSQL我可以写 SQL 做联表查询、做行级安全策略这一点非常关键——后面我会用 RLS 保证用户只能查询自己的订单和下载记录不用在应用层写一堆 if else 判断。最后是 Cloudflare R2。它本质上是 S3 兼容的对象存储但最关键的区别是出口流量不收费。对于壁纸站这种下载密集型业务这笔账太重要了。S3 的免费层只给 12 个月的 100GB 存储而且出口流量按每 GB 收费。R2 免费层是 10GB 存储 每月 1 万次 GET 请求 每月 1000 万次读操作对于我个人体量的壁纸站来绰绰有余。还有一个重要因素R2 可以绑定 Cloudflare 自己的域名和 CDN 网络下载速度比从 S3 跨洋拉文件快得多。这套组合还有一个隐性优点是各自干各自擅长的事。认证和数据一致性交给 Supabase——PostgreSQL 的 ACID 特性保证订单不会丢文件分发交给 R2——Cloudflare 的全球边缘网络保证下载快前端逻辑交给 Next.js——服务端组件和 API 路由减少客户端往返。每层都极其简单出了问题定位也快。相比之下我之前用 WordPress 的时候一个问题往往牵扯 PHP、MySQL、Nginx、插件四层排查成本高得多。2. 核心架构与数据模型设计2.1 整体流程拆解在写代码之前先把我这套系统的数据流画清楚。用户进入网站浏览壁纸包列表点进详情页看到预览图和下载列表说明点击购买按钮后跳转到支付页。支付成功后Supabase 的orders表写入一条状态为paid的记录前端拿到支付回执后调用 Next.js 的下载 APIAPI 先验证订单状态再向 R2 生成一个大文件下载的预签名 URL用户点开链接直接从 R2 下载 ZIP 包。这里有一个关键设计用户下载时不经过 Next.js 服务器而是由 API 返回一个 R2 的临时签名链接。这样做的好处很明显大文件下载如果走 Node 服务中转不仅占用服务器内存和带宽Vercel 的无服务器函数还有 10 秒的超时限制直接下载到一半就断。用预签名 URL 的方式下载流量完全走 Cloudflare 网络用户速度快服务端零压力。另一个设计决策是竖切存储桶。我建了两个 R2 bucket一个叫wallpaper-covers存放展示用的封面图和预览图这些图要经常被页面加载所以设置了较长的 CDN 缓存另一个叫wallpaper-files存放实际售卖的 ZIP 包这些文件不直接公开访问必须通过鉴权后的 API 获取签名链接。两个桶的权限策略完全不同从根上杜绝了预览图泄漏原图这种事。2.2 数据表设计数据库在 Supabase 里我用 SQL 建了四张表。这里直接给出建表语句你复制到 Supabase 的 SQL Editor 里执行即可。-- 壁纸包基础信息表 create table public.wallpaper_packs ( id uuid primary key default gen_random_uuid(), slug text unique not null, title text not null, description text, cover_url text, -- R2 封面图路径 preview_urls text[], -- 预览图路径数组 file_key text not null, -- R2 ZIP 包的 key file_size_bytes bigint, price_cents integer not null default 199, -- 单位分 is_published boolean default false, created_at timestamptz default now() ); -- 订单表 create table public.orders ( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users not null, pack_id uuid references public.wallpaper_packs not null, amount_cents integer not null, status text not null default pending, -- pending / paid / refunded payment_provider text, -- stripe 或其他 payment_session_id text, created_at timestamptz default now(), paid_at timestamptz ); -- 下载记录表 create table public.downloads ( id uuid primary key default gen_random_uuid(), user_id uuid references auth.users not null, pack_id uuid references public.wallpaper_packs not null, downloaded_at timestamptz default now() ); -- 购买关系唯一索引 create unique index orders_user_pack_unique on public.orders (user_id, pack_id) where status paid;订单表上这个部分唯一索引是精髓。它确保同一个用户对同一个壁纸包只能有一条成功的付费订单重复购买时应用层能直接拿到冲突信号返回你已经买过了。Supabase 还提供了行级安全策略下面这段代码要一并执行alter table public.orders enable row level security; alter table public.downloads enable row level security; create policy users can view own orders on public.orders for select using (auth.uid() user_id); create policy users can view own downloads on public.downloads for select using (auth.uid() user_id);注意wallpaper_packs表不要开 RLS或者开一个for select using (is_published true)的策略否则未登录用户会连商品列表都看不到。壁纸信息是公开的商品展示数据不需要权限遮蔽。2.3 为什么不用 Supabase Storage肯定有人会问Supabase 本身就带 Storage 功能为什么文件还要单独放到 Cloudflare R2我实测对比过Supabase Storage 的免费额度是 1GB且文件访问走的是 Supabase 自带的 CDN流量费用计入项目用量。对壁纸站这种单文件几百 MB 的场景1GB 存储撑死放两三个壁纸包完全不够用。更关键的是下载带宽。Supabase 免费层的出站流量虽然没有明码标价但超过一定量级项目会被限流甚至暂停。而 R2 免费层明确包含 10GB 存储和每月 1 万次 GET 请求超出部分的定价也远低于主流云厂商的 CDN 费用。把大文件放在 R2把结构化数据和用户身份放在 Supabase两者的免费额度都能发挥到极致。这在架构上叫专业分工。Supabase Storage 本身做得不差但它更适合存头像、文章配图这类小文件。像我卖的壁纸压缩包体积大、下载频率集中晚上用户活跃时段、需要预签名授权R2 的 S3 兼容接口和 Lambda 预签名能力才是对路的。3. 实操过程与关键实现3.1 Supabase 初始化与认证配置第一步是注册 Supabase 项目创建完成后进入 Dashboard 拿到项目的 URL 和 anon key。这两个值要填到 Next.js 的环境变量里客户端组件用NEXT_PUBLIC_SUPABASE_URL和NEXT_PUBLIC_SUPABASE_ANON_KEY服务端组件用SUPABASE_SERVICE_ROLE_KEY。这里有个很容易踩的坑服务端组件和 API 路由里如果混用了 anon keyRLS 策略会拦住你的数据库访问因为 anon key 默认是未登录身份RLS 规则里所有auth.uid()都会是 NULL。我一开始在 API 路由里图省事客户端和服务端共用一个 Supabase 实例结果所有订单查询都被 RLS 挡住报错报得莫名其妙。认证方式我直接用了 Supabase 的邮箱密码登录。注册页面写好之后调用supabase.auth.signUp()然后监听onAuthStateChange事件更新前端的登录状态。这里有个体验层面的细节壁纸站用户很多是一次性买家让他们注册邮箱再下单流失率很高。所以我同时接入了 GitHub OAuth并且在订单流程里做了一个游客邮箱下单模式——用户只需要填一个邮箱Supabase 帮他创建一个无密码的 magic link 账户支付完成后用 magic link 登录就能在自己的账户里找到历史订单。这个模式在工程上稍微绕一点但转化率比强制注册高了一截。3.2 R2 存储接入与 S3 兼容配置R2 这边首先在 Cloudflare Dashboard 创建两个 bucket然后在 Manage R2 API Tokens 里生成一个 Access Key ID 和 Secret Access Key。因为 R2 是 S3 兼容的我直接在 Next.js 里用了官方 AWS SDK 的 S3 客户端只需要改 endpoint。import { S3Client } from aws-sdk/client-s3; const s3Client new S3Client({ region: auto, endpoint: https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, }, });注意region必须写成autoR2 没有传统意义上的区域概念填us-east-1或ap-southeast-1会导致请求签名不匹配。这个坑我印象很深第一次配置的时候报了一堆The request signature we calculated does not match查了半天才发现是 region 的问题。上传 ZIP 包用的还是这套 SDK。实际生产里我写了一个服务端 API 路由/api/admin/upload-pack接收文件后先上传到 R2然后把返回的 key 写进wallpaper_packs表。上传的时候还可以顺手算一下文件的 etag 或大小存进file_size_bytes字段详情页就能直接显示压缩包大小428MB。3.3 Next.js 服务端渲染与页面实现页面结构上首页用generateStaticParams和revalidate做静态化。壁纸包列表这种数据变化频率很低的页面直接静态生成访问时由 Vercel 的边缘节点缓存性能最好。// app/page.tsx export const revalidate 3600; // 每小时重新验证一次 async function getPacks() { const supabase createServerSupabase(); const { data } await supabase .from(wallpaper_packs) .select(id, slug, title, cover_url, price_cents, file_size_bytes) .eq(is_published, true) .order(created_at, { ascending: false }); return data ?? []; }详情页则是app/packs/[slug]/page.tsx一样是generateStaticParams生成所有已发布壁纸包的静态路径然后在页面里展示封面图、预览图列表、价格以及一个获取下载链接的按钮。这个按钮不能直接连到下载地址正确的做法是点击后请求/api/download/[packId]API 返回一个临时签名链接前端再重定向到这个链接。下载接口的核心逻辑如下// app/api/download/[packId]/route.ts import { getSignedUrl } from aws-sdk/s3-request-presigner; import { GetObjectCommand } from aws-sdk/client-s3; export async function GET(req: Request, { params }: { params: { packId: string } }) { const supabase createServerSupabase(); const { data: { user } } await supabase.auth.getUser(); if (!user) { return Response.json({ error: 请先登录 }, { status: 401 }); } // 校验订单状态 const { data: order } await supabase .from(orders) .select(*) .eq(user_id, user.id) .eq(pack_id, params.packId) .eq(status, paid) .maybeSingle(); if (!order) { return Response.json({ error: 未检测到有效订单 }, { status: 403 }); } // 查询文件 key const { data: pack } await supabase .from(wallpaper_packs) .select(file_key, title) .eq(id, params.packId) .single(); // 生成 5 分钟有效的预签名链接 const command new GetObjectCommand({ Bucket: wallpaper-files, Key: pack.file_key, ResponseContentDisposition: attachment; filename${encodeURIComponent(pack.title .zip)}, }); const signedUrl await getSignedUrl(s3Client, command, { expiresIn: 300 }); // 记录下载次数 await supabase.from(downloads).insert({ user_id: user.id, pack_id: params.packId }); return Response.json({ downloadUrl: signedUrl }); }这里有个细节值得展开ResponseContentDisposition参数一定要设置。如果不设置浏览器打开签名链接会直接尝试预览 ZIP 内容而不是下载。对壁纸包来说用户需要的是一个明确的.zip文件下载行为加上attachment参数响应头会告诉浏览器这是一个需要保存的附件。预签名的有效期我设为 300 秒。为什么是 5 分钟太短的话用户点开链接还没找到保存位置就过期了太长的话链接有被转发的风险。5 分钟是我实测下来比较稳妥的平衡值。另外签名 URL 里包含了 R2 的完整文件路径理论上拿到链接的人无需权限就能下载所以过期时间必须短这是防盗链的第一道保险。3.4 支付流程与订单回调支付我接的是 Stripe Checkout用 Payment Link 模式省掉前端支付页面的开发。用户点击购买按钮后Next.js API 创建一个 Stripe Checkout Session带上metadata包含userId和packId然后重定向到 Stripe 托管页面。支付完成后Stripe 向/api/webhook/stripe发送事件。Webhook 是整条链路里最容易出问题的一环。我建议你调试的时候先用 Stripe CLI 在本地转发真实事件而不是依赖 Stripe 测试环境。Stripe CLI 的stripe listen --forward-to localhost:3000/api/webhook/stripe命令会把真实签名的请求转发到本机这样能确认签名校验逻辑是否写对。以下是我用的 webhook 处理器// app/api/webhook/stripe/route.ts import Stripe from stripe; import { createClient } from supabase/supabase-js; const stripe new Stripe(process.env.STRIPE_SECRET_KEY!); const supabase createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ); export async function POST(req: Request) { const payload await req.text(); const signature req.headers.get(stripe-signature)!; let event: Stripe.Event; try { event stripe.webhooks.constructEvent( payload, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { return Response.json({ error: 签名校验失败 }, { status: 400 }); } if (event.type checkout.session.completed) { const session event.data.object as Stripe.Checkout.Session; const userId session.metadata?.userId; const packId session.metadata?.packId; if (!userId || !packId) { return Response.json({ error: 缺少元数据 }, { status: 400 }); } await supabase.from(orders).upsert({ user_id: userId, pack_id: packId, amount_cents: session.amount_total, status: paid, payment_session_id: session.id, paid_at: new Date().toISOString(), }); } return Response.json({ received: true }); }这里用upsert而不是insert是为了处理 Stripe 重试事件导致重复写入的问题。订单表上已经建了orders_user_pack_unique部分唯一索引配合 upsert 的 onConflict 参数能保证幂等性。4. 常见问题与排查实录4.1 高频报错Supabase read econnreset这个报错大概是我被问到最多的问题。Supabase read econnreset的字面意思就是 TCP 连接被对端重置了。在 Next.js 项目里它最典型的触发场景是服务端组件或 API 路由在每次请求里都新建一个 Supabase 客户端而 Supabase 的客户端底层使用了连接池连接池在无服务器环境下没有被正确复用和释放。Vercel 等无服务器平台的问题在于每个函数实例的生命周期是不确定的。如果你的函数里创建了一个 Supabase 客户端但没有关闭连接函数执行完毕后事件循环被冻结底层 socket 被挂起之后新的请求尝试复用它时PostgreSQL 服务端已经把这个空闲连接回收了于是报read econnreset。解决方法有几个层面。最直接的是避免在请求作用域内反复创建客户端。我把 Supabase 客户端定义在模块顶层利用 Next.js 的模块缓存来复用连接import { createClient } from supabase/supabase-js; const supabase createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { persistSession: false, autoRefreshToken: false, }, global: { headers: { x-application-name: wallpaper-shop, }, }, } );服务端用途的客户端不需要持久化 session因为我每次都通过supabase.auth.getUser()从请求头里的 JWT 还原用户身份不需要本地 token 管理。把persistSession: false和autoRefreshToken: false关掉可以省掉大量 localStorage 和定时刷新相关的无效逻辑也减少连接池被莫名占用的概率。另外Supabase 官方维护了一个supabase/ssr包专门解决 SSR 环境下的 cookie 同步问题。如果你用 App Router强烈建议跟着官方文档把 cookie 的读写配置好。用这个包后每个用户请求携带的 session cookie 会被正确解析并附带到数据库查询的 RLS 上下文中是解决登录用户却查不到数据这类问题的根本手段。最后还有一个容易被忽略的点如果报错出现在本地开发环境检查一下是不是开了多个 Node 进程比如 Next.js dev server 和 Supabase CLI 本地模拟器抢了同一个端口。这种情况下的econnreset跟代码没关系纯粹是端口冲突导致的连接被拒。4.2 R2 签名链接的常见故障我遇到过两类比较典型的 R2 问题一个是签名链接 403另一个是上传成功但读取时 404。403 的问题九成出在 Cloudflare 账号 ID 配置错误。R2_ACCOUNT_ID不是 Cloudflare 的主账号邮箱而是你的 account identifier格式是一串 32 位十六进制字符可以在 R2 Dashboard 右上角找到。填错的话所有 S3 请求都会被 Cloudflare 当成非法域名拒绝。404 的问题则大概率是因为 bucket 区域配置。R2 的 S3 endpoint 要拼https://account_id.r2.cloudflarestorage.com注意不要拼上/bucket-name前缀。上传和读取时 bucket 名称是在调用PutObjectCommand和GetObjectCommand时单独指定的路径拼接错会导致请求发到了一个不存在的 bucket 路径上。还有一类安全问题也值得提一句R2 的 bucket 默认是私有的但如果你在创建 bucket 的时候勾选了公开访问那么所有文件都会通过pub-hash.r2.dev这个域名直接暴露在公网。壁纸站的原图包一旦泄漏付费转化就无从谈起。我的建议是两个桶都保持私有封面图桶的公开读取通过 Cloudflare 的 Custom Domain 和 Cache Rules 实现但原图桶一律走预签名 URL。4.3 Next.js 静态生成时 Supabase 连接风暴使用generateStaticParams的页面如果数量多Next.js 构建时会并发向 Supabase 发送大量的 SQL 请求。我在部署时遇到过整整 80 多个壁纸包页面一次性发起构建查询Supabase 免费层直接报了Too many connections。这个报错不是因为 Supabase 配置有问题而是构建进程的并发数太大超过了免费层的连接数限制。解决办法是在构建查询里加个并发限制或者用 p-limit 这样的库控制并发数。import pLimit from p-limit; const limit pLimit(5); const tasks slugs.map((slug) limit(() supabase.from(wallpaper_packs).select(*).eq(slug, slug).single()) ); const results await Promise.all(tasks);将并发数限制在 5 左右构建时的连接数就不会冲爆数据库。如果你租的是 Vercel 免费层构建时会跑到 4 个并发构建进程每个进程里再限流效果会更明显。实际上我后来把部分页面的内容缓存到了一个 JSON 文件构建时直接读文件彻底绕开了数据库查询。对于壁纸包这种内容更新频率很低的场景这个方案反而最简单可靠。5. 成本核算与免费额度明细5.1 月成本接近 $0 的账本构成这个项目的月度成本我详细记了一笔分项列出供参考。服务免费额度我的实际用量月成本Vercel部署 Next.js100GB 带宽函数 100 万次调用约 3 万次页面请求约 2 万次函数调用$0Supabase认证 Postgres500MB 数据库每月 5 万 MAU数据量约 80MB日均活跃用户 200 左右$0Cloudflare R2文件存储10GB 存储1 万次 GET / 1000 万次读操作当前约 6GB 数据日均 GET 请求 3000 次$0Stripe支付无月费按 2.9% $0.30 抽成每单约 $0.75 手续费随订单量变化域名每年约 $10一次性。用 Cloudflare Registrar 续费仅成本价约 $0.83/月严格来说每笔订单的 Stripe 手续费不算成本但如果你把手续费也算进去那每一单赚得就更薄了。总体看固定成本方面真的是月成本接近 $0唯一的变量是订单手续费而这是任何收款方式都绕不开的。为什么能压到这么低核心在于把不同的资源开销拆分给了不同服务的免费额度。Vercel 免费层的 100GB 带宽听起来不多但如果页面内容和 API 响应都是轻量的 JSON真正占带宽的大头是封面图而封面图是从 R2 的公开域名加载的完全不走 Vercel。也就是说Vercel 免费层只承担了 HTML 和 API 流量这部分是很小的量。同理Supabase 免费层只承担了 JWT 验证和数据库查询不承担文件传输流量500MB 数据库对我的数据量来说非常宽裕。5.2 后续扩展方向如果你的壁纸站流量增长免费额度被吃完扩展路径其实非常平滑。R2 是第一个需要关注的地方超过 10GB 存储之后每 GB 的存储费是 $0.015/月而 GET 请求是每 100 万次 $0.36仍然比传统 CDN 便宜得多。Supabase 超出免费额度后按用量计费数据库 500MB 之上每 GB 大概是 $0.18/月也很低。Vercel 免费层如果带宽超了可以升到 Pro 套餐$20/月包含更多带宽和无服务器函数并发。我个人的建议是前三五个月就让它在免费层跑着不要过早升级。等订单量稳定在月入 500 美元以上再把部署和数据库挪到付费层分摊成本完全可控。这时候你会发现这套技术栈最大的优势反而不是省钱而是每加一层服务都有清晰的价目表不会像传统主机一样在不知道发生了什么的情况下收到超额账单。5.3 关于Next.js Payload和 CMS 扩展最近有人问我说壁纸站的商品内容管理用什么后台。我最初也考虑过给这套系统加一个 Payload CMS 做内容管理毕竟 Next.js Payload 的组合在技术圈很热门Payload 可以直接嵌进 Next.js App Router 的路由里提供可视化的表单和列表页面。实际折腾了半天之后我放弃了。原因是壁纸站的商品数据极其简单标题、描述、封面图、预览图、文件 key、价格。这种程度的数据我直接做一个/admin的简单表单页面就能管理用 Supabase 的 Postgres 表做增删改查比引入一整个 CMS 框架轻得多。Payload 优势在内容型站点博客、文档、杂志里特别明显但在这种商品 下载的交易场景里CMS 的管理界面反而显得笨重。当然如果你后续想加壁纸故事、风格专题这类内容栏目Payload 会是绝佳的选择因为它本身就为 Next.js 生态设计跟现有代码可以无缝衔接。末了提一句经验建议这套方案框架上比较标准但真正让它跑得稳的是那些不起眼的小决策——订单表的唯一索引、预签名 URL 的短过期时间、构建时查库的并发限流。这些细节每一项都是我在实际流量下踩坑踩出来的。你先按文章搭起来跑通了再回头优化这些点大概率会遇到跟我类似的问题那时候你再回来看这一章节应该能会心一笑。