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

2753 lines
83 KiB
Markdown
Raw 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.
# 缓存策略落地专项重构 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 建立全栈缓存策略一致性基础设施(服务端 `cacheFn` + Redis driver + 集中式 `INVALIDATION_MAP` + 客户端 queryKey 工厂 + Hook 重构),并完成 classes 标杆模块全场景迁移。
**Architecture:** 复用项目已有的 `rate-limit` driver 切换模式(`CACHE_DRIVER=memory|redis` + 动态 import + `webpackIgnore`)。新增 `shared/lib/cache/` 子模块提供 `cacheFn` 包装器与 `invalidateFor` 编排函数;新增 `shared/lib/query-keys.ts` 客户端 queryKey 工厂;重构 `useActionQuery` / `useActionMutation` 接入 TanStack Query。classes 模块 6 个 data-access + 5 个 actions 全量迁移作为标杆验证。
**Tech Stack:** Next.js 16 App Router / React 19 / TanStack Query v5 / Drizzle ORM / Zod / Vitest + jsdom / @upstash/redis (动态可选)
---
## File Structure
### 新增文件
| 文件 | 责任 | 行数预算 |
|------|------|---------|
| `src/shared/lib/cache/types.ts` | `CacheFnOptions` / `CacheStore` / `InvalidationRule` 接口 | ≤80 |
| `src/shared/lib/cache/memory-store.ts` | 内存 LRU 实现maxEntries=500 | ≤150 |
| `src/shared/lib/cache/redis-store.ts` | Redis 实现SMEMBERS+DEL 批量失效) | ≤200 |
| `src/shared/lib/cache/store-factory.ts` | 按 `CACHE_DRIVER` 动态加载 | ≤40 |
| `src/shared/lib/cache/cache-fn.ts` | `cacheFn(fn, options)` 双层包装react.cache + cacheStore | ≤80 |
| `src/shared/lib/cache/invalidation-map.ts` | 集中失效映射表 `INVALIDATION_MAP` | ≤300随模块增长 |
| `src/shared/lib/cache/client-invalidation-map.ts` | 客户端可见的 queryKeys 子集 | ≤80 |
| `src/shared/lib/cache/invalidate.ts` | `invalidateFor(actionId, params)` 编排 | ≤80 |
| `src/shared/lib/cache/index.ts` | 公共 API 聚合导出 | ≤30 |
| `src/shared/lib/redis-client.ts` | 共享 Redis 单例rate-limit + cache 共用) | ≤80 |
| `src/shared/lib/upstash-modules.d.ts` | 提升至 shared/lib 的 @upstash 类型声明 | ≤80 |
| `src/shared/lib/query-keys.ts` | queryKey 工厂(按 模块/资源/操作 分层) | ≤100 |
| `src/shared/lib/cache/memory-store.test.ts` | LRU 淘汰、TTL 过期、tag 索引维护单测 | ≤200 |
| `src/shared/lib/cache/redis-store.test.ts` | mock Redis 客户端的单测 | ≤200 |
| `src/shared/lib/cache/cache-fn.test.ts` | 双层包装、keyParts 生成、tags 传递单测 | ≤150 |
| `src/shared/lib/cache/invalidation-map.test.ts` | 模板填充、未知 actionId 抛错单测 | ≤120 |
| `src/shared/lib/cache/invalidate.test.ts` | invalidateFor 三步编排集成测试 | ≤150 |
| `src/shared/hooks/use-action-query.test.tsx` | QueryClient 集成测试 | ≤200 |
| `src/shared/hooks/use-action-mutation.test.tsx` | actionId 自动 invalidate 集成测试 | ≤200 |
### 修改文件
| 文件 | 修改内容 |
|------|---------|
| `src/env.mjs` | 新增 `CACHE_DRIVER` 字段 |
| `src/shared/lib/rate-limit/redis-limiter.ts` | `getRedisClient()` 改为引用 `shared/lib/redis-client.ts` |
| `src/shared/lib/rate-limit/upstash-modules.d.ts` | 改为 re-export `shared/lib/upstash-modules.d.ts`(向后兼容 shim |
| `src/shared/hooks/use-action-query.ts` | 内部改走 `useQuery`,强制 `queryKey` 参数 |
| `src/shared/hooks/use-action-mutation.ts` | 内部改走 `useMutation` + `actionId` 自动 invalidate |
| `src/modules/classes/data-access-teacher.ts` | `cache(...)``cacheFn(...)` + 双导出 raw 版本 |
| `src/modules/classes/data-access-admin.ts` | 同上 |
| `src/modules/classes/data-access-students.ts` | 同上 |
| `src/modules/classes/data-access-stats.ts` | 同上 |
| `src/modules/classes/data-access-schedule.ts` | 同上 |
| `src/modules/classes/actions-teacher.ts` | `revalidatePath(...)``invalidateFor("classes.*", params)` |
| `src/modules/classes/actions-admin.ts` | 同上 |
| `src/modules/classes/actions-grade.ts` | 同上 |
| `src/modules/classes/actions-invitations.ts` | 同上 |
| `src/modules/classes/actions-schedule.ts` | 同上 |
| `eslint.config.mjs` | 新增 `no-restricted-syntax` 禁止直接 `revalidatePath`/`revalidateTag` |
| `docs/architecture/004_architecture_impact_map.md` | 新增"缓存基础设施"章节 + 更新 classes 模块 |
| `docs/architecture/005_architecture_data.json` | 新增 `shared.lib.cache.*``INVALIDATION_MAP` 节点 |
| `docs/troubleshooting/known-issues.md` | 新增"缓存策略规则"章节 |
---
## Task 1: 新增 `CACHE_DRIVER` 环境变量
**Files:**
- Modify: `src/env.mjs:14-37`
- [ ] **Step 1: 修改 `src/env.mjs`,新增 `CACHE_DRIVER`**
修改 `server` 字段,在 `RATE_LIMIT_DRIVER` 之后新增 `CACHE_DRIVER`;在 `runtimeEnv` 中映射。
```javascript
// src/env.mjs修改后片段
server: {
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
NEXTAUTH_SECRET: z.string().min(1).optional(),
NEXTAUTH_URL: z.string().url().optional(),
AI_API_KEY: z.string().min(1).optional(),
AI_BASE_URL: z.string().url().optional(),
AI_MODEL: z.string().min(1).optional(),
RATE_LIMIT_DRIVER: z.enum(["memory", "redis"]).default("memory"),
UPSTASH_REDIS_REST_URL: z.string().url().optional(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
// Cache driver: "memory" (default, single-instance LRU) or "redis" (distributed).
CACHE_DRIVER: z.enum(["memory", "redis"]).default("memory"),
CRON_SECRET: z.string().min(1).optional(),
},
// ...
runtimeEnv: {
// ...
CACHE_DRIVER: process.env.CACHE_DRIVER,
// ...
},
```
- [ ] **Step 2: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS零错误
- [ ] **Step 3: 提交**
```bash
git add src/env.mjs
git commit -m "feat(cache): 新增 CACHE_DRIVER 环境变量"
```
---
## Task 2: 提升至 `shared/lib/upstash-modules.d.ts`
**Files:**
- Create: `src/shared/lib/upstash-modules.d.ts`
- Modify: `src/shared/lib/rate-limit/upstash-modules.d.ts`(改为 re-export shim
- [ ] **Step 1: 创建 `src/shared/lib/upstash-modules.d.ts`**
`src/shared/lib/rate-limit/upstash-modules.d.ts` 的完整内容复制到新文件,文件头注释更新为:
```typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Upstash SDK 可选依赖类型声明cache + rate-limit 共用)
*
* 这两个包通过动态 import 在运行时加载:
* - cache 模块在 CACHE_DRIVER=redis 时加载 @upstash/redis
* - rate-limit 模块在 RATE_LIMIT_DRIVER=redis 时加载 @upstash/ratelimit + @upstash/redis
*
* 安装命令:
* npm install @upstash/redis # cache + rate-limit 共用
* npm install @upstash/ratelimit # 仅 rate-limit 需要
*
* 安装后这两个包自带的类型声明将覆盖此处的 any 声明。
*/
declare module "@upstash/ratelimit" {
export class Ratelimit {
constructor(config: {
redis: any
limiter: any
analytics?: boolean
prefix?: string
})
limit(
identifier: string,
): Promise<{
success: boolean
limit: number
remaining: number
reset: number
pending: Promise<unknown>
}>
reset(identifier: string): Promise<void>
static slidingWindow(
max: number,
window: string,
): { type: "sliding-window"; max: number; window: string }
static fixedWindow(
max: number,
window: string,
): { type: "fixed-window"; max: number; window: string }
static tokenBucket(
max: number,
window: string,
refillRate: number,
): { type: "token-bucket"; max: number; window: string; refillRate: number }
}
}
declare module "@upstash/redis" {
export class Redis {
constructor(config: {
url: string
token: string
automaticDeserialization?: boolean
responseEncoding?: "base64" | "none"
retry?: {
retries: number
backoff: (retryCount: number) => number
}
})
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<"OK">
del(...keys: string[]): Promise<number>
incr(key: string): Promise<number>
expire(key: string, seconds: number): Promise<number>
pexpire(key: string, milliseconds: number): Promise<number>
[key: string]: any
}
}
```
- [ ] **Step 2: 修改 `src/shared/lib/rate-limit/upstash-modules.d.ts` 为 re-export shim**
```typescript
/**
* 向后兼容 shim实际声明已提升至 shared/lib/upstash-modules.d.ts
* rate-limit 与 cache 模块共用同一份类型声明。
*
* 保留此文件避免破坏现有 import 路径。
* 新代码请直接从 @/shared/lib/upstash-modules.d.ts 引用。
*/
export {}
```
- [ ] **Step 3: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 4: 提交**
```bash
git add src/shared/lib/upstash-modules.d.ts src/shared/lib/rate-limit/upstash-modules.d.ts
git commit -m "refactor(cache): 提升 upstash-modules 类型声明至 shared/lib"
```
---
## Task 3: 共享 Redis 客户端单例
**Files:**
- Create: `src/shared/lib/redis-client.ts`
- Modify: `src/shared/lib/rate-limit/redis-limiter.ts:22-104`
- [ ] **Step 1: 创建 `src/shared/lib/redis-client.ts`**
```typescript
import "server-only"
import { env } from "@/env.mjs"
/**
* 共享 Redis 客户端单例rate-limit + cache 共用)。
*
* 复用 env.UPSTASH_REDIS_REST_URL / TOKEN。
* 动态 import + webpackIgnore与原 redis-limiter.ts 同模式。
*
* 故障降级:返回 null调用方走 fail-open。
*/
let singleton: unknown | null = null
let loadPromise: Promise<unknown> | null = null
/**
* 获取共享 Redis 客户端。
*
* - 首次调用时动态 import @upstash/redis 并构造 Redis 实例
* - 后续调用复用单例
* - 若 env 未配置 UPSTASH_REDIS_REST_URL/TOKEN返回 null
* - 若动态 import 抛错(包未安装),返回 null
*
* 调用方需自行处理 nullfail-open 降级)。
*/
export async function getRedisClient(): Promise<unknown | null> {
if (singleton) return singleton
if (loadPromise) return loadPromise
if (!env.UPSTASH_REDIS_REST_URL || !env.UPSTASH_REDIS_REST_TOKEN) {
return null
}
loadPromise = (async () => {
try {
// webpackIgnore: 让 @upstash/redis 成为运行时可选依赖
const { Redis } = await import(/* webpackIgnore: true */ "@upstash/redis")
const client = new Redis({
url: env.UPSTASH_REDIS_REST_URL!,
token: env.UPSTASH_REDIS_REST_TOKEN!,
})
singleton = client
return client
} catch (error) {
console.error(
"[redis-client] Failed to load @upstash/redis:",
error instanceof Error ? error.message : String(error),
)
return null
} finally {
loadPromise = null
}
})()
return loadPromise
}
```
- [ ] **Step 2: 修改 `src/shared/lib/rate-limit/redis-limiter.ts`,删除内部 `getRedisClient`,改用共享单例**
`redis-limiter.ts` 顶部新增 import
```typescript
import { getRedisClient } from "@/shared/lib/redis-client"
```
删除原文件 22-104 行的 `getRedisClient` 私有方法及 `redisClient` 字段。将原 `this.getRedisClient()` 调用替换为 `getRedisClient()`。修改后 `RedisRateLimiter` 类的 `getRatelimit` 方法片段:
```typescript
/** 按 (limit, windowMs) 获取或创建 Ratelimit 实例 */
private async getRatelimit(
params: RateLimitParams,
): Promise<UpstashRatelimitInstance> {
const signature = `${params.limit}:${params.windowMs}`
const existing = this.instances.get(signature)
if (existing) return existing
const [Ratelimit, redis] = await Promise.all([
this.getRatelimitCtor(),
getRedisClient(),
])
if (!redis) {
throw new Error(
"[rate-limit] Redis client unavailable. Check UPSTASH_REDIS_REST_URL/TOKEN.",
)
}
const instance = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(
params.limit,
msToSlidingWindowArg(params.windowMs),
),
analytics: false,
prefix: "next-edu:rl",
})
this.instances.set(signature, instance)
return instance
}
```
删除原 `private redisClient: unknown = null` 字段与 `private async getRedisClient()` 方法。
- [ ] **Step 3: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 4: 验证 lint 通过**
Run: `npm run lint`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add src/shared/lib/redis-client.ts src/shared/lib/rate-limit/redis-limiter.ts
git commit -m "refactor(redis): 抽出共享 Redis 客户端单例至 shared/lib/redis-client.ts"
```
---
## Task 4: `cache/types.ts` 类型定义
**Files:**
- Create: `src/shared/lib/cache/types.ts`
- [ ] **Step 1: 创建 `src/shared/lib/cache/types.ts`**
```typescript
/**
* 缓存基础设施类型定义。
*/
/**
* cacheFn 包装器配置。
*/
export interface CacheFnOptions {
/**
* 缓存标签,用于按 tag 失效。
* 必填,至少 1 个。支持模板占位符 `{id}` 等,由 invalidateFor 填充。
*/
readonly tags: readonly string[]
/**
* TTL 秒数。不传则永久缓存(仅靠 tag 失效)。
*/
readonly ttl?: number
/**
* 自定义 key 段。默认根据 fn.name + 参数 JSON 自动生成。
* 推荐显式传入以便跨实例一致。
*/
readonly keyParts?: readonly unknown[]
}
/**
* CacheStore 接口。memory 与 redis 两种实现。
*/
export interface CacheStore {
/**
* 获取或设置缓存值。
* - 命中且未过期:返回缓存值
* - 未命中或过期:调用 producer写入缓存后返回
* - producer 抛错:透传错误,不写入缓存
*/
getOrSet<T>(
key: string,
producer: () => Promise<T>,
options: { tags: readonly string[]; ttl?: number },
): Promise<T>
/**
* 按 tag 批量失效。
* - 解析 tag 对应的 key 集合
* - 删除所有 key
* - 删除 tag 索引
*/
invalidateTags(tags: readonly string[]): Promise<void>
}
/**
* 失效映射表规则。
*/
export interface InvalidationRule {
/** 服务端 cacheStore + Next.js revalidateTag 失效的标签 */
readonly tags: readonly string[]
/** 客户端 TanStack Query 失效的 queryKey 前缀 */
readonly queryKeys: readonly (readonly (string | number)[])[]
/** Next.js revalidatePath 失效的路径 */
readonly paths: readonly string[]
}
```
- [ ] **Step 2: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 3: 提交**
```bash
git add src/shared/lib/cache/types.ts
git commit -m "feat(cache): 新增 cache/types.ts 类型定义"
```
---
## Task 5: `cache/memory-store.ts` 实现
**Files:**
- Create: `src/shared/lib/cache/memory-store.ts`
- Test: `src/shared/lib/cache/memory-store.test.ts`
- [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/memory-store.test.ts`**
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest"
import { MemoryCacheStore } from "./memory-store"
describe("MemoryCacheStore", () => {
let store: MemoryCacheStore
beforeEach(() => {
store = new MemoryCacheStore({ maxEntries: 3 })
})
it("未命中时调用 producer 并缓存结果", async () => {
const producer = vi.fn().mockResolvedValue({ count: 42 })
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toEqual({ count: 42 })
expect(producer).toHaveBeenCalledTimes(1)
})
it("命中时不调用 producer", async () => {
const producer = vi.fn().mockResolvedValue("v1")
await store.getOrSet("k1", producer, { tags: ["users"] })
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toBe("v1")
expect(producer).toHaveBeenCalledTimes(1)
})
it("TTL 过期后重新调用 producer", async () => {
vi.useFakeTimers()
const producer = vi.fn().mockResolvedValue("v1")
await store.getOrSet("k1", producer, { tags: ["users"], ttl: 60 })
vi.advanceTimersByTime(61_000)
producer.mockResolvedValue("v2")
const result = await store.getOrSet("k1", producer, { tags: ["users"], ttl: 60 })
expect(result).toBe("v2")
expect(producer).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it("invalidateTags 删除关联 key", async () => {
const producer = vi.fn()
producer.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2")
await store.getOrSet("k1", producer, { tags: ["users"] })
await store.invalidateTags(["users"])
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toBe("v2")
expect(producer).toHaveBeenCalledTimes(2)
})
it("LRU 淘汰最久未访问的 key", async () => {
const producer = vi.fn()
producer.mockResolvedValue("v")
await store.getOrSet("k1", producer, { tags: ["a"] })
await store.getOrSet("k2", producer, { tags: ["b"] })
// 访问 k1使 k2 成为 LRU
await store.getOrSet("k1", producer, { tags: ["a"] })
await store.getOrSet("k3", producer, { tags: ["c"] })
// k4 触发淘汰k2 应被淘汰
producer.mockResolvedValue("new-v2")
const result = await store.getOrSet("k2", producer, { tags: ["b"] })
expect(result).toBe("new-v2")
expect(producer).toHaveBeenCalledTimes(5) // 4 次初始 + 1 次 k2 重新调用
})
it("producer 抛错时不写入缓存", async () => {
const producer = vi.fn().mockRejectedValueOnce(new Error("DB down"))
await expect(
store.getOrSet("k1", producer, { tags: ["users"] }),
).rejects.toThrow("DB down")
producer.mockResolvedValueOnce("v1")
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toBe("v1")
})
})
```
- [ ] **Step 2: 运行测试验证失败**
Run: `npm run test:unit -- memory-store`
Expected: FAIL with "Cannot find module './memory-store'"
- [ ] **Step 3: 实现 `src/shared/lib/cache/memory-store.ts`**
```typescript
import "server-only"
import type { CacheStore } from "./types"
interface CacheEntry {
value: unknown
expireAt: number // 0 表示永久
tags: ReadonlySet<string>
}
export interface MemoryCacheStoreOptions {
/** 最大条目数,超出 LRU 淘汰。默认 500。 */
readonly maxEntries?: number
}
/**
* 内存 LRU 缓存实现。
*
* - 使用 Map 维护插入顺序FIFO访问时删除再插入实现 LRU
* - TTL 过期惰性删除getOrSet 时检查)
* - tag 索引Map<tag, Set<key>> 反查表
* - 适用于单实例 dev/test
*/
export class MemoryCacheStore implements CacheStore {
private readonly entries = new Map<string, CacheEntry>()
private readonly tagIndex = new Map<string, Set<string>>()
private readonly maxEntries: number
constructor(options: MemoryCacheStoreOptions = {}) {
this.maxEntries = options.maxEntries ?? 500
}
async getOrSet<T>(
key: string,
producer: () => Promise<T>,
options: { tags: readonly string[]; ttl?: number },
): Promise<T> {
const now = Date.now()
const existing = this.entries.get(key)
// 命中且未过期
if (existing && (existing.expireAt === 0 || existing.expireAt > now)) {
// LRU删除再插入使其成为最新访问
this.entries.delete(key)
this.entries.set(key, existing)
return existing.value as T
}
// 未命中或过期:调用 producer
if (existing) {
this.removeEntry(key)
}
const value = await producer()
const expireAt = options.ttl ? now + options.ttl * 1000 : 0
const tagSet = new Set(options.tags)
this.entries.set(key, { value, expireAt, tags: tagSet })
for (const tag of options.tags) {
let keys = this.tagIndex.get(tag)
if (!keys) {
keys = new Set()
this.tagIndex.set(tag, keys)
}
keys.add(key)
}
this.evictIfNeeded()
return value
}
async invalidateTags(tags: readonly string[]): Promise<void> {
const keysToDelete = new Set<string>()
for (const tag of tags) {
const keys = this.tagIndex.get(tag)
if (keys) {
for (const key of keys) {
keysToDelete.add(key)
}
this.tagIndex.delete(tag)
}
}
for (const key of keysToDelete) {
this.removeEntry(key)
}
}
/** LRU 淘汰:超出 maxEntries 时删除最旧的 */
private evictIfNeeded(): void {
while (this.entries.size > this.maxEntries) {
const oldestKey = this.entries.keys().next().value
if (oldestKey === undefined) break
this.removeEntry(oldestKey)
}
}
private removeEntry(key: string): void {
const entry = this.entries.get(key)
if (!entry) return
this.entries.delete(key)
for (const tag of entry.tags) {
const keys = this.tagIndex.get(tag)
if (keys) {
keys.delete(key)
if (keys.size === 0) {
this.tagIndex.delete(tag)
}
}
}
}
}
```
- [ ] **Step 4: 运行测试验证通过**
Run: `npm run test:unit -- memory-store`
Expected: PASS6 个测试全过)
- [ ] **Step 5: 验证 lint 与类型校验**
Run: `npm run lint && npx tsc --noEmit`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add src/shared/lib/cache/memory-store.ts src/shared/lib/cache/memory-store.test.ts
git commit -m "feat(cache): 实现 MemoryCacheStoreLRU + TTL + tag 索引)"
```
---
## Task 6: `cache/redis-store.ts` 实现
**Files:**
- Create: `src/shared/lib/cache/redis-store.ts`
- Test: `src/shared/lib/cache/redis-store.test.ts`
- [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/redis-store.test.ts`**
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest"
import { RedisCacheStore } from "./redis-store"
// Mock 共享 Redis 客户端
const mockRedis = {
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
sadd: vi.fn(),
smembers: vi.fn(),
expire: vi.fn(),
}
vi.mock("@/shared/lib/redis-client", () => ({
getRedisClient: vi.fn().mockResolvedValue(mockRedis),
}))
import { RedisCacheStore } from "./redis-store"
describe("RedisCacheStore", () => {
let store: RedisCacheStore
beforeEach(() => {
vi.clearAllMocks()
store = new RedisCacheStore()
})
it("未命中时调用 producer 并缓存set + sadd 索引)", async () => {
mockRedis.get.mockResolvedValue(null)
const producer = vi.fn().mockResolvedValue({ count: 42 })
const result = await store.getOrSet("k1", producer, {
tags: ["users"],
ttl: 60,
})
expect(result).toEqual({ count: 42 })
expect(mockRedis.set).toHaveBeenCalledTimes(1)
expect(mockRedis.sadd).toHaveBeenCalledWith("next-edu:cache:tag:users", "k1")
expect(mockRedis.expire).toHaveBeenCalledWith("next-edu:cache:tag:users", 60)
})
it("命中时直接返回缓存值,不调用 producer", async () => {
mockRedis.get.mockResolvedValue(JSON.stringify({ count: 99 }))
const producer = vi.fn()
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toEqual({ count: 99 })
expect(producer).not.toHaveBeenCalled()
})
it("invalidateTags 通过 smembers+del 批量删除", async () => {
mockRedis.smembers
.mockResolvedValueOnce(["k1", "k2"]) // tag users
.mockResolvedValueOnce(["k3"]) // tag classes
await store.invalidateTags(["users", "classes"])
expect(mockRedis.smembers).toHaveBeenCalledWith("next-edu:cache:tag:users")
expect(mockRedis.smembers).toHaveBeenCalledWith("next-edu:cache:tag:classes")
expect(mockRedis.del).toHaveBeenCalledWith("k1", "k2", "k3")
expect(mockRedis.del).toHaveBeenCalledWith("next-edu:cache:tag:users", "next-edu:cache:tag:classes")
})
it("Redis 不可用时降级直查 producer", async () => {
vi.mocked(getRedisClient).mockResolvedValueOnce(null)
mockRedis.get.mockResolvedValue(null)
const producer = vi.fn().mockResolvedValue("v1")
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toBe("v1")
expect(producer).toHaveBeenCalledTimes(1)
})
it("Redis 调用抛错时降级直查 producer", async () => {
mockRedis.get.mockRejectedValue(new Error("Redis down"))
const producer = vi.fn().mockResolvedValue("v1")
const result = await store.getOrSet("k1", producer, { tags: ["users"] })
expect(result).toBe("v1")
expect(producer).toHaveBeenCalledTimes(1)
})
})
```
- [ ] **Step 2: 运行测试验证失败**
Run: `npm run test:unit -- redis-store`
Expected: FAIL with "Cannot find module './redis-store'"
- [ ] **Step 3: 实现 `src/shared/lib/cache/redis-store.ts`**
```typescript
import "server-only"
import { getRedisClient } from "@/shared/lib/redis-client"
import type { CacheStore } from "./types"
const KEY_PREFIX = "next-edu:cache:"
const TAG_PREFIX = `${KEY_PREFIX}tag:`
/**
* Redis 多实例缓存实现。
*
* - key 格式next-edu:cache:{key}
* - valueJSON.stringify
* - tag 反查索引next-edu:cache:tag:{tag} → Redis Set 存放 keys
* - TTLset 时通过 expire 原子设置
* - invalidateTagssmembers → del 批量
*
* 故障降级Redis 不可用或调用抛错时,透传 producer不阻断主流程。
*/
export class RedisCacheStore implements CacheStore {
async getOrSet<T>(
key: string,
producer: () => Promise<T>,
options: { tags: readonly string[]; ttl?: number },
): Promise<T> {
const redis = (await getRedisClient()) as RedisLike | null
const redisKey = `${KEY_PREFIX}${key}`
if (redis) {
try {
const cached = await redis.get(redisKey)
if (cached) {
return JSON.parse(cached) as T
}
} catch (error) {
console.error(
"[cache] Redis get failure, falling back to producer:",
error instanceof Error ? error.message : String(error),
)
}
}
// 未命中或 Redis 不可用:调用 producer
const value = await producer()
if (redis) {
try {
await redis.set(redisKey, JSON.stringify(value))
if (options.ttl) {
await redis.expire(redisKey, options.ttl)
}
// 维护 tag 索引
for (const tag of options.tags) {
const tagKey = `${TAG_PREFIX}${tag}`
await redis.sadd(tagKey, redisKey)
if (options.ttl) {
await redis.expire(tagKey, options.ttl)
}
}
} catch (error) {
console.error(
"[cache] Redis set failure, ignoring:",
error instanceof Error ? error.message : String(error),
)
}
}
return value
}
async invalidateTags(tags: readonly string[]): Promise<void> {
const redis = (await getRedisClient()) as RedisLike | null
if (!redis) return
try {
const keysToDelete: string[] = []
const tagKeysToDelete: string[] = []
for (const tag of tags) {
const tagKey = `${TAG_PREFIX}${tag}`
const keys = await redis.smembers(tagKey)
keysToDelete.push(...keys)
tagKeysToDelete.push(tagKey)
}
if (keysToDelete.length > 0) {
await redis.del(...keysToDelete)
}
if (tagKeysToDelete.length > 0) {
await redis.del(...tagKeysToDelete)
}
} catch (error) {
console.error(
"[cache] Redis invalidateTags failure, ignoring:",
error instanceof Error ? error.message : String(error),
)
}
}
}
/** @upstash/redis 的最小可用接口 */
interface RedisLike {
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<unknown>
del(...keys: string[]): Promise<number>
sadd(key: string, ...members: string[]): Promise<number>
smembers(key: string): Promise<string[]>
expire(key: string, seconds: number): Promise<number>
}
```
- [ ] **Step 4: 运行测试验证通过**
Run: `npm run test:unit -- redis-store`
Expected: PASS5 个测试全过)
- [ ] **Step 5: 验证 lint 与类型校验**
Run: `npm run lint && npx tsc --noEmit`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add src/shared/lib/cache/redis-store.ts src/shared/lib/cache/redis-store.test.ts
git commit -m "feat(cache): 实现 RedisCacheStore多实例 + tag 索引 + fail-open"
```
---
## Task 7: `cache/store-factory.ts` driver 切换
**Files:**
- Create: `src/shared/lib/cache/store-factory.ts`
- [ ] **Step 1: 创建 `src/shared/lib/cache/store-factory.ts`**
```typescript
import "server-only"
import { env } from "@/env.mjs"
import type { CacheStore } from "./types"
import { MemoryCacheStore } from "./memory-store"
let singleton: CacheStore | null = null
let loadPromise: Promise<CacheStore> | null = null
/**
* 获取当前进程的 CacheStore 实例。
*
* - 默认返回 MemoryCacheStore
* - 当 CACHE_DRIVER=redis 时动态加载 RedisCacheStore
* - Redis 实现懒加载 @upstash/redis 依赖,未安装时首次调用抛错
*
* 返回 PromiseRedis 实现需动态 import 模块,故为异步。
*/
export async function getCacheStore(): Promise<CacheStore> {
if (singleton) return singleton
if (loadPromise) return loadPromise
loadPromise = (async () => {
if (env.CACHE_DRIVER === "redis") {
const { RedisCacheStore } = await import("./redis-store")
singleton = new RedisCacheStore()
} else {
singleton = new MemoryCacheStore()
}
loadPromise = null
return singleton
})()
return loadPromise
}
```
- [ ] **Step 2: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 3: 提交**
```bash
git add src/shared/lib/cache/store-factory.ts
git commit -m "feat(cache): 新增 store-factoryCACHE_DRIVER 切换)"
```
---
## Task 8: `cache/cache-fn.ts` 双层包装
**Files:**
- Create: `src/shared/lib/cache/cache-fn.ts`
- Test: `src/shared/lib/cache/cache-fn.test.ts`
- [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/cache-fn.test.ts`**
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest"
import { cacheFn } from "./cache-fn"
// Mock store-factory
const mockStore = {
getOrSet: vi.fn(),
invalidateTags: vi.fn(),
}
vi.mock("./store-factory", () => ({
getCacheStore: vi.fn().mockResolvedValue(mockStore),
}))
import { cacheFn } from "./cache-fn"
describe("cacheFn", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("首次调用触发 producer第二次命中缓存", async () => {
const producer = vi.fn().mockResolvedValue("v1")
mockStore.getOrSet.mockImplementation(async (_key, prod) => prod())
const cached = cacheFn(producer, { tags: ["users"], ttl: 60, keyParts: ["users", "by-id"] })
const r1 = await cached("u1")
const r2 = await cached("u1")
expect(r1).toBe("v1")
expect(r2).toBe("v1")
expect(producer).toHaveBeenCalledTimes(1) // react.cache 在同请求内去重
expect(mockStore.getOrSet).toHaveBeenCalledTimes(2)
})
it("不同参数生成不同 key", async () => {
const producer = vi.fn().mockResolvedValue("v")
mockStore.getOrSet.mockImplementation(async (_key, prod) => prod())
const cached = cacheFn(producer, { tags: ["users"], keyParts: ["users"] })
await cached("u1")
await cached("u2")
const keys = mockStore.getOrSet.mock.calls.map((c) => c[0])
expect(keys[0]).not.toBe(keys[1])
})
it("keyParts 参与生成 key", async () => {
const producer = vi.fn().mockResolvedValue("v")
mockStore.getOrSet.mockImplementation(async (_key, prod) => prod())
const cached = cacheFn(producer, { tags: ["users"], keyParts: ["users", "by-id"] })
await cached("u1")
const key = mockStore.getOrSet.mock.calls[0][0]
expect(key).toContain("users")
expect(key).toContain("by-id")
})
it("tags 与 ttl 透传给 store", async () => {
const producer = vi.fn().mockResolvedValue("v")
mockStore.getOrSet.mockImplementation(async (_key, prod) => prod())
const cached = cacheFn(producer, { tags: ["users", "users:detail"], ttl: 120 })
await cached("u1")
const options = mockStore.getOrSet.mock.calls[0][2]
expect(options.tags).toEqual(["users", "users:detail"])
expect(options.ttl).toBe(120)
})
})
```
- [ ] **Step 2: 运行测试验证失败**
Run: `npm run test:unit -- cache-fn`
Expected: FAIL with "Cannot find module './cache-fn'"
- [ ] **Step 3: 实现 `src/shared/lib/cache/cache-fn.ts`**
```typescript
import "server-only"
import { cache as reactCache } from "react"
import { getCacheStore } from "./store-factory"
import type { CacheFnOptions } from "./types"
/**
* 缓存包装器:双层职责。
*
* - 外层 react.cache请求级 memoization同一 RSC 请求内去重)
* - 内层 cacheStore.getOrSet跨请求/跨实例数据缓存
*
* @param fn 原始异步函数
* @param options tags必填、ttl、keyParts
* @returns 与原函数同类型的包装函数
*/
export function cacheFn<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => Promise<TResult>,
options: CacheFnOptions,
): (...args: TArgs) => Promise<TResult> {
return reactCache((async (...args: TArgs): Promise<TResult> => {
const key = buildKey(fn, args, options)
const store = await getCacheStore()
return store.getOrSet(key, () => fn(...args), {
tags: options.tags,
ttl: options.ttl,
})
}) as (...args: TArgs) => Promise<TResult>)
}
/**
* 生成缓存 key。
* 格式:{keyParts joined}|{args JSON}
*/
function buildKey<TArgs extends unknown[]>(
fn: (...args: TArgs) => Promise<unknown>,
args: TArgs,
options: CacheFnOptions,
): string {
const prefix = options.keyParts
? options.keyParts.map(String).join(":")
: fn.name || "anonymous"
const argsHash = JSON.stringify(args)
return `${prefix}|${argsHash}`
}
```
- [ ] **Step 4: 运行测试验证通过**
Run: `npm run test:unit -- cache-fn`
Expected: PASS4 个测试全过)
- [ ] **Step 5: 验证 lint 与类型校验**
Run: `npm run lint && npx tsc --noEmit`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add src/shared/lib/cache/cache-fn.ts src/shared/lib/cache/cache-fn.test.ts
git commit -m "feat(cache): 实现 cacheFn 双层包装react.cache + cacheStore"
```
---
## Task 9: `cache/invalidation-map.ts` 失效映射表
**Files:**
- Create: `src/shared/lib/cache/invalidation-map.ts`
- Test: `src/shared/lib/cache/invalidation-map.test.ts`
- [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/invalidation-map.test.ts`**
```typescript
import { describe, it, expect } from "vitest"
import { INVALIDATION_MAP, fillTemplate } from "./invalidation-map"
describe("INVALIDATION_MAP", () => {
it("classes.create 包含 tags + queryKeys + paths", () => {
const rule = INVALIDATION_MAP["classes.create"]
expect(rule.tags).toContain("classes")
expect(rule.tags).toContain("classes:list")
expect(rule.queryKeys).toContainEqual(["classes", "list"])
expect(rule.paths).toContain("/teacher/classes/my")
})
it("classes.update 支持 {id} 占位符", () => {
const rule = INVALIDATION_MAP["classes.update"]
expect(rule.tags).toContain("classes:detail:{id}")
})
})
describe("fillTemplate", () => {
it("填充 {id} 占位符", () => {
expect(fillTemplate("classes:detail:{id}", { id: "cls_123" })).toBe(
"classes:detail:cls_123",
)
})
it("无占位符时原样返回", () => {
expect(fillTemplate("classes:list", {})).toBe("classes:list")
})
it("多占位符同时填充", () => {
expect(
fillTemplate("{module}:{resource}:{id}", {
module: "classes",
resource: "students",
id: "cls_123",
}),
).toBe("classes:students:cls_123")
})
it("缺少参数时保留占位符(不抛错)", () => {
expect(fillTemplate("classes:detail:{id}", {})).toBe("classes:detail:{id}")
})
})
```
- [ ] **Step 2: 运行测试验证失败**
Run: `npm run test:unit -- invalidation-map`
Expected: FAIL with "Cannot find module './invalidation-map'"
- [ ] **Step 3: 实现 `src/shared/lib/cache/invalidation-map.ts`**
```typescript
import type { InvalidationRule } from "./types"
/**
* 模板占位符填充。
*
* @param template 含 {key} 占位符的字符串
* @param params 参数字典
* @returns 填充后的字符串;缺失参数时保留原占位符
*/
export function fillTemplate(
template: string,
params: Record<string, string>,
): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
return params[key] ?? match
})
}
/**
* 集中式失效映射表。
*
* 每个 mutation actionId 声明其副作用:
* - tags服务端 cacheStore + Next.js revalidateTag 失效的标签
* - queryKeys客户端 TanStack Query 失效的 queryKey 前缀
* - pathsNext.js revalidatePath 失效的路径
*
* 新增写操作必须在此登记,否则 invalidateFor 会抛错。
*/
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>
export type InvalidationActionId = keyof typeof INVALIDATION_MAP
```
- [ ] **Step 4: 运行测试验证通过**
Run: `npm run test:unit -- invalidation-map`
Expected: PASS6 个测试全过)
- [ ] **Step 5: 验证 lint 与类型校验**
Run: `npm run lint && npx tsc --noEmit`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add src/shared/lib/cache/invalidation-map.ts src/shared/lib/cache/invalidation-map.test.ts
git commit -m "feat(cache): 新增 INVALIDATION_MAP 集中式失效映射表classes 模块)"
```
---
## Task 10: `cache/client-invalidation-map.ts` 客户端子集
**Files:**
- Create: `src/shared/lib/cache/client-invalidation-map.ts`
- [ ] **Step 1: 创建 `src/shared/lib/cache/client-invalidation-map.ts`**
```typescript
/**
* 客户端可见的失效映射子集(仅 queryKeys 字段)。
*
* 从 invalidation-map.ts 提取 queryKeys避免客户端 bundle 拉入
* revalidateTag 等 server-only 依赖。
*
* 由 useActionMutation 在 onSuccess 中查询以自动 invalidateQueries。
*/
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
export type ClientInvalidationActionId = keyof typeof CLIENT_INVALIDATION_MAP
```
- [ ] **Step 2: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 3: 提交**
```bash
git add src/shared/lib/cache/client-invalidation-map.ts
git commit -m "feat(cache): 新增客户端失效映射子集queryKeys only"
```
---
## Task 11: `cache/invalidate.ts` 编排函数
**Files:**
- Create: `src/shared/lib/cache/invalidate.ts`
- Test: `src/shared/lib/cache/invalidate.test.ts`
- [ ] **Step 1: 编写失败测试 `src/shared/lib/cache/invalidate.test.ts`**
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest"
const mockStore = {
getOrSet: vi.fn(),
invalidateTags: vi.fn(),
}
vi.mock("./store-factory", () => ({
getCacheStore: vi.fn().mockResolvedValue(mockStore),
}))
const mockRevalidateTag = vi.fn()
const mockRevalidatePath = vi.fn()
vi.mock("next/cache", () => ({
revalidateTag: (...args: unknown[]) => mockRevalidateTag(...args),
revalidatePath: (...args: unknown[]) => mockRevalidatePath(...args),
}))
import { invalidateFor } from "./invalidate"
describe("invalidateFor", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("三步编排store.invalidateTags → revalidateTag → revalidatePath", async () => {
await invalidateFor("classes.update", { id: "cls_123" })
// 1. store.invalidateTags
expect(mockStore.invalidateTags).toHaveBeenCalledWith([
"classes",
"classes:detail",
"classes:detail:cls_123",
])
// 2. revalidateTag × 3
expect(mockRevalidateTag).toHaveBeenCalledTimes(3)
expect(mockRevalidateTag).toHaveBeenCalledWith("classes")
expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail")
expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail:cls_123")
// 3. revalidatePath × 2
expect(mockRevalidatePath).toHaveBeenCalledTimes(2)
expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my")
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/classes")
})
it("未知 actionId 抛错", async () => {
await expect(invalidateFor("unknown.action")).rejects.toThrow(
"[cache] Unknown actionId: unknown.action",
)
})
it("params 缺失时保留占位符(不抛错)", async () => {
await invalidateFor("classes.update", {})
expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail:{id}")
expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my")
})
it("paths 含占位符时填充", async () => {
await invalidateFor("classes.invitation.create", { id: "cls_1" })
expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my/cls_1")
})
})
```
- [ ] **Step 2: 运行测试验证失败**
Run: `npm run test:unit -- invalidate.test`
Expected: FAIL with "Cannot find module './invalidate'"
- [ ] **Step 3: 实现 `src/shared/lib/cache/invalidate.ts`**
```typescript
import "server-only"
import { revalidatePath, revalidateTag } from "next/cache"
import { INVALIDATION_MAP, fillTemplate } from "./invalidation-map"
import { getCacheStore } from "./store-factory"
/**
* 由 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 as Record<string, { tags: readonly string[]; queryKeys: readonly (readonly (string | number)[])[]; paths: readonly string[] }>)[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 失效
for (const tag of resolvedTags) {
revalidateTag(tag)
}
// 3. 路径级 RSC 缓存失效
for (const path of rule.paths) {
revalidatePath(fillTemplate(path, params))
}
}
```
- [ ] **Step 4: 运行测试验证通过**
Run: `npm run test:unit -- invalidate.test`
Expected: PASS4 个测试全过)
- [ ] **Step 5: 验证 lint 与类型校验**
Run: `npm run lint && npx tsc --noEmit`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add src/shared/lib/cache/invalidate.ts src/shared/lib/cache/invalidate.test.ts
git commit -m "feat(cache): 实现 invalidateFor 三步编排函数"
```
---
## Task 12: `cache/index.ts` 公共 API 聚合
**Files:**
- Create: `src/shared/lib/cache/index.ts`
- [ ] **Step 1: 创建 `src/shared/lib/cache/index.ts`**
```typescript
/**
* 缓存基础设施公共 API。
*
* 服务端调用方使用:
* - data-access`cacheFn(fn, { tags, ttl, keyParts })` 包装查询函数
* - actions`invalidateFor(actionId, params)` 编排写操作后失效
*
* 客户端调用方使用:
* - `CLIENT_INVALIDATION_MAP` 查询 queryKey 失效列表(自动由 useActionMutation 调用)
*/
export { cacheFn } from "./cache-fn"
export { invalidateFor } from "./invalidate"
export {
INVALIDATION_MAP,
fillTemplate,
} from "./invalidation-map"
export type {
CacheFnOptions,
CacheStore,
InvalidationRule,
} from "./types"
export type { InvalidationActionId } from "./invalidation-map"
// 客户端可见的子集(无 server-only 依赖)
export { CLIENT_INVALIDATION_MAP } from "./client-invalidation-map"
export type { ClientInvalidationActionId } from "./client-invalidation-map"
```
- [ ] **Step 2: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 3: 提交**
```bash
git add src/shared/lib/cache/index.ts
git commit -m "feat(cache): 新增 index.ts 公共 API 聚合导出"
```
---
## Task 13: `shared/lib/query-keys.ts` 客户端 queryKey 工厂
**Files:**
- Create: `src/shared/lib/query-keys.ts`
- [ ] **Step 1: 创建 `src/shared/lib/query-keys.ts`**
```typescript
/**
* 客户端 queryKey 工厂。
*
* 命名约定:[module, resource, ...args]
* - 第一段:模块名(与 src/modules/[module]/ 一致)
* - 第二段资源名list / detail / students / schedule / stats
* - 后续段唯一标识id / classId或过滤条件对象
*
* 失效时使用前缀匹配:
* invalidateQueries({ queryKey: ["classes", "detail"] })
* → 失效所有 ["classes", "detail", *]
*/
export interface ClassFilters {
readonly search?: string
readonly subject?: string
readonly gradeId?: string
}
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
```
- [ ] **Step 2: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 3: 提交**
```bash
git add src/shared/lib/query-keys.ts
git commit -m "feat(cache): 新增 query-keys.ts 工厂classes 模块)"
```
---
## Task 14: 重构 `useActionQuery` 接入 QueryClient
**Files:**
- Modify: `src/shared/hooks/use-action-query.ts`
- Test: `src/shared/hooks/use-action-query.test.tsx`
- [ ] **Step 1: 编写失败测试 `src/shared/hooks/use-action-query.test.tsx`**
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import React from "react"
import { useActionQuery } from "./use-action-query"
import type { ActionState } from "@/shared/types/action-state"
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children,
)
}
}
describe("useActionQuery", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("成功时返回 data", async () => {
const action = vi
.fn()
.mockResolvedValue<ActionState<{ name: string }>>({
success: true,
data: { name: "Class A" },
})
const { result } = renderHook(
() => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.data).toEqual({ name: "Class A" })
expect(result.current.error).toBeNull()
})
it("ActionState 失败时设置 error", async () => {
const action = vi.fn().mockResolvedValue<ActionState<unknown>>({
success: false,
message: "Not found",
})
const { result } = renderHook(
() => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.data).toBeUndefined()
expect(result.current.error?.message).toBe("Not found")
})
it("enabled=false 时不发起请求", async () => {
const action = vi.fn()
const { result } = renderHook(
() =>
useActionQuery(action, {
queryKey: ["classes", "detail", "c1"],
enabled: false,
}),
{ wrapper: createWrapper() },
)
expect(result.current.loading).toBe(false)
expect(action).not.toHaveBeenCalled()
})
it("refetch 触发重新获取", async () => {
const action = vi.fn().mockResolvedValue<ActionState<number>>({
success: true,
data: 1,
})
const { result } = renderHook(
() => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }),
{ wrapper: createWrapper() },
)
await waitFor(() => expect(result.current.loading).toBe(false))
result.current.refetch()
await waitFor(() => expect(action).toHaveBeenCalledTimes(2))
})
it("相同 queryKey 跨 Hook 共享缓存", async () => {
const action = vi.fn().mockResolvedValue<ActionState<string>>({
success: true,
data: "shared",
})
const wrapper = createWrapper()
const { result: r1 } = renderHook(
() => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }),
{ wrapper },
)
await waitFor(() => expect(r1.current.loading).toBe(false))
const { result: r2 } = renderHook(
() => useActionQuery(action, { queryKey: ["classes", "detail", "c1"] }),
{ wrapper },
)
await waitFor(() => expect(r2.current.data).toBe("shared"))
// 共享缓存action 只被调用 1 次
expect(action).toHaveBeenCalledTimes(1)
})
})
```
- [ ] **Step 2: 运行测试验证失败**
Run: `npm run test:unit -- use-action-query`
Expected: FAIL当前实现用 useEffect + useState不接入 QueryClient
- [ ] **Step 3: 重写 `src/shared/hooks/use-action-query.ts`**
```typescript
"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 interface UseActionQueryResult<T> {
data: T | undefined
loading: boolean
error: Error | null
refetch: () => void
}
/**
* 通用 Server Action 查询 Hook重构版
*
* - 内部走 TanStack Query useQuery自动跨页共享缓存
* - 解包 ActionState失败时抛错成功时返回 data
* - 必须传 queryKey强制集中化命名
*
* @example
* const { data, loading } = useActionQuery(
* () => getClassDetailAction({ classId }),
* { queryKey: queryKeys.classes.detail(classId) }
* )
*/
export function useActionQuery<T>(
action: () => Promise<ActionState<T>>,
options: UseActionQueryOptions<T>,
): UseActionQueryResult<T> {
const query = useQuery<ActionState<T>>({
queryKey: options.queryKey,
queryFn: action,
enabled: options.enabled ?? true,
...options,
})
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()
},
}
}
```
- [ ] **Step 4: 运行测试验证通过**
Run: `npm run test:unit -- use-action-query`
Expected: PASS5 个测试全过)
- [ ] **Step 5: 验证 lint 与类型校验**
Run: `npm run lint && npx tsc --noEmit`
Expected: PASS
> **注意**:此变更破坏性,原本 11 个文件使用旧 API不传 queryKey
> 编译会立即报错提示漏传 queryKey逐个迁移即可。本期仅迁移 classes 标杆页面,其他下期处理。
- [ ] **Step 6: 提交**
```bash
git add src/shared/hooks/use-action-query.ts src/shared/hooks/use-action-query.test.tsx
git commit -m "refactor(hooks): useActionQuery 改走 QueryClient + 强制 queryKey"
```
---
## Task 15: 重构 `useActionMutation` 接入 QueryClient
**Files:**
- Modify: `src/shared/hooks/use-action-mutation.ts`
- Test: `src/shared/hooks/use-action-mutation.test.tsx`
- [ ] **Step 1: 编写失败测试 `src/shared/hooks/use-action-mutation.test.tsx`**
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query"
import React from "react"
import { useActionMutation } from "./use-action-mutation"
import type { ActionState } from "@/shared/types/action-state"
// Mock notify
vi.mock("@/shared/lib/notify", () => ({
notify: {
success: vi.fn(),
error: vi.fn(),
},
}))
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return {
queryClient,
Wrapper: function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children,
)
},
}
}
describe("useActionMutation", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("成功时调用 onSuccess 并显示 toast", async () => {
const onSuccess = vi.fn()
const mutationFn = vi.fn().mockResolvedValue<ActionState<{ id: string }>>({
success: true,
data: { id: "cls_1" },
message: "Created",
})
const { result } = renderHook(
() =>
useActionMutation<{ id: string }>({
mutationFn,
successMessage: "创建成功",
onSuccess,
}),
{ wrapper: createWrapper().Wrapper },
)
await act(async () => {
await result.current.mutate()
})
expect(onSuccess).toHaveBeenCalledWith({ id: "cls_1" })
// notify.success 由测试 mock 验证
})
it("ActionState 失败时调用 onError", async () => {
const onError = vi.fn()
const mutationFn = vi.fn().mockResolvedValue<ActionState<unknown>>({
success: false,
message: "Validation failed",
})
const { result } = renderHook(
() =>
useActionMutation({
mutationFn,
onError,
}),
{ wrapper: createWrapper().Wrapper },
)
await act(async () => {
await result.current.mutate()
})
expect(onError).toHaveBeenCalledWith(expect.any(Error))
expect((onError.mock.calls[0][0] as Error).message).toBe("Validation failed")
})
it("actionId 关联自动 invalidate queries", async () => {
const { queryClient, Wrapper } = createWrapper()
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries")
// 模拟已有 classes.detail 缓存
const queryKey = ["classes", "detail", "c1"]
const { result: queryResult } = renderHook(
() =>
useQuery({
queryKey,
queryFn: async () =>
({ success: true, data: { id: "c1", name: "Old" } }) as ActionState<{
id: string
name: string
}>,
}),
{ wrapper: Wrapper },
)
await waitFor(() => expect(queryResult.current.isSuccess).toBe(true))
// 触发 mutation
const mutationFn = vi.fn().mockResolvedValue<ActionState<unknown>>({
success: true,
data: undefined,
})
const { result } = renderHook(
() =>
useActionMutation({
mutationFn,
actionId: "classes.update",
params: { id: "c1" },
}),
{ wrapper: Wrapper },
)
await act(async () => {
await result.current.mutate()
})
// 自动 invalidate ["classes", "detail"](含 c1
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["classes", "detail"],
})
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["classes", "list"],
})
})
it("未传 actionId 时不自动 invalidate", async () => {
const { queryClient, Wrapper } = createWrapper()
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries")
const mutationFn = vi.fn().mockResolvedValue<ActionState<unknown>>({
success: true,
data: undefined,
})
const { result } = renderHook(
() => useActionMutation({ mutationFn }),
{ wrapper: Wrapper },
)
await act(async () => {
await result.current.mutate()
})
expect(invalidateSpy).not.toHaveBeenCalled()
})
it("显式 invalidateQueryKeys 回退", async () => {
const { queryClient, Wrapper } = createWrapper()
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries")
const mutationFn = vi.fn().mockResolvedValue<ActionState<unknown>>({
success: true,
data: undefined,
})
const { result } = renderHook(
() =>
useActionMutation({
mutationFn,
invalidateQueryKeys: [["custom", "key"]],
}),
{ wrapper: Wrapper },
)
await act(async () => {
await result.current.mutate()
})
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["custom", "key"] })
})
})
```
- [ ] **Step 2: 运行测试验证失败**
Run: `npm run test:unit -- use-action-mutation`
Expected: FAIL当前实现用 useState + try/catch
- [ ] **Step 3: 重写 `src/shared/hooks/use-action-mutation.ts`**
```typescript
"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"
import { fillTemplate } from "@/shared/lib/cache/invalidation-map"
export interface UseActionMutationOptions<T> {
/** mutation 函数 */
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)[])[]
/** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
successMessage?: string | false
/** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
errorMessage?: string | false
/** 成功回调 */
onSuccess?: (data: T | undefined) => void
/** 失败回调result.success === false 或抛出异常时) */
onError?: (error: unknown) => void
}
export interface UseActionMutationResult {
/** 是否正在执行 */
isWorking: boolean
/** 执行 mutation */
mutate: () => Promise<ActionState<unknown> | undefined>
}
/**
* 通用 Server Action mutation Hook重构版
*
* - 内部走 TanStack Query useMutation
* - 通过 actionId 关联 INVALIDATION_MAP 自动 invalidateQueries
* - 未传 actionId 时可用 invalidateQueryKeys 显式声明
* - toast 通过 notify统一 i18n
*
* @example
* const { mutate, isWorking } = useActionMutation({
* mutationFn: () => updateClassAction(input),
* actionId: "classes.update",
* params: { id: input.classId },
* onSuccess: () => setOpen(false),
* })
*/
export function useActionMutation<T = unknown>(
options: UseActionMutationOptions<T>,
): UseActionMutationResult {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: options.mutationFn,
onError: (error: unknown) => {
if (options.errorMessage !== false) {
notify.error(
options.errorMessage ??
(error instanceof Error ? error.message : "Operation failed"),
)
}
options.onError?.(error)
},
onSuccess: async (result: ActionState<T>) => {
if (result.success) {
if (options.successMessage !== false) {
notify.success(
options.successMessage ?? result.message ?? "Operation succeeded",
)
}
// 自动失效:根据 actionId 查 CLIENT_INVALIDATION_MAP
const queryKeysToInvalidate = resolveClientInvalidations(
options.actionId,
options.params,
options.invalidateQueryKeys,
)
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: async () => {
try {
return await mutation.mutateAsync()
} catch {
// onError 已处理
return undefined
}
},
}
}
/**
* 解析需要失效的 queryKey 列表。
*
* 优先使用 actionId 关联的 CLIENT_INVALIDATION_MAP
* 叠加显式声明的 invalidateQueryKeys。
*/
function resolveClientInvalidations(
actionId: string | undefined,
params: Record<string, string> | undefined,
explicit: readonly (readonly (string | number | object)[])[] | undefined,
): (string | number | object)[][] {
const result: (string | number | object)[][] = []
if (actionId) {
const rule = (
CLIENT_INVALIDATION_MAP as Record<
string,
{ queryKeys: readonly (readonly (string | number)[])[] }
>
)[actionId]
if (rule) {
for (const qk of rule.queryKeys) {
// queryKey 不含模板占位符,直接使用
result.push([...qk])
}
}
}
if (explicit) {
for (const qk of explicit) {
result.push([...qk])
}
}
// params 用于 tag 模板填充queryKey 通常不含占位符
// 但若未来 queryKey 需要参数化,可在此处扩展
void params
void fillTemplate
return result
}
```
- [ ] **Step 4: 运行测试验证通过**
Run: `npm run test:unit -- use-action-mutation`
Expected: PASS5 个测试全过)
- [ ] **Step 5: 验证 lint 与类型校验**
Run: `npm run lint && npx tsc --noEmit`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add src/shared/hooks/use-action-mutation.ts src/shared/hooks/use-action-mutation.test.tsx
git commit -m "refactor(hooks): useActionMutation 改走 useMutation + actionId 自动 invalidate"
```
---
## Task 16: classes data-access 迁移至 cacheFn
**Files:**
- Modify: `src/modules/classes/data-access-teacher.ts`
- Modify: `src/modules/classes/data-access-admin.ts`
- Modify: `src/modules/classes/data-access-students.ts`
- Modify: `src/modules/classes/data-access-stats.ts`
- Modify: `src/modules/classes/data-access-schedule.ts`
- [ ] **Step 1: 修改 `data-access-teacher.ts`**
读取文件,找到所有 `cache(async ...) =>` 包装的导出函数。对每个函数:
1. 重命名原函数为 `${name}Raw`(无 cache 包装)
2. 新增 `${name}``cacheFn` 包装
在文件顶部 import 区新增:
```typescript
import { cacheFn } from "@/shared/lib/cache"
```
将原 `cache(async (params) => { ... })` 模式改为:
```typescript
// 修改前
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
// ...
})
// 修改后
export const getTeacherClassesRaw = async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
// ...
}
export const getTeacherClasses = cacheFn(getTeacherClassesRaw, {
tags: ["classes", "classes:list"],
ttl: 300,
keyParts: ["classes", "teacher", "by-teacher-id"],
})
```
`data-access-teacher.ts` 中所有 `cache(...)` 包装的导出函数执行此操作。每个函数的 tags/ttl 按下表:
| 函数 | tags | ttl | keyParts |
|------|------|-----|---------|
| `getTeacherClasses` | `["classes", "classes:list"]` | 300 | `["classes", "teacher", "list"]` |
| `getTeacherClassById` | `["classes:detail", "classes:detail:{id}"]` | 300 | `["classes", "teacher", "by-id"]` |
| 其他 cache 包装函数 | `["classes"]` | 300 | `["classes", "teacher", fn.name]` |
> **注意**:保留 `import { cache } from "react"` 行可删除(若文件中无其他 cache 调用)。
- [ ] **Step 2: 修改 `data-access-admin.ts`**
同 Step 1 模式。tags/ttl 按下表:
| 函数 | tags | ttl | keyParts |
|------|------|-----|---------|
| `getAllClassesForAdmin` | `["classes", "classes:list"]` | 300 | `["classes", "admin", "list"]` |
| 其他 | `["classes"]` | 300 | `["classes", "admin", fn.name]` |
- [ ] **Step 3: 修改 `data-access-students.ts`**
| 函数 | tags | ttl | keyParts |
|------|------|-----|---------|
| `getStudentsByClass` | `["classes:students:{classId}"]` | 60 | `["classes", "students", "by-class"]` |
| 其他 | `["classes:students"]` | 60 | `["classes", "students", fn.name]` |
- [ ] **Step 4: 修改 `data-access-stats.ts`**
| 函数 | tags | ttl | keyParts |
|------|------|-----|---------|
| `getClassStats` | `["classes:stats"]` | 60 | `["classes", "stats", "by-class"]` |
| 其他 | `["classes:stats"]` | 60 | `["classes", "stats", fn.name]` |
- [ ] **Step 5: 修改 `data-access-schedule.ts`**
| 函数 | tags | ttl | keyParts |
|------|------|-----|---------|
| `getScheduleByClass` | `["classes:schedule:{classId}"]` | 600 | `["classes", "schedule", "by-class"]` |
| 其他 | `["classes:schedule"]` | 600 | `["classes", "schedule", fn.name]` |
- [ ] **Step 6: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 7: 验证 lint 通过**
Run: `npm run lint`
Expected: PASS
- [ ] **Step 8: 提交**
```bash
git add src/modules/classes/data-access-*.ts
git commit -m "refactor(classes): data-access 迁移至 cacheFn + 双导出 raw 版本"
```
---
## Task 17: classes actions 迁移至 invalidateFor
**Files:**
- Modify: `src/modules/classes/actions-teacher.ts`
- Modify: `src/modules/classes/actions-admin.ts`
- Modify: `src/modules/classes/actions-grade.ts`
- Modify: `src/modules/classes/actions-invitations.ts`
- Modify: `src/modules/classes/actions-schedule.ts`
- [ ] **Step 1: 修改 `actions-teacher.ts`**
在文件顶部 import 区新增:
```typescript
import { invalidateFor } from "@/shared/lib/cache"
```
移除 `import { revalidatePath } from "next/cache"`(若文件中无其他用途)。
将每个 Server Action 中的 `revalidatePath(...)` 调用替换为 `invalidateFor(...)`
```typescript
// 修改前
"use server"
import { revalidatePath } from "next/cache"
// ...
export async function updateClassAction(input: UpdateClassInput) {
// ...
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/my/[id]")
return { success: true, ... }
}
// 修改后
"use server"
import { invalidateFor } from "@/shared/lib/cache"
// ...
export async function updateClassAction(input: UpdateClassInput) {
// ...
await invalidateFor("classes.update", { id: input.classId })
return { success: true, ... }
}
```
按此模式处理 `actions-teacher.ts` 中所有写操作:
- `createClassAction``invalidateFor("classes.create")`
- `updateClassAction``invalidateFor("classes.update", { id: input.classId })`
- `deleteClassAction``invalidateFor("classes.delete", { id: classId })`
- [ ] **Step 2: 修改 `actions-admin.ts`**
同 Step 1 模式。映射:
| 原 revalidatePath | 替换为 |
|------------------|-------|
| `revalidatePath("/admin/classes")` | `invalidateFor("classes.create/update/delete")` |
- [ ] **Step 3: 修改 `actions-grade.ts`**
```typescript
// 替换 revalidatePath("/admin/grades") 等
await invalidateFor("classes.grade.update")
```
- [ ] **Step 4: 修改 `actions-invitations.ts`**
```typescript
// 创建邀请码后
await invalidateFor("classes.invitation.create", { classId })
// 撤销邀请码后
await invalidateFor("classes.invitation.revoke", { classId })
```
> **注意**INVALIDATION_MAP 中 `classes.invitation.*` 的 tags 与 paths 均使用 `{classId}` 占位符(已统一),调用时传 `params: { classId }`。
- [ ] **Step 5: 修改 `actions-schedule.ts`**
```typescript
await invalidateFor("classes.schedule.update", { classId })
```
- [ ] **Step 6: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 7: 验证 lint 通过**
Run: `npm run lint`
Expected: PASS此时 ESLint 规则尚未启用,下一 Task 添加)
- [ ] **Step 8: 提交**
```bash
git add src/modules/classes/actions-*.ts
git commit -m "refactor(classes): actions 迁移至 invalidateFor 集中编排"
```
---
## Task 18: ESLint 规则禁止直接调用 revalidatePath/revalidateTag
**Files:**
- Modify: `eslint.config.mjs`
- [ ] **Step 1: 修改 `eslint.config.mjs`,新增规则与豁免**
`defineConfig` 数组中(在 design-tokens 自定义规则块之后)新增:
```javascript
// 缓存策略规则:禁止在 actions / route.ts 中直接调用 revalidatePath/revalidateTag
{
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/ 内部允许调用
{
files: ["src/shared/lib/cache/**/*.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
```
- [ ] **Step 2: 验证 lint 通过classes 已迁移,应零错误)**
Run: `npm run lint`
Expected: PASS
> **注意**:其他 29 个模块的 actions 文件可能仍含 `revalidatePath` 直接调用。ESLint 规则会立即报错。
> 本期范围仅 classes 标杆,其他模块需在后续迁移中逐个修复。
> 临时方案:在 ESLint 配置中对未迁移模块的 actions 文件添加 `// eslint-disable-next-line no-restricted-syntax` 注释,或暂时将规则降级为 `warn`。
> 推荐方案:保持 `error`,但为未迁移模块的 actions 文件添加豁免覆盖配置,逐模块移除豁免。
- [ ] **Step 3: 若其他模块报错,添加临时豁免覆盖**
读取 lint 输出,对每个仍含 `revalidatePath` 的模块 actions 文件添加豁免:
```javascript
// 临时豁免:未迁移模块(下期逐模块移除)
{
files: [
"src/modules/lesson-preparation/actions*.ts",
"src/modules/exams/actions*.ts",
"src/modules/homework/actions*.ts",
"src/modules/grades/actions*.ts",
"src/modules/attendance/actions*.ts",
"src/modules/leave-requests/actions*.ts",
"src/modules/course-plans/actions*.ts",
"src/modules/standards/actions*.ts",
"src/modules/messaging/actions*.ts",
"src/modules/classes/actions-invitations.ts", // 若仍有遗漏
"src/modules/settings/actions*.ts",
"src/modules/audit/actions*.ts",
"src/modules/users/data-access.ts", // 仅含 revalidatePath 调用
"src/modules/parent/data-access.ts",
"src/modules/invitation-codes/actions*.ts",
"src/modules/onboarding/actions*.ts",
"src/modules/elective/actions*.ts",
"src/modules/exams/stats-service.ts",
],
rules: {
"no-restricted-syntax": "off",
},
},
```
> **后续工作**:每迁移一个模块,从此豁免列表中移除对应行。
- [ ] **Step 4: 验证 lint 通过**
Run: `npm run lint`
Expected: PASS零错误
- [ ] **Step 5: 提交**
```bash
git add eslint.config.mjs
git commit -m "feat(eslint): 新增缓存策略规则禁止直接调用 revalidatePath/revalidateTag"
```
---
## Task 19: 客户端组件迁移示范1 处)
**Files:**
- Modify: `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx`
- [ ] **Step 1: 读取当前 `page.tsx` 实现**
Run: 用 Read 工具读取 `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx`
识别当前数据获取模式(可能为 `useState + useEffect + Action().then()``useActionQuery` 旧版)。
- [ ] **Step 2: 重构为 useQuery + queryKeys 工厂**
在文件顶部 import 区新增:
```typescript
import { useQuery } from "@tanstack/react-query"
import { queryKeys } from "@/shared/lib/query-keys"
import { getClassDetailAction } from "@/modules/classes/actions-teacher"
```
将数据获取改为:
```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 } }) {
const { data: classDetail, isLoading } = useQuery({
queryKey: queryKeys.classes.detail(params.id),
queryFn: () => getClassDetailAction({ classId: params.id }),
})
if (isLoading) return <div>Loading...</div>
if (!classDetail?.success) return <div>Not found</div>
return (
<div>
{/* 渲染 classDetail.data */}
</div>
)
}
```
> **注意**:具体渲染逻辑保留原样,仅替换数据获取部分。`getClassDetailAction` 返回 `ActionState<T>`useQuery 解包时需检查 `success` 字段。
- [ ] **Step 3: 验证类型校验通过**
Run: `npx tsc --noEmit`
Expected: PASS
- [ ] **Step 4: 验证 lint 通过**
Run: `npm run lint`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add "src/app/(dashboard)/teacher/classes/my/[id]/page.tsx"
git commit -m "refactor(classes): teacher/classes/[id] 迁移至 useQuery + queryKeys"
```
---
## Task 20: 架构文档同步
**Files:**
- Modify: `docs/architecture/004_architecture_impact_map.md`
- Modify: `docs/architecture/005_architecture_data.json`
- Modify: `docs/troubleshooting/known-issues.md`
- [ ] **Step 1: 修改 `004_architecture_impact_map.md`,新增"缓存基础设施"章节**
在 rate-limit 章节后新增(参考 rate-limit 章节的格式):
```markdown
## 缓存基础设施shared/lib/cache/
**2026-07-05 新增**:全栈缓存策略一致性基础设施。复用 rate-limit 的 driver 切换模式。
### 模块结构
| 文件 | 行数 | 职责 |
|------|------|------|
| `cache/types.ts` | 60 | CacheFnOptions / CacheStore / InvalidationRule 接口 |
| `cache/memory-store.ts` | 130 | 内存 LRU 实现maxEntries=500TTL 过期tag 索引) |
| `cache/redis-store.ts` | 150 | Redis 多实例实现SMEMBERS+DEL 批量失效fail-open 降级) |
| `cache/store-factory.ts` | 35 | 按 CACHE_DRIVER 动态加载 |
| `cache/cache-fn.ts` | 60 | cacheFn 双层包装react.cache + cacheStore |
| `cache/invalidation-map.ts` | 200 | 集中式 INVALIDATION_MAP 失效映射表 |
| `cache/client-invalidation-map.ts` | 40 | 客户端可见的 queryKeys 子集 |
| `cache/invalidate.ts` | 50 | invalidateFor 三步编排函数 |
| `cache/index.ts` | 25 | 公共 API 聚合导出 |
### 共享资产
| 资产 | 文件 | 用途 |
|------|------|------|
| `redis-client.ts` | 60 | rate-limit + cache 共用的 Redis 单例(动态 import @upstash/redis |
| `upstash-modules.d.ts` | 80 | @upstash/ratelimit + @upstash/redis 类型声明(提升至 shared/lib |
| `query-keys.ts` | 80 | 客户端 queryKey 工厂(按 模块/资源/操作 分层) |
### cacheFn 用法
| 场景 | 正确写法 |
|------|---------|
| 包装查询函数 | `cacheFn(fn, { tags: ["classes"], ttl: 300, keyParts: ["classes", "by-id"] })` |
| 双导出 raw 版本 | `export const fnRaw = ...; export const fn = cacheFn(fnRaw, ...)` |
### invalidateFor 用法
| 场景 | 正确写法 |
|------|---------|
| Server Action 写后失效 | `await invalidateFor("classes.update", { id })` |
| 未知 actionId | 抛错(强制登记 INVALIDATION_MAP |
### Tag 命名规范
| 格式 | 示例 | 失效场景 |
|------|------|---------|
| `{module}` | `classes` | 模块任意写操作全模块失效 |
| `{module}:{resource}` | `classes:list`, `classes:detail` | 资源级失效 |
| `{module}:{resource}:{id}` | `classes:detail:cls_abc123` | 单条记录失效 |
### TTL 策略
| 资源 | TTL | 理由 |
|------|-----|------|
| 列表/详情 | 300s | 容忍 5 分钟滞后,写后 tag 立即失效 |
| 学生名单 | 60s | 转班/调班需较快感知 |
| 课表 | 600s | 课表变更频率极低 |
| 统计 | 60s | 聚合数据短 TTL 兜底 |
```
- [ ] **Step 2: 更新 `004` 的 classes 模块章节,标注 cacheFn 包装**
在 classes 模块章节的 data-access 描述后新增:
```markdown
- ✅ 2026-07-05 缓存策略迁移6 个 data-access 文件全部用 `cacheFn` 包装,双导出 raw 版本(解决 P2 单测可 mock 性5 个 actions 文件全部用 `invalidateFor` 集中编排失效
```
- [ ] **Step 3: 修改 `005_architecture_data.json`,新增 cache 节点**
`shared.lib` 节点下新增 `cache` 子节点(参考 rate-limit 节点结构):
```json
{
"cache": {
"type": "directory",
"purpose": "2026-07-05 新增:全栈缓存策略一致性基础设施。复用 rate-limit 的 driver 切换模式。提供 cacheFn 包装器与 invalidateFor 编排函数。",
"files": {
"types.ts": {
"lines": 60,
"purpose": "CacheFnOptions / CacheStore / InvalidationRule 接口"
},
"memory-store.ts": {
"lines": 130,
"purpose": "内存 LRU 实现maxEntries=500TTL 过期tag 索引)",
"exports": ["MemoryCacheStore"]
},
"redis-store.ts": {
"lines": 150,
"purpose": "Redis 多实例实现SMEMBERS+DEL 批量失效fail-open 降级)",
"exports": ["RedisCacheStore"]
},
"store-factory.ts": {
"lines": 35,
"purpose": "按 CACHE_DRIVER 动态加载,对标 rate-limit/store-factory 模式",
"exports": ["getCacheStore"]
},
"cache-fn.ts": {
"lines": 60,
"purpose": "cacheFn 双层包装:外层 react.cache 请求级 memoize内层 cacheStore 跨请求/跨实例数据缓存",
"exports": ["cacheFn"]
},
"invalidation-map.ts": {
"lines": 200,
"purpose": "集中式 INVALIDATION_MAP 失效映射表。每个 mutation actionId 声明其副作用tags / queryKeys / paths",
"exports": ["INVALIDATION_MAP", "fillTemplate"]
},
"client-invalidation-map.ts": {
"lines": 40,
"purpose": "客户端可见的 queryKeys 子集,避免客户端 bundle 拉入 server-only 依赖",
"exports": ["CLIENT_INVALIDATION_MAP"]
},
"invalidate.ts": {
"lines": 50,
"purpose": "invalidateFor 三步编排store.invalidateTags → revalidateTag → revalidatePath",
"exports": ["invalidateFor"]
},
"index.ts": {
"lines": 25,
"purpose": "公共 API 聚合导出"
}
}
},
"redis-client": {
"type": "file",
"lines": 60,
"purpose": "2026-07-05 新增rate-limit + cache 共用的 Redis 单例。动态 import @upstash/redis + webpackIgnoreenv 未配置时返回 nullfail-open",
"exports": ["getRedisClient"]
},
"query-keys": {
"type": "file",
"lines": 80,
"purpose": "2026-07-05 新增:客户端 queryKey 工厂。命名约定 [module, resource, ...args]。目前仅含 classes 模块",
"exports": ["queryKeys"]
}
```
- [ ] **Step 4: 修改 `known-issues.md`,新增"缓存策略规则"章节**
在文件末尾新增:
```markdown
---
## 九、缓存策略规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| data-access 查询函数包装 | `export const fn = cacheFn(fnRaw, { tags, ttl, keyParts })` | `cache(fn)` 或不包装 |
| 双导出 raw 版本 | `export const fnRaw = ...; export const fn = cacheFn(fnRaw, ...)` | 仅导出缓存版本(单测无法 mock |
| Server Action 写后失效 | `await invalidateFor("module.action", { id })` | `revalidatePath("/path")` 直接调用 |
| 未知 actionId | 抛错(必须先登记 INVALIDATION_MAP | 静默忽略 |
| 客户端 useQuery queryKey | `queryKeys.classes.detail(id)` 工厂调用 | 手写字符串数组 `["classes", "detail", id]` |
| useActionQuery 必传 queryKey | `useActionQuery(action, { queryKey: queryKeys.classes.detail(id) })` | 不传 queryKey |
| useActionMutation 自动 invalidate | `useActionMutation({ actionId: "classes.update", params: { id } })` | onSuccess 中手动 invalidateQueries |
| Tag 命名 | `classes:detail:{id}` 模块:资源:ID | `class_detail``ClassesDetail` |
| TTL 选择 | 列表 300s / 学生 60s / 课表 600s / 统计 60s | 全局统一 TTL |
```
- [ ] **Step 5: 提交**
```bash
git add docs/architecture/004_architecture_impact_map.md docs/architecture/005_architecture_data.json docs/troubleshooting/known-issues.md
git commit -m "docs(architecture): 同步缓存基础设施 + classes 标杆迁移 + 缓存策略规则"
```
---
## Task 21: 最终验证与全量提交
- [ ] **Step 1: 全量 lint**
Run: `npm run lint`
Expected: PASS零错误含新增 ESLint 规则)
- [ ] **Step 2: 全量类型校验**
Run: `npx tsc --noEmit`
Expected: PASS零错误
- [ ] **Step 3: 全量单测**
Run: `npm run test:unit`
Expected: PASS含新增 8 个测试文件,全部通过)
- [ ] **Step 4: 集成测试**
Run: `npm run test:integration`
Expected: PASS现有测试不破
- [ ] **Step 5: 验证 classes 模块迁移完整性**
Run: 用 Grep 验证 classes data-access 已全部 cacheFn 包装
```bash
# 应有 5 个文件含 cacheFn import
grep -l "cacheFn" src/modules/classes/data-access-*.ts
# 应有 5 个文件含 invalidateFor import
grep -l "invalidateFor" src/modules/classes/actions-*.ts
# 应零处直接 revalidatePath 调用(在 classes actions 中)
grep "revalidatePath" src/modules/classes/actions-*.ts
```
Expected:
- 5 个 data-access 文件含 cacheFn
- 5 个 actions 文件含 invalidateFor
- classes actions 中零 revalidatePath
- [ ] **Step 6: 若有遗漏的提交,统一推送**
```bash
git status
git log --oneline -20
```
如有未提交的修改,统一提交。
- [ ] **Step 7: 完成**
本期缓存策略落地专项重构完成。后续工作:
- 按模块推进其他 29 个模块的迁移(每个模块从 ESLint 豁免列表移除)
- 按页面推进客户端组件 useQuery 迁移
- 评估缓存命中率监控