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 获取的数据不要放客户端。客户端只管两件事:
- 交互后的实时更新:用户操作后需要立刻反映在 UI 上(如流式回复)
- 纯 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 订阅
status和error→ 控制错误栏和停止按钮
三个组件各自独立,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-id,getCurrentWorkspace()全请求复用,Server Action 必须检查权限 - 对话状态机:
idle → sending → streaming → idle/error,联合类型比多个 boolean 更安全 - 选择性订阅:消息列表、输入框、Token 计数器各自独立渲染
- 组合策略:Server Component(首屏)+ TanStack Query(交互)+ Zustand(UI)+ nuqs(URL)
至此,第五篇「状态管理」全部完成。下一篇将深入样式与主题系统。