Server Actions 进阶实战
基础篇讲了 Server Actions 的
'use server'定义和表单绑定。本章深入实际场景:文件上传、数据库事务、权限校验、乐观更新和 revalidate 策略——这些是 SaaS 应用必须解决的服务端操作问题。
1. Server Actions 回顾
Server Actions 是标注了 'use server' 的异步函数,在服务端执行但可以在客户端调用。它的核心优势:
- 渐进增强:
<form action={...}>在 JS 禁用时仍然工作 - 自动序列化:参数和返回值自动序列化/反序列化
- 和 revalidate 集成:调用
revalidatePath()/revalidateTag()自动刷新页面数据 - 不暴露 API 端点:客户端看不到 URL,减少攻击面
// actions/chat.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function createChat(formData: FormData) {
const title = formData.get('title') as string
const chat = await db.insert(chats).values({
title,
userId: (await auth())!.user.id,
}).returning()
revalidateTag('chats')
return chat[0]
}2. 文件上传
2.1 基础上传
Server Actions 原生支持 FormData——文件通过 formData.get() 获取,类型是 Web 标准的 File 对象:
// actions/upload.ts
'use server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File
if (!file || file.size === 0) {
return { error: '请选择文件' }
}
// 校验文件类型和大小
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
return { error: '不支持的文件类型' }
}
if (file.size > 10 * 1024 * 1024) {
return { error: '文件不能超过 10MB' }
}
// 生成唯一文件名
const ext = file.name.split('.').pop()
const filename = `${crypto.randomUUID()}.${ext}`
// 写入文件系统(生产环境应上传到 S3/R2)
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await writeFile(join(process.cwd(), 'public/uploads', filename), buffer)
return { url: `/uploads/${filename}` }
}2.2 上传到云存储
生产环境不应该写入本地文件系统——部署到 Vercel/Fly.io 后文件系统是只读或临时的。用 S3 兼容存储(AWS S3、Cloudflare R2、MinIO):
// actions/upload.ts
'use server'
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY!,
secretAccessKey: process.env.R2_SECRET_KEY!,
},
})
export async function uploadToCloud(formData: FormData) {
const file = formData.get('file') as File
if (!file) return { error: '请选择文件' }
const filename = `${Date.now()}-${crypto.randomUUID()}.${file.name.split('.').pop()}`
const bytes = await file.arrayBuffer()
await s3.send(new PutObjectCommand({
Bucket: process.env.R2_BUCKET,
Key: `uploads/${filename}`,
Body: Buffer.from(bytes),
ContentType: file.type,
}))
return { url: `${process.env.CDN_URL}/uploads/${filename}` }
}2.3 客户端表单
'use client'
import { useActionState } from 'react'
import { uploadToCloud } from '@/actions/upload'
export function UploadForm() {
const [state, action, isPending] = useActionState(uploadToCloud, null)
return (
<form action={action}>
<input type="file" name="file" accept="image/*,.pdf" required />
<button type="submit" disabled={isPending}>
{isPending ? '上传中...' : '上传'}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.url && <img src={state.url} alt="已上传" className="mt-2 max-w-xs" />}
</form>
)
}超过 50MB 的文件不适合通过 Server Actions 上传——序列化和传输效率低。改用 Presigned URL 模式:Server Action 生成签名 URL → 客户端直传 S3 → 上传完成后通知服务端。
3. 数据库事务
3.1 为什么需要事务
创建一个 AI 对话涉及多步操作:插入对话记录、扣减 Token 配额、记录操作日志。如果扣减 Token 成功但插入对话失败,数据就不一致了。事务保证要么全部成功,要么全部回滚。
3.2 Drizzle 事务
// actions/chat.ts
'use server'
export async function createChatWithMessage(formData: FormData) {
const session = await auth()
if (!session) throw new Error('Unauthorized')
const title = formData.get('title') as string
const content = formData.get('content') as string
const workspace = await getCurrentWorkspace()
// 事务:对话 + 消息 + 配额扣减 原子完成
const result = await db.transaction(async (tx) => {
// 1. 创建对话
const [chat] = await tx.insert(chats).values({
title,
workspaceId: workspace!.id,
userId: session.user.id,
}).returning()
// 2. 添加首条消息
await tx.insert(messages).values({
chatId: chat.id,
role: 'user',
content,
})
// 3. 扣减 Token 配额(预估)
const estimatedTokens = Math.ceil(content.length / 4)
const [quota] = await tx.update(tokenQuotas)
.set({ used: sql`used + ${estimatedTokens}` })
.where(eq(tokenQuotas.workspaceId, workspace!.id))
.returning()
if (quota.used > quota.limit) {
throw new Error('Token 配额不足') // 抛出错误 → 自动回滚
}
return chat
})
revalidateTag('chats')
redirect(`/chat/${result.id}`)
}3.3 事务注意事项
- 保持事务尽量短:事务期间会持有数据库锁,长时间事务会阻塞其他查询
- 不要在事务中做外部调用:调用第三方 API 如果超时,事务会一直挂着
- 乐观锁处理并发:高并发场景用
version字段避免更新冲突
// 乐观锁示例:更新对话标题
const result = await db.update(chats)
.set({ title: newTitle, version: sql`version + 1` })
.where(and(
eq(chats.id, chatId),
eq(chats.version, currentVersion), // 只有版本匹配才更新
))
.returning()
if (result.length === 0) {
return { error: '对话已被其他人修改,请刷新后重试' }
}4. 权限校验
4.1 认证检查
每个 Server Action 必须独立校验权限——不能依赖客户端传入的 userId。攻击者可以绕过 UI 直接调用 Server Action。
// lib/auth-guard.ts — 统一认证检查
import { auth } from '@/lib/auth'
export async function requireAuth() {
const session = await auth()
if (!session?.user) throw new Error('请先登录')
return session
}
export async function requireWorkspaceAccess() {
const session = await requireAuth()
const workspace = await getCurrentWorkspace()
if (!workspace) throw new Error('工作空间不存在')
return { session, workspace }
}4.2 数据级权限
不仅要检查"用户已登录",还要检查"用户有权操作这条数据":
// actions/chat.ts
'use server'
export async function deleteChat(chatId: string) {
const { workspace } = await requireWorkspaceAccess()
// 同时匹配 chatId 和 workspaceId — 防止越权删除
const result = await db.delete(chats).where(
and(
eq(chats.id, chatId),
eq(chats.workspaceId, workspace.id),
),
).returning()
if (result.length === 0) {
throw new Error('对话不存在或无权删除')
}
revalidateTag('chats')
return { success: true }
}4.3 角色权限
团队协作场景需要角色权限(owner / admin / member):
export async function requireRole(role: 'owner' | 'admin' | 'member') {
const { session, workspace } = await requireWorkspaceAccess()
const member = await db.query.workspaceMembers.findFirst({
where: and(
eq(workspaceMembers.workspaceId, workspace.id),
eq(workspaceMembers.userId, session.user.id),
),
})
const roleHierarchy = { owner: 3, admin: 2, member: 1 }
if (!member || roleHierarchy[member.role] < roleHierarchy[role]) {
throw new Error(`需要 ${role} 或更高权限`)
}
return { session, workspace, member }
}
// 使用:只有 admin 以上才能删除工作空间
export async function deleteWorkspace(workspaceId: string) {
const { workspace } = await requireRole('admin')
// ...
}5. 返回值与错误处理
5.1 统一返回格式
Server Actions 的返回值必须是可序列化的(不能返回 Date、Map 等)。推荐统一返回格式:
// lib/action-result.ts
type ActionResult<T = void> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> }// actions/chat.ts
'use server'
import { z } from 'zod'
const updateChatSchema = z.object({
chatId: z.string().uuid(),
title: z.string().min(1, '标题不能为空').max(100, '标题过长'),
})
export async function updateChat(input: z.input<typeof updateChatSchema>): Promise<ActionResult> {
try {
const { chatId, title } = updateChatSchema.parse(input)
const { workspace } = await requireWorkspaceAccess()
await db.update(chats)
.set({ title, updatedAt: new Date() })
.where(and(eq(chats.id, chatId), eq(chats.workspaceId, workspace.id)))
revalidateTag('chats')
return { success: true, data: undefined }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: '输入无效', fieldErrors: error.flatten().fieldErrors }
}
return { success: false, error: (error as Error).message }
}
}5.2 客户端消费
'use client'
export function EditChatTitle({ chatId, currentTitle }: Props) {
async function handleSubmit(formData: FormData) {
const result = await updateChat({
chatId,
title: formData.get('title') as string,
})
if (result.success) {
toast.success('标题已更新')
} else {
toast.error(result.error)
}
}
return (
<form action={handleSubmit}>
<input name="title" defaultValue={currentTitle} />
<button type="submit">保存</button>
</form>
)
}6. 乐观更新
6.1 useOptimistic
用户点击"删除"后立刻从列表移除(不等服务端响应),如果服务端失败再恢复。这让 UI 感觉很快:
'use client'
import { useOptimistic } from 'react'
import { deleteChat } from '@/actions/chat'
export function ChatList({ chats }: { chats: Chat[] }) {
const [optimisticChats, removeOptimistic] = useOptimistic(
chats,
(state, deletedId: string) => state.filter((c) => c.id !== deletedId),
)
async function handleDelete(chatId: string) {
removeOptimistic(chatId) // 立刻从 UI 移除
const result = await deleteChat(chatId)
if (!result.success) {
toast.error('删除失败:' + result.error)
// Server Action 完成后 revalidate 会恢复真实数据
}
}
return (
<ul>
{optimisticChats.map((chat) => (
<li key={chat.id}>
{chat.title}
<button onClick={() => handleDelete(chat.id)}>删除</button>
</li>
))}
</ul>
)
}6.2 什么时候用乐观更新
| 操作 | 乐观更新? | 原因 |
|---|---|---|
| 删除对话 | ✅ | 失败概率低,恢复成本低 |
| 切换收藏状态 | ✅ | 即时反馈,失败可回滚 |
| 创建对话 | ❌ | 需要服务端生成 ID 和跳转 |
| 发起 AI 对话 | ❌ | 涉及复杂流程(流式、Token 计费) |
| 修改工作空间设置 | ❌ | 影响范围大,需确认成功 |
7. Revalidate 策略
Server Actions 执行完数据变更后,需要让页面数据刷新。Next.js 提供两种 revalidate 方式:
7.1 revalidatePath
按路径重验证——该路径下所有缓存都失效:
import { revalidatePath } from 'next/cache'
// 重验证特定页面
revalidatePath('/dashboard')
// 重验证动态路由
revalidatePath(`/chat/${chatId}`)
// 重验证整个布局(包括所有子页面)
revalidatePath('/dashboard', 'layout')7.2 revalidateTag
按标签重验证——更精准,只失效对应标签的缓存:
import { revalidateTag } from 'next/cache'
import { unstable_cache } from 'next/cache'
// 数据查询时打标签
export const getChats = unstable_cache(
async (workspaceId: string) => {
return db.query.chats.findMany({
where: eq(chats.workspaceId, workspaceId),
})
},
['chats'],
{ tags: ['chats'] },
)
// Server Action 中按标签 revalidate
export async function deleteChat(chatId: string) {
// ...删除操作
revalidateTag('chats') // 只失效 chats 标签的缓存
}7.3 选择策略
| 场景 | 方式 | 原因 |
|---|---|---|
| 删除/创建对话 | revalidateTag('chats') | 精准失效对话列表缓存 |
| 更新用户资料 | revalidatePath('/settings') | 只影响设置页 |
| 切换工作空间 | revalidatePath('/', 'layout') | 整站数据都要刷新 |
| 修改侧边栏数据 | revalidateTag('sidebar-data') | 精准失效 |
优先用 revalidateTag——它比 revalidatePath 更精准,不会误杀无关缓存。
本章小结
- 文件上传:Server Actions 原生支持
FormData,生产环境用 Presigned URL + 云存储 - 数据库事务:
db.transaction()保证原子性,事务内不做外部调用 - 权限校验:每个 Action 独立检查认证和数据级权限,永远不信任客户端输入
- 返回值:统一
ActionResult<T>格式,zod校验输入 - 乐观更新:
useOptimistic让 UI 即时响应,适合低风险操作 - Revalidate:优先
revalidateTag(精准),其次revalidatePath(按路径)
下一章讲数据库集成。