📚 现代前端开发 目录
阶段5:全栈与部署
第 35 / 35 课

毕业项目:全栈SaaS应用

从零构建一个完整的SaaS产品

📖 核心概念

💻 代码实现

SaaS应用架构 ✅ prisma
// 全栈SaaS项目结构
// my-saas/
// ├── src/
// │   ├── app/                    # Next.js App Router
// │   │   ├── (auth)/             # 认证相关页面
// │   │   │   ├── login/
// │   │   │   └── register/
// │   │   ├── (dashboard)/        # 主应用
// │   │   │   ├── layout.tsx      # 仪表盘布局
// │   │   │   ├── page.tsx        # 仪表盘首页
// │   │   │   ├── projects/       # 项目管理
// │   │   │   ├── settings/       # 设置
// │   │   │   └── billing/        # 计费
// │   │   ├── (landing)/          # 落地页
// │   │   │   ├── page.tsx
// │   │   │   └── pricing/
// │   │   └── api/
// │   │       ├── stripe/         # 支付Webhook
// │   │       └── webhooks/
// │   ├── components/
// │   │   ├── ui/                 # 基础UI组件
// │   │   └── features/           # 业务组件
// │   ├── lib/
// │   │   ├── db.ts               # Prisma实例
// │   │   ├── auth.ts             # 认证配置
// │   │   └── stripe.ts           # 支付配置
// │   └── stores/                 # Zustand状态
// ├── prisma/
// │   └── schema.prisma
// └── public/

// 多租户数据模型
// prisma/schema.prisma (核心部分)
model Tenant {
  id        String   @id @default(cuid())
  name      String
  slug      String   @unique
  plan      Plan     @default(FREE)
  members   Member[]
  projects  Project[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Member {
  id       String   @id @default(cuid())
  role     Role     @default(MEMBER)
  user     User     @relation(fields: [userId], references: [id])
  userId   String
  tenant   Tenant   @relation(fields: [tenantId], references: [id])
  tenantId String
  joinedAt DateTime @default(now())

  @@unique([userId, tenantId])
}

model Project {
  id          String    @id @default(cuid())
  name        String
  description String?
  status      Status    @default(ACTIVE)
  tenant      Tenant    @relation(fields: [tenantId], references: [id])
  tenantId    String
  tasks       Task[]
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

model Task {
  id          String   @id @default(cuid())
  title       String
  description String?
  priority    Priority @default(MEDIUM)
  status      TaskStatus @default(TODO)
  assignee    User?    @relation(fields: [assigneeId], references: [id])
  assigneeId  String?
  project     Project  @relation(fields: [projectId], references: [id])
  projectId   String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

enum Plan { FREE PRO ENTERPRISE }
enum Role { OWNER ADMIN MEMBER }
enum Status { ACTIVE ARCHIVED }
enum Priority { LOW MEDIUM HIGH URGENT }
enum TaskStatus { TODO IN_PROGRESS IN_REVIEW DONE }
订阅计费与核心功能 ✅ tsx
// Stripe订阅计费
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// 创建订阅
export async function createSubscription(tenantId: string, priceId: string) {
  const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
  if (!tenant) throw new Error('Tenant not found');

  let customerId = tenant.stripeCustomerId;
  if (!customerId) {
    const customer = await stripe.customers.create({
      metadata: { tenantId },
    });
    customerId = customer.id;
    await prisma.tenant.update({
      where: { id: tenantId },
      data: { stripeCustomerId: customerId },
    });
  }

  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    metadata: { tenantId },
  });

  return subscription;
}

// Webhook处理
// app/api/stripe/webhook/route.ts
export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get('stripe-signature')!;

  const event = stripe.webhooks.constructEvent(
    body, sig, process.env.STRIPE_WEBHOOK_SECRET!
  );

  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object;
      await prisma.tenant.update({
        where: { id: session.metadata.tenantId },
        data: { plan: 'PRO' },
      });
      break;
    case 'customer.subscription.deleted':
      // 降级为免费版
      break;
  }

  return new Response('OK', { status: 200 });
}

// 核心功能:任务看板
'use client';
import { useStore } from '@/stores/taskStore';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';

function TaskBoard() {
  const { tasks, moveTask } = useStore();

  const columns: Record<TaskStatus, Task[]> = {
    TODO: tasks.filter(t => t.status === 'TODO'),
    IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS'),
    IN_REVIEW: tasks.filter(t => t.status === 'IN_REVIEW'),
    DONE: tasks.filter(t => t.status === 'DONE'),
  };

  const handleDragEnd = (result: any) => {
    if (!result.destination) return;
    moveTask(result.draggableId, result.destination.droppableId);
  };

  return (
    <DragDropContext onDragEnd={handleDragEnd}>
      <div className="board">
        {Object.entries(columns).map(([status, items]) => (
          <Droppable key={status} droppableId={status}>
            {(provided) => (
              <div ref={provided.innerRef} {...provided.droppableProps} className="column">
                <h3>{status}</h3>
                {items.map((task, index) => (
                  <Draggable key={task.id} draggableId={task.id} index={index}>
                    {(provided) => (
                      <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
                        <TaskCard task={task} />
                      </div>
                    )}
                  </Draggable>
                )}
                {provided.placeholder}
              </div>
            )}
          </Droppable>
        ))}
      </div>
    </DragDropContext>
  );
}

🎯 练习任务

🏆 成就解锁
🏆 全栈SaaS工程师 — 从零构建完整的SaaS产品,成为全栈前端工程师!