2753 lines
83 KiB
Markdown
2753 lines
83 KiB
Markdown
# 缓存策略落地专项重构 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
|
||
*
|
||
* 调用方需自行处理 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:
|
||
|
||
```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: PASS(6 个测试全过)
|
||
|
||
- [ ] **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): 实现 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`**
|
||
|
||
```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}
|
||
* - 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: 提交**
|
||
|
||
```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 依赖,未安装时首次调用抛错
|
||
*
|
||
* 返回 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: 提交**
|
||
|
||
```bash
|
||
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`**
|
||
|
||
```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: PASS(4 个测试全过)
|
||
|
||
- [ ] **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 前缀
|
||
* - 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: 提交**
|
||
|
||
```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 × 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: 提交**
|
||
|
||
```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: PASS(5 个测试全过)
|
||
|
||
- [ ] **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: PASS(5 个测试全过)
|
||
|
||
- [ ] **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=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 描述后新增:
|
||
|
||
```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=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`,新增"缓存策略规则"章节**
|
||
|
||
在文件末尾新增:
|
||
|
||
```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 迁移
|
||
- 评估缓存命中率监控
|