baja-lite 1.8.11 → 1.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/cache.d.ts +189 -69
  2. package/cache.js +385 -170
  3. package/package.json +1 -1
package/cache.d.ts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * @internal 统一的 stale-allowed 注册表 key。
3
- * 用带 TTL 的 string(而非全局 set),每个 stale-allowed 缓存独立管理自己的标记。
4
- * key = [cache-meta]realKey,value = '1',TTL = 缓存 TTL。
5
- * 好处:自动过期,不再需要全局 set,内存占用与缓存数量而非请求数量成正比。
6
- */
7
1
  /**
8
2
  * 缓存被清理时的行为策略。
9
3
  * 由 @MethodCache 的 `staleMode` 配置决定,clearMethodCache 依此自动选择清理方式。
@@ -67,75 +61,173 @@ export declare function MethodLock<T = any>(config: {
67
61
  lockMaxTime?: number;
68
62
  }): (target: T, _propertyKey: string, descriptor: PropertyDescriptor) => void;
69
63
  /**
70
- * 清空方法缓存。
71
- * - redis 侧只在配置了 redis 时才操作;没配 redis 时安静跳过,不抛错。
72
- * - 关联清除(clearKey)生效。
73
- * - 被清理的 key 如果启用了 StaleWhileRevalidate(CacheValue.sa=1),会保留旧值并标记 stale。
64
+ * 清理指定 key 的缓存。支持两种模式:
65
+ *
66
+ * **直接清理** — 传入具体缓存 key:
67
+ * - Purge 模式(sa=0):物理删除缓存
68
+ * - Stale 模式(sa=1):保留旧值,n 计数器 +1,后续请求读到 stale 数据并触发后台刷新
69
+ *
70
+ * **级联清理** — 传入 clearKey(关联清除 key):
71
+ * - 遍历所有注册了该 clearKey 的缓存,逐个执行上述清理逻辑
72
+ *
73
+ * @example <caption>直接清理某个缓存</caption>
74
+ * ```ts
75
+ * // Purge 模式:删除缓存,下次请求执行 fn 重建
76
+ * await clearMethodCache(`user-${userId}`);
77
+ *
78
+ * // Stale 模式:保留旧值 + 标记过期,后台异步刷新
79
+ * await clearMethodCache(`match-${matchId}`);
80
+ * ```
81
+ *
82
+ * @example <caption>级联清理 — 清理所有关联了某 clearKey 的缓存</caption>
83
+ * ```ts
84
+ * // 假设以下缓存都注册了 clearKey: ['match']
85
+ * // - match-${id}
86
+ * // - match-timer-${id}
87
+ * // - match-stat-${id}
74
88
  *
75
- * @param key 要清理的缓存 key 或 parent key
89
+ * // 一次调用清理所有
90
+ * await clearMethodCache('match');
91
+ * ```
76
92
  */
77
93
  export declare function clearMethodCache(key: string): Promise<void>;
78
94
  /**
79
- * 执行一个方法fn
80
- * 如果有缓存,则返回缓存,否则【加锁、二次检查】后执行方法并缓存,防止缓存击穿。
81
- * 可选 `cacheNullValue=true` 开启负缓存,防穿透;负缓存默认沿用 autoClearTime,可单独用 nullCacheTime 设短。
82
- * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
95
+ * 执行一个带缓存的方法。先查缓存 → 命中直接返回;未命中则加锁执行 fn 并缓存结果。
96
+ *
97
+ * 支持三种缓存策略(通过 `staleMode` 切换):
98
+ * - **Purge**(默认):清理时物理删除缓存,下次请求阻塞执行 fn 重建
99
+ * - **StaleWhileRevalidate**:清理时保留旧值 + 标记过期,后续请求零阻塞读到旧值,后台异步刷新
100
+ *
101
+ * 防击穿机制:
102
+ * - 缓存 miss 时通过 `[cache-sf]key` 原子锁(SETNX)选出"首飞"执行 fn
103
+ * - 其他并发请求轮询缓存,首飞写完后立即命中,避免重复执行 fn
104
+ *
105
+ * @example <caption>基础用法 — Purge 模式</caption>
106
+ * ```ts
107
+ * const result = await excuteWithCache({
108
+ * key: `user-${userId}`,
109
+ * autoClearTime: 60, // 60分钟后自动过期
110
+ * cacheNullValue: true, // null 也缓存(防穿透)
111
+ * }, () => loadUserFromDB(userId));
112
+ * ```
113
+ *
114
+ * @example <caption>Stale 模式 — 清理时保留旧值 + 后台刷新</caption>
115
+ * ```ts
116
+ * const result = await excuteWithCache({
117
+ * key: `match-${matchId}`,
118
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
119
+ * onCacheUpdated(value) {
120
+ * // 缓存刷新后触发:推送 WebSocket、预热下游等
121
+ * this.broadcast(value);
122
+ * },
123
+ * }, () => computeMatchData(matchId));
124
+ * ```
125
+ *
126
+ * @example <caption>cacheOnly — 只读缓存,不调用 fn</caption>
127
+ * ```ts
128
+ * const cached = await excuteWithCache({
129
+ * key: `config`,
130
+ * cacheOnly: true, // 没有缓存返回 null,不执行 fn
131
+ * }, () => loadConfig());
132
+ * ```
133
+ *
134
+ * @example <caption>forceRefresh — 跳过缓存直接执行 fn</caption>
135
+ * ```ts
136
+ * const fresh = await excuteWithCache({
137
+ * key: `match-${id}`,
138
+ * forceRefresh: true, // 忽略已有缓存,执行 fn 后写回(sa=0)
139
+ * }, () => computeFreshData(id));
140
+ * ```
141
+ *
142
+ * @example <caption>关联清除 — clearKey 级联清理</caption>
143
+ * ```ts
144
+ * const result = await excuteWithCache({
145
+ * key: `match-timer-${id}`,
146
+ * clearKey: ['match'], // clearMethodCache('match') 时级联清除
147
+ * }, () => computeTimer(id));
148
+ *
149
+ * // 清理所有关联了 'match' 的缓存
150
+ * await clearMethodCache('match');
151
+ * ```
83
152
  */
84
153
  export declare function excuteWithCache<T>(config: {
85
- /** 返回缓存key,参数=方法的参数+当前用户对象,可以用来清空缓存。 */
86
154
  key: ((...args: any[]) => string) | string;
87
- /** 返回缓存清除key,参数=方法的参数+当前用户对象,可以用来批量清空缓存 */
88
- clearKey?: string[];
89
- /**
90
- * 自动清空缓存的时间,单位分钟。
91
- * ⚠️ 不能与 `staleMode: StaleWhileRevalidate` 同时使用:
92
- * stale 只是把缓存"标记为过期",旧值仍在;而 autoClearTime 到期后会物理删除 key。
93
- * 两者语义冲突——stale 期望保留旧值等后台刷新,autoClearTime 却会直接删掉它。
94
- */
155
+ clearKey?: ((...args: any[]) => string[]) | string[];
95
156
  autoClearTime?: number;
96
- /** 是否缓存 null / undefined(负缓存防穿透),默认 false。 */
97
157
  cacheNullValue?: boolean;
98
- /** 负缓存 TTL(分钟)。默认沿用 autoClearTime;都不设时不写入。 */
99
158
  nullCacheTime?: number;
100
- /** 随着当前用户sesion的清空而一起清空 */
101
159
  clearWithSession?: boolean;
102
- /**
103
- * 缓存被清理时的行为策略。默认 Purge。
104
- * StaleWhileRevalidate 时设 sa=1 使 clearMethodCache 走"标记 stale + 保留旧值"路径。
105
- */
106
160
  staleMode?: CacheStaleMode;
107
- /**
108
- * 缓存更新回调。异步方法,在 setMethodCache 成功写缓存后调用。
109
- * 调用时三个参数:this → 方法的执行上下文(config.ctx);value → 缓存值;args → 方法输入参数。
110
- */
111
161
  onCacheUpdated?: (this: any, value: T, ...args: any[]) => void | Promise<void>;
162
+ ctx?: any;
163
+ args?: any[];
164
+ devid?: string | false | undefined;
112
165
  /**
113
- * 方法的执行上下文。在 onCacheUpdated 回调中作为 this 传入。
114
- * @example
115
- * ```ts
116
- * excuteWithCache({
117
- * key: 'calibration',
118
- * ctx: service,
119
- * args: ['device001'],
120
- * onCacheUpdated(this: MyService, value, args) {
121
- * this.broadcast(value);
122
- * },
123
- * }, () => service.getCalibration('device001'));
124
- * ```
166
+ * 只用缓存,绝不调用原始方法。无论缓存是否过期都直接返回;没有缓存则返回 null。
125
167
  */
126
- ctx?: any;
168
+ cacheOnly?: boolean;
127
169
  /**
128
- * 方法的输入参数。在 onCacheUpdated 回调中作为 args 传入。
129
- * 用于告知回调是哪些参数触发了本次缓存更新。
170
+ * 忽略已有缓存,直接调用原始方法并更新缓存(写回时 sa=0)。
130
171
  */
131
- args?: any[];
172
+ forceRefresh?: boolean;
132
173
  }, fn: () => Promise<T>): Promise<T>;
133
174
  /**
134
- * 缓存注解:先查缓存 miss 才加锁 → 锁内再查一次 → 仍 miss 才执行原方法。
135
- * 已内置 single-flight,不需要再叠 `@MethodLock`。
136
- * 可选 `cacheNullValue=true` 开启负缓存,防穿透。
137
- * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
138
- * 可选 `onCacheUpdated` 设置缓存更新回调(带 this 上下文)。
175
+ * 方法装饰器:为方法添加 Redis 缓存能力。
176
+ *
177
+ * 工作流程:
178
+ * 1. 调用方法时先查 Redis 缓存
179
+ * 2. 命中 直接返回缓存值(若 stale 则触发后台异步刷新)
180
+ * 3. 未命中 → 原子锁(SETNX)选出"首飞"执行原方法 → 写缓存 → 返回
181
+ * 4. 其他并发请求轮询缓存,首飞写完后立即命中
182
+ *
183
+ * @example <caption>基础用法 — Purge 模式(默认)</caption>
184
+ * ```ts
185
+ * @MethodCache({
186
+ * key: (userId: string) => `user-${userId}`,
187
+ * autoClearTime: 60, // 60分钟后自动过期
188
+ * })
189
+ * async getUser(userId: string) {
190
+ * return await this.db.query(...);
191
+ * }
192
+ * ```
193
+ *
194
+ * @example <caption>Stale 模式 — 清理时保留旧值 + 后台刷新</caption>
195
+ * ```ts
196
+ * @MethodCache({
197
+ * key: (matchId: string) => `match-${matchId}`,
198
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
199
+ * onCacheUpdated(this: MyService, value) {
200
+ * // 缓存刷新后触发(this = 当前 service 实例)
201
+ * this.webSocket.broadcast(value);
202
+ * },
203
+ * })
204
+ * async getMatch(matchId: string) { ... }
205
+ *
206
+ * // 清理缓存 → 旧值保留,后台异步刷新
207
+ * await clearMethodCache(`match-${id}`);
208
+ * ```
209
+ *
210
+ * @example <caption>关联清除 — clearKey 级联清理</caption>
211
+ * ```ts
212
+ * @MethodCache({
213
+ * key: (matchId: string) => `match-timer-${matchId}`,
214
+ * clearKey: ['match'], // 声明依赖关系
215
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
216
+ * })
217
+ * async getMatchTimer(matchId: string) { ... }
218
+ *
219
+ * // 清理所有关联了 'match' 的缓存(包括 match-timer-*)
220
+ * await clearMethodCache('match');
221
+ * ```
222
+ *
223
+ * @example <caption>cacheOnly — 只读缓存,绝不调用原方法</caption>
224
+ * ```ts
225
+ * @MethodCache({
226
+ * key: 'app-config',
227
+ * cacheOnly: true, // 没有缓存返回 null
228
+ * })
229
+ * async loadConfig() { ... }
230
+ * ```
139
231
  */
140
232
  export declare function MethodCache<T = any>(config: {
141
233
  /** 返回缓存key,参数=方法的参数[注意:必须和主方法的参数数量、完全一致,同时会追加一个当前用户对象]+当前用户对象,可以用来清空缓存。 */
@@ -163,28 +255,54 @@ export declare function MethodCache<T = any>(config: {
163
255
  * 调用时三个参数:this → 执行上下文;args → 方法输入参数;value → 缓存值。
164
256
  */
165
257
  onCacheUpdated?: (this: any, value: T, ...args: any[]) => void | Promise<void>;
258
+ /**
259
+ * 只用缓存,绝不调用原始方法。无论缓存是否过期都直接返回;没有缓存则返回 null。
260
+ */
261
+ cacheOnly?: boolean;
262
+ /** 忽略已有缓存,直接调用原始方法并更新缓存(写回时 sa=0)。 */
263
+ forceRefresh?: boolean;
166
264
  }): (_target: T, _propertyKey: string, descriptor: PropertyDescriptor) => void;
167
265
  /**
168
- * 外部直接设置缓存值。用于绕过 @MethodCache 装饰器直接更新缓存的场景
169
- * (如外部事件驱动的数据变更、手动刷新等)。
266
+ * 外部直接写入缓存(绕过 @MethodCache 装饰器)。
170
267
  *
171
- * 内部使用统一格式 { t, d, s, sa } 序列化,确保与 @MethodCache 的读取逻辑兼容。
268
+ * @MethodCache 共享同一套存储格式 `{ t, d, sa, n }`,写入的缓存可被
269
+ * @MethodCache 装饰的方法正常读取(包括 stale 判断、计数器等)。
172
270
  *
173
- * @param cacheKey 缓存 key(不含 [cache] 前缀,与 @MethodCache 的 key 一致)
174
- * @param value 要缓存的值
175
- * @param options 选项
271
+ * 适用场景:
272
+ * - 外部事件驱动的数据变更(如 MQ 消息、定时任务)
273
+ * - 手动刷新缓存(如管理后台"清除并重建"按钮)
274
+ * - 跨服务同步缓存状态
176
275
  *
177
- * @example
276
+ * @example <caption>基础用法 — 写入缓存</caption>
178
277
  * ```ts
179
- * // 外部事件驱动更新缓存
180
- * await setCache('match-timer-123', timer, { autoClearTime: 120 });
278
+ * await setCache('match-timer-123', timer, {
279
+ * autoClearTime: 120, // 120分钟后过期
280
+ * });
281
+ * ```
181
282
  *
182
- * // 同时触发 onCacheUpdated 回调(如果有)
283
+ * @example <caption>关联清除 注册 clearKey 级联关系</caption>
284
+ * ```ts
183
285
  * await setCache('match-timer-123', timer, {
184
286
  * autoClearTime: 120,
185
- * onCacheUpdated(this: MyService, value) {
186
- * this.broadcast(value);
187
- * }
287
+ * clearKey: ['match'], // clearMethodCache('match') 时级联清除
288
+ * });
289
+ *
290
+ * // 函数式 clearKey(参数来自 options.args)
291
+ * await setCache('match-timer-123', timer, {
292
+ * clearKey: (matchId) => [`match-${matchId}`],
293
+ * args: [matchId],
294
+ * });
295
+ * ```
296
+ *
297
+ * @example <caption>回调 — 缓存写入后触发通知</caption>
298
+ * ```ts
299
+ * await setCache('match-timer-123', timer, {
300
+ * autoClearTime: 120,
301
+ * ctx: this,
302
+ * args: [matchId],
303
+ * onCacheUpdated(value, matchId) {
304
+ * this.webSocket.sendRoom(matchId, { action: 'timer', param: value });
305
+ * },
188
306
  * });
189
307
  * ```
190
308
  */
@@ -193,6 +311,8 @@ export declare function setCache<T = any>(cacheKey: string, value: T, options?:
193
311
  autoClearTime?: number;
194
312
  /** 是否缓存 null / undefined。默认 false。 */
195
313
  cacheNullValue?: boolean;
314
+ /** 关联清除 key。clearMethodCache(clearKey) 时级联清除本缓存。 */
315
+ clearKey?: ((...args: any[]) => string[]) | string[];
196
316
  /** 缓存更新回调 */
197
317
  onCacheUpdated?: (this: any, value: T, ...args: any[]) => void | Promise<void>;
198
318
  /** 回调的 this 上下文 */
package/cache.js CHANGED
@@ -18,28 +18,26 @@ function cacheLog(message, ...params) {
18
18
  */
19
19
  const globalStaleInflight = new Map();
20
20
  /**
21
- * @internal [cache] 前缀下的子 key 命名全部收拢到这里,避免散落在各处 magic string。
21
+ * @internal [cache] 前缀下的子 key 命名全部收拢到这里。
22
22
  *
23
23
  * 命名规范:
24
- * - [cache]key 缓存值(JSON: { v, d, s } — v=版本 d=数据 s=是否 stale
24
+ * - [cache]key 缓存值(JSON: { t, d, sa?, n? })
25
25
  * - [cache-sf]key single-flight 首飞标记(SETNX 抢锁用)
26
- * - [cache-meta]key stale-allowed 标记(带 TTL,与缓存同生命周期)
27
26
  * - [cache-parent]key / [cache-child]key 关联清除的 parent↔child 双向 set
28
27
  */
29
28
  const CacheKey = {
30
29
  value: (k) => `[cache]${k}`,
31
30
  sf: (k) => `[cache-sf]${k}`,
32
- meta: (k) => `[cache-meta]${k}`,
33
31
  parent: (k) => `[cache-parent]${k}`,
34
32
  child: (k) => `[cache-child]${k}`,
35
33
  };
36
34
  /** @internal 序列化缓存值 */
37
- function serializeCacheValue(data, timestamp, stale = false, staleAllowed = false) {
35
+ function serializeCacheValue(data, timestamp, staleAllowed = false, staleCount = 0) {
38
36
  const v = { t: timestamp, d: data };
39
- if (stale)
40
- v.s = 1;
41
37
  if (staleAllowed)
42
38
  v.sa = 1;
39
+ if (staleCount > 0)
40
+ v.n = staleCount;
43
41
  return JSON.stringify(v);
44
42
  }
45
43
  /** @internal 反序列化缓存值 */
@@ -53,12 +51,6 @@ function deserializeCacheValue(raw) {
53
51
  return { t: 0, d: raw, isNullish: raw === 'null' || raw === 'undefined' };
54
52
  }
55
53
  }
56
- /**
57
- * @internal 统一的 stale-allowed 注册表 key。
58
- * 用带 TTL 的 string(而非全局 set),每个 stale-allowed 缓存独立管理自己的标记。
59
- * key = [cache-meta]realKey,value = '1',TTL = 缓存 TTL。
60
- * 好处:自动过期,不再需要全局 set,内存占用与缓存数量而非请求数量成正比。
61
- */
62
54
  /**
63
55
  * 缓存被清理时的行为策略。
64
56
  * 由 @MethodCache 的 `staleMode` 配置决定,clearMethodCache 依此自动选择清理方式。
@@ -124,7 +116,8 @@ export async function GetRedisLock(key, lockMaxActive) {
124
116
  await initLock.release();
125
117
  // eslint-disable-next-line no-empty
126
118
  }
127
- catch (error) {
119
+ catch {
120
+ // ignore
128
121
  }
129
122
  }
130
123
  }
@@ -209,22 +202,28 @@ async function setMethodCache(config, devid) {
209
202
  }
210
203
  }
211
204
  // 写入缓存值(带时间戳 + staleAllowed 标志)。
212
- // 保留旧 sa:只有显式 StaleWhileRevalidate 才设 1,否则沿用旧值(防止覆盖已有的 sa=1)。
213
205
  const timestamp = Date.now();
214
- const existingSa = await db.get(CacheKey.value(config.key)).then(r => r ? deserializeCacheValue(r).sa : undefined);
215
- const staleAllowed = config.staleMode === CacheStaleMode.StaleWhileRevalidate ? true : (existingSa ?? false);
216
- const payload = serializeCacheValue(dataStr, timestamp, false, staleAllowed);
206
+ let staleAllowed;
207
+ if (config.staleAllowedOverride !== undefined) {
208
+ staleAllowed = config.staleAllowedOverride; // 显式指定
209
+ }
210
+ else {
211
+ // 保留旧 sa:显式 StaleWhileRevalidate → 1,否则沿用旧值
212
+ const existingSa = await db.get(CacheKey.value(config.key)).then(r => r ? deserializeCacheValue(r).sa : undefined);
213
+ staleAllowed = config.staleMode === CacheStaleMode.StaleWhileRevalidate ? true : (existingSa ?? false);
214
+ }
215
+ const payload = serializeCacheValue(dataStr, timestamp, staleAllowed);
217
216
  if (ttl) { // 自动清空
218
217
  await db.set(CacheKey.value(config.key), payload, 'EX', ttl * 60);
219
- cacheLog(`cache ${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
218
+ cacheLog(`${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
220
219
  }
221
220
  else {
222
221
  await db.set(CacheKey.value(config.key), payload);
223
- cacheLog(`cache ${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
222
+ cacheLog(`${config.key} seted t${timestamp}${isNullish ? ' (null)' : ''}!`);
224
223
  }
225
224
  // staleAllowed 已存储在缓存值内部(CacheValue.sa),无需单独的 meta key
226
225
  // 删除旧格式残留(兼容升级)
227
- await db.del(CacheKey.meta(config.key));
226
+ // await db.del(`[cache-meta]${config.key}`); // 清理旧格式残留
228
227
  // 订阅:清空 clear list —— 同一 key 在缓存生命周期内只注册一次监听器,
229
228
  // 否则每次 miss 都会 .on 一次,clearMethodCache 触发时回调会被执行 N 遍,
230
229
  // 长期运行造成监听器泄漏。
@@ -233,7 +232,7 @@ async function setMethodCache(config, devid) {
233
232
  if (globalThis[_EventBus].listenerCount(event) === 0) {
234
233
  globalThis[_EventBus].on(event, async (key) => {
235
234
  await clearCacheKey(key);
236
- cacheLog(`cache ${key} clear by key!`);
235
+ cacheLog(`${key} clear by key!`);
237
236
  });
238
237
  }
239
238
  }
@@ -243,7 +242,7 @@ async function setMethodCache(config, devid) {
243
242
  if (globalThis[_EventBus].listenerCount(event) === 0) {
244
243
  globalThis[_EventBus].on(event, async (key) => {
245
244
  await clearCacheKey(key);
246
- cacheLog(`cache ${key} clear by devid!`);
245
+ cacheLog(`${key} clear by devid!`);
247
246
  });
248
247
  }
249
248
  }
@@ -265,7 +264,7 @@ async function clearCacheKey(key) {
265
264
  if (parentType === 'set') {
266
265
  const childKeys = await db.smembers(CacheKey.parent(key));
267
266
  for (const child of childKeys) {
268
- cacheLog(`cache ${child} cleared via parent ${key}!`);
267
+ cacheLog(`${child} cleared via parent ${key}!`);
269
268
  await clearCacheKey(child);
270
269
  affectedChildren.push(child);
271
270
  }
@@ -275,24 +274,29 @@ async function clearCacheKey(key) {
275
274
  const valueRaw = await db.get(CacheKey.value(key));
276
275
  const v = valueRaw ? deserializeCacheValue(valueRaw) : null;
277
276
  if (v?.sa) {
278
- // stale-allowed:保留缓存值,标记 s=1,同时保留 sa=1(防止下次 clear 变成 purge)
279
- const newPayload = serializeCacheValue(v.d, v.t, true, true);
280
- const ttlSec = await db.ttl(CacheKey.value(key));
281
- if (ttlSec > 0) {
282
- await db.set(CacheKey.value(key), newPayload, 'EX', ttlSec);
277
+ // stale-allowed:n + 1(原子 Lua;失败退化为普通写入)
278
+ try {
279
+ await db.eval(CLEAR_INCR_LUA, 1, CacheKey.value(key));
283
280
  }
284
- else {
285
- await db.set(CacheKey.value(key), newPayload);
281
+ catch {
282
+ const newPayload = serializeCacheValue(v.d, v.t, true, (v.n ?? 0) + 1);
283
+ const ttlSec = await db.ttl(CacheKey.value(key));
284
+ if (ttlSec > 0) {
285
+ await db.set(CacheKey.value(key), newPayload, 'EX', ttlSec);
286
+ }
287
+ else {
288
+ await db.set(CacheKey.value(key), newPayload);
289
+ }
286
290
  }
287
- cacheLog(`cache ${key} marked stale (t${v.t})`);
291
+ cacheLog(`${key} clear n+1`);
288
292
  }
289
293
  else {
290
294
  // Purge:直接删缓存值
291
295
  await db.del(CacheKey.value(key));
292
- cacheLog(`cache ${key} purged!`);
296
+ cacheLog(`${key} purged!`);
293
297
  }
294
298
  // 清理旧格式残留
295
- await db.del(CacheKey.meta(key));
299
+ // await db.del(`[cache-meta]${key}`); // 清理旧格式残留
296
300
  // 3. 清理 child→parent 反向关联
297
301
  const childType = await db.type(CacheKey.child(key));
298
302
  if (childType === 'set') {
@@ -308,12 +312,34 @@ async function clearCacheKey(key) {
308
312
  return affectedChildren;
309
313
  }
310
314
  /**
311
- * 清空方法缓存。
312
- * - redis 侧只在配置了 redis 时才操作;没配 redis 时安静跳过,不抛错。
313
- * - 关联清除(clearKey)生效。
314
- * - 被清理的 key 如果启用了 StaleWhileRevalidate(CacheValue.sa=1),会保留旧值并标记 stale。
315
+ * 清理指定 key 的缓存。支持两种模式:
316
+ *
317
+ * **直接清理** — 传入具体缓存 key:
318
+ * - Purge 模式(sa=0):物理删除缓存
319
+ * - Stale 模式(sa=1):保留旧值,n 计数器 +1,后续请求读到 stale 数据并触发后台刷新
320
+ *
321
+ * **级联清理** — 传入 clearKey(关联清除 key):
322
+ * - 遍历所有注册了该 clearKey 的缓存,逐个执行上述清理逻辑
323
+ *
324
+ * @example <caption>直接清理某个缓存</caption>
325
+ * ```ts
326
+ * // Purge 模式:删除缓存,下次请求执行 fn 重建
327
+ * await clearMethodCache(`user-${userId}`);
315
328
  *
316
- * @param key 要清理的缓存 key parent key
329
+ * // Stale 模式:保留旧值 + 标记过期,后台异步刷新
330
+ * await clearMethodCache(`match-${matchId}`);
331
+ * ```
332
+ *
333
+ * @example <caption>级联清理 — 清理所有关联了某 clearKey 的缓存</caption>
334
+ * ```ts
335
+ * // 假设以下缓存都注册了 clearKey: ['match']
336
+ * // - match-${id}
337
+ * // - match-timer-${id}
338
+ * // - match-stat-${id}
339
+ *
340
+ * // 一次调用清理所有
341
+ * await clearMethodCache('match');
342
+ * ```
317
343
  */
318
344
  export async function clearMethodCache(key) {
319
345
  const redisDao = globalThis[_dao]?.[DBType.Redis]?.[_primaryDB];
@@ -342,32 +368,99 @@ const SINGLEFLIGHT_LOCK_TTL_MS = 30000; // 首飞标记 TTL,兜底首飞挂掉
342
368
  const SINGLEFLIGHT_POLL_INTERVAL_MS = 50; // 等待者轮询缓存的间隔
343
369
  const SINGLEFLIGHT_MAX_WAIT_MS = 30000; // 等待者最长等多久;超时就自己跑 fn(保底,不应该常发生)
344
370
  /**
345
- * 后台异步刷新:fire-and-forget,不阻塞调用方。
346
- * 只有抢到首飞标记的实例才会执行 fn() 并写缓存;其它实例直接放弃。
347
- * 刷新完成后删除 stale 标记,后续读取将命中新值。
348
- * 如果配置了 onCacheUpdated 回调,调用时传入 ctx(方法的 this 上下文)。
371
+ * Lua 脚本:clear —— 原子 n+1(保留旧值和 TTL)。
372
+ * 返回新 n 值;-1 表示 key 不存在或不是 sa。
373
+ */
374
+ const CLEAR_INCR_LUA = `
375
+ local raw = redis.call('GET', KEYS[1])
376
+ if not raw then return -1 end
377
+ local ok, val = pcall(cjson.decode, raw)
378
+ if not ok then return -1 end
379
+ if not val.sa then return -1 end
380
+ val.n = (tonumber(val.n) or 0) + 1
381
+ local ttl = redis.call('TTL', KEYS[1])
382
+ if ttl > 0 then
383
+ redis.call('SET', KEYS[1], cjson.encode(val), 'EX', ttl)
384
+ else
385
+ redis.call('SET', KEYS[1], cjson.encode(val))
386
+ end
387
+ return val.n
388
+ `;
389
+ /**
390
+ * Lua 脚本:refresh 完成 —— 原子读当前 n、写新数据、n-1。
391
+ * 返回 [newN, status]:status=1 成功,-1 不存在,-2 非 sa。
392
+ *
393
+ * KEYS[1] = cache key
394
+ * ARGV[1] = 新数据的原始 JSON 字符串(将作为 CacheValue.d)
395
+ * ARGV[2] = TTL 秒数(0 表示保持原 TTL)
396
+ * ARGV[3] = 当前时间戳(毫秒,将作为 CacheValue.t)
349
397
  */
398
+ const REFRESH_DECR_LUA = `
399
+ local raw = redis.call('GET', KEYS[1])
400
+ if not raw then return {-2, 0} end
401
+ local ok, val = pcall(cjson.decode, raw)
402
+ if not ok then return {-2, 0} end
403
+ if not val.sa then return {-1, 0} end
404
+ local n = tonumber(val.n) or 0
405
+ local newN = math.max(0, n - 1)
406
+ -- 重建统一格式 {t, d, sa, n?}
407
+ local newData = {
408
+ t = tonumber(ARGV[3]) or 0,
409
+ d = ARGV[1],
410
+ sa = val.sa
411
+ }
412
+ if newN > 0 then newData.n = newN end
413
+ local ttl = tonumber(ARGV[2]) or 0
414
+ if ttl > 0 then
415
+ redis.call('SET', KEYS[1], cjson.encode(newData), 'EX', ttl)
416
+ else
417
+ redis.call('SET', KEYS[1], cjson.encode(newData))
418
+ end
419
+ return {newN, 1}
420
+ `;
350
421
  async function staleBackgroundRefresh(opts, setOpts) {
351
422
  const db = getRedisDB();
352
423
  const sfKey = CacheKey.sf(opts.key);
353
424
  try {
354
- const ok = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
355
- if (ok !== 'OK')
356
- return; // 别人已经在刷新了,自己放弃
425
+ const locked = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
426
+ if (locked !== 'OK')
427
+ return;
357
428
  }
358
429
  catch {
359
- return; // redis 出错,放弃刷新,下次读取再试
430
+ return;
360
431
  }
361
432
  try {
433
+ cacheLog(`${opts.key} refresh start...`);
362
434
  const result = await opts.fn();
363
- // 写新值(setMethodCache 内部会清除 stale 标志 s=1
364
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
365
- // 调用缓存更新回调(带 ctx)
366
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
367
- cacheLog(`cache ${opts.key} stale background refresh done!`);
435
+ // 序列化新数据(不含 n
436
+ const rawData = (result === null || result === undefined) ? 'null' : JSON.stringify(result);
437
+ const ttlSec = await db.ttl(CacheKey.value(opts.key));
438
+ // 原子地写新数据 + n-1(Lua 脚本内完成)
439
+ let r;
440
+ try {
441
+ r = await db.eval(REFRESH_DECR_LUA, 1, CacheKey.value(opts.key), rawData, String(Math.max(0, ttlSec)), String(Date.now()));
442
+ }
443
+ catch {
444
+ return;
445
+ }
446
+ const [newN, status] = r ?? [0, 0];
447
+ if (status === -2 || status === -1) {
448
+ cacheLog(`${opts.key} refresh skipped (status=${status})`);
449
+ }
450
+ else {
451
+ cacheLog(`${opts.key} refresh done (n → ${newN})`);
452
+ void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
453
+ // n > 0 → 刷新期间又被 clear,继续下一轮刷新
454
+ if (newN > 0 && !globalStaleInflight.has(opts.key)) {
455
+ globalStaleInflight.set(opts.key, staleBackgroundRefresh(opts, setOpts).finally(() => {
456
+ globalStaleInflight.delete(opts.key);
457
+ }));
458
+ }
459
+ }
368
460
  }
369
461
  catch (error) {
370
- globalThis[_LoggerService].warn?.(`cache ${opts.key} stale background refresh failed: ${error?.message}`);
462
+ const msg = error instanceof Error ? error.message : String(error);
463
+ globalThis[_LoggerService].warn?.(`cache ${opts.key} background refresh failed: ${msg}`);
371
464
  }
372
465
  finally {
373
466
  try {
@@ -376,97 +469,167 @@ async function staleBackgroundRefresh(opts, setOpts) {
376
469
  catch { /* TTL 兜底 */ }
377
470
  }
378
471
  }
379
- async function redisCacheCore(opts) {
472
+ /**
473
+ * 执行一个带缓存的方法。先查缓存 → 命中直接返回;未命中则加锁执行 fn 并缓存结果。
474
+ *
475
+ * 支持三种缓存策略(通过 `staleMode` 切换):
476
+ * - **Purge**(默认):清理时物理删除缓存,下次请求阻塞执行 fn 重建
477
+ * - **StaleWhileRevalidate**:清理时保留旧值 + 标记过期,后续请求零阻塞读到旧值,后台异步刷新
478
+ *
479
+ * 防击穿机制:
480
+ * - 缓存 miss 时通过 `[cache-sf]key` 原子锁(SETNX)选出"首飞"执行 fn
481
+ * - 其他并发请求轮询缓存,首飞写完后立即命中,避免重复执行 fn
482
+ *
483
+ * @example <caption>基础用法 — Purge 模式</caption>
484
+ * ```ts
485
+ * const result = await excuteWithCache({
486
+ * key: `user-${userId}`,
487
+ * autoClearTime: 60, // 60分钟后自动过期
488
+ * cacheNullValue: true, // null 也缓存(防穿透)
489
+ * }, () => loadUserFromDB(userId));
490
+ * ```
491
+ *
492
+ * @example <caption>Stale 模式 — 清理时保留旧值 + 后台刷新</caption>
493
+ * ```ts
494
+ * const result = await excuteWithCache({
495
+ * key: `match-${matchId}`,
496
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
497
+ * onCacheUpdated(value) {
498
+ * // 缓存刷新后触发:推送 WebSocket、预热下游等
499
+ * this.broadcast(value);
500
+ * },
501
+ * }, () => computeMatchData(matchId));
502
+ * ```
503
+ *
504
+ * @example <caption>cacheOnly — 只读缓存,不调用 fn</caption>
505
+ * ```ts
506
+ * const cached = await excuteWithCache({
507
+ * key: `config`,
508
+ * cacheOnly: true, // 没有缓存返回 null,不执行 fn
509
+ * }, () => loadConfig());
510
+ * ```
511
+ *
512
+ * @example <caption>forceRefresh — 跳过缓存直接执行 fn</caption>
513
+ * ```ts
514
+ * const fresh = await excuteWithCache({
515
+ * key: `match-${id}`,
516
+ * forceRefresh: true, // 忽略已有缓存,执行 fn 后写回(sa=0)
517
+ * }, () => computeFreshData(id));
518
+ * ```
519
+ *
520
+ * @example <caption>关联清除 — clearKey 级联清理</caption>
521
+ * ```ts
522
+ * const result = await excuteWithCache({
523
+ * key: `match-timer-${id}`,
524
+ * clearKey: ['match'], // clearMethodCache('match') 时级联清除
525
+ * }, () => computeTimer(id));
526
+ *
527
+ * // 清理所有关联了 'match' 的缓存
528
+ * await clearMethodCache('match');
529
+ * ```
530
+ */
531
+ export async function excuteWithCache(config, fn) {
532
+ const callArgs = config.args ?? [];
533
+ const key = typeof config.key === 'function' ? config.key(...callArgs) : config.key;
534
+ const clearKey = typeof config.clearKey === 'function' ? config.clearKey(...callArgs) : config.clearKey;
535
+ const { autoClearTime, cacheNullValue, nullCacheTime, staleMode, onCacheUpdated, ctx, devid, cacheOnly, forceRefresh } = config;
380
536
  const db = getRedisDB();
381
- const cacheKey = CacheKey.value(opts.key);
382
- const raw = await db.get(cacheKey);
383
- if (raw !== null) {
384
- // 一次读取拿到全部信息(值 + stale 状态)
537
+ const cacheKey = CacheKey.value(key);
538
+ /**
539
+ * cache命中后:检查是否 stale→触发后台刷新;最终返回缓存值(新鲜或过期均可)。
540
+ * 单一 keep 变量名:`v` 代表当前缓存值(类型 CacheValue)。
541
+ */
542
+ const readAndMaybeRefresh = async () => {
543
+ const raw = await db.get(cacheKey);
544
+ if (raw === null)
545
+ return null;
385
546
  const v = deserializeCacheValue(raw);
386
- const isStale = v.s === 1;
387
- if (isStale) {
388
- // stale 窗口内:返回旧值 + 触发后台刷新(不阻塞)
389
- cacheLog(`cache ${opts.key} hit (stale t${v.t})!`);
390
- const setOpts = {
391
- clearKey: opts.clearKey,
392
- autoClearTime: opts.autoClearTime,
393
- cacheNullValue: opts.cacheNullValue,
394
- nullCacheTime: opts.nullCacheTime,
395
- staleMode: opts.staleMode
396
- };
397
- if (!globalStaleInflight.has(opts.key)) {
398
- const refreshP = staleBackgroundRefresh(opts, setOpts).finally(() => {
399
- globalStaleInflight.delete(opts.key);
547
+ if (!cacheOnly) {
548
+ // 有未消耗的 clear 计数(n>0)且刷新尚未运行 → 触发后台刷新
549
+ if ((v.n ?? 0) > 0 && !globalStaleInflight.has(key)) {
550
+ const refreshOpts = { key, clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode, fn, ctx, args: callArgs, onCacheUpdated };
551
+ const refreshSetOpts = { clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode };
552
+ const refreshP = staleBackgroundRefresh(refreshOpts, refreshSetOpts).finally(() => {
553
+ globalStaleInflight.delete(key);
400
554
  });
401
- globalStaleInflight.set(opts.key, refreshP);
555
+ globalStaleInflight.set(key, refreshP);
402
556
  }
557
+ cacheLog(`${key} hit (${(v.n ?? 0) > 0 ? `stale n=${v.n}` : 'purge'})`);
403
558
  }
404
559
  else {
405
- cacheLog(`cache ${opts.key} hit (t${v.t})!`);
560
+ cacheLog(`${key} hit (cacheOnly)`);
406
561
  }
407
562
  return JSON.parse(v.d);
408
- }
409
- cacheLog(`cache ${opts.key} miss!`);
410
- const setOpts = {
411
- clearKey: opts.clearKey,
412
- autoClearTime: opts.autoClearTime,
413
- cacheNullValue: opts.cacheNullValue,
414
- nullCacheTime: opts.nullCacheTime,
415
- staleMode: opts.staleMode
416
563
  };
417
- // 用一个独立的"首飞标记" key 选出谁去执行 fn——SET NX PX 是原子操作,
418
- // 同一时刻只有一个调用者拿到 true,其它返回 null。这把"标记"不重入也不计数,
419
- // 只是个 single-flight 指示器,和 redlock / GetRedisLock 的并发计数器不冲突。
420
- const sfKey = CacheKey.sf(opts.key);
564
+ // forceRefresh:跳过缓存读取,直接走执行路径
565
+ if (!forceRefresh) {
566
+ const cached = await readAndMaybeRefresh();
567
+ if (cached !== null)
568
+ return cached;
569
+ if (cacheOnly)
570
+ return null;
571
+ cacheLog(`${key} miss!`);
572
+ }
573
+ else {
574
+ cacheLog(`${key} force refresh start`);
575
+ }
576
+ // forceRefresh 写回时强制 sa=0;其他情况按 staleMode/旧值自动判断
577
+ const setOpts = { clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode, staleAllowedOverride: forceRefresh ? false : undefined };
578
+ const sfKey = CacheKey.sf(key);
421
579
  let isLeader = false;
422
580
  try {
423
581
  const ok = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
424
582
  isLeader = ok === 'OK';
425
583
  }
426
- catch (error) {
427
- // redis 出错:退化为无锁直跑,保留可用性
428
- globalThis[_LoggerService].warn?.(`single-flight setnx ${opts.key} failed: ${error?.message}, fallback to no-lock`);
429
- const result = await opts.fn();
430
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
431
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
584
+ catch {
585
+ const result = await fn();
586
+ await setMethodCache({ key, ...setOpts, result }, devid);
587
+ void onCacheUpdated?.call(ctx, result, ...callArgs);
588
+ cacheLog(`${key} force refresh end(error-force)`);
432
589
  return result;
433
590
  }
434
591
  if (isLeader) {
435
- // 首飞:跑 fn、写缓存、释放首飞标记
436
592
  try {
437
- const result = await opts.fn();
438
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
439
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
593
+ const result = await fn();
594
+ await setMethodCache({ key, ...setOpts, result }, devid);
595
+ void onCacheUpdated?.call(ctx, result, ...callArgs);
596
+ cacheLog(`${key} force refresh end(leader)`);
440
597
  return result;
441
598
  }
442
599
  finally {
443
600
  try {
444
601
  await db.del(sfKey);
445
602
  }
446
- catch {
447
- // 标记 TTL 会兜底,删失败不影响正确性
448
- }
603
+ catch { /* TTL 兜底 */ }
449
604
  }
450
605
  }
451
- // 等待者:循环只看缓存,不抢锁
452
606
  const waitStart = Date.now();
453
607
  while (true) {
454
608
  await sleep(SINGLEFLIGHT_POLL_INTERVAL_MS);
455
609
  const recheck = await db.get(cacheKey);
456
610
  if (recheck !== null) {
457
- cacheLog(`cache ${opts.key} hit after wait!`);
458
- return JSON.parse(deserializeCacheValue(recheck).d);
611
+ const v = deserializeCacheValue(recheck);
612
+ // 拿到数据后二次检查:若仍 stale(刷新期间又被 clear),触发下一轮刷新
613
+ if ((v.n ?? 0) > 0 && !globalStaleInflight.has(key)) {
614
+ const refreshOpts = { key, clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode, fn, ctx, args: callArgs, onCacheUpdated };
615
+ const refreshSetOpts = { clearKey, autoClearTime, cacheNullValue, nullCacheTime, staleMode };
616
+ globalStaleInflight.set(key, staleBackgroundRefresh(refreshOpts, refreshSetOpts).finally(() => {
617
+ globalStaleInflight.delete(key);
618
+ }));
619
+ }
620
+ cacheLog(`${key} hit after wait!`);
621
+ return JSON.parse(v.d);
459
622
  }
460
- // 首飞挂了(TTL 到期,sf 标记自动消失)但缓存仍然空——抢做下一任首飞
461
623
  const leaderGone = await db.get(sfKey);
462
624
  if (leaderGone === null) {
463
625
  const taken = await db.set(sfKey, '1', 'PX', SINGLEFLIGHT_LOCK_TTL_MS, 'NX');
464
626
  if (taken === 'OK') {
465
- cacheLog(`cache ${opts.key}: previous leader gone, taking over`);
627
+ cacheLog(`${key}: previous leader gone, taking over`);
466
628
  try {
467
- const result = await opts.fn();
468
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
469
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
629
+ const result = await fn();
630
+ await setMethodCache({ key, ...setOpts, result }, devid);
631
+ cacheLog(`${key} force refresh end(leader gone)`);
632
+ void onCacheUpdated?.call(ctx, result, ...callArgs);
470
633
  return result;
471
634
  }
472
635
  finally {
@@ -476,101 +639,143 @@ async function redisCacheCore(opts) {
476
639
  catch { /* TTL 兜底 */ }
477
640
  }
478
641
  }
479
- // 没抢到说明又有别人接手了,继续等
480
642
  }
481
- // 等待上限保底:极端情况下首飞和接班都不正常时,自己直接跑,宁可重复执行也不挂死
482
643
  if (Date.now() - waitStart > SINGLEFLIGHT_MAX_WAIT_MS) {
483
- globalThis[_LoggerService].warn?.(`cache ${opts.key}: wait timed out, running fn without lock`);
484
- const result = await opts.fn();
485
- await setMethodCache({ key: opts.key, ...setOpts, result }, opts.devid);
486
- void opts.onCacheUpdated?.call(opts.ctx, result, ...(opts.args ?? []));
644
+ globalThis[_LoggerService].warn?.(`cache ${key}: wait timed out, running fn without lock`);
645
+ const result = await fn();
646
+ await setMethodCache({ key, ...setOpts, result }, devid);
647
+ cacheLog(`${key} force refresh end(recheck)`);
648
+ void onCacheUpdated?.call(ctx, result, ...callArgs);
487
649
  return result;
488
650
  }
489
651
  }
490
652
  }
491
- /** @internal 唯一的执行入口(Memory 后端已移除) */
492
- async function excuteCacheCore(opts) {
493
- return await redisCacheCore(opts);
494
- }
495
653
  /**
496
- * 执行一个方法fn,
497
- * 如果有缓存,则返回缓存,否则【加锁、二次检查】后执行方法并缓存,防止缓存击穿。
498
- * 可选 `cacheNullValue=true` 开启负缓存,防穿透;负缓存默认沿用 autoClearTime,可单独用 nullCacheTime 设短。
499
- * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
500
- */
501
- export async function excuteWithCache(config, fn) {
502
- const key = typeof config.key === 'function' ? config.key() : config.key;
503
- return await excuteCacheCore({
504
- key,
505
- clearKey: config.clearKey,
506
- autoClearTime: config.autoClearTime,
507
- cacheNullValue: config.cacheNullValue,
508
- nullCacheTime: config.nullCacheTime,
509
- staleMode: config.staleMode,
510
- onCacheUpdated: config.onCacheUpdated,
511
- ctx: config.ctx,
512
- args: config.args ?? [],
513
- fn
514
- });
515
- }
516
- /**
517
- * 缓存注解:先查缓存 → miss 才加锁 → 锁内再查一次 → 仍 miss 才执行原方法。
518
- * 已内置 single-flight,不需要再叠 `@MethodLock`。
519
- * 可选 `cacheNullValue=true` 开启负缓存,防穿透。
520
- * 可选 `staleMode: CacheStaleMode.StaleWhileRevalidate` 开启异步重新验证(清理时保留旧值,零阻塞)。
521
- * 可选 `onCacheUpdated` 设置缓存更新回调(带 this 上下文)。
654
+ * 方法装饰器:为方法添加 Redis 缓存能力。
655
+ *
656
+ * 工作流程:
657
+ * 1. 调用方法时先查 Redis 缓存
658
+ * 2. 命中 → 直接返回缓存值(若 stale 则触发后台异步刷新)
659
+ * 3. 未命中 原子锁(SETNX)选出"首飞"执行原方法 → 写缓存 → 返回
660
+ * 4. 其他并发请求轮询缓存,首飞写完后立即命中
661
+ *
662
+ * @example <caption>基础用法 — Purge 模式(默认)</caption>
663
+ * ```ts
664
+ * @MethodCache({
665
+ * key: (userId: string) => `user-${userId}`,
666
+ * autoClearTime: 60, // 60分钟后自动过期
667
+ * })
668
+ * async getUser(userId: string) {
669
+ * return await this.db.query(...);
670
+ * }
671
+ * ```
672
+ *
673
+ * @example <caption>Stale 模式 — 清理时保留旧值 + 后台刷新</caption>
674
+ * ```ts
675
+ * @MethodCache({
676
+ * key: (matchId: string) => `match-${matchId}`,
677
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
678
+ * onCacheUpdated(this: MyService, value) {
679
+ * // 缓存刷新后触发(this = 当前 service 实例)
680
+ * this.webSocket.broadcast(value);
681
+ * },
682
+ * })
683
+ * async getMatch(matchId: string) { ... }
684
+ *
685
+ * // 清理缓存 → 旧值保留,后台异步刷新
686
+ * await clearMethodCache(`match-${id}`);
687
+ * ```
688
+ *
689
+ * @example <caption>关联清除 — clearKey 级联清理</caption>
690
+ * ```ts
691
+ * @MethodCache({
692
+ * key: (matchId: string) => `match-timer-${matchId}`,
693
+ * clearKey: ['match'], // 声明依赖关系
694
+ * staleMode: CacheStaleMode.StaleWhileRevalidate,
695
+ * })
696
+ * async getMatchTimer(matchId: string) { ... }
697
+ *
698
+ * // 清理所有关联了 'match' 的缓存(包括 match-timer-*)
699
+ * await clearMethodCache('match');
700
+ * ```
701
+ *
702
+ * @example <caption>cacheOnly — 只读缓存,绝不调用原方法</caption>
703
+ * ```ts
704
+ * @MethodCache({
705
+ * key: 'app-config',
706
+ * cacheOnly: true, // 没有缓存返回 null
707
+ * })
708
+ * async loadConfig() { ... }
709
+ * ```
522
710
  */
523
711
  export function MethodCache(config) {
524
712
  return function (_target, _propertyKey, descriptor) {
525
713
  const fn = descriptor.value;
526
714
  descriptor.value = async function (...args) {
527
- const key = typeof config.key === 'function' ? config.key.call(this, ...args) : config.key;
528
- const clearKey = config.clearKey
529
- ? (typeof config.clearKey === 'function' ? config.clearKey.call(this, ...args) : config.clearKey)
530
- : undefined;
531
715
  const devid = config.clearWithSession && this.ctx && this.ctx.me && this.ctx.me.devid;
532
- return await excuteCacheCore({
533
- key,
534
- clearKey,
716
+ return await excuteWithCache({
717
+ key: config.key,
718
+ clearKey: config.clearKey,
535
719
  autoClearTime: config.autoClearTime,
536
720
  cacheNullValue: config.cacheNullValue,
537
721
  nullCacheTime: config.nullCacheTime,
538
722
  staleMode: config.staleMode,
539
723
  onCacheUpdated: config.onCacheUpdated,
540
- ctx: this, // 传递当前 this 上下文
541
- args, // 传递方法输入参数
724
+ cacheOnly: config.cacheOnly,
725
+ forceRefresh: config.forceRefresh,
726
+ ctx: this,
542
727
  devid,
543
- fn: async () => await fn.call(this, ...args)
544
- });
728
+ args,
729
+ }, async () => await fn.call(this, ...args));
545
730
  };
546
731
  };
547
732
  }
548
733
  /**
549
- * 外部直接设置缓存值。用于绕过 @MethodCache 装饰器直接更新缓存的场景
550
- * (如外部事件驱动的数据变更、手动刷新等)。
734
+ * 外部直接写入缓存(绕过 @MethodCache 装饰器)。
551
735
  *
552
- * 内部使用统一格式 { t, d, s, sa } 序列化,确保与 @MethodCache 的读取逻辑兼容。
736
+ * @MethodCache 共享同一套存储格式 `{ t, d, sa, n }`,写入的缓存可被
737
+ * @MethodCache 装饰的方法正常读取(包括 stale 判断、计数器等)。
553
738
  *
554
- * @param cacheKey 缓存 key(不含 [cache] 前缀,与 @MethodCache 的 key 一致)
555
- * @param value 要缓存的值
556
- * @param options 选项
739
+ * 适用场景:
740
+ * - 外部事件驱动的数据变更(如 MQ 消息、定时任务)
741
+ * - 手动刷新缓存(如管理后台"清除并重建"按钮)
742
+ * - 跨服务同步缓存状态
557
743
  *
558
- * @example
744
+ * @example <caption>基础用法 — 写入缓存</caption>
559
745
  * ```ts
560
- * // 外部事件驱动更新缓存
561
- * await setCache('match-timer-123', timer, { autoClearTime: 120 });
746
+ * await setCache('match-timer-123', timer, {
747
+ * autoClearTime: 120, // 120分钟后过期
748
+ * });
749
+ * ```
562
750
  *
563
- * // 同时触发 onCacheUpdated 回调(如果有)
751
+ * @example <caption>关联清除 注册 clearKey 级联关系</caption>
752
+ * ```ts
564
753
  * await setCache('match-timer-123', timer, {
565
754
  * autoClearTime: 120,
566
- * onCacheUpdated(this: MyService, value) {
567
- * this.broadcast(value);
568
- * }
755
+ * clearKey: ['match'], // clearMethodCache('match') 时级联清除
756
+ * });
757
+ *
758
+ * // 函数式 clearKey(参数来自 options.args)
759
+ * await setCache('match-timer-123', timer, {
760
+ * clearKey: (matchId) => [`match-${matchId}`],
761
+ * args: [matchId],
762
+ * });
763
+ * ```
764
+ *
765
+ * @example <caption>回调 — 缓存写入后触发通知</caption>
766
+ * ```ts
767
+ * await setCache('match-timer-123', timer, {
768
+ * autoClearTime: 120,
769
+ * ctx: this,
770
+ * args: [matchId],
771
+ * onCacheUpdated(value, matchId) {
772
+ * this.webSocket.sendRoom(matchId, { action: 'timer', param: value });
773
+ * },
569
774
  * });
570
775
  * ```
571
776
  */
572
777
  export async function setCache(cacheKey, value, options = {}) {
573
- const { autoClearTime, cacheNullValue = false, onCacheUpdated, ctx, args = [] } = options;
778
+ const { autoClearTime, cacheNullValue = false, clearKey, onCacheUpdated, ctx, args = [] } = options;
574
779
  const isNullish = value === null || value === undefined;
575
780
  if (isNullish && !cacheNullValue) {
576
781
  return;
@@ -580,14 +785,24 @@ export async function setCache(cacheKey, value, options = {}) {
580
785
  const db = getRedisDB();
581
786
  // 保留旧 sa:沿用已有标志(防止外部直写清掉 sa=1,导致 clearMethodCache 变 purge)
582
787
  const existingSa = await db.get(CacheKey.value(cacheKey)).then(r => r ? deserializeCacheValue(r).sa : undefined);
583
- const payload = serializeCacheValue(dataStr, timestamp, false, !!existingSa);
788
+ const payload = serializeCacheValue(dataStr, timestamp, !!existingSa);
584
789
  if (autoClearTime) {
585
790
  await db.set(CacheKey.value(cacheKey), payload, 'EX', autoClearTime * 60);
586
791
  }
587
792
  else {
588
793
  await db.set(CacheKey.value(cacheKey), payload);
589
794
  }
795
+ // 注册关联清除关系(与 setMethodCache 一致)
796
+ if (clearKey) {
797
+ const clearKeys = typeof clearKey === 'function' ? clearKey(...args) : clearKey;
798
+ if (clearKeys && clearKeys.length > 0) {
799
+ for (const clear of clearKeys) {
800
+ await db.sadd(CacheKey.parent(clear), cacheKey);
801
+ await db.sadd(CacheKey.child(cacheKey), clear);
802
+ }
803
+ }
804
+ }
590
805
  // 触发回调
591
806
  void onCacheUpdated?.call(ctx, value, ...args);
592
- cacheLog(`cache ${cacheKey} externally set (t${timestamp})`);
807
+ cacheLog(`${cacheKey} externally set`);
593
808
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baja-lite",
3
- "version": "1.8.11",
3
+ "version": "1.8.12",
4
4
  "description": "some util for self",
5
5
  "homepage": "https://github.com/void-soul/baja-lite",
6
6
  "repository": {