docs(cache): 缓存策略落地专项设计文档 v1 - 全栈缓存策略一致性(服务端数据缓存+客户端 TanStack Query+失效编排)

This commit is contained in:
SpecialX
2026-07-05 01:30:59 +08:00
parent 9ec1be1528
commit 8afd7af6dc

View File

@@ -0,0 +1,742 @@
# 缓存策略落地专项重构设计文档
| 字段 | 值 |
|------|---|
| 文档版本 | v1 |
| 创建日期 | 2026-07-05 |
| 作者 | Trae 协作生成 |
| 状态 | 待用户审查 |
| 范围 | 全栈缓存策略一致性(基础设施 + 规范 + 标杆示范) |
| 标杆模块 | classes |
---
## 1. 背景与现状
### 1.1 项目缓存现状
| 层 | 现状 | 问题 |
|----|------|------|
| 服务端数据缓存 | 仅用 `react.cache`(请求级 memoization30 个 data-access 文件)+ `revalidatePath`(路径级失效) | 未用 `unstable_cache` / `revalidateTag` / Redis 数据缓存;多实例下无跨实例共享 |
| 客户端缓存 | TanStack Query V5 基础设施已建(`createQueryClient` 默认 staleTime 30s/retry 1 | `useActionQuery` / `useActionMutation` 用 useEffect + useState 自实现,绕过 QueryClient30+ 组件直接 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 # 公共 APIcacheFn / 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 # 重构:内部改走 useQueryQueryClient
└─ 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 × NNext.js fetch 缓存 + unstable_cache 失效)
* 3. revalidatePath × NRSC 静态缓存失效)
*
* @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 |
---
## 附录 BqueryKey 命名约定
格式:`[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"] })` 失效所有详情