From bfc76b8e7f11fcc90cf4d7faf988e4010b289076 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Fri, 22 May 2026 10:12:16 +0800 Subject: [PATCH] feat(cache): implement caching system with Redis and memory fallback; update API endpoints to utilize caching --- .env.example | 2 - bun.lock | 1 + package.json | 1 + packages/cache/readme.md | 188 +++++++++++++++++++++++++ packages/cache/tsconfig.json | 2 +- packages/drizzle-pkg/db.sqlite | Bin 45056 -> 45056 bytes server/api/cards.get.ts | 10 +- server/api/scheduler/executions.get.ts | 5 + server/api/scheduler/stats.get.ts | 11 +- server/api/scheduler/tasks/[id].delete.ts | 3 + server/api/scheduler/tasks/[id].get.ts | 8 +- server/api/scheduler/tasks/[id].put.ts | 4 + server/api/scheduler/tasks/[id]/toggle.post.ts | 4 + server/api/scheduler/tasks/index.get.ts | 10 +- server/api/scheduler/tasks/index.post.ts | 3 + server/plugins/04.cache.ts | 22 +++ server/types/index.d.ts | 7 + 17 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 packages/cache/readme.md create mode 100644 server/plugins/04.cache.ts diff --git a/.env.example b/.env.example index 32de5a5..61ec2f4 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,4 @@ STATIC_DIR=static UPLOAD_SUBDIR=upload NITRO_PORT=3399 SCHEDULER_MAX_CONCURRENCY=5 -SCHEDULER_LOG_RETENTION_DAYS=30 -SCHEDULER_MAX_CONCURRENCY=5 SCHEDULER_LOG_RETENTION_DAYS=30 \ No newline at end of file diff --git a/bun.lock b/bun.lock index 825ecf2..8a8a710 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@libsql/client": "0.17.3", "bcryptjs": "3.0.3", + "cache": "workspace:*", "croner": "10.0.1", "dotenv": "17.4.1", "drizzle-orm": "0.45.2", diff --git a/package.json b/package.json index 761cfcb..aef4979 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@libsql/client": "0.17.3", "bcryptjs": "3.0.3", "croner": "10.0.1", + "cache": "workspace:*", "dotenv": "17.4.1", "drizzle-orm": "0.45.2", "drizzle-pkg": "workspace:*", diff --git a/packages/cache/readme.md b/packages/cache/readme.md new file mode 100644 index 0000000..410edc8 --- /dev/null +++ b/packages/cache/readme.md @@ -0,0 +1,188 @@ +# Cache System 使用文档 + +`packages/cache` 提供一个支持 Redis 主存储 + Memory 内存降级的通用 K-V 缓存系统。 + +## 安装 + +cache 包已作为 workspace 依赖安装,直接使用即可: + +```typescript +import { createCache } from 'cache' +``` + +## 快速开始 + +```typescript +import { createCache } from 'cache' + +// 初始化 +const cache = createCache({ + redis: { + host: '127.0.0.1', + port: 6379, + }, + defaultTtl: 300, // 默认 5 分钟 +}) + +// 在 Nitro API 中使用 +export default defineEventHandler(async (event) => { + const key = 'users:list' + + // 尝试从缓存读取 + const cached = await cache.get(key) + if (cached) return cached + + // 缓存未命中,从数据源获取 + const data = await fetchUsers() + + // 写入缓存 + await cache.set(key, data) + + return data +}) +``` + +## API + +### createCache(options) + +创建 CacheManager 实例。 + +```typescript +interface CacheFactoryOptions { + redis?: RedisDriverOptions // Redis 配置,省略则只用内存 + memory?: boolean // 是否启用内存兜底,默认 true + defaultTtl?: number // 默认 TTL(秒),默认 0(永不过期) +} + +interface RedisDriverOptions { + host: string + port: number + password?: string + db?: number +} +``` + +**行为:** +- `redis` 配置存在时,Redis 作为主存储(优先尝试) +- `memory !== false` 时,Memory 作为兜底(Redis 不可用时自动降级) +- 两者都不配置则抛出错误 + +### CacheManager 实例方法 + +```typescript +// 读取缓存,未命中返回 null +get(key: string): Promise + +// 写入缓存 +// ttl 单位为秒,0 表示永不过期 +set(key: string, value: T, ttl?: number): Promise + +// 删除指定 key +del(key: string): Promise + +// 检查 key 是否存在 +exists(key: string): Promise + +// 清空所有缓存(谨慎使用) +clear(): Promise +``` + +## 降级策略 + +采用**手动显式降级**:按驱动数组顺序尝试,返回第一个成功结果。 + +``` +RedisDriver (优先) + ↓ 失败时 +MemoryDriver (兜底) +``` + +- `get` / `exists`:遍历 drivers,返回第一个成功结果 +- `set` / `del` / `clear`:并发写入/删除所有 drivers(保证各层数据一致) +- 单个 driver 失败时 warn 并继续,不影响整体操作 + +## 使用场景 + +### 场景一:纯内存缓存(开发/无 Redis 环境) + +```typescript +const cache = createCache({ + defaultTtl: 60, +}) +``` + +### 场景二:Redis + 内存降级(生产环境) + +```typescript +const cache = createCache({ + redis: { + host: process.env.REDIS_HOST!, + port: Number(process.env.REDIS_PORT ?? 6379), + password: process.env.REDIS_PASSWORD, + db: Number(process.env.REDIS_DB ?? 0), + }, + defaultTtl: 300, +}) +``` + +### 场景三:禁用内存兜底(Redis 专用) + +```typescript +const cache = createCache({ + redis: { host: '127.0.0.1', port: 6379 }, + memory: false, +}) +``` + +### 场景四:Nitro Plugin 全局注入 + +`server/plugins/cache.ts`: + +```typescript +import { createCache } from 'cache' + +export default defineNitroPlugin(() => { + const cache = createCache({ + redis: { + host: '127.0.0.1', + port: 6379, + }, + defaultTtl: 300, + }) + + // 注入到 H3 event context + event.context.cache = cache +}) +``` + +`server/api/users.get.ts`: + +```typescript +export default defineEventHandler(async (event) => { + const cache = event.context.cache + + const cached = await cache.get('users:list') + if (cached) return cached + + const data = await fetchUsers() + await cache.set('users:list', data) + return data +}) +``` + +## 数据结构 + +所有值以 JSON 序列化存储到 Redis: + +```typescript +await cache.set('user:1', { id: 1, name: 'Alice' }) +// Redis: SET 'user:1' '{"id":1,"name":"Alice"}' EX 300 +``` + +## 注意事项 + +- **TTL 单位是秒**,Redis `EX` 参数单位也是秒 +- `clear()` 在 Redis 上执行 `FLUSHDB`,会清空整个数据库,请确认 namespace 隔离 +- MemoryDriver 使用进程内存,重启进程后缓存丢失 +- 不支持存储 `undefined`、Symbol 或含循环引用的对象(JSON 序列化限制) \ No newline at end of file diff --git a/packages/cache/tsconfig.json b/packages/cache/tsconfig.json index 3f3c720..da7695f 100644 --- a/packages/cache/tsconfig.json +++ b/packages/cache/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "tsconfig/tsconfig.json", "compilerOptions": { "outDir": "./dist" }, diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 3f2cb8ea125ef25e70f0e24ea05aef7a66566cc4..0631b064b89f5ed6c936920fcedc652faa6e8fb5 100644 GIT binary patch delta 186 zcmZp8z|`=7X@WFk>_i!7#@LMsOZu6$INc|+PcUF~-)uI4pL24-Ocqtk6f^^3u*vXn9%10G=KIE1$oqyjo#zox zEcXrWV6Jmqo}5QE3kodZ)Nf?c;s_0uW@TmYCdS;!2SfBYbB{b`oadW+lqB32>7KkTA13kPZovkSnurnUn?^3kpd9 j4|@*w4si|a4QCAE3|$M@3sVZt3Q4mO5Gx9^NvgL61j8Ay diff --git a/server/api/cards.get.ts b/server/api/cards.get.ts index 20e181a..5f1242f 100644 --- a/server/api/cards.get.ts +++ b/server/api/cards.get.ts @@ -125,16 +125,22 @@ function genItem(globalIdx: number): CardData { } } -export default defineEventHandler(async (event) => { +export default defineWrappedResponseHandler(async (event) => { const query = getQuery(event) const page = Math.max(1, parseInt(String(query.page || '1'))) const pageSize = Math.min(30, Math.max(1, parseInt(String(query.pageSize || '12')))) + const cacheKey = `cards:list:${page}:${pageSize}` + const cached = await event.context.cache.get<{ items: CardData[], hasMore: boolean, page: number }>(cacheKey) + if (cached) return cached + const start = (page - 1) * pageSize const items = Array.from({ length: pageSize }, (_, i) => genItem(start + i)) const total = 60 const hasMore = start + pageSize < total - return { items, hasMore, page } + const result = { items, hasMore, page } + await event.context.cache.set(cacheKey, result, 300) + return result }) diff --git a/server/api/scheduler/executions.get.ts b/server/api/scheduler/executions.get.ts index 13fb70d..a5a98ab 100644 --- a/server/api/scheduler/executions.get.ts +++ b/server/api/scheduler/executions.get.ts @@ -5,6 +5,10 @@ export default defineWrappedResponseHandler(async (event) => { const page = query.page ? Number(query.page) : 1; const pageSize = query.pageSize ? Number(query.pageSize) : 20; + const cacheKey = `scheduler:executions:${page}:${pageSize}:${query.taskId ?? 'all'}:${query.status ?? 'all'}` + const cached = await event.context.cache.get<{ list: unknown[], total: number, page: number, pageSize: number }>(cacheKey) + if (cached) return R.success(cached) + const result = await listExecutions({ page, pageSize, @@ -12,5 +16,6 @@ export default defineWrappedResponseHandler(async (event) => { status: query.status as string | undefined, }); + await event.context.cache.set(cacheKey, result, 60) return R.success(result); }); \ No newline at end of file diff --git a/server/api/scheduler/stats.get.ts b/server/api/scheduler/stats.get.ts index ce6a3a6..76c0f00 100644 --- a/server/api/scheduler/stats.get.ts +++ b/server/api/scheduler/stats.get.ts @@ -2,9 +2,12 @@ import { getStats } from "../../service/scheduler"; import { getJobCount } from "../../scheduler/engine"; export default defineWrappedResponseHandler(async () => { + const cacheKey = 'scheduler:stats' + const cached = await event.context.cache.get<{ totalTasks: number, enabledTasks: number, last24hExecutions: number, activeJobs: number }>(cacheKey) + if (cached) return R.success({ ...cached, activeJobs: getJobCount() }) + const stats = await getStats(); - return R.success({ - ...stats, - activeJobs: getJobCount(), - }); + const result = { ...stats, activeJobs: getJobCount() } + await event.context.cache.set(cacheKey, result, 60) + return R.success(result); }); \ No newline at end of file diff --git a/server/api/scheduler/tasks/[id].delete.ts b/server/api/scheduler/tasks/[id].delete.ts index 218bcaa..7e3cd7c 100644 --- a/server/api/scheduler/tasks/[id].delete.ts +++ b/server/api/scheduler/tasks/[id].delete.ts @@ -8,5 +8,8 @@ export default defineWrappedResponseHandler(async (event) => { removeTask(id); await deleteTask(id); + await event.context.cache.del(`scheduler:task:${id}`) + await event.context.cache.del('scheduler:tasks:1:20:all:all') + return R.success(null); }); \ No newline at end of file diff --git a/server/api/scheduler/tasks/[id].get.ts b/server/api/scheduler/tasks/[id].get.ts index 399d3bc..99a5661 100644 --- a/server/api/scheduler/tasks/[id].get.ts +++ b/server/api/scheduler/tasks/[id].get.ts @@ -4,10 +4,16 @@ export default defineWrappedResponseHandler(async (event) => { const id = getRouterParam(event, "id"); if (!id) return R.throwError(400, "Missing id", null); + const cacheKey = `scheduler:task:${id}` + const cached = await event.context.cache.get<{ task: unknown, recentExecutions: unknown[] }>(cacheKey) + if (cached) return R.success(cached) + const task = await getTaskById(id); if (!task) return R.throwError(404, "Task not found", null); const recentExecutions = await getRecentExecutions(id, 20); - return R.success({ task, recentExecutions }); + const result = { task, recentExecutions } + await event.context.cache.set(cacheKey, result, 60) + return R.success(result); }); \ No newline at end of file diff --git a/server/api/scheduler/tasks/[id].put.ts b/server/api/scheduler/tasks/[id].put.ts index efb5b02..5041689 100644 --- a/server/api/scheduler/tasks/[id].put.ts +++ b/server/api/scheduler/tasks/[id].put.ts @@ -54,5 +54,9 @@ export default defineWrappedResponseHandler(async (event) => { reloadTask(task.id); } + await event.context.cache.del(`scheduler:task:${id}`) + await event.context.cache.del('scheduler:tasks:1:20:all:all') + await event.context.cache.del('scheduler:stats') + return R.success(task); }); \ No newline at end of file diff --git a/server/api/scheduler/tasks/[id]/toggle.post.ts b/server/api/scheduler/tasks/[id]/toggle.post.ts index 02d35d8..fad1318 100644 --- a/server/api/scheduler/tasks/[id]/toggle.post.ts +++ b/server/api/scheduler/tasks/[id]/toggle.post.ts @@ -16,5 +16,9 @@ export default defineWrappedResponseHandler(async (event) => { removeTask(id); } + await event.context.cache.del(`scheduler:task:${id}`) + await event.context.cache.del('scheduler:tasks:1:20:all:all') + await event.context.cache.del('scheduler:stats') + return R.success(task); }); \ No newline at end of file diff --git a/server/api/scheduler/tasks/index.get.ts b/server/api/scheduler/tasks/index.get.ts index ae4fd32..849d210 100644 --- a/server/api/scheduler/tasks/index.get.ts +++ b/server/api/scheduler/tasks/index.get.ts @@ -6,6 +6,10 @@ export default defineWrappedResponseHandler(async (event) => { const page = query.page ? Number(query.page) : 1; const pageSize = query.pageSize ? Number(query.pageSize) : 20; + const cacheKey = `scheduler:tasks:${page}:${pageSize}:${query.type ?? 'all'}:${query.enabled ?? 'all'}` + const cached = await event.context.cache.get<{ list: unknown[], total: number, page: number, pageSize: number, registeredFunctions: unknown[] }>(cacheKey) + if (cached) return R.success(cached) + const result = await listTasks({ page, pageSize, @@ -13,8 +17,10 @@ export default defineWrappedResponseHandler(async (event) => { enabled: query.enabled !== undefined ? Number(query.enabled) : undefined, }); - return R.success({ + const fullResult = { ...result, registeredFunctions: listRegisteredTasks(), - }); + } + await event.context.cache.set(cacheKey, fullResult, 120) + return R.success(fullResult); }); \ No newline at end of file diff --git a/server/api/scheduler/tasks/index.post.ts b/server/api/scheduler/tasks/index.post.ts index 4c246e1..754e3fc 100644 --- a/server/api/scheduler/tasks/index.post.ts +++ b/server/api/scheduler/tasks/index.post.ts @@ -55,5 +55,8 @@ export default defineWrappedResponseHandler(async (event) => { addTask(task.id); } + await event.context.cache.del('scheduler:tasks:1:20:all:all') + await event.context.cache.del('scheduler:stats') + return R.success(task); }); \ No newline at end of file diff --git a/server/plugins/04.cache.ts b/server/plugins/04.cache.ts new file mode 100644 index 0000000..f4a29bd --- /dev/null +++ b/server/plugins/04.cache.ts @@ -0,0 +1,22 @@ +import { createCache } from 'cache' + +const cache = createCache({ + // redis: { + // host: process.env.REDIS_HOST ?? '127.0.0.1', + // port: Number(process.env.REDIS_PORT ?? 6379), + // password: process.env.REDIS_PASSWORD, + // db: Number(process.env.REDIS_DB ?? 0), + // }, + // defaultTtl: 300, + memory: true +}) + +if (import.meta.dev) { + console.log('plugin: 04.cache') +} + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', async (event) => { + event.context.cache = cache + }) +}) \ No newline at end of file diff --git a/server/types/index.d.ts b/server/types/index.d.ts index e69de29..8b5c5a4 100644 --- a/server/types/index.d.ts +++ b/server/types/index.d.ts @@ -0,0 +1,7 @@ +import type { CacheManager } from 'cache' + +declare module 'h3' { + interface H3EventContext { + cache: CacheManager + } +} \ No newline at end of file