SaaS 应用状态架构设计

前面几章讲了单个工具的用法,本章拉高维度——为一个完整的 AI SaaS 应用设计状态架构。关键不是"用什么工具",而是"什么状态放在哪"。

1. 状态全景架构

一个 AI SaaS(类似 ChatGPT / Cursor)的状态可以分为六层,每层有最适合的工具:

状态层工具典型数据
服务端状态Server Component async/await用户信息、对话列表、订阅计划、Token 统计
客户端缓存TanStack Query对话列表交互刷新、消息无限滚动、配额轮询
客户端 UI 状态Zustand当前对话流式回复、侧边栏折叠、通知队列
URL 状态nuqs搜索 ?q=、筛选 ?model=、分页 ?page=
持久化状态Zustand persist用户偏好(主题/语言/默认模型)
表单状态React Hook Form对话设置、知识库上传、资料编辑

1.1 设计原则

服务端优先——能在 Server Component 获取的数据不要放客户端。客户端只管两件事:

  1. 交互后的实时更新:用户操作后需要立刻反映在 UI 上(如流式回复)
  2. 纯 UI 状态:侧边栏折叠、模态框开关等和业务数据无关的状态

同一数据不存两个地方——对话列表在 Server Component 首屏渲染后,如果客户端需要交互(筛选、删除后刷新),用 TanStack Query 管理。不要同时在 Zustand 和 TanStack Query 中各存一份。

状态就近原则——状态放在使用它的最近公共祖先。对话流式状态只在聊天页用,不需要全局 Store。

2. 多租户上下文

2.1 为什么需要多租户

AI SaaS 通常支持多工作空间(个人 / 团队)。用户可能同时属于多个团队,每个团队有独立的对话历史、知识库、Token 配额。多租户的核心挑战是数据隔离——确保 A 团队看不到 B 团队的数据。

2.2 实现策略

用 cookie 存当前 workspace-id,服务端用 React.cache() 封装 getCurrentWorkspace() 函数。cache() 确保同一个请求中多次调用只查一次数据库:

// lib/data/workspace.ts
import { cache } from 'react'
import { cookies } from 'next/headers'
 
export const getCurrentWorkspace = cache(async () => {
  const session = await auth()
  if (!session?.user) return null
 
  // 1. 从 cookie 读取当前工作空间 ID
  const workspaceId = cookies().get('workspace-id')?.value
 
  if (workspaceId) {
    // 2. 验证用户确实属于该工作空间(防止伪造 cookie)
    const workspace = await db.query.workspaces.findFirst({
      where: and(
        eq(workspaces.id, workspaceId),
        exists(
          db.select().from(workspaceMembers).where(
            and(
              eq(workspaceMembers.workspaceId, workspaceId),
              eq(workspaceMembers.userId, session.user.id),
            ),
          ),
        ),
      ),
    })
    if (workspace) return workspace
  }
 
  // 3. 兜底:返回个人工作空间
  return db.query.workspaces.findFirst({
    where: and(
      eq(workspaces.ownerId, session.user.id),
      eq(workspaces.type, 'personal'),
    ),
  })
})

2.3 数据隔离

所有数据查询都必须基于当前工作空间——这是安全红线:

// 查询总是带 workspaceId
export async function getChats() {
  const workspace = await getCurrentWorkspace()
  if (!workspace) throw new Error('No workspace')
 
  return db.query.chats.findMany({
    where: eq(chats.workspaceId, workspace.id),
    orderBy: desc(chats.updatedAt),
  })
}
 
// Server Action 也必须检查 workspaceId,防止越权
'use server'
export async function deleteChat(chatId: string) {
  const workspace = await getCurrentWorkspace()
  // where 同时匹配 chatId 和 workspaceId
  await db.delete(chats).where(
    and(eq(chats.id, chatId), eq(chats.workspaceId, workspace!.id)),
  )
  revalidateTag('chats')
}

如果 deleteChat 只检查 chatId 而不检查 workspaceId,用户可以构造请求删除其他团队的对话。这是 SaaS 应用最常见的安全漏洞之一。

3. 对话状态管理

AI 对话是最复杂的前端状态——涉及状态机、流式更新、中断/重试。这是 Zustand 真正发挥价值的地方。

3.1 状态机

对话有四种状态,转换关系清晰:

idle → 用户发送消息 → sending → 收到响应流 → streaming → 流结束 → idle
                                                   ↓ 错误
                                                 error → 用户点重试 → sending
                                                       → 用户取消 → idle

为什么用状态机而不是一堆 boolean(isLoading, isStreaming, hasError)?因为 boolean 会出现不可能状态:isLoading = true && isStreaming = true && hasError = true 在逻辑上不应该存在。用联合类型 'idle' | 'sending' | 'streaming' | 'error' 约束后,不可能状态在类型层面就被排除了。

3.2 Store 实现

// stores/chat-store.ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
 
type ChatStatus = 'idle' | 'sending' | 'streaming' | 'error'
 
type ChatState = {
  status: ChatStatus
  messages: Message[]
  currentModel: string
  error: string | null
  abortController: AbortController | null
 
  sendMessage: (content: string) => Promise<void>
  cancelStream: () => void
  retry: () => Promise<void>
  reset: () => void
}
 
export const useChatStore = create<ChatState>()(
  immer((set, get) => ({
    status: 'idle',
    messages: [],
    currentModel: 'gpt-4o',
    error: null,
    abortController: null,
 
    sendMessage: async (content) => {
      const controller = new AbortController()
 
      // 1. idle → sending:添加用户消息
      set((s) => {
        s.status = 'sending'
        s.error = null
        s.abortController = controller
        s.messages.push({
          id: crypto.randomUUID(),
          role: 'user',
          content,
          createdAt: new Date(),
        })
      })
 
      try {
        // 2. 发起 API 请求
        const res = await fetch('/api/chat', {
          method: 'POST',
          body: JSON.stringify({
            messages: get().messages,
            model: get().currentModel,
          }),
          signal: controller.signal,
        })
 
        if (!res.ok) throw new Error('请求失败')
        if (!res.body) throw new Error('无响应体')
 
        // 3. sending → streaming:添加空的 AI 消息
        set((s) => {
          s.status = 'streaming'
          s.messages.push({
            id: crypto.randomUUID(),
            role: 'assistant',
            content: '',
            createdAt: new Date(),
          })
        })
 
        // 4. 流式读取,逐 chunk 追加内容
        const reader = res.body.getReader()
        const decoder = new TextDecoder()
 
        while (true) {
          const { done, value } = await reader.read()
          if (done) break
 
          const chunk = decoder.decode(value)
          set((s) => {
            const last = s.messages[s.messages.length - 1]
            if (last) last.content += chunk
          })
        }
 
        // 5. streaming → idle
        set((s) => { s.status = 'idle'; s.abortController = null })
      } catch (err) {
        if ((err as Error).name === 'AbortError') {
          // 用户主动取消
          set((s) => { s.status = 'idle'; s.abortController = null })
        } else {
          // 请求出错 → error 状态
          set((s) => {
            s.status = 'error'
            s.error = (err as Error).message
            s.abortController = null
          })
        }
      }
    },
 
    cancelStream: () => {
      get().abortController?.abort()
      set((s) => { s.status = 'idle'; s.abortController = null })
    },
 
    retry: async () => {
      const messages = get().messages
      const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
      if (lastUserMsg) {
        // 移除上一次失败的 AI 回复
        set((s) => {
          if (s.messages[s.messages.length - 1]?.role === 'assistant') {
            s.messages.pop()
          }
        })
        await get().sendMessage(lastUserMsg.content)
      }
    },
 
    reset: () => set((s) => {
      s.status = 'idle'
      s.messages = []
      s.error = null
      s.abortController = null
    }),
  })),
)

3.3 状态驱动 UI

UI 根据 status 决定显示什么——每个组件只订阅自己需要的字段:

'use client'
 
export function ChatInterface() {
  const status = useChatStore((s) => s.status)
  const error = useChatStore((s) => s.error)
  const cancelStream = useChatStore((s) => s.cancelStream)
  const retry = useChatStore((s) => s.retry)
 
  return (
    <div className="flex flex-col h-full">
      <MessageList />
 
      {status === 'error' && (
        <div className="bg-destructive/10 p-3 flex justify-between items-center">
          <span className="text-destructive text-sm">{error}</span>
          <button onClick={retry} className="text-sm underline">重试</button>
        </div>
      )}
 
      {status === 'streaming' && (
        <button onClick={cancelStream} className="mx-auto text-sm text-muted-foreground">
          ■ 停止生成
        </button>
      )}
 
      <ChatInput disabled={status === 'sending' || status === 'streaming'} />
    </div>
  )
}

因为用了 Zustand 的选择性订阅:

  • MessageList 只订阅 messages → 每个 chunk 都重渲染(必须的)
  • ChatInput 只订阅 status → 只在状态切换时重渲染(2-3 次)
  • ChatInterface 订阅 statuserror → 控制错误栏和停止按钮

三个组件各自独立,messages 高频更新不会拖慢 ChatInput 的响应。

4. Token 消耗追踪

AI 对话需要实时显示 Token 消耗和费用。这是典型的高频更新状态——每个 chunk 都可能更新计数。

用 Zustand 单独建一个 Store,和 ChatStore 分开管理。这样 Token 计数的高频更新不会触发消息列表重渲染:

type TokenStore = {
  inputTokens: number
  outputTokens: number
  estimatedCost: number
  addTokens: (input: number, output: number) => void
  reset: () => void
}
 
export const useTokenStore = create<TokenStore>((set) => ({
  inputTokens: 0,
  outputTokens: 0,
  estimatedCost: 0,
 
  addTokens: (input, output) =>
    set((s) => {
      const newInput = s.inputTokens + input
      const newOutput = s.outputTokens + output
      return {
        inputTokens: newInput,
        outputTokens: newOutput,
        // GPT-4o 定价:输入 $5/M,输出 $15/M
        estimatedCost: newInput * 0.000005 + newOutput * 0.000015,
      }
    }),
 
  reset: () => set({ inputTokens: 0, outputTokens: 0, estimatedCost: 0 }),
}))

在 UI 中只订阅需要的字段:

// 只显示费用的组件 — 只订阅 estimatedCost
function TokenCounter() {
  const cost = useTokenStore((s) => s.estimatedCost)
  return <span className="text-xs text-muted-foreground">费用:${cost.toFixed(4)}</span>
}
 
// 显示详细 Token 的组件 — 订阅 inputTokens + outputTokens
function TokenDetail() {
  const input = useTokenStore((s) => s.inputTokens)
  const output = useTokenStore((s) => s.outputTokens)
  return <span>输入 {input} / 输出 {output} tokens</span>
}

5. 状态选择速查表

状态类型工具为什么
用户/会话信息auth() + cache()服务端直查,请求级去重
对话列表(首屏)Server Component服务端渲染,零客户端 JS
对话列表(交互)TanStack Query筛选、分页、删除后刷新
当前对话流式状态Zustand高频更新 + 状态机 + 中断/重试
Token 计数Zustand(独立 Store)高频更新,不影响消息列表
侧边栏折叠Context / Zustand低频跨组件 UI 状态
搜索/筛选/分页nuqs (URL)可分享、可刷新、触发 Server 重渲染
用户偏好Zustand persist持久化到 localStorage
主题next-themes (Context)全站主题切换,SSR 兼容
表单React Hook Form校验、提交状态、脏检查
工作空间上下文Server Component + cookie多租户数据隔离

本章小结

  • 服务端优先:能在 Server Component 获取的数据不要放客户端
  • 同一数据不存两个地方:避免 Zustand 和 TanStack Query 同时管理同一份数据
  • 多租户:cookie 存 workspace-idgetCurrentWorkspace() 全请求复用,Server Action 必须检查权限
  • 对话状态机idle → sending → streaming → idle/error,联合类型比多个 boolean 更安全
  • 选择性订阅:消息列表、输入框、Token 计数器各自独立渲染
  • 组合策略:Server Component(首屏)+ TanStack Query(交互)+ Zustand(UI)+ nuqs(URL)

至此,第五篇「状态管理」全部完成。下一篇将深入样式与主题系统。