Better SaaS Docs
开发者指南

API 开发指南

本指南涵盖了 Better SaaS 中 API 开发模式、Server Actions 和 Webhook 实现。

API 架构

Next.js App Router API 结构

src/app/api/
├── auth/
│   └── [...all]/
│       └── route.ts          # Better Auth 处理器
└── webhooks/
    └── stripe/
        └── route.ts          # Stripe webhook 处理器

API 路由模式

// src/app/api/example/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth/auth';

export async function GET(request: NextRequest) {
  try {
    // 身份验证检查
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session?.user) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    // 业务逻辑
    const data = await getExampleData(session.user.id);

    return NextResponse.json({ data });
  } catch (error) {
    console.error('API Error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

export async function POST(request: NextRequest) {
  try {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session?.user) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    const body = await request.json();
    
    // 验证输入
    const validatedData = validateInput(body);
    
    // 处理请求
    const result = await processData(validatedData, session.user.id);

    return NextResponse.json(result);
  } catch (error) {
    console.error('API Error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Server Actions

Server Action 模式

// src/server/actions/example-actions.ts
'use server';

import { auth } from '@/lib/auth/auth';
import { headers } from 'next/headers';
import { getErrorMessage } from './error-messages';
import { ErrorLogger } from '@/lib/logger/logger-utils';

const actionLogger = new ErrorLogger('example-actions');

export interface ExampleResponse {
  success: boolean;
  data?: any;
  error?: string;
}

export async function exampleAction(
  formData: FormData
): Promise<ExampleResponse> {
  let session: { user?: { id: string } } | null = null;

  try {
    // 身份验证
    session = await auth.api.getSession({
      headers: await headers(),
    });

    if (!session?.user) {
      throw new Error(await getErrorMessage('unauthorizedAccess'));
    }

    // 提取并验证数据
    const data = extractFormData(formData);
    validateData(data);

    // 业务逻辑
    const result = await processAction(data, session.user.id);

    return {
      success: true,
      data: result,
    };
  } catch (error) {
    actionLogger.logError(error as Error, {
      operation: 'exampleAction',
      userId: session?.user?.id,
    });

    return {
      success: false,
      error: error instanceof Error ? error.message : 'Action failed',
    };
  }
}

文件上传 Action

// src/server/actions/file-actions.ts
export async function uploadFileAction(
  formData: FormData
): Promise<FileUploadResponse> {
  let session: { user?: User } | null = null;
  let file: File | null = null;

  try {
    session = await auth.api.getSession({
      headers: await headers(),
    });

    if (!session?.user) {
      throw new Error(await getErrorMessage('unauthorizedAccess'));
    }

    file = formData.get('file') as File;

    if (!file) {
      throw new Error(await getErrorMessage('noFileSelected'));
    }

    const fileInfo = await uploadFile(file, session.user.id);

    return {
      success: true,
      file: fileInfo,
    };
  } catch (error) {
    fileErrorLogger.logError(error as Error, {
      operation: 'uploadFile',
      userId: session?.user?.id,
      fileName: file?.name,
    });

    throw new Error(
      error instanceof Error ? error.message : '文件上传失败'
    );
  }
}

错误处理

// src/server/actions/error-messages.ts
import { getLocale } from 'next-intl/server';

export async function getErrorMessage(key: string): Promise<string> {
  const locale = await getLocale();
  
  const messages = {
    zh: {
      unauthorizedAccess: '未授权访问',
      fileNotFound: '未找到文件',
      fileUploadFailed: '文件上传失败',
      // ... 更多消息
    },
    en: {
      unauthorizedAccess: 'Unauthorized access',
      fileNotFound: 'File not found',
      fileUploadFailed: 'File upload failed',
      // ... 更多消息
    },
  };

  const localeMessages = messages[locale as keyof typeof messages] || messages.en;
  return localeMessages[key as keyof typeof localeMessages] || key;
}

Webhook 实现

Stripe Webhook 处理器

// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { StripeProvider } from '@/payment/stripe/provider';
import { paymentRepository } from '@/server/db/repositories/payment-repository';

export async function POST(request: NextRequest) {
  try {
    const body = await request.text();
    const signature = request.headers.get('stripe-signature');

    if (!signature) {
      return NextResponse.json(
        { error: 'Missing signature' },
        { status: 400 }
      );
    }

    const stripeProvider = new StripeProvider();
    
    // 验证 webhook 签名
    const isValid = await stripeProvider.verifyWebhook(body, signature);
    if (!isValid) {
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 400 }
      );
    }

    // 构造事件
    const event = stripeProvider.constructWebhookEvent(body, signature);

    // 处理事件
    await handleStripeEvent(event);

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook error:', error);
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    );
  }
}

async function handleStripeEvent(event: Stripe.Event) {
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutSessionCompleted(event);
      break;
    case 'customer.subscription.updated':
      await handleSubscriptionUpdated(event);
      break;
    // ... 更多事件处理器
  }
}

身份验证集成

受保护的 API 路由

// src/lib/auth/api-middleware.ts
import { auth } from '@/lib/auth/auth';
import { isAdmin } from '@/lib/auth/permissions';
import { NextRequest } from 'next/server';

export async function withAuth(
  request: NextRequest,
  handler: (request: NextRequest, user: any) => Promise<Response>
) {
  try {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session?.user) {
      return new Response(
        JSON.stringify({ error: 'Unauthorized' }),
        { status: 401 }
      );
    }

    return handler(request, session.user);
  } catch (error) {
    return new Response(
      JSON.stringify({ error: 'Authentication failed' }),
      { status: 401 }
    );
  }
}

export async function withAdminAuth(
  request: NextRequest,
  handler: (request: NextRequest, user: any) => Promise<Response>
) {
  return withAuth(request, async (req, user) => {
    if (!isAdmin(user)) {
      return new Response(
        JSON.stringify({ error: 'Admin access required' }),
        { status: 403 }
      );
    }

    return handler(req, user);
  });
}

API 测试

集成测试

// tests/integration/api/example-api.test.ts
import { describe, it, expect } from '@jest/globals';

describe('Example API Integration Tests', () => {
  it('should handle authenticated requests', async () => {
    const mockSession = {
      user: { id: 'user_123', email: 'test@example.com' }
    };

    // Mock authentication
    jest.mocked(auth.api.getSession).mockResolvedValue(mockSession);

    const response = await GET(mockRequest);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data).toHaveProperty('data');
  });

  it('should reject unauthenticated requests', async () => {
    jest.mocked(auth.api.getSession).mockResolvedValue(null);

    const response = await GET(mockRequest);
    const data = await response.json();

    expect(response.status).toBe(401);
    expect(data.error).toBe('Unauthorized');
  });
});

最佳实践

1. 错误处理

  • 始终在 API 处理器中使用 try-catch 块
  • 使用一致的错误响应格式
  • 记录带有上下文信息的错误
  • 返回适当的 HTTP 状态码

2. 身份验证

  • 在每个受保护的端点上验证会话
  • 使用中间件处理通用身份验证逻辑
  • 实现适当的权限检查
  • 优雅地处理身份验证错误

3. 输入验证

  • 验证所有输入数据
  • 使用 TypeScript 进行类型安全
  • 为公共端点实现速率限制
  • 清理用户输入

4. 性能

  • 使用数据库连接池
  • 在适当的地方实现缓存
  • 优化数据库查询
  • 对大响应使用流式传输

5. 安全性

  • 验证 webhook 签名
  • 在生产环境中使用 HTTPS
  • 正确实现 CORS
  • 永远不要在响应中暴露敏感数据

常见模式

分页

export async function getListAction(options: {
  page?: number;
  limit?: number;
  search?: string;
}) {
  const { page = 1, limit = 20, search = '' } = options;
  const offset = (page - 1) * limit;

  const result = await db.select()
    .from(table)
    .where(search ? ilike(table.name, `%${search}%`) : undefined)
    .limit(limit)
    .offset(offset);

  const total = await db.select({ count: count() })
    .from(table)
    .where(search ? ilike(table.name, `%${search}%`) : undefined);

  return {
    data: result,
    pagination: {
      page,
      limit,
      total: total[0]?.count || 0,
      totalPages: Math.ceil((total[0]?.count || 0) / limit),
    },
  };
}

文件上传

export async function handleFileUpload(
  file: File,
  userId: string
): Promise<FileInfo> {
  // 验证文件
  const validation = validateFile(file);
  if (!validation.valid) {
    throw new Error(validation.error);
  }

  // 生成唯一文件名
  const filename = generateUniqueFilename(file.name);
  const r2Key = generateR2Key(filename);

  // 上传到存储
  const uploadResult = await r2Client.upload(r2Key, file);
  
  // 保存到数据库
  const fileRecord = await db.insert(fileTable).values({
    id: generateId(),
    filename,
    originalName: file.name,
    mimeType: file.type,
    size: file.size,
    r2Key,
    uploadUserId: userId,
  }).returning();

  return toFileInfo(fileRecord[0]);
}

本指南为在 Better SaaS 中开发强大的 API 提供了基础。遵循这些模式和最佳实践,以保持 API 端点的一致性和可靠性。