docs(cache): 缓存策略落地专项设计文档 v1 - 全栈缓存策略一致性(服务端数据缓存+客户端 TanStack Query+失效编排)
This commit is contained in:
742
docs/superpowers/specs/2026-07-05-cache-strategy-design.md
Normal file
742
docs/superpowers/specs/2026-07-05-cache-strategy-design.md
Normal file
@@ -0,0 +1,742 @@
|
||||
# 缓存策略落地专项重构设计文档
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|---|
|
||||
| 文档版本 | v1 |
|
||||
| 创建日期 | 2026-07-05 |
|
||||
| 作者 | Trae 协作生成 |
|
||||
| 状态 | 待用户审查 |
|
||||
| 范围 | 全栈缓存策略一致性(基础设施 + 规范 + 标杆示范) |
|
||||
| 标杆模块 | classes |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与现状
|
||||
|
||||
### 1.1 项目缓存现状
|
||||
|
||||
| 层 | 现状 | 问题 |
|
||||
|----|------|------|
|
||||
| 服务端数据缓存 | 仅用 `react.cache`(请求级 memoization,30 个 data-access 文件)+ `revalidatePath`(路径级失效) | 未用 `unstable_cache` / `revalidateTag` / Redis 数据缓存;多实例下无跨实例共享 |
|
||||
| 客户端缓存 | TanStack Query V5 基础设施已建(`createQueryClient` 默认 staleTime 30s/retry 1) | `useActionQuery` / `useActionMutation` 用 useEffect + useState 自实现,绕过 QueryClient;30+ 组件直接 useQuery,缺乏统一 queryKey 约定 |
|
||||
| 失效编排 | revalidatePath 散落在 30+ actions 文件 | 易漏失效;无集中审计点 |
|
||||
| 单测可 mock 性 | data-access 函数直接 export 缓存包装版本 | 难以绕过缓存层 mock(架构文档 P2 待办) |
|
||||
|
||||
### 1.2 已有可复用资产
|
||||
|
||||
| 资产 | 文件 | 复用方式 |
|
||||
|------|------|---------|
|
||||
| Redis driver 切换模式 | `shared/lib/rate-limit/{index.ts,redis-limiter.ts,memory-limiter.ts,types.ts}` | 缓存层同构复用 `CACHE_DRIVER=memory\|redis` + 动态 import + `webpackIgnore` |
|
||||
| Upstash Redis 类型声明 | `shared/lib/rate-limit/upstash-modules.d.ts` | 缓存层直接复用,避免重复声明 |
|
||||
| TanStack Query 工厂 | `shared/lib/query-client.ts` | 不动,新增 queryKey 工厂与之配合 |
|
||||
| `useActionQuery` / `useActionMutation` Hook | `shared/hooks/use-action-{query,mutation}.ts` | 重构内部实现,对外 API 调整(破坏性变更,需迁移消费方) |
|
||||
| env 校验 | `src/env.mjs` | 新增 `CACHE_DRIVER` 字段 |
|
||||
|
||||
### 1.3 部署形态约束
|
||||
|
||||
- 多实例部署(PM2 cluster / Kubernetes 多 Pod)
|
||||
- 必须支持跨实例共享缓存 → 引入 Redis 后端
|
||||
- 复用 `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` 环境变量
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
1. 建立 `shared/lib/cache/` 数据缓存基础设施,支持 `memory` / `redis` 双 driver 切换
|
||||
2. 建立 `shared/lib/query-keys.ts` 客户端 queryKey 工厂,集中化命名
|
||||
3. 建立 `shared/lib/cache/invalidation-map.ts` 集中式失效映射表
|
||||
4. 重构 `useActionQuery` / `useActionMutation` 接入 QueryClient + 自动 invalidate
|
||||
5. 完成 classes 标杆模块全场景迁移(6 data-access + 5 actions)
|
||||
6. 引入 ESLint 规则强制使用 `invalidateFor`,禁止直接调用 `revalidatePath` / `revalidateTag`
|
||||
7. 同步架构文档(004 / 005 / known-issues)
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 其他 29 个模块的 data-access / actions 迁移(下期按模块推进)
|
||||
- 其他客户端组件迁移到 useQuery(下期按页面推进)
|
||||
- L1+L2 两级缓存(YAGNI,先用单层 Redis 验证)
|
||||
- 缓存预热 / SWR 主动刷新(YAGNI)
|
||||
- 缓存命中率监控埋点(YAGNI)
|
||||
- 现有 `react.cache` 全量替换(保留作为请求级 memoization 内层)
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构设计
|
||||
|
||||
### 3.1 模块边界与文件布局
|
||||
|
||||
```
|
||||
src/shared/lib/
|
||||
├─ cache/ # 新增:数据缓存基础设施
|
||||
│ ├─ index.ts # 公共 API:cacheFn / invalidateFor / getCacheStore
|
||||
│ ├─ types.ts # CacheFnOptions / CacheStore / InvalidationRule 接口
|
||||
│ ├─ memory-store.ts # 内存 LRU 实现(默认,单实例降级用)
|
||||
│ ├─ redis-store.ts # Redis 实现(多实例,复用 @upstash/redis)
|
||||
│ ├─ store-factory.ts # 按 CACHE_DRIVER 动态加载(webpackIgnore)
|
||||
│ ├─ cache-fn.ts # cacheFn(fn, { tags, ttl, keyParts }) 包装器
|
||||
│ ├─ invalidation-map.ts # mutation → { tags, queryKeys, paths } 集中映射表
|
||||
│ ├─ client-invalidation-map.ts # 仅 queryKeys 子集,供客户端 bundle 使用
|
||||
│ ├─ invalidate.ts # invalidateFor(actionId, params) 编排函数
|
||||
│ └─ upstash-modules.d.ts # 复用 rate-limit 已有的 @upstash/redis 类型声明(提升至 shared/lib/,旧路径保留 re-export shim)
|
||||
├─ query-keys.ts # 新增:queryKey 工厂(按 模块/资源/操作 分层)
|
||||
├─ redis-client.ts # 新增:共享 Redis 单例(rate-limit + cache 共用)
|
||||
└─ rate-limit/ # 既有:速率限制(仅 redis-limiter.ts 改为引用 redis-client.ts)
|
||||
|
||||
src/shared/hooks/
|
||||
├─ use-action-query.ts # 重构:内部改走 useQuery(QueryClient)
|
||||
└─ use-action-mutation.ts # 重构:内部改走 useMutation + 自动 invalidate
|
||||
```
|
||||
|
||||
### 3.2 依赖方向(严格遵循三层架构)
|
||||
|
||||
```
|
||||
app/(dashboard)/.../page.tsx
|
||||
↓ 调用
|
||||
modules/[module]/actions.ts → 调用 invalidateFor("classes.update", { id }) 编排失效
|
||||
↓ 调用
|
||||
modules/[module]/data-access.ts → 用 cacheFn(fn, { tags, ttl, keyParts }) 包装查询
|
||||
↓ 调用
|
||||
shared/lib/cache/ # 缓存基础设施
|
||||
↓ 内部
|
||||
shared/lib/redis-client.ts # 复用 rate-limit 的 @upstash/redis 客户端
|
||||
```
|
||||
|
||||
### 3.3 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `CACHE_DRIVER` | `memory` | `memory\|redis`,对标 `RATE_LIMIT_DRIVER` |
|
||||
| `UPSTASH_REDIS_REST_URL` | - | 复用 rate-limit 已有变量 |
|
||||
| `UPSTASH_REDIS_REST_TOKEN` | - | 复用 rate-limit 已有变量 |
|
||||
|
||||
### 3.4 故障降级策略
|
||||
|
||||
- Redis 不可用:catch 错误 → `console.error` + 透传到原函数(直查 DB)→ 不阻断主流程
|
||||
- 与 rate-limit 模块的 "fail-open 限流降级" 策略一致
|
||||
|
||||
---
|
||||
|
||||
## 4. 服务端缓存层详细设计
|
||||
|
||||
### 4.1 `cacheFn` API
|
||||
|
||||
```typescript
|
||||
// shared/lib/cache/types.ts
|
||||
export interface CacheFnOptions {
|
||||
/** 缓存标签,用于按 tag 失效(必填,至少 1 个) */
|
||||
tags: readonly string[]
|
||||
/** TTL 秒数;不传则永久缓存(仅靠 tag 失效) */
|
||||
ttl?: number
|
||||
/** 自定义 key 段,默认根据 fn.name + 参数 JSON 自动生成 */
|
||||
keyParts?: readonly unknown[]
|
||||
}
|
||||
|
||||
// shared/lib/cache/cache-fn.ts
|
||||
export function cacheFn<T extends (...args: any[]) => Promise<unknown>>(
|
||||
fn: T,
|
||||
options: CacheFnOptions
|
||||
): T
|
||||
```
|
||||
|
||||
### 4.2 调用示例(classes data-access)
|
||||
|
||||
```typescript
|
||||
// 修改前
|
||||
export const getClassById = cache(async (id: string) => {
|
||||
return db.query.classes.findFirst({ where: eq(classes.id, id) })
|
||||
})
|
||||
|
||||
// 修改后
|
||||
export const getClassByIdRaw = async (id: string) => {
|
||||
return db.query.classes.findFirst({ where: eq(classes.id, id) })
|
||||
}
|
||||
export const getClassById = cacheFn(getClassByIdRaw, {
|
||||
tags: ["classes:detail", "classes:detail:{id}"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "by-id"],
|
||||
})
|
||||
```
|
||||
|
||||
### 4.3 双层缓存包装
|
||||
|
||||
```typescript
|
||||
import { cache as reactCache } from "react"
|
||||
|
||||
export const cacheFn = <T>(fn: T, options: CacheFnOptions): T => {
|
||||
// 外层:react.cache 提供请求级 memoization(同一 RSC 请求内去重)
|
||||
// 内层:cacheStore.getOrSet 提供跨请求/跨实例数据缓存
|
||||
return reactCache(((...args: any[]) => {
|
||||
return cacheStore.getOrSet(buildKey(fn, args, options), () => fn(...args), options)
|
||||
}) as T)
|
||||
}
|
||||
```
|
||||
|
||||
**双层职责**:
|
||||
- `react.cache`:请求内去重(同请求多次调用只查 1 次 DB)—— 现有行为
|
||||
- `cacheStore`:跨请求/跨实例数据缓存 —— 新增能力
|
||||
|
||||
### 4.4 CacheStore 接口
|
||||
|
||||
```typescript
|
||||
// shared/lib/cache/types.ts
|
||||
export interface CacheStore {
|
||||
getOrSet<T>(
|
||||
key: string,
|
||||
producer: () => Promise<T>,
|
||||
options: { tags: readonly string[]; ttl?: number }
|
||||
): Promise<T>
|
||||
invalidateTags(tags: readonly string[]): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 两种实现
|
||||
|
||||
**memory-store.ts**(默认):
|
||||
- `Map<string, { value, expireAt, tags }>` + LRU 淘汰(maxEntries=500)
|
||||
- 适用于单实例 dev/test
|
||||
|
||||
**redis-store.ts**(多实例):
|
||||
- 复用 `shared/lib/redis-client.ts` 单例
|
||||
- key 格式:`next-edu:cache:{keyParts hash}`
|
||||
- value:`JSON.stringify`
|
||||
- tag 反查索引:`next-edu:cache:tag:{tag}` → Redis Set 存放 keys
|
||||
- TTL:`redis EX` 原子设置
|
||||
- `invalidateTags`:`SMEMBERS` → `DEL` 批量删除
|
||||
|
||||
### 4.6 `redis-client.ts` 共享单例
|
||||
|
||||
```typescript
|
||||
// shared/lib/redis-client.ts
|
||||
// rate-limit 与 cache 共用的 Redis 客户端单例
|
||||
// 复用 env.UPSTASH_REDIS_REST_URL / TOKEN
|
||||
// 动态 import + webpackIgnore(与 redis-limiter.ts 同模式)
|
||||
// 故障降级:返回 null,调用方走 fail-open
|
||||
```
|
||||
|
||||
**改造 `redis-limiter.ts`**:将 `getRedisClient()` 抽出至 `redis-client.ts`,`redis-limiter.ts` 改为 `import { getRedisClient } from "@/shared/lib/redis-client"`。`rate-limit/index.ts` 单例选择逻辑不变。
|
||||
|
||||
### 4.7 `store-factory.ts` driver 切换
|
||||
|
||||
```typescript
|
||||
let singleton: CacheStore | null = null
|
||||
|
||||
export async function getCacheStore(): Promise<CacheStore> {
|
||||
if (singleton) return singleton
|
||||
if (env.CACHE_DRIVER === "redis") {
|
||||
const { RedisCacheStore } = await import("./redis-store")
|
||||
singleton = new RedisCacheStore()
|
||||
} else {
|
||||
singleton = new MemoryCacheStore()
|
||||
}
|
||||
return singleton
|
||||
}
|
||||
```
|
||||
|
||||
### 4.8 Tag 命名规范
|
||||
|
||||
| 格式 | 示例 | 失效场景 |
|
||||
|------|------|---------|
|
||||
| `{module}` | `classes`, `grades`, `users` | 模块任意写操作全模块失效 |
|
||||
| `{module}:{resource}` | `classes:list`, `classes:detail`, `classes:stats` | 资源级失效 |
|
||||
| `{module}:{resource}:{id}` | `classes:detail:cls_abc123` | 单条记录失效 |
|
||||
|
||||
**classes 模块约定的 tags**:
|
||||
- `classes` —— 任意写操作全模块失效
|
||||
- `classes:detail` + `classes:detail:{id}` —— 单条详情失效
|
||||
- `classes:students:{classId}` —— 学生列表失效
|
||||
- `classes:schedule:{classId}` —— 课表失效
|
||||
- `classes:stats` —— 聚合统计失效
|
||||
- `classes:invitations:{classId}` —— 邀请码失效
|
||||
|
||||
---
|
||||
|
||||
## 5. 失效编排与客户端缓存
|
||||
|
||||
### 5.1 集中式失效映射表
|
||||
|
||||
```typescript
|
||||
// shared/lib/cache/invalidation-map.ts
|
||||
|
||||
export interface InvalidationRule {
|
||||
readonly tags: readonly string[]
|
||||
readonly queryKeys: readonly (readonly (string | number)[])[]
|
||||
readonly paths: readonly string[]
|
||||
}
|
||||
|
||||
export const INVALIDATION_MAP = {
|
||||
// ===== classes 模块 =====
|
||||
"classes.create": {
|
||||
tags: ["classes", "classes:list"],
|
||||
queryKeys: [["classes", "list"]],
|
||||
paths: ["/teacher/classes/my", "/admin/classes"],
|
||||
},
|
||||
"classes.update": {
|
||||
tags: ["classes", "classes:detail", "classes:detail:{id}"],
|
||||
queryKeys: [["classes", "detail"], ["classes", "list"]],
|
||||
// paths 不含动态段:revalidatePath("/teacher/classes/my") 已能覆盖详情页([id] 路由)
|
||||
paths: ["/teacher/classes/my", "/admin/classes"],
|
||||
},
|
||||
"classes.delete": {
|
||||
tags: ["classes", "classes:list", "classes:detail:{id}"],
|
||||
queryKeys: [["classes", "list"], ["classes", "detail"]],
|
||||
paths: ["/teacher/classes/my", "/admin/classes"],
|
||||
},
|
||||
"classes.students.update": {
|
||||
tags: ["classes:students:{classId}"],
|
||||
queryKeys: [["classes", "students"]],
|
||||
paths: ["/teacher/classes/my"],
|
||||
},
|
||||
"classes.schedule.update": {
|
||||
tags: ["classes:schedule:{classId}"],
|
||||
queryKeys: [["classes", "schedule"]],
|
||||
paths: ["/teacher/classes/schedule"],
|
||||
},
|
||||
"classes.grade.update": {
|
||||
tags: ["classes"],
|
||||
queryKeys: [["classes", "list"]],
|
||||
paths: [],
|
||||
},
|
||||
"classes.invitation.create": {
|
||||
tags: ["classes:invitations:{classId}"],
|
||||
queryKeys: [["classes", "invitations"]],
|
||||
paths: ["/teacher/classes/my/{id}"],
|
||||
},
|
||||
"classes.invitation.revoke": {
|
||||
tags: ["classes:invitations:{classId}"],
|
||||
queryKeys: [["classes", "invitations"]],
|
||||
paths: ["/teacher/classes/my/{id}"],
|
||||
},
|
||||
} as const satisfies Record<string, InvalidationRule>
|
||||
```
|
||||
|
||||
### 5.2 `invalidateFor` 编排函数
|
||||
|
||||
```typescript
|
||||
// shared/lib/cache/invalidate.ts
|
||||
|
||||
/**
|
||||
* 由 Server Action 在写操作成功后调用,集中编排缓存失效。
|
||||
*
|
||||
* 三件事:
|
||||
* 1. cacheStore.invalidateTags(服务端数据缓存失效)
|
||||
* 2. revalidateTag × N(Next.js fetch 缓存 + unstable_cache 失效)
|
||||
* 3. revalidatePath × N(RSC 静态缓存失效)
|
||||
*
|
||||
* @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key
|
||||
* @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符
|
||||
*/
|
||||
export async function invalidateFor(
|
||||
actionId: string,
|
||||
params: Record<string, string> = {}
|
||||
): Promise<void> {
|
||||
const rule = INVALIDATION_MAP[actionId]
|
||||
if (!rule) {
|
||||
throw new Error(`[cache] Unknown actionId: ${actionId}. Update INVALIDATION_MAP.`)
|
||||
}
|
||||
|
||||
// 1. 服务端数据缓存失效(Redis / 内存)
|
||||
const resolvedTags = rule.tags.map((t) => fillTemplate(t, params))
|
||||
const store = await getCacheStore()
|
||||
await store.invalidateTags(resolvedTags)
|
||||
|
||||
// 2. Next.js fetch 缓存 + unstable_cache 失效
|
||||
resolvedTags.forEach((tag) => revalidateTag(tag))
|
||||
|
||||
// 3. 路径级 RSC 缓存失效
|
||||
rule.paths.forEach((p) => revalidatePath(fillTemplate(p, params)))
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 actions 层调用范式(classes 标杆)
|
||||
|
||||
```typescript
|
||||
// modules/classes/actions-teacher.ts(重构后)
|
||||
|
||||
"use server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { invalidateFor } from "@/shared/lib/cache"
|
||||
import { updateClassInScope } from "../data-access-teacher"
|
||||
|
||||
export async function updateClassAction(
|
||||
input: UpdateClassInput
|
||||
): Promise<ActionState<{ classId: string }>> {
|
||||
await requirePermission(Permission.CLASS_MANAGE)
|
||||
const result = await updateClassInScope(input)
|
||||
|
||||
// 一行代替原来 3-5 行 revalidatePath 散乱调用
|
||||
await invalidateFor("classes.update", { id: result.classId })
|
||||
|
||||
return { success: true, data: { classId: result.classId } }
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 客户端 queryKey 工厂
|
||||
|
||||
```typescript
|
||||
// shared/lib/query-keys.ts
|
||||
|
||||
export const queryKeys = {
|
||||
classes: {
|
||||
all: ["classes"] as const,
|
||||
lists: () => [...queryKeys.classes.all, "list"] as const,
|
||||
list: (filters: ClassFilters) => [...queryKeys.classes.lists(), filters] as const,
|
||||
details: () => [...queryKeys.classes.all, "detail"] as const,
|
||||
detail: (id: string) => [...queryKeys.classes.details(), id] as const,
|
||||
students: (classId: string) => [...queryKeys.classes.all, "students", classId] as const,
|
||||
schedule: (classId: string) => [...queryKeys.classes.all, "schedule", classId] as const,
|
||||
stats: (classId: string) => [...queryKeys.classes.all, "stats", classId] as const,
|
||||
invitations: (classId: string) => [...queryKeys.classes.all, "invitations", classId] as const,
|
||||
},
|
||||
// 后续模块按此模式扩展...
|
||||
} as const
|
||||
|
||||
export type QueryKeys = typeof queryKeys
|
||||
```
|
||||
|
||||
### 5.5 `useActionQuery` 重构
|
||||
|
||||
```typescript
|
||||
// shared/hooks/use-action-query.ts(重构后)
|
||||
|
||||
"use client"
|
||||
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
export interface UseActionQueryOptions<T> extends Omit<
|
||||
UseQueryOptions<ActionState<T>>,
|
||||
"queryKey" | "queryFn"
|
||||
> {
|
||||
/** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id) */
|
||||
queryKey: readonly (string | number | object)[]
|
||||
/** 是否启用,默认 true */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function useActionQuery<T>(
|
||||
action: () => Promise<ActionState<T>>,
|
||||
options: UseActionQueryOptions<T>
|
||||
): { data: T | undefined; loading: boolean; error: Error | null; refetch: () => void } {
|
||||
const query = useQuery({
|
||||
queryKey: options.queryKey,
|
||||
queryFn: action,
|
||||
enabled: options.enabled ?? true,
|
||||
...options,
|
||||
})
|
||||
|
||||
// 解包 ActionState:失败时抛错,成功时返回 data
|
||||
const data = query.data?.success ? query.data.data : undefined
|
||||
const error = query.error ?? (query.data && !query.data.success
|
||||
? new Error(query.data.message ?? "Action failed")
|
||||
: null)
|
||||
|
||||
return {
|
||||
data,
|
||||
loading: query.isLoading,
|
||||
error,
|
||||
refetch: () => { void query.refetch() },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**破坏性变更**:`useActionQuery` 必须传 `queryKey`(之前不需要)。强制集中化 queryKey 是本次重构的核心目标。
|
||||
|
||||
### 5.6 `useActionMutation` 重构
|
||||
|
||||
```typescript
|
||||
// shared/hooks/use-action-mutation.ts(重构后)
|
||||
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { CLIENT_INVALIDATION_MAP } from "@/shared/lib/cache/client-invalidation-map"
|
||||
|
||||
export interface UseActionMutationOptions<T> {
|
||||
/** mutationFn */
|
||||
mutationFn: () => Promise<ActionState<T>>
|
||||
/** 关联的 INVALIDATION_MAP actionId,成功后自动失效相关 queryKey */
|
||||
actionId?: string
|
||||
/** 模板参数,如 { id: "cls_123" } */
|
||||
params?: Record<string, string>
|
||||
/** 显式额外失效的 queryKey(不通过 actionId 时使用) */
|
||||
invalidateQueryKeys?: readonly (readonly (string | number | object)[])[]
|
||||
successMessage?: string | false
|
||||
errorMessage?: string | false
|
||||
onSuccess?: (data: T | undefined) => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export function useActionMutation<T = unknown>(options: UseActionMutationOptions<T>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const mutation = useMutation<ActionState<T>, Error, void>({
|
||||
mutationFn: options.mutationFn,
|
||||
onError: (error) => {
|
||||
if (options.errorMessage !== false) {
|
||||
notify.error(options.errorMessage ?? error.message)
|
||||
}
|
||||
options.onError?.(error)
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
if (result.success) {
|
||||
if (options.successMessage !== false) {
|
||||
notify.success(options.successMessage ?? result.message ?? "Operation succeeded")
|
||||
}
|
||||
// 自动失效:根据 actionId 查 CLIENT_INVALIDATION_MAP 的客户端 queryKey 列表
|
||||
const queryKeysToInvalidate = resolveClientInvalidations(options.actionId, options.params)
|
||||
await Promise.all(
|
||||
queryKeysToInvalidate.map((qk) => queryClient.invalidateQueries({ queryKey: qk }))
|
||||
)
|
||||
options.onSuccess?.(result.data)
|
||||
} else {
|
||||
if (options.errorMessage !== false) {
|
||||
notify.error(options.errorMessage ?? result.message ?? "Operation failed")
|
||||
}
|
||||
options.onError?.(new Error(result.message ?? "Action returned failure"))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return { isWorking: mutation.isPending, mutate: mutation.mutateAsync }
|
||||
}
|
||||
```
|
||||
|
||||
### 5.7 客户端可见的失效映射子集
|
||||
|
||||
```typescript
|
||||
// shared/lib/cache/client-invalidation-map.ts
|
||||
|
||||
/**
|
||||
* 仅客户端 queryKey 失效映射(INVALIDATION_MAP 的子集)。
|
||||
* 从 invalidation-map.ts 提取 queryKeys 字段,避免客户端 bundle 拉入 revalidateTag 等 server-only。
|
||||
*/
|
||||
export const CLIENT_INVALIDATION_MAP = {
|
||||
"classes.create": { queryKeys: [["classes", "list"]] },
|
||||
"classes.update": { queryKeys: [["classes", "detail"], ["classes", "list"]] },
|
||||
"classes.delete": { queryKeys: [["classes", "list"], ["classes", "detail"]] },
|
||||
"classes.students.update": { queryKeys: [["classes", "students"]] },
|
||||
"classes.schedule.update": { queryKeys: [["classes", "schedule"]] },
|
||||
"classes.grade.update": { queryKeys: [["classes", "list"]] },
|
||||
"classes.invitation.create": { queryKeys: [["classes", "invitations"]] },
|
||||
"classes.invitation.revoke": { queryKeys: [["classes", "invitations"]] },
|
||||
} as const
|
||||
```
|
||||
|
||||
### 5.8 ESLint 强制规则
|
||||
|
||||
```javascript
|
||||
// eslint.config.js(节选)
|
||||
{
|
||||
files: ["src/modules/**/actions*.ts", "src/app/api/**/route.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector: "CallExpression[callee.name='revalidatePath']",
|
||||
message: "使用 invalidateFor() 替代直接 revalidatePath(),参见 docs/architecture/004 缓存章节",
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='revalidateTag']",
|
||||
message: "使用 invalidateFor() 替代直接 revalidateTag(),参见 docs/architecture/004 缓存章节",
|
||||
},
|
||||
],
|
||||
},
|
||||
// 豁免:shared/lib/cache/ 内部允许调用
|
||||
overrides: [{
|
||||
files: ["src/shared/lib/cache/**/*.ts"],
|
||||
rules: { "no-restricted-syntax": "off" },
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. classes 标杆迁移
|
||||
|
||||
### 6.1 data-access 迁移清单
|
||||
|
||||
| 文件 | 当前 react.cache 用法 | 迁移后 cacheFn 用法 |
|
||||
|------|------|------|
|
||||
| `data-access-teacher.ts` | `cache(getClassesByTeacherId)` 等 | `cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 })` |
|
||||
| `data-access-admin.ts` | `cache(getAllClassesForAdmin)` | `cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 })` |
|
||||
| `data-access-students.ts` | `cache(getStudentsByClass)` | `cacheFn(fn, { tags: ["classes:students:{classId}"], ttl: 60 })` |
|
||||
| `data-access-stats.ts` | `cache(getClassStats)` | `cacheFn(fn, { tags: ["classes:stats"], ttl: 60 })` |
|
||||
| `data-access-schedule.ts` | `cache(getScheduleByClass)` | `cacheFn(fn, { tags: ["classes:schedule:{classId}"], ttl: 600 })` |
|
||||
| `data-access.ts` | 共享类型与行映射 | 不动(无 cache 调用) |
|
||||
|
||||
### 6.2 TTL 策略
|
||||
|
||||
| 资源 | TTL | 理由 |
|
||||
|------|------|------|
|
||||
| 列表/详情(`classes`, `classes:detail`) | 300s | 容忍 5 分钟滞后,写后 tag 立即失效 |
|
||||
| 学生名单(`classes:students:{classId}`) | 60s | 转班/调班需较快感知 |
|
||||
| 课表(`classes:schedule:{classId}`) | 600s | 课表变更频率极低 |
|
||||
| 统计(`classes:stats`) | 60s | 聚合数据短 TTL 兜底 |
|
||||
| 邀请码(`classes:invitations:{classId}`) | 300s | 与列表一致 |
|
||||
|
||||
### 6.3 actions 迁移清单
|
||||
|
||||
| 文件 | 当前 revalidatePath | 迁移后 |
|
||||
|------|------|------|
|
||||
| `actions-teacher.ts` | `revalidatePath("/teacher/classes/my")` 等 | `invalidateFor("classes.update", { id })` |
|
||||
| `actions-admin.ts` | 多处 revalidatePath | `invalidateFor("classes.create/update/delete")` |
|
||||
| `actions-grade.ts` | `revalidatePath("/admin/grades")` | `invalidateFor("classes.grade.update")` |
|
||||
| `actions-invitations.ts` | 多处 | `invalidateFor("classes.invitation.create/revoke")` |
|
||||
| `actions-schedule.ts` | 多处 | `invalidateFor("classes.schedule.update")` |
|
||||
|
||||
### 6.4 双导出模式(修复 P2 单测可 mock 性)
|
||||
|
||||
```typescript
|
||||
// data-access-teacher.ts
|
||||
export const getClassByIdRaw = async (id: string) => {
|
||||
return db.query.classes.findFirst({ where: eq(classes.id, id) })
|
||||
}
|
||||
export const getClassById = cacheFn(getClassByIdRaw, {
|
||||
tags: ["classes:detail", "classes:detail:{id}"],
|
||||
ttl: 300,
|
||||
keyParts: ["classes", "by-id"],
|
||||
})
|
||||
```
|
||||
|
||||
测试时 import `getClassByIdRaw` 直接 mock,绕过 cacheStore。
|
||||
|
||||
### 6.5 客户端组件迁移示范
|
||||
|
||||
```typescript
|
||||
// app/(dashboard)/teacher/classes/my/[id]/page.tsx(重构后)
|
||||
|
||||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { queryKeys } from "@/shared/lib/query-keys"
|
||||
import { getClassDetailAction } from "@/modules/classes/actions-teacher"
|
||||
|
||||
export default function ClassDetailPage({ params }: { params: { id: string } }) {
|
||||
// 之前:手动 useState + useEffect + Action().then()
|
||||
// 现在:声明式 useQuery,自动跨页共享缓存
|
||||
const { data: classDetail, isLoading } = useQuery({
|
||||
queryKey: queryKeys.classes.detail(params.id),
|
||||
queryFn: () => getClassDetailAction({ classId: params.id }),
|
||||
})
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**互操作验证**:当 `useActionMutation({ actionId: "classes.update" })` 成功后,`queryClient.invalidateQueries({ queryKey: ["classes", "detail"] })` 自动触发 refetch,无需在 onSuccess 中手写 `router.refresh()`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
| 类型 | 文件 | 验证目标 |
|
||||
|------|------|---------|
|
||||
| 单元 | `cache/memory-store.test.ts` | LRU 淘汰、TTL 过期、tag 索引维护 |
|
||||
| 单元 | `cache/redis-store.test.ts` | mock Redis 客户端,验证 key 格式、SMEMBERS+DEL 批量删除 |
|
||||
| 单元 | `cache/cache-fn.test.ts` | react.cache + cacheStore 双层包装;keyParts 生成;tags 传递 |
|
||||
| 单元 | `cache/invalidation-map.test.ts` | 模板填充 `{id}` 占位符;未知 actionId 抛错 |
|
||||
| 集成 | `cache/invalidate.test.ts` | invalidateFor 三步编排顺序(store.invalidateTags → revalidateTag → revalidatePath) |
|
||||
| 集成 | `hooks/use-action-query.test.tsx` | QueryClient 集成;queryKey 失效后自动 refetch |
|
||||
| 集成 | `hooks/use-action-mutation.test.tsx` | actionId 关联自动 invalidate;显式 invalidateQueryKeys 回退 |
|
||||
| 标杆 | `modules/classes/**/*.test.ts` | 现有测试不破(仅替换 cache 包装) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 架构文档同步
|
||||
|
||||
修改完成后同步以下文档(项目规则强制):
|
||||
|
||||
| 文档 | 修改内容 |
|
||||
|------|---------|
|
||||
| `docs/architecture/004_architecture_impact_map.md` | 新增"缓存基础设施"章节(位于 rate-limit 章节后);更新 classes 模块章节(标注 cacheFn 包装) |
|
||||
| `docs/architecture/005_architecture_data.json` | `shared.lib.cache.*` 节点;classes 模块的 `cacheUsage` 字段;INVALIDATION_MAP 节点 |
|
||||
| `docs/troubleshooting/known-issues.md` | 新增"缓存策略规则"章节(cacheFn 用法、invalidateFor 强制、queryKey 工厂强制) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
| 标准 | 验证方法 |
|
||||
|------|---------|
|
||||
| `npm run lint` 零错误 | 含新增 ESLint 规则 |
|
||||
| `npx tsc --noEmit` 零错误 | 含 cacheFn 类型推导 |
|
||||
| classes 模块 6 个 data-access 全部 cacheFn 包装 | grep 验证 |
|
||||
| classes 模块 5 个 actions 全部用 invalidateFor | ESLint 验证零 revalidatePath 直接调用 |
|
||||
| classes 模块新增 2+ 单测通过 | `npm test cache memory-store` |
|
||||
| Redis 模式下多实例缓存共享验证 | 手动:CACHE_DRIVER=redis 启动两实例,A 实例写后 B 实例读取验证 |
|
||||
| 架构文档同步 | git diff 验证 004/005/known-issues 三个文件已更新 |
|
||||
| rate-limit 模块迁移至 redis-client.ts 共享 | 现有 rate-limit 单测不破 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| `useActionQuery` 破坏性变更影响 11 个文件 | 本期仅迁移 classes 标杆页面;其他文件保持 useEffect 模式待下期;TypeScript 编译会立即提示漏传 queryKey |
|
||||
| `useActionMutation` 破坏性变更影响 50+ 文件 | 提供 `invalidateQueryKeys` 显式回退入口,不强制传 actionId;旧调用方先迁移 onSuccess 的 toast 逻辑,actionId 后续补 |
|
||||
| Redis 故障导致缓存穿透 | fail-open 策略:catch 错误 → 直查 DB;与 rate-limit 一致 |
|
||||
| 多实例下 tag 失效延迟 | Redis pub/sub 暂不引入;依赖 tag 索引在 Redis 中立即可见(同实例内 revalidateTag 立即生效;跨实例依赖下次请求读取 Redis) |
|
||||
| INVALIDATION_MAP 漏登记导致失效不全 | ESLint 规则 + 代码审查双重保障;新增写操作 PR 必须更新 INVALIDATION_MAP |
|
||||
| `cacheFn` 包装函数 `this` 绑定丢失 | 仅包装纯函数(data-access 不使用 `this`);签名约束 `extends (...args) => Promise<unknown>` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 不在本期范围
|
||||
|
||||
- 其他 29 个模块的 data-access / actions 迁移(下期按模块推进)
|
||||
- 其他客户端组件迁移到 useQuery(下期按页面推进)
|
||||
- L1+L2 两级缓存(YAGNI,先用单层 Redis 验证)
|
||||
- 缓存预热 / SWR 主动刷新(YAGNI)
|
||||
- 缓存命中率监控埋点(YAGNI)
|
||||
- Redis pub/sub 跨实例失效广播(YAGNI,单实例内 tag 失效已足够)
|
||||
- 现有 `react.cache` 全量替换(保留作为请求级 memoization 内层)
|
||||
|
||||
---
|
||||
|
||||
## 12. 后续工作(下期预告)
|
||||
|
||||
1. 按模块推进其他 29 个模块的 data-access / actions 迁移
|
||||
2. 按页面推进客户端组件迁移到 useQuery
|
||||
3. 在 100+ 组件中替换 `toast.success/error` 为 `notify.success/error`(V5 状态管理专项已建)
|
||||
4. 评估缓存命中率监控(Prometheus + Grafana)
|
||||
5. 评估引入 Redis pub/sub 实现跨实例实时失效广播(如跨实例失效延迟不可接受)
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:与 rate-limit 模块的复用关系
|
||||
|
||||
| 资产 | rate-limit 现状 | cache 复用方式 |
|
||||
|------|----------------|---------------|
|
||||
| `RATE_LIMIT_DRIVER` env | 已实现 | 新增 `CACHE_DRIVER` env,独立控制 |
|
||||
| `@upstash/redis` 动态 import | `redis-limiter.ts` 内部 `getRedisClient()` | 抽出至 `redis-client.ts`,rate-limit + cache 共用 |
|
||||
| `@upstash/ratelimit` 动态 import | `redis-limiter.ts` 内部 | 不复用,cache 不需要 ratelimit |
|
||||
| `upstash-modules.d.ts` | rate-limit 目录内 | 提升至 `shared/lib/upstash-modules.d.ts`,rate-limit 与 cache 均从提升后的位置 import;旧路径保留 re-export shim 避免破坏现有 import |
|
||||
| fail-open 策略 | catch → 兜底返回 allow | catch → 直查 DB(透传 producer) |
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:queryKey 命名约定
|
||||
|
||||
格式:`[module, resource, ...args]`
|
||||
|
||||
```typescript
|
||||
queryKeys.classes.all // ["classes"]
|
||||
queryKeys.classes.lists() // ["classes", "list"]
|
||||
queryKeys.classes.list(filters) // ["classes", "list", filters]
|
||||
queryKeys.classes.detail(id) // ["classes", "detail", id]
|
||||
queryKeys.classes.students(classId) // ["classes", "students", classId]
|
||||
queryKeys.classes.schedule(classId) // ["classes", "schedule", classId]
|
||||
queryKeys.classes.stats(classId) // ["classes", "stats", classId]
|
||||
queryKeys.classes.invitations(classId) // ["classes", "invitations", classId]
|
||||
```
|
||||
|
||||
**约定**:
|
||||
- 第一段:模块名(与 `src/modules/[module]/` 一致)
|
||||
- 第二段:资源名(复数:list / detail / students / schedule / stats)
|
||||
- 后续段:唯一标识(id / classId)或过滤条件对象
|
||||
- 失效时使用前缀匹配:`invalidateQueries({ queryKey: ["classes", "detail"] })` 失效所有详情
|
||||
Reference in New Issue
Block a user