全栈项目实战——用 Cursor + Claude 在5天内构建 AI 写作 SaaS
第14章:全栈项目实战——用 Cursor + Claude 在5天内构建 AI 写作 SaaS
本章学习目标:掌握 .cursorrules 对多人 AI 辅助开发的约束作用;用 Supabase 设计带 RLS 的数据库 Schema;正确集成 Claude 流式输出到 Next.js Server Action;处理 Stripe Webhook 的签名验证;用 vercel.json 解决函数超时问题。所有代码可直接在真实项目中使用。
项目目标与技术栈
我们要构建的:AI 写作助手 SaaS
- 用户输入主题,AI 生成文章大纲和正文(约 1000 字)
- 付费订阅模型:免费版每天 3 次,Pro 版无限制
- 生成历史记录,按用户隔离
| 层次 | 技术选型 | 选择理由 |
|---|---|---|
| 前端框架 | Next.js 14 App Router | Server Actions 减少 API 层代码;RSC 默认服务端渲染 |
| 数据库 + 认证 | Supabase | PostgreSQL + 内置 Auth + Row Level Security,免运维 |
| 支付 | Stripe | 订阅付费业界标准,Webhook 生态完善 |
| AI 能力 | Claude API (claude-haiku-4-5) | 写作生成场景性价比高;流式输出延迟低 |
| 部署 | Vercel | 与 Next.js 原生集成,推送即部署 |
Phase 0:项目初始化与 .cursorrules
npx create-next-app@latest ai-writer --typescript --tailwind --app
cd ai-writer
npm install @anthropic-ai/sdk @supabase/ssr stripe
先配好 .cursorrules,这是整个项目的基础——它决定 AI 在本项目里遵守的所有规范:
# Project: AI Writer SaaS
# Stack: Next.js 14 App Router, TypeScript strict, Tailwind CSS, Supabase, Stripe
# Architecture Rules
- Server Components by default, Client Components only when needed (interactivity/hooks)
- Database operations only in Server Actions or Route Handlers, never in Client Components
- Use @supabase/ssr package for Supabase clients, NOT @supabase/auth-helpers (deprecated)
- Environment variables validated at startup via /src/lib/env.ts with Zod
# Code Style
- 2-space indent, single quotes, no semicolons
- All functions must have explicit TypeScript return types
- Use cn() from clsx for conditional Tailwind classes
- Error handling: return { data, error } objects instead of throwing from Server Actions
# File Structure
/src/app/ → Next.js pages and layouts
/src/components/ → React components (ui/ for primitives, features/ for domain)
/src/lib/ → utilities, clients (supabase, stripe, claude)
/src/actions/ → Server Actions only
# Security
- Never expose SUPABASE_SERVICE_ROLE_KEY to client
- Stripe Webhook handler must use req.text() not req.json()
- Every Server Action must verify user session before any DB operation
为什么 .cursorrules 要写"使用 @supabase/ssr 而非 @supabase/auth-helpers": AI 的训练数据里大量旧代码使用
@supabase/auth-helpers,这个包已于 2024 年废弃。不在 .cursorrules 里显式禁止,AI 会频繁生成废弃的写法,导致运行时警告甚至 bug。
Phase 1:数据库设计
在 Supabase Dashboard 的 SQL Editor 里执行以下迁移脚本:
-- 用户配置表,扩展 Supabase auth.users
CREATE TABLE public.profiles (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
stripe_customer_id TEXT UNIQUE,
subscription_status TEXT NOT NULL DEFAULT 'free'
CHECK (subscription_status IN ('free', 'pro', 'cancelled')),
daily_generation_count INTEGER NOT NULL DEFAULT 0,
daily_reset_at DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- AI 生成历史记录
CREATE TABLE public.generations (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
topic TEXT NOT NULL,
content TEXT NOT NULL,
tokens_used INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 启用 Row Level Security
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.generations ENABLE ROW LEVEL SECURITY;
-- RLS 策略:用户只能看到自己的数据
CREATE POLICY "own_profile" ON profiles FOR ALL USING (auth.uid() = id);
CREATE POLICY "own_generations" ON generations FOR ALL USING (auth.uid() = user_id);
-- 新用户注册时自动创建 profile
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
INSERT INTO public.profiles (id) VALUES (NEW.id);
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
RLS 的关键意义: 即使应用代码有 bug 忘记加 WHERE user_id = 条件,数据库层面的策略会阻止用户读到其他人的数据。这是 Supabase 最重要的安全特性,必须开启。
Phase 3:AI 生成功能(Route Handler + 流式输出)
重要限制: Next.js Server Actions 不能直接返回 ReadableStream。流式生成必须通过 Route Handler 实现,客户端用 fetch 消费。
// src/app/api/generate/route.ts — Route Handler 实现流式输出
import Anthropic from '@anthropic-ai/sdk'
import { createServerSupabaseClient } from '@/lib/supabase/server'
const claude = new Anthropic()
export async function POST(req: Request): Promise<Response> {
const supabase = createServerSupabaseClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: profile } = await supabase
.from('profiles')
.select('subscription_status, daily_generation_count, daily_reset_at')
.eq('id', user.id)
.single()
if (!profile) return Response.json({ error: 'Profile not found' }, { status: 404 })
// 如果跨天,重置计数
const today = new Date().toISOString().split('T')[0]
if (profile.daily_reset_at !== today) {
await supabase.from('profiles')
.update({ daily_generation_count: 0, daily_reset_at: today })
.eq('id', user.id)
profile.daily_generation_count = 0
}
if (profile.subscription_status === 'free' && profile.daily_generation_count >= 3) {
return Response.json({ error: 'Daily limit reached. Upgrade to Pro.' }, { status: 429 })
}
const { topic } = await req.json()
const stream = await claude.messages.stream({
model: 'claude-haiku-4-5-20251001',
max_tokens: 2048,
messages: [{
role: 'user',
content: `写一篇关于"${topic}"的文章,包含大纲和正文,约1000字,使用 Markdown 格式。`
}]
})
let fullContent = ''
const readableStream = new ReadableStream({
async start(controller) {
for await (const chunk of stream.text_stream) {
fullContent += chunk
controller.enqueue(new TextEncoder().encode(chunk))
}
controller.close()
// 流结束后保存记录并更新计数
const finalMsg = await stream.finalMessage()
await Promise.all([
supabase.from('generations').insert({
user_id: user.id,
topic,
content: fullContent,
tokens_used: finalMsg.usage.output_tokens
}),
supabase.from('profiles')
.update({ daily_generation_count: profile.daily_generation_count + 1 })
.eq('id', user.id)
])
}
})
return new Response(readableStream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
Phase 4:Stripe 订阅
关键踩坑:Stripe Webhook 必须用 req.text(),不能用 req.json()。 Stripe 用原始字节计算签名,JSON 解析后重新序列化会改变字节内容,导致签名验证永远失败。这是最常见的 Stripe 集成 bug。
// src/app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'
import { createClient } from '@supabase/supabase-js'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
// Webhook 处理器里用 Service Role Key 绕过 RLS
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
export async function POST(req: Request): Promise<Response> {
const body = await req.text() // 必须是 text,绝对不能是 json
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return Response.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription
const status = sub.status === 'active' ? 'pro' : 'free'
await supabaseAdmin.from('profiles')
.update({ subscription_status: status })
.eq('stripe_customer_id', sub.customer as string)
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
await supabaseAdmin.from('profiles')
.update({ subscription_status: 'cancelled' })
.eq('stripe_customer_id', sub.customer as string)
break
}
}
return Response.json({ received: true })
}
Phase 5:部署到 Vercel
踩坑:Claude API 流式输出在 Vercel 默认有 10 秒超时。 生成 1000 字文章可能需要 15–30 秒,超时后连接被强制中断,用户看到截断的内容。
{
"functions": {
"src/app/api/generate/route.ts": {
"maxDuration": 60
},
"src/app/api/webhooks/stripe/route.ts": {
"maxDuration": 30
}
}
}
注意:
maxDuration超过 10 秒需要 Vercel Pro 计划($20/月)。免费计划固定 10 秒上限。另一个选择是用 Edge Runtime(无超时),但 Edge 不支持 Node.js API,@anthropic-ai/sdk需要额外配置。
完整踩坑清单
- 坑 1:用了废弃的 Supabase 包。 AI 给出
@supabase/auth-helpers,实际上应该用@supabase/ssr。在 .cursorrules 里显式写明,彻底避免。 - 坑 2:在 Server Action 里直接 return Response。 Server Actions 不支持返回 Response 对象,流式输出必须放在 Route Handler 里,客户端用 fetch 消费。
- 坑 3:Stripe Webhook 签名验证失败。 用了
req.json(),改成req.text()立刻解决。这个问题在 Stack Overflow 上有数百个相同的问题。 - 坑 4:Vercel 10 秒超时截断长文章。 加
vercel.json配置maxDuration: 60,需要升级 Pro 计划。 - 坑 5:RLS 策略忘记创建。 Supabase 默认不开启 RLS,创建表后必须手动执行
ALTER TABLE ... ENABLE ROW LEVEL SECURITY并添加策略,否则所有用户能看到所有数据。
本章要点
- .cursorrules 是多文件 AI 开发的护栏——把废弃包、安全规则、架构约定写进去,避免 AI 在整个项目里前后不一致。
- Supabase RLS 必须显式启用——建表后不会自动开启,CREATE TABLE 完之后立刻 ALTER TABLE 加 ENABLE ROW LEVEL SECURITY 和策略。
- 流式输出用 Route Handler,不用 Server Action——Server Actions 的返回值不支持流,涉及 AI 流式响应的接口统一放
app/api/目录。 - Stripe Webhook 永远用
req.text()——签名基于原始字节,任何形式的解析重序列化都会让签名失效。 - Vercel 函数超时需要提前规划——免费层 10 秒,AI 生成任务必须升 Pro 并配置 maxDuration,或者考虑 Edge Runtime 方案。