Files
NextEdu/docs/superpowers/specs/2026-07-05-cache-strategy-design.md

743 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 缓存策略落地专项重构设计文档
| 字段 | 值 |
|------|---|
| 文档版本 | 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/{classId}"],
},
"classes.invitation.revoke": {
tags: ["classes:invitations:{classId}"],
queryKeys: [["classes", "invitations"]],
paths: ["/teacher/classes/my/{classId}"],
},
} 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"] })` 失效所有详情