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 # 管理员 APItRPC 基础配置
服务器端配置
// 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 生态系统的优势。