我如何用 Next.js + Supabase + Cloudflare R2 搭建壁纸销售平台------月成本接近 $0
独立开发者实战复盘:WallpaperSense 的架构设计、技术选型与踩坑经验。
我想做一个壁纸销售平台。不是那种满屏广告的免费图库,而是一个真正的电商产品------支持主题包和单张壁纸购买、即时下载,覆盖手机到平板到桌面端所有屏幕尺寸。
约束条件:我是一个独立开发者,产品还没验证,所以技术栈必须便宜(最好免费),直到跑出来为止。
技术栈全景
| 层级 | 技术 |
|---|---|
| 框架 | Next.js 16(App Router + Turbopack) |
| 前端 | React 19 + TypeScript 5.7 |
| UI | Tailwind CSS + shadcn/ui(Radix UI) |
| 数据库 | Supabase PostgreSQL(仅数据库,不用 Auth/RLS/Storage) |
| 对象存储 | Cloudflare R2(S3 兼容) |
| 支付 | Creem.io |
| 图片处理 | Sharp |
| 多语言 | next-intl(中文 + 英文) |
| 部署 | Vercel Hobby |
关键决策:Supabase 只当 PostgreSQL 数据库用,认证、存储、业务逻辑全部自己掌控。
分层架构
所有请求走严格的分层:
bash
前端(React 组件)
│ fetch /api/*
▼
API Routes(app/api/*/route.ts)
│ 参数校验、鉴权、响应格式化
▼
Service 层(lib/services/*)
│ 业务逻辑、流程编排
▼
DAO 层(lib/dao/*)
│ createServiceRoleClient()
▼
Supabase PostgreSQL(schema: 'wallpaper')
核心原则:前端永远不直接碰数据库。每次数据库操作都经过 API Route → Service → DAO 三层。对于独立项目来说可能显得"过度设计",但当你需要换数据库、加缓存、或者凌晨两点排查线上问题时,这个结构能救命。
BaseDAO:泛型 CRUD
所有数据访问类继承 BaseDAO,开箱即用的类型安全 CRUD:
typescript
export class BaseDAO<T extends { id?: string }, TInsert = Partial<T>> {
protected tableName: string;
constructor(tableName: string) {
this.tableName = tableName;
}
protected getClient() {
return createServiceRoleClient();
}
async findById(id: string): Promise<{ data: T | null; error: any }> {
const { data, error } = await this.getClient()
.from(this.tableName)
.select('*')
.eq('id', id)
.single();
if (error?.code === 'PGRST116') return { data: null, error: null };
return { data: data as T, error };
}
async findMany(options?: {
filters?: Record<string, any>;
orderBy?: { column: string; ascending?: boolean };
limit?: number;
}): Promise<{ data: T[] | null; error: any }> {
let query = this.getClient().from(this.tableName).select('*');
if (options?.filters) {
Object.entries(options.filters).forEach(([key, value]) => {
query = query.eq(key, value);
});
}
if (options?.orderBy) {
query = query.order(options.orderBy.column, {
ascending: options.orderBy.ascending ?? true,
});
}
if (options?.limit) query = query.limit(options.limit);
const { data, error } = await query;
return { data: (data as T[]) || [], error };
}
// + create(), update(), delete(), count()
}
新建一个表的 DAO 就是几行代码:
typescript
export class WallpaperDAO extends BaseDAO<Wallpaper> {
constructor() { super('wallpapers'); }
async findByCategory(categoryId: string) {
return this.findMany({
filters: { category_id: categoryId },
orderBy: { column: 'created_at', ascending: false },
});
}
}
BaseService:统一校验和错误处理
所有 Service 继承 BaseService,提供 UUID 校验、权限检查、分页封装、结构化错误处理等公共能力:
typescript
export abstract class BaseService {
protected validateUuid(id: string, fieldName = 'ID'): void {
if (!/^[0-9a-f]{8}-...-[0-9a-f]{12}$/i.test(id)) {
throw new ApiError(`Invalid ${fieldName}`, 400, 'INVALID_UUID');
}
}
protected handleError(error: any, operation: string): never {
if (error instanceof ApiError) throw error;
throw new ApiError(`Failed to ${operation}`, 500, 'INTERNAL_ERROR');
}
protected createPaginatedResponse<T>(data: T[], total: number, options: PaginationOptions) {
return { data, pagination: { page: options.page, limit: options.limit, total } };
}
}
handleError 放在一个地方,所有 Service 统一的日志和错误结构。不会再出现 50 个文件里散落着 catch (e) { return Response.json({ error: "出错了" }) } 的情况。
为什么只用 Supabase 的数据库
Supabase 提供 Auth、RLS、Storage、Edge Functions------一整套后端平台。我只用了 PostgreSQL 这一个功能。原因:
认证 :我需要 Google OAuth + 邮箱密码登录,JWT token 完全自己掌控。Supabase Auth 挺好,但一旦需要自定义 claims、角色权限、非标准流程,就要跟抽象层较劲。自己用 jsonwebtoken + bcrypt 撸了一个,花了一个周末,换来完全的灵活性。
RLS:Row-Level Security 很强大,但调试起来很痛苦------查询没返回数据,是策略写错了还是查询条件有问题?用 service-role 客户端 + Service 层显式权限检查,问题永远清晰。
存储:Cloudflare R2 免费 10GB 存储 + 1000 万次读取/月。Supabase Storage 免费 1GB。对壁纸平台来说,图片就是产品,R2 在经济性上完胜。
核心模式是 createServiceRoleClient()------创建一个绕过 RLS 的 Supabase 客户端,只在服务端调用:
typescript
export function createServiceRoleClient() {
return createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, {
db: { schema: 'wallpaper' }, // 自定义 schema,不用默认的 public
auth: { autoRefreshToken: false, persistSession: false },
});
}
注意用了自定义 schema 'wallpaper',让应用表和 Supabase 内部表彻底分开。
图片处理管道
壁纸要适配所有设备------6.7 寸 iPhone、27 寸 iMac、12.9 寸 iPad。一种尺寸行不通。
管理员上传原图到 R2 后,异步任务自动生成 4 个版本:
markdown
原图(上传到 R2)
├── thumbnail → 列表缩略图
├── square → 800×800 方形网格图
├── preview → 高清预览(详情页)
└── watermarked → 带品牌水印的预览图
尺寸按设备类型区分:
| 设备 | 缩略图 | 预览图 |
|---|---|---|
| 手机 | 400×800 | 1440×2560 |
| 桌面 | 800×450 | 2560×1440 |
| 平板 | 600×800 | 2048×2732 |
所有变体存为 WebP(90-92% 质量)。水印用双层策略:右下角品牌文字 + 全图对角线低透明度平铺------简单的角标水印太容易裁掉。
成本明细
| 服务 | 方案 | 月费 |
|---|---|---|
| Vercel | Hobby | $0 |
| Supabase | Free(500MB 数据库) | $0 |
| Cloudflare R2 | Free(10GB 存储 + 1000 万读取) | $0 |
| Creem.io | 按交易抽成 | $0 底费 |
| 域名 | .com | ~ <math xmlns="http://www.w3.org/1998/Math/MathML"> 0.83 ( 0.83( </math>0.83(10/年) |
| 合计 | ~$0.83/月 |
Vercel Hobby 的代价是 10 秒函数超时。大部分 API 调用没问题,但图片处理可能超限。Supabase Free 给 500MB 数据库,对几千张壁纸的早期产品绰绰有余。
这套架构的核心优势:第一块钱的收入几乎是纯利润。只有流量和存储真正上量时才需要付费------而那时候你已经付得起了。
踩坑总结
1. Vercel 10 秒超时是真的
Sharp 处理一张手机壁纸(1440×2560,4 个变体)大约 3-4 秒。但批量处理 5 张就会超时。我的方案:每次只处理 3 张(串行),前端轮询自动续跑。不优雅,但在 $0 方案上跑得稳。
2. Supabase 默认 1000 行限制
这个坑很隐蔽。Supabase 的 PostgREST 默认最多返回 1000 行。如果有 1200 张壁纸,.select('*') 静默返回 1000 条------没有报错,没有警告。现在我每个查询都显式设 limit,并且用 count + head 做统计查询而不是全表 select。
3. R2 Presigned URL 实现秒级下载
用户购买后不经过服务器中转,直接 302 重定向到 R2 的 presigned URL。Cloudflare 边缘网络送文件,比 Vercel 单区域快得多。从"点击后等 5 秒"变成"点击即下载"。
4. 产品内容多语言比 UI 翻译难得多
用 next-intl 翻译按钮文字很简单。翻译产品内容------壁纸标题、Pack 描述------完全不同。需要在数据库里存翻译、优雅处理缺失翻译、决定缺失时显示原文还是隐藏。WallpaperSense 用 AI 异步任务自动翻译,这是另一篇文章的话题。
5. 自定义 Schema 省心
用 'wallpaper' 替代默认的 'public' schema,让应用表和 Supabase 内部表(auth.users、storage.objects 等)彻底分开,迁移时心里更有底。
系列预告
这篇文章覆盖了 WallpaperSense 的高层架构。更多深入话题正在路上:
- AI 自动生成壁纸标题、描述和 SEO Slug
- Vercel Hobby 10 秒超时下处理 8K 图片的生存指南
- Sharp.js 批量生成多版本壁纸实战
- Cloudflare R2 + Presigned URL 秒级下载方案
- Supabase 性能踩坑:全表扫描、1000 行限制、N+1 查询
如果你也在做独立产品,或者对全栈架构感兴趣,欢迎关注这个系列。
WallpaperSense 已上线:wallpapersense.com。一个独立开发者做的壁纸平台------好看的壁纸不贵,跑它的基础设施也不贵。
标签: #nextjs #supabase #cloudflare #typescript #独立开发 #全栈 #架构设计