第 14 章

全栈项目实战——用 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

层次 技术选型 选择理由
前端框架 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. .cursorrules 是多文件 AI 开发的护栏——把废弃包、安全规则、架构约定写进去,避免 AI 在整个项目里前后不一致。
  2. Supabase RLS 必须显式启用——建表后不会自动开启,CREATE TABLE 完之后立刻 ALTER TABLE 加 ENABLE ROW LEVEL SECURITY 和策略。
  3. 流式输出用 Route Handler,不用 Server Action——Server Actions 的返回值不支持流,涉及 AI 流式响应的接口统一放 app/api/ 目录。
  4. Stripe Webhook 永远用 req.text()——签名基于原始字节,任何形式的解析重序列化都会让签名失效。
  5. Vercel 函数超时需要提前规划——免费层 10 秒,AI 生成任务必须升 Pro 并配置 maxDuration,或者考虑 Edge Runtime 方案。
本章评分
4.6  / 5  (18 评分)

💬 留言讨论