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 的返回值必须是可序列化的(不能返回 DateMap 等)。推荐统一返回格式:

// 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&lt;T&gt; 格式,zod 校验输入
  • 乐观更新useOptimistic 让 UI 即时响应,适合低风险操作
  • Revalidate:优先 revalidateTag(精准),其次 revalidatePath(按路径)

下一章讲数据库集成。