Next.js: 综合项目:初始化与认证
最后更新:2026-08-26
搭建一个完整的 SaaS 项目就像盖一栋楼——先用脚手架搭好结构框架,再设计水电线路(数据库),最后装上大门锁具(认证系统)。
1. 你将学到
- 使用
create-next-app+ Tailwind + shadcn/ui 初始化企业级项目 - Prisma Schema 设计五模型(User / Organization / Project / Task / Comment)及枚举类型
- Auth.js v5 集成 Credentials + Google OAuth 双认证方案
- 组织邀请码机制与成员管理
- Middleware 路由保护与集中式 Session 管理
- 数据库 Seeding 生成 100 条演示数据
2. 一个全栈工程师的真实故事
(1) 痛点:从零搭建 SaaS 平台的混乱开局
Alice 是一家 50 人创业公司的全栈工程师。公司决定开发一个内部项目协同管理平台 "TaskFlow",服务 10,000 名用户,每人管理 200+ 任务。Alice 试过手动搭建项目结构——但路由保护混乱、数据库迁移频繁出错、认证方案选了 3 个都被否决。团队每天浪费 4 小时在环境配置上,项目进度滞后 2 周。
(2) 脚手架 + 认证框架的解法
用
create-next-app一键初始化 + Prisma ORM 声明式建模 + Auth.js 开箱即用。
BASH
npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
npx prisma init --datasource-provider postgresql
npx auth add
三行命令解决了 80% 的初始化工作。
(3) 收益
| 维度 | 手动搭建 | TaskFlow 方案 |
|---|---|---|
| 初始化时间 | 2 天 | 2 小时 |
| 认证方案研发 | 1 周(自研 JWT) | 30 分钟(Auth.js 集成) |
| 数据库迁移 | 手动 SQL 脚本 | Prisma Migrate 声明式 |
| 项目结构规范 | 团队各有风格 | App Router 约定式路由 |
| 可维护性 | 低(无类型安全) | 高(TypeScript + Prisma 类型) |
3. 项目初始化与配置
(1) create-next-app 脚手架选型
graph LR
A[create-next-app] --> B[TypeScript]
A --> C[Tailwind CSS]
A --> D[ESLint]
A --> E[App Router]
A --> F[src/ 目录]
A --> G[@/ 别名]
style A fill:#cce5ff
style B fill:#d4edda
style C fill:#d4edda
| 选项 | 选择值 | 说明 |
|---|---|---|
| TypeScript | Yes | 类型安全,生产必备 |
| ESLint | Yes | 代码规范 |
| Tailwind CSS | Yes | 与 shadcn/ui 配合 |
src/ 目录 |
Yes | 与 app/ 分开,结构清晰 |
| App Router | Yes | Next.js 16 默认 |
| import alias | @/* |
简洁导入路径 |
▶ 示例:初始化 TaskFlow 项目
BASH
# 1. 创建项目
npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
# 2. 进入目录
cd taskflow
# 3. 安装 shadcn/ui
npx shadcn@latest init -d
# 4. 添加 shadcn/ui 常用组件
npx shadcn@latest add button card input label select dialog dropdown-menu avatar badge separator
# 5. 启动开发服务器
npm run dev
TEXT
📖 仅展示
✔ Project created at: taskflow/
✔ shadcn/ui initialized successfully
✔ Component button installed
✔ Component card installed
✔ Component input installed
✔ 7 components installed in total
(2) 项目目录结构
TEXT
📖 仅展示
taskflow/
├── src/
│ ├── app/ # App Router 页面
│ │ ├── (auth)/ # 认证相关路由组
│ │ ├── (dashboard)/ # 仪表盘路由组
│ │ ├── api/ # API Routes
│ │ ├── layout.tsx # 根布局
│ │ └── page.tsx # 首页
│ ├── components/ # 共享组件
│ │ ├── ui/ # shadcn/ui 组件
│ │ └── forms/ # 表单组件
│ ├── lib/ # 工具函数
│ │ ├── auth.ts # Auth.js 配置
│ │ ├── db.ts # Prisma 客户端
│ │ └── utils.ts # 通用工具
│ ├── prisma/ # Prisma Schema + 迁移
│ │ ├── schema.prisma
│ │ └── seed.ts
│ └── middleware.ts # 路由保护中间件
├── public/ # 静态资源
├── next.config.ts # Next.js 配置
├── tailwind.config.ts # Tailwind 配置
└── package.json
4. Prisma Schema 五模型设计
(1) 数据模型关系图
graph TB
U[User] -->|belongs to| O[Organization]
O -->|has many| P[Project]
P -->|has many| T[Task]
T -->|has many| C[Comment]
U -->|assigned to| T
U -->|created by| C
U -->|created by| P
style U fill:#cce5ff
style O fill:#d4edda
style P fill:#ffeeba
style T fill:#f8d7da
style C fill:#d6d8db
(2) 枚举类型定义
| 枚举名 | 值 | 说明 |
|---|---|---|
Role |
OWNER / ADMIN / MEMBER / VIEWER |
组织角色 |
ProjectStatus |
ACTIVE / ARCHIVED / COMPLETED |
项目状态 |
TaskStatus |
TODO / IN_PROGRESS / DONE |
任务状态 |
TaskPriority |
LOW / MEDIUM / HIGH / URGENT |
任务优先级 |
(3) 完整 Prisma Schema
PRISMA
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
OWNER
ADMIN
MEMBER
VIEWER
}
enum ProjectStatus {
ACTIVE
ARCHIVED
COMPLETED
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
enum TaskPriority {
LOW
MEDIUM
HIGH
URGENT
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
passwordHash String?
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
role Role @default(MEMBER)
projects Project[]
assignedTasks Task[] @relation("TaskAssignee")
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
inviteCode String @unique @default(cuid())
logo String?
users User[]
projects Project[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Project {
id String @id @default(cuid())
name String
description String?
status ProjectStatus @default(ACTIVE)
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
creatorId String
creator User @relation(fields: [creatorId], references: [id])
tasks Task[]
color String @default("#3b82f6")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(TODO)
priority TaskPriority @default(MEDIUM)
projectId String
project Project @relation(fields: [projectId], references: [id])
assigneeId String?
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
creatorId String
creator User @relation(fields: [creatorId], references: [id])
order Int @default(0)
dueDate DateTime?
attachments Attachment[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(cuid())
content String
taskId String
task Task @relation(fields: [taskId], references: [id])
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Attachment {
id String @id @default(cuid())
fileName String
fileUrl String
fileSize Int
mimeType String
taskId String
task Task @relation(fields: [taskId], references: [id])
uploaderId String
uploader User @relation(fields: [uploaderId], references: [id])
createdAt DateTime @default(now())
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
▶ 示例:运行数据库迁移
BASH
# 1. 创建迁移
npx prisma migrate dev --name init
# 2. 生成 Prisma Client
npx prisma generate
# 3. 查看数据库
npx prisma studio
TEXT
📖 仅展示
✔ Generated Prisma Client (v6.x) to .\node_modules\@prisma\client
✔ Your database is now in sync with your Prisma schema
✔ Prisma Studio opened at http://localhost:5555
5. Auth.js 认证集成
(1) 认证方案对比
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| Auth.js v5 | 多 Provider、内置 Session、类型安全 | 学习曲线中等 | 需要灵活认证的项目 |
| Clerk | 开箱即用 UI、免配置 | 付费、厂商锁定 | 快速原型开发 |
| Supabase Auth | 免费额度大、与数据库集成 | 需绑定 Supabase 生态 | 全栈托管项目 |
| 自研 JWT | 完全控制 | 安全风险高、开发成本大 | 不推荐 |
(2) Auth.js 配置
TYPESCRIPT
// src/lib/auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import Google from "next-auth/providers/google"
import { compare } from "bcryptjs"
import { prisma } from "./db"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
})
if (!user || !user.passwordHash) {
return null
}
const isValid = await compare(
credentials.password as string,
user.passwordHash
)
if (!isValid) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
}
return session
},
},
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
session: {
strategy: "jwt",
},
})
▶ 示例:Auth.js API Routes
TYPESCRIPT
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
TYPESCRIPT
// src/app/api/auth/signup/route.ts
import { NextResponse } from "next/server"
import { hash } from "bcryptjs"
import { prisma } from "@/lib/db"
export async function POST(request: Request) {
try {
const { name, email, password, inviteCode } = await request.json()
const existingUser = await prisma.user.findUnique({
where: { email },
})
if (existingUser) {
return NextResponse.json(
{ error: "Email already registered" },
{ status: 400 }
)
}
const passwordHash = await hash(password, 12)
let organizationId: string | null = null
let role: "OWNER" | "ADMIN" | "MEMBER" | "VIEWER" = "MEMBER"
if (inviteCode) {
const org = await prisma.organization.findUnique({
where: { inviteCode },
})
if (org) {
organizationId = org.id
}
}
const user = await prisma.user.create({
data: {
name,
email,
passwordHash,
organizationId,
role: organizationId ? "MEMBER" : "OWNER",
},
})
if (!organizationId) {
const org = await prisma.organization.create({
data: {
name: `${name}'s Organization`,
slug: email.split("@")[0],
users: { connect: { id: user.id } },
},
})
await prisma.user.update({
where: { id: user.id },
data: { organizationId: org.id, role: "OWNER" },
})
}
return NextResponse.json(
{ message: "User created successfully" },
{ status: 201 }
)
} catch (error) {
return NextResponse.json(
{ error: "Something went wrong" },
{ status: 500 }
)
}
}
(3) 组织邀请码机制
TYPESCRIPT
// src/lib/invite.ts
import { prisma } from "./db"
export async function generateInviteCode(organizationId: string) {
const org = await prisma.organization.update({
where: { id: organizationId },
data: { inviteCode: crypto.randomUUID().slice(0, 8) },
})
return org.inviteCode
}
export async function validateInviteCode(code: string) {
// TKFLW-XXXXXX format
const fullCode = `TKFLW-${code.toUpperCase()}`
const org = await prisma.organization.findUnique({
where: { inviteCode: fullCode },
})
return org
}
▶ 示例:登录与注册页面
TSX
// src/app/(auth)/auth/signin/page.tsx
import { AuthCard } from "@/components/auth/auth-card"
import { SignInForm } from "@/components/auth/sign-in-form"
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<AuthCard
title="Sign in to TaskFlow"
description="Enter your credentials to continue"
>
<SignInForm />
</AuthCard>
</div>
)
}
TSX
// src/components/auth/sign-in-form.tsx
"use client"
import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent } from "@/components/ui/card"
export function SignInForm() {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setIsLoading(true)
setError(null)
const formData = new FormData(event.currentTarget)
const result = await signIn("credentials", {
email: formData.get("email") as string,
password: formData.get("password") as string,
redirect: false,
})
if (result?.error) {
setError("Invalid email or password")
setIsLoading(false)
return
}
router.push("/dashboard")
router.refresh()
}
return (
<Card>
<CardContent className="pt-6">
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="alice@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter your password"
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
Sign in with Google
</Button>
</form>
</CardContent>
</Card>
)
}
6. Middleware 路由保护
(1) 中间件工作流程
graph LR
A[Request] --> B{Middleware}
B -->|Public path| C[Allow]
B -->|Protected path| D{Has session?}
D -->|Yes| C
D -->|No| E[Redirect /auth/signin]
B -->|API route| F{Valid token?}
F -->|Yes| G[Forward]
F -->|No| H[401 Unauthorized]
style B fill:#ffeeba
style C fill:#d4edda
style E fill:#f8d7da
(2) Middleware 实现
TYPESCRIPT
// src/middleware.ts
import { auth } from "@/lib/auth"
import { NextResponse } from "next/server"
export default auth((req) => {
const { nextUrl } = req
const isLoggedIn = !!req.auth
const isApiRoute = nextUrl.pathname.startsWith("/api")
const isAuthRoute = nextUrl.pathname.startsWith("/auth")
const isDashboardRoute = nextUrl.pathname.startsWith("/dashboard")
const isPublicRoute =
nextUrl.pathname === "/" ||
nextUrl.pathname.startsWith("/_next") ||
nextUrl.pathname.startsWith("/static") ||
nextUrl.pathname === "/api/auth/signup"
if (isApiRoute && !isPublicRoute && !isLoggedIn) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
if (isDashboardRoute && !isLoggedIn) {
return NextResponse.redirect(new URL("/auth/signin", nextUrl))
}
if (isAuthRoute && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", nextUrl))
}
return NextResponse.next()
})
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico).*)",
],
}
▶ 示例:Middleware 路由保护测试
BASH
# 访问受保护路由(未登录)
curl -v http://localhost:3000/dashboard 2>&1 | grep Location
# 访问 API 路由(未登录)
curl -v http://localhost:3000/api/projects 2>&1
TEXT
📖 仅展示
< Location: http://localhost:3000/auth/signin
< HTTP/1.1 302 Found
{"error":"Unauthorized"}
(3) Matcher 配置策略
| 模式 | 匹配路径 | 排除路径 |
|---|---|---|
/((?!_next/static).*) |
所有路由 | 静态资源 |
/((?!api/auth).*) |
所有路由 | 认证 API |
| `/((?!_next/static | _next/image).*)` | 所有路由 |
7. 数据库 Seeding
(1) Seeding 策略
graph TB
A[seed.ts] --> B[Create Organization]
B --> C[Create Users x5]
C --> D[Create Projects x5]
D --> E[Create Tasks x50]
E --> F[Create Comments x40]
F --> G[Create Attachments x10]
style A fill:#cce5ff
style G fill:#d4edda
▶ 示例:完整 Seeding 脚本
TYPESCRIPT
// src/prisma/seed.ts
import { PrismaClient, Role, TaskStatus, TaskPriority } from "@prisma/client"
import { hash } from "bcryptjs"
const prisma = new PrismaClient()
async function main() {
console.log("🌱 Seeding database...")
const passwordHash = await hash("password123", 12)
const org = await prisma.organization.create({
data: {
name: "Acme Corp",
slug: "acme-corp",
inviteCode: "TKFLW-ACMECORP",
},
})
const users = await Promise.all([
prisma.user.create({
data: {
name: "Alice Wang",
email: "alice@acme.com",
passwordHash,
organizationId: org.id,
role: "OWNER",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=alice",
},
}),
prisma.user.create({
data: {
name: "Bob Chen",
email: "bob@acme.com",
passwordHash,
organizationId: org.id,
role: "ADMIN",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=bob",
},
}),
prisma.user.create({
data: {
name: "Charlie Liu",
email: "charlie@acme.com",
passwordHash,
organizationId: org.id,
role: "MEMBER",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=charlie",
},
}),
prisma.user.create({
data: {
name: "Diana Park",
email: "diana@acme.com",
passwordHash,
organizationId: org.id,
role: "MEMBER",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=diana",
},
}),
prisma.user.create({
data: {
name: "Eve Zhang",
email: "eve@acme.com",
passwordHash,
organizationId: org.id,
role: "VIEWER",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=eve",
},
}),
])
const projectData = [
{ name: "Website Redesign", description: "Redesign the company website with modern UI", color: "#3b82f6" },
{ name: "Mobile App v2", description: "Version 2 of the mobile application", color: "#10b981" },
{ name: "API Integration", description: "Third-party API integrations", color: "#f59e0b" },
{ name: "Data Migration", description: "Migrate data from legacy system", color: "#ef4444" },
{ name: "Security Audit", description: "Q3 security audit and compliance", color: "#8b5cf6" },
]
const projects = await Promise.all(
projectData.map((p) =>
prisma.project.create({
data: {
...p,
organizationId: org.id,
creatorId: users[0].id,
},
})
)
)
const taskTitles = [
"Set up CI/CD pipeline", "Design system components", "Write unit tests",
"Implement user authentication", "Create database schema", "API documentation",
"Performance optimization", "Accessibility audit", "Mobile responsive layout",
"Error handling middleware", "Data validation", "Search functionality",
"Notification system", "File upload feature", "User dashboard",
"Admin panel", "Reporting module", "Email templates",
"WebSocket integration", "Cache layer", "Rate limiting",
"Logging system", "Backup strategy", "Monitoring setup",
"Load testing",
]
const statuses: TaskStatus[] = ["TODO", "IN_PROGRESS", "DONE"]
const priorities: TaskPriority[] = ["LOW", "MEDIUM", "HIGH", "URGENT"]
for (const project of projects.slice(0, 3)) {
const taskCount = project.name === "Website Redesign" ? 20 : 15
for (let i = 0; i < taskCount; i++) {
const task = await prisma.task.create({
data: {
title: taskTitles[i % taskTitles.length],
description: `Detailed description for task #${i + 1} in ${project.name}`,
status: statuses[i % statuses.length],
priority: priorities[i % priorities.length],
projectId: project.id,
assigneeId: users[i % users.length].id,
creatorId: users[0].id,
order: i,
dueDate: new Date(Date.now() + (i + 1) * 86400000 * 7),
},
})
if (i % 3 === 0) {
await prisma.comment.create({
data: {
content: `This task is progressing well. Need to review the implementation.`,
taskId: task.id,
authorId: users[(i + 1) % users.length].id,
},
})
}
}
}
const totalTasks = await prisma.task.count()
const totalComments = await prisma.comment.count()
console.log(`✅ Seeding complete:
- 1 Organization (${org.name})
- ${users.length} Users
- ${projects.length} Projects
- ${totalTasks} Tasks
- ${totalComments} Comments`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
BASH
# package.json 添加 seed 脚本
# "prisma": { "seed": "tsx src/prisma/seed.ts" }
# 运行 seeding
npx prisma db seed
TEXT
📖 仅展示
🌱 Seeding database...
✅ Seeding complete:
- 1 Organization (Acme Corp)
- 5 Users
- 5 Projects
- 50 Tasks
- 16 Comments
8. 完整示例:项目初始化到认证完整流程
BASH
# ============================================
# TaskFlow 初始化完整流程
# ============================================
# 1. 创建项目
npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd taskflow
# 2. 安装依赖
npm install next-auth@beta @prisma/client @auth/prisma-adapter bcryptjs
npm install prisma tsx --save-dev
npm install @types/bcryptjs --save-dev
# 3. 初始化 shadcn/ui
npx shadcn@latest init -d
npx shadcn@latest add button card input label select dialog dropdown-menu avatar badge separator
# 4. 初始化 Prisma
npx prisma init --datasource-provider postgresql
# 复制 schema.prisma 内容
npx prisma migrate dev --name init
npx prisma generate
# 5. 配置环境变量 (.env)
cat > .env << EOF
DATABASE_URL="postgresql://postgres:password@localhost:5432/taskflow"
AUTH_SECRET="my-super-secret-key-change-in-production"
AUTH_URL="http://localhost:3000"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
EOF
# 6. 运行 Seeding
npx prisma db seed
# 7. 启动开发服务器
npm run dev
预期输出(浏览器访问 http://localhost:3000):
TEXT
📖 仅展示
→ 自动跳转到 /auth/signin(未登录)
→ 显示登录表单(Email + Password + Google)
→ 使用 alice@acme.com / password123 登录
→ 跳转到 /dashboard
→ 侧边栏显示:Acme Corp · 5 Projects · 50 Tasks
❓ 常见问题
Q 为什么用 Auth.js 而不是 Clerk?
A Auth.js(NextAuth v5)是开源方案,支持 80+ Provider,无厂商锁定。Clerk 适合快速原型但付费后成本较高。TaskFlow 用 Auth.js 可以完全自控认证逻辑,且与 Prisma 无缝集成。
Q Prisma Schema 修改后如何同步数据库?
A 运行
npx prisma migrate dev --name <描述> 创建新迁移,Prisma 会自动生成 SQL 并执行。开发环境可以用 npx prisma db push 直接同步(不生成迁移文件)。Q 邀请码 TKFLW- 前缀有什么用?
A 前缀用于标识这是 TaskFlow 的邀请码,避免与其他系统混淆。完整邀请码示例:
TKFLW-ACMECORP。前缀 + 组织 slug 的组合让邀请码可读性高且唯一。Q Middleware 中的 matcher 配置为什么匹配所有路径?
A matcher 配置
/((?!_next/static|_next/image|favicon.ico).*) 匹配所有非静态资源路径,让中间件对所有页面路由和 API 路由进行认证检查。Next.js 推荐在 middleware 内部做路径判断而非在 matcher 中过度细分。Q Seeding 中的 tsx 是什么?
A tsx 是 TypeScript 执行器(
npm i tsx -D),可以直接运行 .ts 文件而无需编译。Prisma 官方推荐 tsx 作为 seed runner。也可以用 ts-node,但 tsx 速度更快且兼容性更好。Q 为什么 User 模型中的 passwordHash 是可选的?
A 因为支持 Google OAuth 登录,OAuth 用户不需要密码。Credentials 登录用户的 passwordHash 有值,OAuth 用户的 passwordHash 为 null。这体现了 Schema 设计的灵活性。
Q 多个 Provider 的 session 如何共享?
A Auth.js 的 JWT 策略会自动处理。无论用户通过 Credentials 还是 Google 登录,生成的 JWT token 包含相同的 userId。Session 回调中通过 userId 查询数据库获取完整用户信息。
📖 小节
create-next-app脚手架选择 TypeScript + Tailwind + App Router + src 目录作为企业级项目起点- shadcn/ui 提供无依赖的 UI 组件库,按需安装不增加包体积
- Prisma Schema 用声明式语法定义 User / Organization / Project / Task / Comment 五模型及枚举类型
- Auth.js v5 支持 Credentials + Google OAuth 双认证,JWT Session 策略无需数据库存储 Session
- Middleware 通过
auth()helper + matcher 配置实现全局路由保护 - 组织邀请码机制(
TKFLW-前缀)支持团队成员自助加入 - 数据库 Seeding 使用 tsx 驱动,可生成 100+ 条真实演示数据
📝 作业
-
基础题(⭐):按照本课步骤完成 TaskFlow 项目初始化,成功运行
npm run dev并在浏览器中看到登录页面。截图保存运行结果。 -
进阶题(⭐⭐):在 Prisma Schema 中添加一个
Label模型(标签系统),与 Task 建立多对多关系(通过中间表TaskLabel),然后运行迁移并生成类型。实现标签的 CRUD API Route。 -
挑战题(⭐⭐⭐):扩展 Auth.js 配置,添加 GitHub OAuth Provider;实现一个
middleware.ts中的角色检查逻辑——只有OWNER和ADMIN角色的用户可以访问/dashboard/admin路径;编写单元测试验证角色守卫逻辑。