83 KiB
缓存策略落地专项重构 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 中映射。
// 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: 提交
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 的完整内容复制到新文件,文件头注释更新为:
/* 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
/**
* 向后兼容 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: 提交
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
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
*
* 调用方需自行处理 null(fail-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:
import { getRedisClient } from "@/shared/lib/redis-client"
删除原文件 22-104 行的 getRedisClient 私有方法及 redisClient 字段。将原 this.getRedisClient() 调用替换为 getRedisClient()。修改后 RedisRateLimiter 类的 getRatelimit 方法片段:
/** 按 (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: 提交
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
/**
* 缓存基础设施类型定义。
*/
/**
* 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: 提交
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
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
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: PASS(6 个测试全过)
- Step 5: 验证 lint 与类型校验
Run: npm run lint && npx tsc --noEmit
Expected: PASS
- Step 6: 提交
git add src/shared/lib/cache/memory-store.ts src/shared/lib/cache/memory-store.test.ts
git commit -m "feat(cache): 实现 MemoryCacheStore(LRU + 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
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
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}
* - value:JSON.stringify
* - tag 反查索引:next-edu:cache:tag:{tag} → Redis Set 存放 keys
* - TTL:set 时通过 expire 原子设置
* - invalidateTags:smembers → 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: PASS(5 个测试全过)
- Step 5: 验证 lint 与类型校验
Run: npm run lint && npx tsc --noEmit
Expected: PASS
- Step 6: 提交
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
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 依赖,未安装时首次调用抛错
*
* 返回 Promise:Redis 实现需动态 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: 提交
git add src/shared/lib/cache/store-factory.ts
git commit -m "feat(cache): 新增 store-factory(CACHE_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
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
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: PASS(4 个测试全过)
- Step 5: 验证 lint 与类型校验
Run: npm run lint && npx tsc --noEmit
Expected: PASS
- Step 6: 提交
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
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
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 前缀
* - paths:Next.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: PASS(6 个测试全过)
- Step 5: 验证 lint 与类型校验
Run: npm run lint && npx tsc --noEmit
Expected: PASS
- Step 6: 提交
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
/**
* 客户端可见的失效映射子集(仅 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: 提交
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
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
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 × N(Next.js fetch 缓存 + unstable_cache 失效)
* 3. revalidatePath × N(RSC 静态缓存失效)
*
* @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key
* @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符
*/
export async function invalidateFor(
actionId: string,
params: Record<string, string> = {},
): Promise<void> {
const rule = (INVALIDATION_MAP 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: PASS(4 个测试全过)
- Step 5: 验证 lint 与类型校验
Run: npm run lint && npx tsc --noEmit
Expected: PASS
- Step 6: 提交
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
/**
* 缓存基础设施公共 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: 提交
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
/**
* 客户端 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: 提交
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
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
"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: PASS(5 个测试全过)
- Step 5: 验证 lint 与类型校验
Run: npm run lint && npx tsc --noEmit
Expected: PASS
注意:此变更破坏性,原本 11 个文件使用旧 API(不传 queryKey)。 编译会立即报错提示漏传 queryKey,逐个迁移即可。本期仅迁移 classes 标杆页面,其他下期处理。
- Step 6: 提交
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
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
"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: PASS(5 个测试全过)
- Step 5: 验证 lint 与类型校验
Run: npm run lint && npx tsc --noEmit
Expected: PASS
- Step 6: 提交
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 ...) => 包装的导出函数。对每个函数:
- 重命名原函数为
${name}Raw(无 cache 包装) - 新增
${name}用cacheFn包装
在文件顶部 import 区新增:
import { cacheFn } from "@/shared/lib/cache"
将原 cache(async (params) => { ... }) 模式改为:
// 修改前
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: 提交
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 区新增:
import { invalidateFor } from "@/shared/lib/cache"
移除 import { revalidatePath } from "next/cache"(若文件中无其他用途)。
将每个 Server Action 中的 revalidatePath(...) 调用替换为 invalidateFor(...):
// 修改前
"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
// 替换 revalidatePath("/admin/grades") 等
await invalidateFor("classes.grade.update")
- Step 4: 修改
actions-invitations.ts
// 创建邀请码后
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
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: 提交
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 自定义规则块之后)新增:
// 缓存策略规则:禁止在 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 文件添加豁免:
// 临时豁免:未迁移模块(下期逐模块移除)
{
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: 提交
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 区新增:
import { useQuery } from "@tanstack/react-query"
import { queryKeys } from "@/shared/lib/query-keys"
import { getClassDetailAction } from "@/modules/classes/actions-teacher"
将数据获取改为:
"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: 提交
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 章节的格式):
## 缓存基础设施(shared/lib/cache/)
**2026-07-05 新增**:全栈缓存策略一致性基础设施。复用 rate-limit 的 driver 切换模式。
### 模块结构
| 文件 | 行数 | 职责 |
|------|------|------|
| `cache/types.ts` | 60 | CacheFnOptions / CacheStore / InvalidationRule 接口 |
| `cache/memory-store.ts` | 130 | 内存 LRU 实现(maxEntries=500,TTL 过期,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 描述后新增:
- ✅ 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 节点结构):
{
"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=500,TTL 过期,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 + webpackIgnore,env 未配置时返回 null(fail-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,新增"缓存策略规则"章节
在文件末尾新增:
---
## 九、缓存策略规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 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: 提交
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 包装
# 应有 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: 若有遗漏的提交,统一推送
git status
git log --oneline -20
如有未提交的修改,统一提交。
- Step 7: 完成
本期缓存策略落地专项重构完成。后续工作:
- 按模块推进其他 29 个模块的迁移(每个模块从 ESLint 豁免列表移除)
- 按页面推进客户端组件 useQuery 迁移
- 评估缓存命中率监控