AI SaaS Template Docs
AI SaaS Template Docs
AI SaaS Template简介快速开始项目架构
配置管理
项目结构
数据库开发指南
API 开发指南
认证系统
文件管理
支付和账单
自定义主题

API 开发指南

本指南详细介绍了 AI SaaS Template 中基于 tRPC 的现代化 API 开发模式,包括类型安全的端到端开发、Clerk Auth 认证集成、以及 Stripe Webhook 处理等核心功能。

技术栈概述

核心技术

  • API 框架: tRPC - 端到端类型安全的 API 开发
  • 查询库: TanStack Query - 强大的数据获取和状态管理
  • 认证系统: Clerk Auth - 现代化的认证和用户管理
  • 数据验证: Zod - TypeScript 优先的验证库
  • 数据库: Drizzle ORM + PostgreSQL

API 架构特点

  • 类型安全: 从客户端到服务器的完整类型推断
  • 实时同步: 自动的客户端缓存和同步
  • 错误处理: 统一的错误处理和用户友好的错误消息
  • 中间件: 灵活的中间件系统支持认证、日志等
  • 代码共享: 客户端和服务器共享类型定义

tRPC 项目结构

src/lib/trpc/
├── client.ts              # tRPC 客户端配置
├── server.ts              # tRPC 服务器配置
├── context.ts             # tRPC 上下文 (认证、数据库等)
├── middleware.ts          # 中间件 (认证检查等)
└── routers/               # API 路由定义
    ├── index.ts          # 根路由器
    ├── auth.ts           # 认证相关 API
    ├── user.ts           # 用户管理 API
    ├── ai.ts             # AI 功能 API
    ├── payment.ts        # 支付相关 API
    └── admin.ts          # 管理员 API

tRPC 基础配置

服务器端配置

// src/lib/trpc/server.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { ZodError } from 'zod'
import superjson from 'superjson'
import { Context } from './context'

// 初始化 tRPC
const t = initTRPC.context<Context>().create({
  transformer: superjson, // 支持 Date、Map、Set 等类型
  errorFormatter(opts) {
    const { shape, error } = opts
    
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    }
  },
})

// 导出路由器和程序构建器
export const createTRPCRouter = t.router
export const publicProcedure = t.procedure
export const createCallerFactory = t.createCallerFactory

// 认证中间件
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.userId) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: '您需要登录才能访问此功能',
    })
  }
  
  return next({
    ctx: {
      ...ctx,
      userId: ctx.userId,
    },
  })
})

// 管理员中间件
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
  if (!ctx.userId || ctx.userRole !== 'admin') {
    throw new TRPCError({
      code: 'FORBIDDEN',
      message: '您没有管理员权限',
    })
  }
  
  return next({
    ctx: {
      ...ctx,
      userId: ctx.userId,
      userRole: ctx.userRole,
    },
  })
})

// 导出受保护的程序
export const protectedProcedure = publicProcedure.use(enforceUserIsAuthed)
export const adminProcedure = publicProcedure.use(enforceUserIsAdmin)

tRPC 上下文

// src/lib/trpc/context.ts
import { auth } from '@clerk/nextjs'
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
import { db } from '@/lib/db'

// 创建 tRPC 上下文
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
  const { userId } = auth()
  
  // 获取用户信息
  let user = null
  if (userId) {
    user = await db.query.users.findFirst({
      where: (users, { eq }) => eq(users.clerkId, userId),
    })
  }
  
  return {
    db,
    userId,
    user,
    userRole: user?.role,
    headers: opts.req.headers,
  }
}

export type Context = Awaited<ReturnType<typeof createTRPCContext>>

客户端配置

// src/lib/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query'
import { loggerLink, unstable_httpBatchStreamLink } from '@trpc/client'
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server'
import superjson from 'superjson'
import { AppRouter } from './routers'

// 创建 tRPC React 钩子
export const api = createTRPCReact<AppRouter>()

// 获取基础 URL
function getBaseUrl() {
  if (typeof window !== 'undefined') return '' // 浏览器使用相对 URL
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` // Vercel
  return `http://localhost:${process.env.PORT ?? 3000}` // 开发环境
}

// tRPC 客户端配置
export function createTRPCClient() {
  return api.createClient({
    transformer: superjson,
    links: [
      loggerLink({
        enabled: (op) =>
          process.env.NODE_ENV === 'development' ||
          (op.direction === 'down' && op.result instanceof Error),
      }),
      unstable_httpBatchStreamLink({
        url: `${getBaseUrl()}/api/trpc`,
        headers() {
          const headers = new Map<string, string>()
          headers.set('x-trpc-source', 'react')
          return Object.fromEntries(headers)
        },
      }),
    ],
  })
}

// 类型推断
export type RouterInputs = inferRouterInputs<AppRouter>
export type RouterOutputs = inferRouterOutputs<AppRouter>

API 路由器定义

根路由器

// src/lib/trpc/routers/index.ts
import { createTRPCRouter } from '../server'
import { authRouter } from './auth'
import { userRouter } from './user'
import { aiRouter } from './ai'
import { paymentRouter } from './payment'
import { adminRouter } from './admin'

// 应用程序的主路由器
export const appRouter = createTRPCRouter({
  auth: authRouter,
  user: userRouter,
  ai: aiRouter,
  payment: paymentRouter,
  admin: adminRouter,
})

export type AppRouter = typeof appRouter

认证路由器

// src/lib/trpc/routers/auth.ts
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../server'
import { createUser, getUserByClerkId, updateUser } from '@/lib/db/queries/users'

export const authRouter = createTRPCRouter({
  // 获取当前用户信息
  me: protectedProcedure.query(async ({ ctx }) => {
    const user = await getUserByClerkId(ctx.userId)
    
    if (!user) {
      throw new TRPCError({
        code: 'NOT_FOUND',
        message: '用户不存在',
      })
    }
    
    return user
  }),

  // 同步用户数据 (从 Clerk 到数据库)
  syncUser: publicProcedure
    .input(
      z.object({
        clerkId: z.string(),
        email: z.string().email(),
        name: z.string().optional(),
        imageUrl: z.string().url().optional(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const existingUser = await getUserByClerkId(input.clerkId)
      
      if (existingUser) {
        // 更新现有用户
        return await updateUser(input.clerkId, {
          email: input.email,
          name: input.name,
          imageUrl: input.imageUrl,
        })
      } else {
        // 创建新用户
        return await createUser(input)
      }
    }),

  // 更新用户资料
  updateProfile: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100).optional(),
        imageUrl: z.string().url().optional(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      return await updateUser(ctx.userId, input)
    }),
})

AI 功能路由器

// src/lib/trpc/routers/ai.ts
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { createTRPCRouter, protectedProcedure } from '../server'
import { 
  createConversation, 
  getUserConversations, 
  getConversationWithMessages,
  addMessageToConversation,
  deleteConversation 
} from '@/lib/db/queries/conversations'
import { incrementUserAIUsage } from '@/lib/db/queries/users'
import { generateAIResponse } from '@/lib/ai'

export const aiRouter = createTRPCRouter({
  // 获取用户的对话列表
  getConversations: protectedProcedure
    .input(
      z.object({
        page: z.number().min(1).default(1),
        limit: z.number().min(1).max(50).default(20),
      })
    )
    .query(async ({ input, ctx }) => {
      const user = ctx.user!
      return await getUserConversations(user.id, input.page, input.limit)
    }),

  // 创建新对话
  createConversation: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1).max(100),
        model: z.string().min(1),
        provider: z.enum(['openai', 'anthropic', 'google', 'xai']),
        systemPrompt: z.string().optional(),
        temperature: z.number().min(0).max(2).optional(),
        maxTokens: z.number().min(1).max(8000).optional(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const user = ctx.user!
      
      return await createConversation({
        userId: user.id,
        ...input,
      })
    }),

  // 获取对话详情和消息
  getConversation: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      const user = ctx.user!
      const conversation = await getConversationWithMessages(input.id, user.id)
      
      if (!conversation) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: '对话不存在',
        })
      }
      
      return conversation
    }),

  // 发送消息
  sendMessage: protectedProcedure
    .input(
      z.object({
        conversationId: z.string(),
        content: z.string().min(1),
        model: z.string(),
        provider: z.enum(['openai', 'anthropic', 'google', 'xai']),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const user = ctx.user!
      
      // 检查用户使用限制
      if (user.aiUsageCount >= user.aiUsageLimit && user.planType === 'free') {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: '您已达到免费计划的使用限制,请升级到付费计划',
        })
      }
      
      // 添加用户消息
      const userMessage = await addMessageToConversation({
        conversationId: input.conversationId,
        role: 'user',
        content: input.content,
      })
      
      try {
        // 生成 AI 响应
        const aiResponse = await generateAIResponse({
          provider: input.provider,
          model: input.model,
          messages: [{ role: 'user', content: input.content }],
        })
        
        // 添加 AI 响应消息
        const assistantMessage = await addMessageToConversation({
          conversationId: input.conversationId,
          role: 'assistant',
          content: aiResponse.content,
          tokenCount: aiResponse.usage?.total_tokens,
          metadata: {
            model: input.model,
            provider: input.provider,
            usage: aiResponse.usage,
          },
        })
        
        // 增加用户使用次数
        await incrementUserAIUsage(user.id)
        
        return {
          userMessage,
          assistantMessage,
        }
      } catch (error) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'AI 响应生成失败,请稍后重试',
          cause: error,
        })
      }
    }),

  // 删除对话
  deleteConversation: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input, ctx }) => {
      const user = ctx.user!
      const success = await deleteConversation(input.id, user.id)
      
      if (!success) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: '对话不存在或无权限删除',
        })
      }
      
      return { success: true }
    }),
})

支付路由器

// src/lib/trpc/routers/payment.ts
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { createTRPCRouter, protectedProcedure } from '../server'
import { createStripeCheckoutSession, createStripePortalSession } from '@/lib/stripe'
import { getUserPayments, getActiveSubscription } from '@/lib/db/queries/payments'

export const paymentRouter = createTRPCRouter({
  // 获取用户的支付历史
  getPayments: protectedProcedure
    .input(
      z.object({
        page: z.number().min(1).default(1),
        limit: z.number().min(1).max(50).default(20),
      })
    )
    .query(async ({ input, ctx }) => {
      const user = ctx.user!
      return await getUserPayments(user.id, input.page, input.limit)
    }),

  // 获取当前订阅状态
  getSubscription: protectedProcedure.query(async ({ ctx }) => {
    const user = ctx.user!
    const subscription = await getActiveSubscription(user.id)
    
    return {
      hasActiveSubscription: !!subscription,
      subscription,
      planType: user.planType,
      aiUsageCount: user.aiUsageCount,
      aiUsageLimit: user.aiUsageLimit,
    }
  }),

  // 创建 Stripe 结账会话
  createCheckoutSession: protectedProcedure
    .input(
      z.object({
        planType: z.enum(['basic', 'pro']),
        billingPeriod: z.enum(['monthly', 'yearly']).default('monthly'),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const user = ctx.user!
      
      try {
        const checkoutUrl = await createStripeCheckoutSession({
          userId: user.id,
          userEmail: user.email,
          planType: input.planType,
          billingPeriod: input.billingPeriod,
        })
        
        return { checkoutUrl }
      } catch (error) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: '创建支付会话失败',
          cause: error,
        })
      }
    }),

  // 创建 Stripe 客户门户会话
  createPortalSession: protectedProcedure.mutation(async ({ ctx }) => {
    const user = ctx.user!
    
    try {
      const portalUrl = await createStripePortalSession({
        userId: user.id,
        userEmail: user.email,
      })
      
      return { portalUrl }
    } catch (error) {
      throw new TRPCError({
        code: 'INTERNAL_SERVER_ERROR',
        message: '创建客户门户会话失败',
        cause: error,
      })
    }
  }),
})

管理员路由器

// src/lib/trpc/routers/admin.ts
import { z } from 'zod'
import { createTRPCRouter, adminProcedure } from '../server'
import { db } from '@/lib/db'
import { users, conversations, payments } from '@/lib/db/schema'
import { count, desc, sql } from 'drizzle-orm'

export const adminRouter = createTRPCRouter({
  // 获取系统统计信息
  getStats: adminProcedure.query(async () => {
    const [userCount] = await db.select({ count: count() }).from(users)
    const [conversationCount] = await db.select({ count: count() }).from(conversations)
    const [paymentCount] = await db.select({ count: count() }).from(payments)
    
    // 获取收入统计
    const [revenueStats] = await db
      .select({
        totalRevenue: sql<number>`COALESCE(SUM(CAST(amount AS DECIMAL)), 0)`,
        monthlyRevenue: sql<number>`COALESCE(SUM(CASE WHEN payments.created_at >= DATE_TRUNC('month', CURRENT_DATE) THEN CAST(amount AS DECIMAL) ELSE 0 END), 0)`,
      })
      .from(payments)
      .where(sql`status = 'succeeded'`)
    
    return {
      totalUsers: userCount.count,
      totalConversations: conversationCount.count,
      totalPayments: paymentCount.count,
      totalRevenue: revenueStats.totalRevenue,
      monthlyRevenue: revenueStats.monthlyRevenue,
    }
  }),

  // 获取用户列表
  getUsers: adminProcedure
    .input(
      z.object({
        page: z.number().min(1).default(1),
        limit: z.number().min(1).max(100).default(20),
        search: z.string().optional(),
      })
    )
    .query(async ({ input }) => {
      const offset = (input.page - 1) * input.limit
      
      let query = db
        .select({
          id: users.id,
          clerkId: users.clerkId,
          email: users.email,
          name: users.name,
          role: users.role,
          planType: users.planType,
          aiUsageCount: users.aiUsageCount,
          createdAt: users.createdAt,
        })
        .from(users)
      
      if (input.search) {
        query = query.where(
          sql`${users.email} ILIKE ${`%${input.search}%`} OR ${users.name} ILIKE ${`%${input.search}%`}`
        )
      }
      
      const userList = await query
        .orderBy(desc(users.createdAt))
        .limit(input.limit)
        .offset(offset)
      
      // 获取总数
      const [{ total }] = await db
        .select({ total: count() })
        .from(users)
        .where(
          input.search
            ? sql`${users.email} ILIKE ${`%${input.search}%`} OR ${users.name} ILIKE ${`%${input.search}%`}`
            : sql`1=1`
        )
      
      return {
        users: userList,
        total,
        page: input.page,
        limit: input.limit,
        totalPages: Math.ceil(total / input.limit),
      }
    }),

  // 更新用户角色
  updateUserRole: adminProcedure
    .input(
      z.object({
        userId: z.string(),
        role: z.enum(['user', 'admin']),
      })
    )
    .mutation(async ({ input }) => {
      const [updatedUser] = await db
        .update(users)
        .set({ role: input.role, updatedAt: new Date() })
        .where(sql`id = ${input.userId}`)
        .returning()
      
      return updatedUser
    }),
})

Webhook 处理

Stripe Webhook 处理器

// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import Stripe from 'stripe'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
import { users, payments } from '@/lib/db/schema'
import { createPayment, updatePaymentStatus } from '@/lib/db/queries/payments'
import { updateUser } from '@/lib/db/queries/users'
import { eq } from 'drizzle-orm'

export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = headers().get('stripe-signature') as string
  
  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 NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    )
  }
  
  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session
        await handleCheckoutCompleted(session)
        break
      }
      
      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription
        await handleSubscriptionChange(subscription)
        break
      }
      
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription
        await handleSubscriptionDeleted(subscription)
        break
      }
      
      case 'invoice.payment_succeeded': {
        const invoice = event.data.object as Stripe.Invoice
        await handlePaymentSucceeded(invoice)
        break
      }
      
      case 'invoice.payment_failed': {
        const invoice = event.data.object as Stripe.Invoice
        await handlePaymentFailed(invoice)
        break
      }
      
      default:
        console.log(`Unhandled event type: ${event.type}`)
    }
    
    return NextResponse.json({ received: true })
  } catch (error) {
    console.error('Webhook handler failed:', error)
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    )
  }
}

// 处理结账完成
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const { customer, subscription, metadata } = session
  
  if (!metadata?.userId) {
    throw new Error('Missing userId in checkout session metadata')
  }
  
  // 创建支付记录
  await createPayment({
    userId: metadata.userId,
    stripeCustomerId: customer as string,
    stripeSubscriptionId: subscription as string,
    amount: ((session.amount_total || 0) / 100).toString(),
    currency: session.currency || 'usd',
    status: 'succeeded',
    planType: metadata.planType || 'basic',
    billingPeriod: metadata.billingPeriod,
  })
  
  // 更新用户订阅状态
  await updateUser(metadata.clerkId, {
    subscriptionId: subscription as string,
    subscriptionStatus: 'active',
    planType: metadata.planType as 'basic' | 'pro',
    aiUsageLimit: metadata.planType === 'pro' ? 10000 : 1000,
  })
}

// 处理订阅变更
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string
  
  // 根据客户 ID 查找用户
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.subscriptionId, subscription.id))
    .limit(1)
  
  if (!user) {
    console.error('User not found for subscription:', subscription.id)
    return
  }
  
  // 更新用户订阅状态
  await updateUser(user.clerkId, {
    subscriptionStatus: subscription.status,
    planType: subscription.status === 'active' ? user.planType : 'free',
  })
}

// 处理订阅删除
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.subscriptionId, subscription.id))
    .limit(1)
  
  if (!user) {
    console.error('User not found for subscription:', subscription.id)
    return
  }
  
  // 重置用户为免费计划
  await updateUser(user.clerkId, {
    subscriptionId: null,
    subscriptionStatus: null,
    planType: 'free',
    aiUsageLimit: 10,
  })
}

// 处理支付成功
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string
  
  // 更新支付状态
  const payment = await db.query.payments.findFirst({
    where: (payments, { eq }) => eq(payments.stripeSubscriptionId, subscriptionId),
  })
  
  if (payment) {
    await updatePaymentStatus(payment.id, 'succeeded')
  }
}

// 处理支付失败
async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string
  
  const payment = await db.query.payments.findFirst({
    where: (payments, { eq }) => eq(payments.stripeSubscriptionId, subscriptionId),
  })
  
  if (payment) {
    await updatePaymentStatus(payment.id, 'failed')
  }
}

Clerk Webhook 处理器

// src/app/api/webhooks/clerk/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { Webhook } from 'svix'
import { WebhookEvent } from '@clerk/nextjs/server'
import { createUser, updateUser, deleteUser } from '@/lib/db/queries/users'

export async function POST(req: NextRequest) {
  const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET
  
  if (!WEBHOOK_SECRET) {
    throw new Error('Please add CLERK_WEBHOOK_SECRET to .env.local')
  }
  
  // 获取头部
  const headerPayload = headers()
  const svix_id = headerPayload.get('svix-id')
  const svix_timestamp = headerPayload.get('svix-timestamp')
  const svix_signature = headerPayload.get('svix-signature')
  
  if (!svix_id || !svix_timestamp || !svix_signature) {
    return new Response('Error occurred -- no svix headers', {
      status: 400,
    })
  }
  
  // 获取请求体
  const payload = await req.json()
  const body = JSON.stringify(payload)
  
  // 创建 Svix 实例
  const wh = new Webhook(WEBHOOK_SECRET)
  
  let evt: WebhookEvent
  
  try {
    evt = wh.verify(body, {
      'svix-id': svix_id,
      'svix-timestamp': svix_timestamp,
      'svix-signature': svix_signature,
    }) as WebhookEvent
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error occurred', {
      status: 400,
    })
  }
  
  const eventType = evt.type
  
  if (eventType === 'user.created') {
    const { id, email_addresses, first_name, last_name, image_url } = evt.data
    
    await createUser({
      clerkId: id,
      email: email_addresses[0]?.email_address,
      name: `${first_name || ''} ${last_name || ''}`.trim() || null,
      imageUrl: image_url,
    })
  }
  
  if (eventType === 'user.updated') {
    const { id, email_addresses, first_name, last_name, image_url } = evt.data
    
    await updateUser(id, {
      email: email_addresses[0]?.email_address,
      name: `${first_name || ''} ${last_name || ''}`.trim() || null,
      imageUrl: image_url,
    })
  }
  
  if (eventType === 'user.deleted') {
    const { id } = evt.data
    
    if (id) {
      await deleteUser(id)
    }
  }
  
  return NextResponse.json({ message: 'Webhook processed successfully' })
}

客户端使用

tRPC Provider 设置

// src/components/providers/trpc-provider.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
import { api, createTRPCClient } from '@/lib/trpc/client'

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000, // 5 分钟
            refetchOnWindowFocus: false,
          },
        },
      })
  )
  
  const [trpcClient] = useState(() => createTRPCClient())
  
  return (
    <api.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </api.Provider>
  )
}

在组件中使用 API

// src/components/dashboard/conversation-list.tsx
'use client'

import { api } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Loader2, MessageSquare, Trash2 } from 'lucide-react'

export function ConversationList() {
  // 获取对话列表
  const {
    data: conversationsData,
    isLoading,
    refetch,
  } = api.ai.getConversations.useQuery({
    page: 1,
    limit: 20,
  })
  
  // 删除对话
  const deleteConversation = api.ai.deleteConversation.useMutation({
    onSuccess: () => {
      refetch() // 重新获取列表
    },
  })
  
  if (isLoading) {
    return (
      <div className="flex items-center justify-center p-8">
        <Loader2 className="h-8 w-8 animate-spin" />
      </div>
    )
  }
  
  const conversations = conversationsData?.conversations || []
  
  return (
    <div className="space-y-4">
      <h2 className="text-2xl font-bold">我的对话</h2>
      
      {conversations.length === 0 ? (
        <Card>
          <CardContent className="flex items-center justify-center py-12">
            <div className="text-center">
              <MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
              <p className="text-muted-foreground">还没有对话记录</p>
            </div>
          </CardContent>
        </Card>
      ) : (
        <div className="grid gap-4">
          {conversations.map((conversation) => (
            <Card key={conversation.id}>
              <CardHeader className="flex flex-row items-center justify-between">
                <CardTitle className="text-lg">{conversation.title}</CardTitle>
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => deleteConversation.mutate({ id: conversation.id })}
                  disabled={deleteConversation.isLoading}
                >
                  <Trash2 className="h-4 w-4" />
                </Button>
              </CardHeader>
              <CardContent>
                <div className="flex items-center justify-between text-sm text-muted-foreground">
                  <span>{conversation.provider} - {conversation.model}</span>
                  <span>{conversation.messageCount} 条消息</span>
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </div>
  )
}

发送 AI 消息的表单

// src/components/ai/chat-form.tsx
'use client'

import { useState } from 'react'
import { api } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { toast } from '@/components/ui/use-toast'
import { Send, Loader2 } from 'lucide-react'

interface ChatFormProps {
  conversationId: string
  onMessageSent?: () => void
}

export function ChatForm({ conversationId, onMessageSent }: ChatFormProps) {
  const [message, setMessage] = useState('')
  
  // 发送消息
  const sendMessage = api.ai.sendMessage.useMutation({
    onSuccess: () => {
      setMessage('')
      onMessageSent?.()
      toast({
        title: '消息发送成功',
        description: 'AI 正在生成回复中...',
      })
    },
    onError: (error) => {
      toast({
        title: '发送失败',
        description: error.message,
        variant: 'destructive',
      })
    },
  })
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    
    if (!message.trim()) return
    
    sendMessage.mutate({
      conversationId,
      content: message.trim(),
      model: 'gpt-4',
      provider: 'openai',
    })
  }
  
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <Textarea
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        placeholder="输入您的消息..."
        rows={3}
        disabled={sendMessage.isLoading}
      />
      
      <Button
        type="submit"
        disabled={!message.trim() || sendMessage.isLoading}
        className="w-full"
      >
        {sendMessage.isLoading ? (
          <Loader2 className="h-4 w-4 animate-spin mr-2" />
        ) : (
          <Send className="h-4 w-4 mr-2" />
        )}
        发送消息
      </Button>
    </form>
  )
}

错误处理和类型推断

客户端错误处理

// src/lib/trpc/error-handler.ts
import { TRPCClientError } from '@trpc/client'
import { toast } from '@/components/ui/use-toast'
import { AppRouter } from './routers'

export function handleTRPCError(error: TRPCClientError<AppRouter>) {
  // 处理不同类型的错误
  switch (error.data?.code) {
    case 'UNAUTHORIZED':
      toast({
        title: '认证失败',
        description: '请重新登录',
        variant: 'destructive',
      })
      // 重定向到登录页面
      window.location.href = '/auth/sign-in'
      break
      
    case 'FORBIDDEN':
      toast({
        title: '权限不足',
        description: error.message,
        variant: 'destructive',
      })
      break
      
    case 'BAD_REQUEST':
      if (error.data?.zodError) {
        // 处理 Zod 验证错误
        const fieldErrors = error.data.zodError.fieldErrors
        Object.entries(fieldErrors).forEach(([field, messages]) => {
          toast({
            title: `${field} 验证失败`,
            description: messages?.[0],
            variant: 'destructive',
          })
        })
      } else {
        toast({
          title: '请求错误',
          description: error.message,
          variant: 'destructive',
        })
      }
      break
      
    default:
      toast({
        title: '操作失败',
        description: error.message || '请稍后重试',
        variant: 'destructive',
      })
  }
}

类型安全的使用方式

// 完全类型安全的 API 调用
import { api, type RouterInputs, type RouterOutputs } from '@/lib/trpc/client'

// 输入类型
type CreateConversationInput = RouterInputs['ai']['createConversation']
type SendMessageInput = RouterInputs['ai']['sendMessage']

// 输出类型  
type ConversationList = RouterOutputs['ai']['getConversations']
type ConversationDetail = RouterOutputs['ai']['getConversation']

// 在组件中使用
export function MyComponent() {
  const conversation = api.ai.getConversation.useQuery({ id: 'conversation-id' })
  
  // TypeScript 会自动推断类型
  if (conversation.data) {
    console.log(conversation.data.title) // ✅ 类型安全
    console.log(conversation.data.messages) // ✅ 类型安全
  }
}

最佳实践总结

1. API 设计原则

  • 使用 Zod 进行输入验证和类型推断
  • 实现统一的错误处理和用户友好的错误消息
  • 合理使用中间件进行认证和权限检查
  • 保持 API 的 RESTful 语义

2. 性能优化

  • 使用 TanStack Query 的缓存功能
  • 实现适当的分页和限制
  • 使用事务确保数据一致性
  • 批量操作减少数据库查询次数

3. 安全考虑

  • 严格的认证和授权检查
  • 输入验证和数据清理
  • Webhook 签名验证
  • 错误信息不暴露敏感数据

4. 开发体验

  • 完整的 TypeScript 类型推断
  • 优秀的开发者工具支持
  • 清晰的错误消息和调试信息
  • 统一的代码风格和结构

这个 API 开发指南为构建类型安全、高性能的现代化 API 提供了完整的解决方案,充分利用了 tRPC、Clerk Auth 和现代 TypeScript 生态系统的优势。

On this page

技术栈概述核心技术API 架构特点tRPC 项目结构tRPC 基础配置服务器端配置tRPC 上下文客户端配置API 路由器定义根路由器认证路由器AI 功能路由器支付路由器管理员路由器Webhook 处理Stripe Webhook 处理器Clerk Webhook 处理器客户端使用tRPC Provider 设置在组件中使用 API发送 AI 消息的表单错误处理和类型推断客户端错误处理类型安全的使用方式最佳实践总结1. API 设计原则2. 性能优化3. 安全考虑4. 开发体验