0.0.0.5
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
# Next.js API Routes + PostgreSQL 实施步骤
|
||||
|
||||
## 第一步:安装依赖
|
||||
|
||||
```bash
|
||||
# 数据库和 ORM
|
||||
pnpm add @prisma/client
|
||||
pnpm add -D prisma
|
||||
|
||||
# 认证
|
||||
pnpm add next-auth@beta
|
||||
pnpm add bcryptjs
|
||||
pnpm add -D @types/bcryptjs
|
||||
|
||||
# 数据验证
|
||||
pnpm add zod
|
||||
```
|
||||
|
||||
## 第二步:初始化 Prisma
|
||||
|
||||
```bash
|
||||
npx prisma init
|
||||
```
|
||||
|
||||
这会创建:
|
||||
- `prisma/schema.prisma` - 数据库模型定义
|
||||
- `.env` - 环境变量文件
|
||||
|
||||
## 第三步:配置数据库连接
|
||||
|
||||
### 选择数据库提供商
|
||||
|
||||
**推荐选项:**
|
||||
|
||||
1. **Vercel Postgres**(推荐)
|
||||
- 与 Vercel 部署完美集成
|
||||
- 免费额度:60小时计算时间/月
|
||||
- 自动备份
|
||||
|
||||
2. **Neon**
|
||||
- 无服务器 PostgreSQL
|
||||
- 免费额度:3GB 存储
|
||||
- 自动扩展
|
||||
|
||||
3. **Supabase**(仅用数据库)
|
||||
- 免费额度:500MB 数据库
|
||||
- 自动备份
|
||||
|
||||
### 配置 `.env`
|
||||
|
||||
```env
|
||||
# 数据库连接(示例使用 Vercel Postgres)
|
||||
DATABASE_URL="postgres://username:password@host:5432/database"
|
||||
|
||||
# NextAuth 配置
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
|
||||
|
||||
# 可选:邮件服务(用于邀请协作者)
|
||||
SMTP_HOST="smtp.gmail.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="your-email@gmail.com"
|
||||
SMTP_PASSWORD="your-app-password"
|
||||
```
|
||||
|
||||
## 第四步:定义数据库模型
|
||||
|
||||
编辑 `prisma/schema.prisma`:
|
||||
|
||||
```prisma
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// 用户表
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
password String
|
||||
avatar String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// 关系
|
||||
ownedTrees FamilyTree[] @relation("TreeOwner")
|
||||
collaborations TreeCollaborator[]
|
||||
activityLogs ActivityLog[]
|
||||
}
|
||||
|
||||
// 家族树表
|
||||
model FamilyTree {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
rootMemberId String?
|
||||
ownerId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// 关系
|
||||
owner User @relation("TreeOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
members FamilyMember[]
|
||||
collaborators TreeCollaborator[]
|
||||
activityLogs ActivityLog[]
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
// 协作者表
|
||||
model TreeCollaborator {
|
||||
id String @id @default(cuid())
|
||||
treeId String
|
||||
userId String
|
||||
role Role @default(VIEWER)
|
||||
invitedBy String
|
||||
invitedAt DateTime @default(now())
|
||||
|
||||
// 关系
|
||||
tree FamilyTree @relation(fields: [treeId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([treeId, userId])
|
||||
@@index([treeId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum Role {
|
||||
OWNER
|
||||
EDITOR
|
||||
VIEWER
|
||||
}
|
||||
|
||||
// 家族成员表
|
||||
model FamilyMember {
|
||||
id String @id @default(cuid())
|
||||
treeId String
|
||||
|
||||
// 基本信息
|
||||
surname String
|
||||
givenName String
|
||||
fullName String
|
||||
gender Gender
|
||||
generation Int
|
||||
generationName String?
|
||||
courtesyName String?
|
||||
artName String?
|
||||
posthumousName String?
|
||||
rank Int?
|
||||
|
||||
// 日期
|
||||
birthDate String?
|
||||
deathDate String?
|
||||
isLunarDate Boolean @default(false)
|
||||
|
||||
// 地点
|
||||
ancestralHome String?
|
||||
birthPlace String?
|
||||
burialPlace String?
|
||||
|
||||
// 联系方式
|
||||
phone String?
|
||||
telephone String?
|
||||
email String?
|
||||
address String?
|
||||
|
||||
// 关系
|
||||
fatherId String?
|
||||
motherId String?
|
||||
spouseIds String[] @default([])
|
||||
childrenIds String[] @default([])
|
||||
|
||||
// 配偶父母信息
|
||||
spouseFatherName String?
|
||||
spouseMotherName String?
|
||||
|
||||
// 内容
|
||||
bio String?
|
||||
tags String[] @default([])
|
||||
avatarUrl String?
|
||||
avatarImageId String?
|
||||
photoIds String[] @default([])
|
||||
|
||||
// 元数据
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// 关系
|
||||
tree FamilyTree @relation(fields: [treeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([treeId])
|
||||
@@index([fatherId])
|
||||
@@index([motherId])
|
||||
}
|
||||
|
||||
enum Gender {
|
||||
MALE
|
||||
FEMALE
|
||||
}
|
||||
|
||||
// 家族故事表
|
||||
model FamilyStory {
|
||||
id String @id @default(cuid())
|
||||
memberId String
|
||||
title String
|
||||
content String
|
||||
date String?
|
||||
tags String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// 操作日志表
|
||||
model ActivityLog {
|
||||
id String @id @default(cuid())
|
||||
treeId String
|
||||
userId String
|
||||
action Action
|
||||
entityType EntityType
|
||||
entityId String?
|
||||
entityName String?
|
||||
changes Json?
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
// 关系
|
||||
tree FamilyTree @relation(fields: [treeId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([treeId])
|
||||
@@index([userId])
|
||||
@@index([timestamp])
|
||||
}
|
||||
|
||||
enum Action {
|
||||
CREATE
|
||||
UPDATE
|
||||
DELETE
|
||||
IMPORT
|
||||
EXPORT
|
||||
}
|
||||
|
||||
enum EntityType {
|
||||
MEMBER
|
||||
PHOTO
|
||||
STORY
|
||||
SETTINGS
|
||||
}
|
||||
```
|
||||
|
||||
## 第五步:创建数据库表
|
||||
|
||||
```bash
|
||||
# 创建迁移
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 生成 Prisma Client
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
## 第六步:配置 NextAuth
|
||||
|
||||
创建 `lib/auth.ts`:
|
||||
|
||||
```typescript
|
||||
import { NextAuthOptions } from "next-auth"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter"
|
||||
import { prisma } from "./prisma"
|
||||
import bcrypt from "bcryptjs"
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error("请输入邮箱和密码")
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email }
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
throw new Error("用户不存在")
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(credentials.password, user.password)
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("密码错误")
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt"
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
创建 `lib/prisma.ts`:
|
||||
|
||||
```typescript
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
```
|
||||
|
||||
## 第七步:创建 API Routes
|
||||
|
||||
### 认证 API
|
||||
|
||||
`app/api/auth/[...nextauth]/route.ts`:
|
||||
|
||||
```typescript
|
||||
import NextAuth from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
```
|
||||
|
||||
`app/api/auth/register/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { email, password, name } = registerSchema.parse(body)
|
||||
|
||||
// 检查用户是否已存在
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "用户已存在" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
// 创建用户
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("注册失败:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "注册失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 家族树 API
|
||||
|
||||
`app/api/trees/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
// 获取用户的所有家族树
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const trees = await prisma.familyTree.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: session.user.id },
|
||||
{
|
||||
collaborators: {
|
||||
some: {
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ trees })
|
||||
}
|
||||
|
||||
// 创建新家族树
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { name, description } = await req.json()
|
||||
|
||||
const tree = await prisma.familyTree.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
ownerId: session.user.id,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ tree })
|
||||
}
|
||||
```
|
||||
|
||||
## 第八步:创建权限中间件
|
||||
|
||||
`lib/permissions.ts`:
|
||||
|
||||
```typescript
|
||||
import { prisma } from "./prisma"
|
||||
|
||||
export async function checkPermission(
|
||||
userId: string,
|
||||
treeId: string,
|
||||
requiredRole: "OWNER" | "EDITOR" | "VIEWER"
|
||||
) {
|
||||
// 检查是否是所有者
|
||||
const tree = await prisma.familyTree.findFirst({
|
||||
where: {
|
||||
id: treeId,
|
||||
ownerId: userId
|
||||
}
|
||||
})
|
||||
|
||||
if (tree) {
|
||||
return { hasPermission: true, role: "OWNER" }
|
||||
}
|
||||
|
||||
// 检查协作者权限
|
||||
const collaborator = await prisma.treeCollaborator.findFirst({
|
||||
where: {
|
||||
treeId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
if (!collaborator) {
|
||||
return { hasPermission: false, role: null }
|
||||
}
|
||||
|
||||
const roleHierarchy = {
|
||||
OWNER: 3,
|
||||
EDITOR: 2,
|
||||
VIEWER: 1
|
||||
}
|
||||
|
||||
const hasPermission = roleHierarchy[collaborator.role] >= roleHierarchy[requiredRole]
|
||||
|
||||
return { hasPermission, role: collaborator.role }
|
||||
}
|
||||
```
|
||||
|
||||
## 第九步:部署到 Vercel
|
||||
|
||||
1. **推送代码到 GitHub**
|
||||
|
||||
2. **在 Vercel 创建项目**
|
||||
- 导入 GitHub 仓库
|
||||
- 配置环境变量
|
||||
|
||||
3. **配置 Vercel Postgres**
|
||||
```bash
|
||||
# 在 Vercel 项目中
|
||||
Storage -> Create Database -> Postgres
|
||||
```
|
||||
|
||||
4. **设置环境变量**
|
||||
- `DATABASE_URL`(自动设置)
|
||||
- `NEXTAUTH_URL`
|
||||
- `NEXTAUTH_SECRET`
|
||||
|
||||
5. **部署**
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
完成后端配置后,需要:
|
||||
|
||||
1. 更新前端代码,使用 API 而不是 IndexedDB
|
||||
2. 添加登录/注册页面
|
||||
3. 实现数据迁移工具(从 IndexedDB 到 PostgreSQL)
|
||||
4. 添加协作者邀请功能
|
||||
|
||||
---
|
||||
|
||||
**需要我继续实施这些步骤吗?**
|
||||
Reference in New Issue
Block a user