@taicode/common-server 3.7.10 → 3.7.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.
package/output/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './schema/index.js';
2
2
  export * from './redis-queue/index.js';
3
+ export * from './redis-cache/index.js';
3
4
  export * from './rate-limiter/index.js';
4
5
  export * from './context/index.js';
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AACA,cAAc,mBAAmB,CAAA;AACjC,cAAc,wBAAwB,CAAA;AACtC,cAAc,yBAAyB,CAAA;AACvC,cAAc,oBAAoB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AACA,cAAc,mBAAmB,CAAA;AACjC,cAAc,wBAAwB,CAAA;AACtC,cAAc,wBAAwB,CAAA;AACtC,cAAc,yBAAyB,CAAA;AACvC,cAAc,oBAAoB,CAAA"}
package/output/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // 根 index.ts,统一导出 mock 目录下所有能力
2
2
  export * from './schema/index.js';
3
3
  export * from './redis-queue/index.js';
4
+ export * from './redis-cache/index.js';
4
5
  export * from './rate-limiter/index.js';
5
6
  export * from './context/index.js';
@@ -1,4 +1,5 @@
1
1
  export * from './types.js';
2
2
  export * from './rate-limiter.js';
3
3
  export * from './memory-store.js';
4
+ export * from './redis-store.js';
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../source/rate-limiter/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../source/rate-limiter/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA;AACjC,cAAc,kBAAkB,CAAA"}
@@ -1,3 +1,4 @@
1
1
  export * from './types.js';
2
2
  export * from './rate-limiter.js';
3
3
  export * from './memory-store.js';
4
+ export * from './redis-store.js';
@@ -0,0 +1,45 @@
1
+ import { type RedisClientType } from 'redis';
2
+ import type { RateLimitInfo, RateLimitStore } from './types.js';
3
+ export interface RedisRateLimitStoreOptions {
4
+ /** 键前缀,避免与其他用途的键混在一起 */
5
+ prefix?: string;
6
+ /** Redis 连接串;与 redisClient 二选一 */
7
+ redisUrl?: string;
8
+ /** 外部 Redis 客户端;与 redisUrl 二选一 */
9
+ redisClient?: RedisClientType;
10
+ /**
11
+ * Redis 不可用时的降级存储,默认使用进程内 MemoryRateLimitStore。
12
+ * 刻意不降级为「不限流」:多实例之间不再共享计数已经是能力损失,
13
+ * 但单实例内仍然能挡住突发,比彻底放行安全得多。
14
+ */
15
+ fallback?: RateLimitStore;
16
+ }
17
+ /**
18
+ * 基于 Redis 的限流存储。
19
+ *
20
+ * 与 MemoryRateLimitStore 保持完全相同的「固定窗口」语义:
21
+ * 窗口在第一次请求时开启,窗口结束时间写入存储并随窗口一起过期。
22
+ * 区别只在于计数放在 Redis 里,因此多副本部署下 limit 是全局生效的
23
+ * (进程内计数在 N 个副本下等于把 limit 放大了 N 倍)。
24
+ *
25
+ * 读改写之间存在极小的竞态窗口(并发请求可能少计一次),
26
+ * 对限流来说这种近似完全可接受,换来的是无需 Lua 脚本的简单实现。
27
+ */
28
+ export declare class RedisRateLimitStore implements RateLimitStore {
29
+ private readonly prefix;
30
+ private readonly redis;
31
+ private readonly isExternalRedis;
32
+ private readonly fallback;
33
+ private lastErrorLogTime;
34
+ constructor(options?: RedisRateLimitStoreOptions);
35
+ connect(): Promise<void>;
36
+ disconnect(): void;
37
+ get(key: string): Promise<RateLimitInfo | null>;
38
+ set(key: string, info: RateLimitInfo): Promise<void>;
39
+ delete(key: string): Promise<void>;
40
+ /** Redis 的键自带过期时间,无需额外清理 */
41
+ cleanup(): Promise<void>;
42
+ private buildKey;
43
+ private logError;
44
+ }
45
+ //# sourceMappingURL=redis-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-store.d.ts","sourceRoot":"","sources":["../../source/rate-limiter/redis-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,MAAM,OAAO,CAAA;AAG1D,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAE/D,MAAM,WAAW,0BAA0B;IACzC,wBAAwB;IACxB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,kCAAkC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kCAAkC;IAClC,WAAW,CAAC,EAAE,eAAe,CAAA;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,cAAc,CAAA;CAC1B;AAED;;;;;;;;;;GAUG;AACH,qBAAa,mBAAoB,YAAW,cAAc;IACxD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;IACrD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAiB;IACjD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IAEzC,OAAO,CAAC,gBAAgB,CAAI;gBAEhB,OAAO,GAAE,0BAA+B;IAyBvC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAK9B,UAAU,IAAI,IAAI;IAKZ,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAqB/C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBpD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAY/C,4BAA4B;IACf,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAIrC,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,QAAQ;CAOjB"}
@@ -0,0 +1,114 @@
1
+ import { createClient } from 'redis';
2
+ import { logger } from '@taicode/common-base';
3
+ import { MemoryRateLimitStore } from './memory-store.js';
4
+ /**
5
+ * 基于 Redis 的限流存储。
6
+ *
7
+ * 与 MemoryRateLimitStore 保持完全相同的「固定窗口」语义:
8
+ * 窗口在第一次请求时开启,窗口结束时间写入存储并随窗口一起过期。
9
+ * 区别只在于计数放在 Redis 里,因此多副本部署下 limit 是全局生效的
10
+ * (进程内计数在 N 个副本下等于把 limit 放大了 N 倍)。
11
+ *
12
+ * 读改写之间存在极小的竞态窗口(并发请求可能少计一次),
13
+ * 对限流来说这种近似完全可接受,换来的是无需 Lua 脚本的简单实现。
14
+ */
15
+ export class RedisRateLimitStore {
16
+ prefix;
17
+ redis = null;
18
+ isExternalRedis = false;
19
+ fallback;
20
+ lastErrorLogTime = 0;
21
+ constructor(options = {}) {
22
+ if (options.redisUrl && options.redisClient) {
23
+ throw new Error('[RedisRateLimitStore] Cannot specify both redisUrl and redisClient');
24
+ }
25
+ this.prefix = options.prefix ?? 'rate-limit:';
26
+ this.fallback = options.fallback ?? new MemoryRateLimitStore();
27
+ if (options.redisClient) {
28
+ this.redis = options.redisClient;
29
+ this.isExternalRedis = true;
30
+ }
31
+ else if (options.redisUrl) {
32
+ this.redis = createClient({
33
+ url: options.redisUrl,
34
+ socket: {
35
+ connectTimeout: 5_000,
36
+ reconnectStrategy: (retries) => Math.min(retries * 200, 3_000),
37
+ },
38
+ });
39
+ this.redis.on('error', (error) => {
40
+ logger.error('[RedisRateLimitStore] Redis Client Error:', error);
41
+ });
42
+ }
43
+ }
44
+ async connect() {
45
+ if (this.redis === null || this.isExternalRedis || this.redis.isOpen)
46
+ return;
47
+ await this.redis.connect();
48
+ }
49
+ disconnect() {
50
+ if (this.redis === null || this.isExternalRedis || !this.redis.isOpen)
51
+ return;
52
+ this.redis.disconnect();
53
+ }
54
+ async get(key) {
55
+ if (!this.redis?.isReady) {
56
+ return this.fallback.get(key);
57
+ }
58
+ try {
59
+ const raw = await this.redis.get(this.buildKey(key));
60
+ if (raw === null)
61
+ return null;
62
+ const info = JSON.parse(raw);
63
+ return {
64
+ count: info.count,
65
+ windowStart: new Date(info.windowStart),
66
+ windowEnd: new Date(info.windowEnd),
67
+ };
68
+ }
69
+ catch (error) {
70
+ this.logError('get', error);
71
+ return this.fallback.get(key);
72
+ }
73
+ }
74
+ async set(key, info) {
75
+ if (!this.redis?.isReady) {
76
+ return this.fallback.set(key, info);
77
+ }
78
+ const ttlSeconds = Math.ceil((info.windowEnd.getTime() - Date.now()) / 1000);
79
+ if (ttlSeconds <= 0)
80
+ return;
81
+ try {
82
+ await this.redis.set(this.buildKey(key), JSON.stringify(info), { EX: ttlSeconds });
83
+ }
84
+ catch (error) {
85
+ this.logError('set', error);
86
+ await this.fallback.set(key, info);
87
+ }
88
+ }
89
+ async delete(key) {
90
+ await this.fallback.delete(key);
91
+ if (!this.redis?.isReady)
92
+ return;
93
+ try {
94
+ await this.redis.del(this.buildKey(key));
95
+ }
96
+ catch (error) {
97
+ this.logError('delete', error);
98
+ }
99
+ }
100
+ /** Redis 的键自带过期时间,无需额外清理 */
101
+ async cleanup() {
102
+ await this.fallback.cleanup?.();
103
+ }
104
+ buildKey(key) {
105
+ return `${this.prefix}${key}`;
106
+ }
107
+ logError(operation, error) {
108
+ const now = Date.now();
109
+ if (now - this.lastErrorLogTime < 30_000)
110
+ return;
111
+ this.lastErrorLogTime = now;
112
+ logger.error(`[RedisRateLimitStore] ${operation} failed, fallback to memory store:`, error);
113
+ }
114
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=redis-store.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-store.test.d.ts","sourceRoot":"","sources":["../../source/rate-limiter/redis-store.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,70 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { createRedisClient } from '../redis-cache/redis-cache.js';
3
+ import { RateLimiter } from './rate-limiter.js';
4
+ import { RedisRateLimitStore } from './redis-store.js';
5
+ const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
6
+ describe('RedisRateLimitStore', () => {
7
+ let redis;
8
+ let store;
9
+ beforeEach(async () => {
10
+ redis = createRedisClient({ url: REDIS_URL });
11
+ await redis.connect();
12
+ store = new RedisRateLimitStore({ prefix: 'test:rate-limit:', redisClient: redis });
13
+ await store.delete('ip:1.2.3.4');
14
+ });
15
+ afterEach(async () => {
16
+ await store.delete('ip:1.2.3.4');
17
+ if (redis.isOpen)
18
+ redis.disconnect();
19
+ });
20
+ it('应该在窗口内累计计数', async () => {
21
+ await store.set('ip:1.2.3.4', {
22
+ count: 1,
23
+ windowStart: new Date(),
24
+ windowEnd: new Date(Date.now() + 60_000),
25
+ });
26
+ const info = await store.get('ip:1.2.3.4');
27
+ expect(info?.count).toBe(1);
28
+ });
29
+ it('应该让 RateLimiter 在多实例间共享计数', async () => {
30
+ const anotherStore = new RedisRateLimitStore({ prefix: 'test:rate-limit:', redisClient: redis });
31
+ const limiterA = new RateLimiter({ store });
32
+ const limiterB = new RateLimiter({ store: anotherStore });
33
+ const config = { limit: 2, window: 60 };
34
+ expect((await limiterA.checkLimit('ip:1.2.3.4', config)).allowed).toBe(true);
35
+ // 另一个「实例」能看到前一个实例的计数,因此第三次就被拦下
36
+ expect((await limiterB.checkLimit('ip:1.2.3.4', config)).allowed).toBe(true);
37
+ const third = await limiterA.checkLimit('ip:1.2.3.4', config);
38
+ expect(third.allowed).toBe(false);
39
+ expect(third.remaining).toBe(0);
40
+ });
41
+ it('应该为限流键设置与窗口一致的过期时间', async () => {
42
+ await store.set('ip:1.2.3.4', {
43
+ count: 1,
44
+ windowStart: new Date(),
45
+ windowEnd: new Date(Date.now() + 30_000),
46
+ });
47
+ const ttl = await redis.ttl('test:rate-limit:ip:1.2.3.4');
48
+ expect(ttl).toBeGreaterThan(0);
49
+ expect(ttl).toBeLessThanOrEqual(30);
50
+ });
51
+ it('窗口已过期时不应写入', async () => {
52
+ await store.set('ip:1.2.3.4', {
53
+ count: 1,
54
+ windowStart: new Date(Date.now() - 120_000),
55
+ windowEnd: new Date(Date.now() - 60_000),
56
+ });
57
+ expect(await redis.get('test:rate-limit:ip:1.2.3.4')).toBeNull();
58
+ });
59
+ it('Redis 不可用时应降级到进程内存储而不是放行', async () => {
60
+ redis.disconnect();
61
+ const result = await store.get('ip:1.2.3.4');
62
+ expect(result).toBeNull();
63
+ await store.set('ip:1.2.3.4', {
64
+ count: 3,
65
+ windowStart: new Date(),
66
+ windowEnd: new Date(Date.now() + 60_000),
67
+ });
68
+ expect((await store.get('ip:1.2.3.4'))?.count).toBe(3);
69
+ });
70
+ });
@@ -0,0 +1,4 @@
1
+ export { RedisCache, createRedisClient } from './redis-cache.js';
2
+ export type { RedisCacheL1Options, RedisCacheOptions } from './redis-cache.js';
3
+ export type { RedisClientType } from 'redis';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../source/redis-cache/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAChE,YAAY,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAE9E,YAAY,EAAE,eAAe,EAAE,MAAM,OAAO,CAAA"}
@@ -0,0 +1 @@
1
+ export { RedisCache, createRedisClient } from './redis-cache.js';
@@ -0,0 +1,138 @@
1
+ import { type RedisClientType } from 'redis';
2
+ /**
3
+ * 进程内一级缓存配置。
4
+ *
5
+ * 一级缓存的意义是把「同一个进程内的高频重复读取」压缩到零网络开销:
6
+ * Redis 的一次 GET 大约 0.2~1ms,而进程内查表是纳秒级。
7
+ * 鉴权这类每请求都要走的路径,一级缓存带来的收益非常可观。
8
+ */
9
+ export interface RedisCacheL1Options {
10
+ /**
11
+ * 一级缓存容量(条目数),默认 500。
12
+ * 达到容量后按 LRU 淘汰,因此只需保证「热点」能装下即可。
13
+ */
14
+ maxSize?: number;
15
+ /**
16
+ * 一级缓存存活时间(毫秒),默认 5000。
17
+ *
18
+ * 刻意比二级缓存短一个数量级:跨实例的失效通知依赖 Redis 的键删除,
19
+ * 一级缓存无法被其他实例感知,只能靠短 TTL 自然过期来收敛。
20
+ * 这也意味着一级缓存越短,权限变更生效越快。
21
+ */
22
+ ttlMs?: number;
23
+ }
24
+ export interface RedisCacheOptions {
25
+ /**
26
+ * 键前缀,通常形如 `account:`。
27
+ * 服务名 + 环境一起放进来可以避免不同服务、不同环境互相污染。
28
+ */
29
+ prefix: string;
30
+ /** Redis 连接串;与 redisClient 二选一 */
31
+ redisUrl?: string;
32
+ /** 外部 Redis 客户端(多个缓存共享同一条连接时使用);与 redisUrl 二选一 */
33
+ redisClient?: RedisClientType;
34
+ /** 默认过期时间(秒),默认 300 */
35
+ defaultTtlSeconds?: number;
36
+ /**
37
+ * 空值(null / undefined)的过期时间(秒),默认 10。
38
+ * 空值必须缓存但必须短,否则「删除后立刻重建」的语义会被缓存彻底挡住(缓存穿透)。
39
+ */
40
+ nullTtlSeconds?: number;
41
+ /** 一级缓存配置;传 false 关闭一级缓存,默认为开启 */
42
+ l1?: RedisCacheL1Options | false;
43
+ }
44
+ /**
45
+ * Redis 缓存(可选带进程内一级缓存)。
46
+ *
47
+ * 设计原则:
48
+ * 1. **缓存故障绝不影响业务**:所有方法都不会抛出异常,Redis 不可用时
49
+ * 自动退化为「不带缓存的直读」或「仅一级缓存」,调用方无需 try/catch。
50
+ * 2. **一级缓存是可选加速层**:`l1` 关闭时退化为纯 Redis 缓存;
51
+ * 不传 `redisUrl`/`redisClient` 时退化为纯一级缓存(单测里就这么用)。
52
+ * 3. **空值也缓存**:防止不存在的 key 每次穿透到数据库。
53
+ * 4. **TTL 带抖动**:避免同一时刻写入的大量键同时过期,形成瞬时数据库尖峰。
54
+ */
55
+ export declare class RedisCache {
56
+ private readonly prefix;
57
+ private readonly redis;
58
+ private readonly isExternalRedis;
59
+ private readonly defaultTtlSeconds;
60
+ private readonly nullTtlSeconds;
61
+ private readonly l1;
62
+ private readonly l1TtlMs;
63
+ /** 请求合并:同一个 key 的并发 miss 只触发一次 loader,防止缓存击穿 */
64
+ private readonly inFlight;
65
+ private lastErrorLogTime;
66
+ constructor(options: RedisCacheOptions);
67
+ /** 一级缓存条目数(未开启时为 0) */
68
+ get size(): number;
69
+ /** 一级缓存容量(未开启时为 0) */
70
+ get capacity(): number;
71
+ /** 二级缓存是否可用。不可用时所有操作都会安静地退化为直读,不会抛错。 */
72
+ get remoteReady(): boolean;
73
+ /**
74
+ * 建立连接。
75
+ * 外部传入的客户端由持有者负责连接,这里直接返回。
76
+ */
77
+ connect(): Promise<void>;
78
+ /** 断开连接(外部分享的客户端不会被断开) */
79
+ disconnect(): void;
80
+ /**
81
+ * 释放实例资源。
82
+ *
83
+ * 只清理进程内的东西,**不会**动 Redis:二级缓存是多实例共享的,
84
+ * 某个进程退出不应该把别人的缓存一起清掉。
85
+ */
86
+ dispose(): Promise<void>;
87
+ /**
88
+ * 清空一级缓存(同步),并尽力异步清空二级缓存里该前缀下的全部键。
89
+ * 二级缓存的清理不阻塞调用方,失败也不影响结果。
90
+ */
91
+ clear(): void;
92
+ get<T>(key: string): Promise<T | null>;
93
+ set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
94
+ delete(key: string): Promise<void>;
95
+ /**
96
+ * 按前缀批量删除。传空字符串表示清空当前缓存实例的全部键。
97
+ *
98
+ * 用于「改了一批数据,相关的缓存全部作废」的场景:
99
+ * 与其维护精细的依赖关系,不如按命名空间整体作废——
100
+ * 这类写操作本身频率很低,一次 SCAN 的代价可以接受。
101
+ */
102
+ deleteByPrefix(prefix: string): Promise<number>;
103
+ /**
104
+ * 读缓存,未命中时执行 loader 并回填。
105
+ *
106
+ * 并发调用同一个 key 时只会执行一次 loader(请求合并),
107
+ * 这是鉴权链路上最容易被打穿的场景:热点用户缓存刚失效,
108
+ * 同一瞬间几十个请求同时涌入。
109
+ */
110
+ getOrSet<T>(key: string, loader: () => Promise<T>, ttlSeconds?: number): Promise<T>;
111
+ /**
112
+ * 生成最终键。
113
+ * 注意:调用方传入的 key 里已经带了业务前缀(如 `user:`),
114
+ * 因此这里只负责拼接实例前缀。
115
+ */
116
+ private buildKey;
117
+ /** 以下两个方法接收「已经拼好前缀的最终键」,供 getOrSet 避免重复拼前缀 */
118
+ private getByCacheKey;
119
+ private setByCacheKey;
120
+ private getLocal;
121
+ private setLocal;
122
+ private resolveTtl;
123
+ private getLogPrefix;
124
+ private logError;
125
+ }
126
+ /**
127
+ * 创建 Redis 客户端。
128
+ *
129
+ * 与 RedisQueueCommon 保持同样的连接策略,但补了两点:
130
+ * - 连接超时受限,避免 Redis 不可达时启动流程被拖住;
131
+ * - 重连采用固定上限的退避,避免无意义的密集重试。
132
+ */
133
+ export declare function createRedisClient(options: {
134
+ url: string;
135
+ logPrefix?: string;
136
+ connectTimeoutMs?: number;
137
+ }): RedisClientType;
138
+ //# sourceMappingURL=redis-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-cache.d.ts","sourceRoot":"","sources":["../../source/redis-cache/redis-cache.ts"],"names":[],"mappings":"AACA,OAAO,EAAgB,KAAK,eAAe,EAAE,MAAM,OAAO,CAAA;AAE1D;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAA;IACd,kCAAkC;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kDAAkD;IAClD,WAAW,CAAC,EAAE,eAAe,CAAA;IAC7B,uBAAuB;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,kCAAkC;IAClC,EAAE,CAAC,EAAE,mBAAmB,GAAG,KAAK,CAAA;CACjC;AAaD;;;;;;;;;;GAUG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;IACrD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAiB;IACjD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAQ;IAC1C,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IAEvC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAA0B;IAC7C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAEhC,gDAAgD;IAChD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsC;IAE/D,OAAO,CAAC,gBAAgB,CAAI;gBAEhB,OAAO,EAAE,iBAAiB;IA6BtC,uBAAuB;IACvB,IAAW,IAAI,IAAI,MAAM,CAExB;IAED,sBAAsB;IACtB,IAAW,QAAQ,IAAI,MAAM,CAE5B;IAED,wCAAwC;IACxC,IAAW,WAAW,IAAI,OAAO,CAEhC;IAED;;;OAGG;IACU,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAOrC,0BAA0B;IACnB,UAAU,IAAI,IAAI;IAOzB;;;;;OAKG;IACU,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAMrC;;;OAGG;IACI,KAAK,IAAI,IAAI;IAQP,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAItC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAa/C;;;;;;OAMG;IACU,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAwB5D;;;;;;OAMG;IACU,QAAQ,CAAC,CAAC,EACrB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACxB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,CAAC,CAAC;IA6Bb;;;;OAIG;IACH,OAAO,CAAC,QAAQ;IAIhB,8CAA8C;YAChC,aAAa;YAmBb,aAAa;IAY3B,OAAO,CAAC,QAAQ;IAahB,OAAO,CAAC,QAAQ;IAUhB,OAAO,CAAC,UAAU;IAalB,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,QAAQ;CAOjB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B,GAAG,eAAe,CAkBlB"}
@@ -0,0 +1,289 @@
1
+ import { LruCache, logger } from '@taicode/common-base';
2
+ import { createClient } from 'redis';
3
+ /** 单次日志的最小间隔(毫秒),避免 Redis 故障时刷爆日志 */
4
+ const ERROR_LOG_INTERVAL_MS = 30_000;
5
+ /** 删除键时的批大小 */
6
+ const SCAN_COUNT = 200;
7
+ /**
8
+ * Redis 缓存(可选带进程内一级缓存)。
9
+ *
10
+ * 设计原则:
11
+ * 1. **缓存故障绝不影响业务**:所有方法都不会抛出异常,Redis 不可用时
12
+ * 自动退化为「不带缓存的直读」或「仅一级缓存」,调用方无需 try/catch。
13
+ * 2. **一级缓存是可选加速层**:`l1` 关闭时退化为纯 Redis 缓存;
14
+ * 不传 `redisUrl`/`redisClient` 时退化为纯一级缓存(单测里就这么用)。
15
+ * 3. **空值也缓存**:防止不存在的 key 每次穿透到数据库。
16
+ * 4. **TTL 带抖动**:避免同一时刻写入的大量键同时过期,形成瞬时数据库尖峰。
17
+ */
18
+ export class RedisCache {
19
+ prefix;
20
+ redis = null;
21
+ isExternalRedis = false;
22
+ defaultTtlSeconds;
23
+ nullTtlSeconds;
24
+ l1;
25
+ l1TtlMs;
26
+ /** 请求合并:同一个 key 的并发 miss 只触发一次 loader,防止缓存击穿 */
27
+ inFlight = new Map();
28
+ lastErrorLogTime = 0;
29
+ constructor(options) {
30
+ if (!options.prefix) {
31
+ throw new Error('[RedisCache] prefix is required');
32
+ }
33
+ if (options.redisUrl && options.redisClient) {
34
+ throw new Error('[RedisCache] Cannot specify both redisUrl and redisClient');
35
+ }
36
+ this.prefix = options.prefix;
37
+ this.defaultTtlSeconds = options.defaultTtlSeconds ?? 300;
38
+ this.nullTtlSeconds = options.nullTtlSeconds ?? 10;
39
+ if (options.l1 !== false) {
40
+ const l1Options = options.l1 ?? {};
41
+ this.l1TtlMs = l1Options.ttlMs ?? 5_000;
42
+ this.l1 = new LruCache(l1Options.maxSize ?? 500);
43
+ }
44
+ else {
45
+ this.l1 = null;
46
+ this.l1TtlMs = 0;
47
+ }
48
+ if (options.redisClient) {
49
+ this.redis = options.redisClient;
50
+ this.isExternalRedis = true;
51
+ }
52
+ else if (options.redisUrl) {
53
+ this.redis = createRedisClient({ url: options.redisUrl, logPrefix: this.getLogPrefix() });
54
+ }
55
+ }
56
+ /** 一级缓存条目数(未开启时为 0) */
57
+ get size() {
58
+ return this.l1?.size ?? 0;
59
+ }
60
+ /** 一级缓存容量(未开启时为 0) */
61
+ get capacity() {
62
+ return this.l1?.capacity ?? 0;
63
+ }
64
+ /** 二级缓存是否可用。不可用时所有操作都会安静地退化为直读,不会抛错。 */
65
+ get remoteReady() {
66
+ return this.redis === null || this.redis.isReady;
67
+ }
68
+ /**
69
+ * 建立连接。
70
+ * 外部传入的客户端由持有者负责连接,这里直接返回。
71
+ */
72
+ async connect() {
73
+ if (this.redis === null || this.isExternalRedis)
74
+ return;
75
+ if (this.redis.isOpen)
76
+ return;
77
+ await this.redis.connect();
78
+ }
79
+ /** 断开连接(外部分享的客户端不会被断开) */
80
+ disconnect() {
81
+ if (this.redis === null || this.isExternalRedis)
82
+ return;
83
+ if (!this.redis.isOpen)
84
+ return;
85
+ this.redis.disconnect();
86
+ }
87
+ /**
88
+ * 释放实例资源。
89
+ *
90
+ * 只清理进程内的东西,**不会**动 Redis:二级缓存是多实例共享的,
91
+ * 某个进程退出不应该把别人的缓存一起清掉。
92
+ */
93
+ async dispose() {
94
+ this.l1?.clear();
95
+ this.inFlight.clear();
96
+ this.disconnect();
97
+ }
98
+ /**
99
+ * 清空一级缓存(同步),并尽力异步清空二级缓存里该前缀下的全部键。
100
+ * 二级缓存的清理不阻塞调用方,失败也不影响结果。
101
+ */
102
+ clear() {
103
+ this.l1?.clear();
104
+ if (this.redis?.isReady) {
105
+ void this.deleteByPrefix('');
106
+ }
107
+ }
108
+ async get(key) {
109
+ return await this.getByCacheKey(this.buildKey(key));
110
+ }
111
+ async set(key, value, ttlSeconds) {
112
+ await this.setByCacheKey(this.buildKey(key), value, ttlSeconds);
113
+ }
114
+ async delete(key) {
115
+ const cacheKey = this.buildKey(key);
116
+ this.l1?.delete(cacheKey);
117
+ if (!this.redis?.isReady)
118
+ return;
119
+ try {
120
+ await this.redis.del(cacheKey);
121
+ }
122
+ catch (error) {
123
+ this.logError('delete', cacheKey, error);
124
+ }
125
+ }
126
+ /**
127
+ * 按前缀批量删除。传空字符串表示清空当前缓存实例的全部键。
128
+ *
129
+ * 用于「改了一批数据,相关的缓存全部作废」的场景:
130
+ * 与其维护精细的依赖关系,不如按命名空间整体作废——
131
+ * 这类写操作本身频率很低,一次 SCAN 的代价可以接受。
132
+ */
133
+ async deleteByPrefix(prefix) {
134
+ this.l1?.clear();
135
+ if (!this.redis?.isReady)
136
+ return 0;
137
+ const match = `${this.prefix}${prefix}*`;
138
+ let deleted = 0;
139
+ let cursor = '0';
140
+ try {
141
+ do {
142
+ const result = await this.redis.scan(cursor, { MATCH: match, COUNT: SCAN_COUNT });
143
+ cursor = result.cursor;
144
+ if (result.keys.length > 0) {
145
+ deleted += await this.redis.del(result.keys);
146
+ }
147
+ } while (cursor !== '0');
148
+ }
149
+ catch (error) {
150
+ this.logError('deleteByPrefix', match, error);
151
+ }
152
+ return deleted;
153
+ }
154
+ /**
155
+ * 读缓存,未命中时执行 loader 并回填。
156
+ *
157
+ * 并发调用同一个 key 时只会执行一次 loader(请求合并),
158
+ * 这是鉴权链路上最容易被打穿的场景:热点用户缓存刚失效,
159
+ * 同一瞬间几十个请求同时涌入。
160
+ */
161
+ async getOrSet(key, loader, ttlSeconds) {
162
+ const cacheKey = this.buildKey(key);
163
+ const local = this.getLocal(cacheKey);
164
+ if (local !== undefined)
165
+ return local;
166
+ const remote = await this.getByCacheKey(cacheKey);
167
+ if (remote !== null)
168
+ return remote;
169
+ const pending = this.inFlight.get(cacheKey);
170
+ if (pending !== undefined)
171
+ return await pending;
172
+ const task = (async () => {
173
+ const value = await loader();
174
+ await this.setByCacheKey(cacheKey, value, ttlSeconds);
175
+ return value;
176
+ })();
177
+ this.inFlight.set(cacheKey, task);
178
+ try {
179
+ return await task;
180
+ }
181
+ finally {
182
+ if (this.inFlight.get(cacheKey) === task) {
183
+ this.inFlight.delete(cacheKey);
184
+ }
185
+ }
186
+ }
187
+ /**
188
+ * 生成最终键。
189
+ * 注意:调用方传入的 key 里已经带了业务前缀(如 `user:`),
190
+ * 因此这里只负责拼接实例前缀。
191
+ */
192
+ buildKey(key) {
193
+ return `${this.prefix}${key}`;
194
+ }
195
+ /** 以下两个方法接收「已经拼好前缀的最终键」,供 getOrSet 避免重复拼前缀 */
196
+ async getByCacheKey(cacheKey) {
197
+ const local = this.getLocal(cacheKey);
198
+ if (local !== undefined)
199
+ return local;
200
+ if (!this.redis?.isReady)
201
+ return null;
202
+ try {
203
+ const raw = await this.redis.get(cacheKey);
204
+ if (raw === null)
205
+ return null;
206
+ const value = JSON.parse(raw);
207
+ this.setLocal(cacheKey, value);
208
+ return value;
209
+ }
210
+ catch (error) {
211
+ this.logError('get', cacheKey, error);
212
+ return null;
213
+ }
214
+ }
215
+ async setByCacheKey(cacheKey, value, ttlSeconds) {
216
+ this.setLocal(cacheKey, value);
217
+ if (!this.redis?.isReady)
218
+ return;
219
+ try {
220
+ await this.redis.set(cacheKey, JSON.stringify(value), { EX: this.resolveTtl(value, ttlSeconds) });
221
+ }
222
+ catch (error) {
223
+ this.logError('set', cacheKey, error);
224
+ }
225
+ }
226
+ getLocal(cacheKey) {
227
+ if (this.l1 === null)
228
+ return undefined;
229
+ const entry = this.l1.get(cacheKey);
230
+ if (entry === undefined)
231
+ return undefined;
232
+ if (entry.expiredTime < Date.now()) {
233
+ this.l1.delete(cacheKey);
234
+ return undefined;
235
+ }
236
+ return entry.value;
237
+ }
238
+ setLocal(cacheKey, value) {
239
+ if (this.l1 === null)
240
+ return;
241
+ // 空值不写入一级缓存:一级缓存无法被其他实例失效,
242
+ // 让「不存在」在本地停留可能会显著拖慢新建资源的可见性。
243
+ if (value === null || value === undefined)
244
+ return;
245
+ this.l1.set(cacheKey, { value, expiredTime: Date.now() + this.l1TtlMs });
246
+ }
247
+ resolveTtl(value, ttlSeconds) {
248
+ if (value === null || value === undefined) {
249
+ return this.nullTtlSeconds;
250
+ }
251
+ const base = ttlSeconds ?? this.defaultTtlSeconds;
252
+ // ±10% 抖动:避免大批量键在同毫秒写入后在同一个时刻集体过期
253
+ const jitter = base * (0.9 + Math.random() * 0.2);
254
+ return Math.max(1, Math.round(jitter));
255
+ }
256
+ getLogPrefix() {
257
+ return `RedisCache:${this.prefix}`;
258
+ }
259
+ logError(operation, key, error) {
260
+ const now = Date.now();
261
+ if (now - this.lastErrorLogTime < ERROR_LOG_INTERVAL_MS)
262
+ return;
263
+ this.lastErrorLogTime = now;
264
+ logger.error(`[${this.getLogPrefix()}] ${operation} failed, cache degraded to pass-through:`, key, error);
265
+ }
266
+ }
267
+ /**
268
+ * 创建 Redis 客户端。
269
+ *
270
+ * 与 RedisQueueCommon 保持同样的连接策略,但补了两点:
271
+ * - 连接超时受限,避免 Redis 不可达时启动流程被拖住;
272
+ * - 重连采用固定上限的退避,避免无意义的密集重试。
273
+ */
274
+ export function createRedisClient(options) {
275
+ const logPrefix = options.logPrefix ?? 'Redis';
276
+ const client = createClient({
277
+ url: options.url,
278
+ socket: {
279
+ connectTimeout: options.connectTimeoutMs ?? 5_000,
280
+ reconnectStrategy: (retries) => Math.min(retries * 200, 3_000),
281
+ },
282
+ });
283
+ client.on('error', (error) => {
284
+ logger.error(`[${logPrefix}] Redis Client Error:`, error);
285
+ });
286
+ // 带 socket 配置调用 createClient 时 TS 会把 RESP 推断成联合类型,
287
+ // 与默认的 RESP2 客户端类型不等价,这里收敛回公开的 RedisClientType。
288
+ return client;
289
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=redis-cache.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-cache.test.d.ts","sourceRoot":"","sources":["../../source/redis-cache/redis-cache.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,156 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { RedisCache, createRedisClient } from './redis-cache.js';
3
+ const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
4
+ describe('RedisCache', () => {
5
+ describe('纯一级缓存(未配置 Redis)', () => {
6
+ let cache;
7
+ beforeEach(() => {
8
+ cache = new RedisCache({ prefix: 'test-l1-only:', l1: { maxSize: 2, ttlMs: 1000 } });
9
+ });
10
+ afterEach(async () => {
11
+ await cache.dispose();
12
+ });
13
+ it('应该在未配置 Redis 时退化为纯进程内缓存', async () => {
14
+ await cache.set('a', { value: 1 });
15
+ expect(await cache.get('a')).toEqual({ value: 1 });
16
+ expect(cache.size).toBe(1);
17
+ expect(cache.capacity).toBe(2);
18
+ });
19
+ it('应该按 LRU 容量淘汰', async () => {
20
+ await cache.set('a', 1);
21
+ await cache.set('b', 2);
22
+ await cache.get('a'); // 让 a 变成最近使用
23
+ await cache.set('c', 3);
24
+ expect(await cache.get('b')).toBeNull();
25
+ expect(await cache.get('a')).toBe(1);
26
+ expect(await cache.get('c')).toBe(3);
27
+ });
28
+ it('一级缓存过期后应该重新读取', async () => {
29
+ await cache.set('a', 1);
30
+ expect(await cache.get('a')).toBe(1);
31
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000);
32
+ expect(await cache.get('a')).toBeNull();
33
+ vi.restoreAllMocks();
34
+ });
35
+ it('clear 应该清空一级缓存', async () => {
36
+ await cache.set('a', 1);
37
+ cache.clear();
38
+ expect(cache.size).toBe(0);
39
+ expect(await cache.get('a')).toBeNull();
40
+ });
41
+ it('getOrSet 应该只在未命中时执行 loader', async () => {
42
+ const loader = vi.fn(async () => ({ value: 42 }));
43
+ expect(await cache.getOrSet('key', loader)).toEqual({ value: 42 });
44
+ expect(await cache.getOrSet('key', loader)).toEqual({ value: 42 });
45
+ expect(loader).toHaveBeenCalledTimes(1);
46
+ });
47
+ it('getOrSet 应该与 get/set/delete 共用同一份缓存', async () => {
48
+ await cache.set('shared', { value: 1 });
49
+ const loader = vi.fn(async () => ({ value: 2 }));
50
+ expect(await cache.getOrSet('shared', loader)).toEqual({ value: 1 });
51
+ expect(loader).not.toHaveBeenCalled();
52
+ await cache.delete('shared');
53
+ expect(await cache.getOrSet('shared', loader)).toEqual({ value: 2 });
54
+ expect(loader).toHaveBeenCalledTimes(1);
55
+ });
56
+ it('并发 getOrSet 应该只执行一次 loader', async () => {
57
+ let release;
58
+ const gate = new Promise(resolve => { release = resolve; });
59
+ const loader = vi.fn(async () => {
60
+ await gate;
61
+ return 7;
62
+ });
63
+ const tasks = [cache.getOrSet('key', loader), cache.getOrSet('key', loader), cache.getOrSet('key', loader)];
64
+ release();
65
+ expect(await Promise.all(tasks)).toEqual([7, 7, 7]);
66
+ expect(loader).toHaveBeenCalledTimes(1);
67
+ });
68
+ });
69
+ describe('二级缓存(Redis)', () => {
70
+ let redis;
71
+ let cache;
72
+ let other;
73
+ beforeEach(async () => {
74
+ redis = createRedisClient({ url: REDIS_URL });
75
+ await redis.connect();
76
+ cache = new RedisCache({ prefix: 'test:redis-cache:', redisClient: redis, defaultTtlSeconds: 60 });
77
+ // 另一个实例模拟「另一个进程」,一级缓存不共享,二级缓存共享
78
+ other = new RedisCache({ prefix: 'test:redis-cache:', redisClient: redis, defaultTtlSeconds: 60 });
79
+ await cache.deleteByPrefix('');
80
+ });
81
+ afterEach(async () => {
82
+ await cache.deleteByPrefix('');
83
+ await cache.dispose();
84
+ await other.dispose();
85
+ if (redis.isOpen)
86
+ redis.disconnect();
87
+ });
88
+ it('应该通过 Redis 让不同实例共享缓存', async () => {
89
+ await cache.set('shared', { hello: 'world' });
90
+ expect(await other.get('shared')).toEqual({ hello: 'world' });
91
+ });
92
+ it('Redis 中的键名应该只拼接一次前缀', async () => {
93
+ await cache.getOrSet('user:1', async () => 'ok');
94
+ expect(await redis.keys('test:redis-cache:*')).toEqual(['test:redis-cache:user:1']);
95
+ expect(await other.get('user:1')).toBe('ok');
96
+ });
97
+ it('应该缓存空值以防止穿透', async () => {
98
+ await cache.set('missing', null);
99
+ expect(await other.get('missing')).toBeNull();
100
+ expect(await redis.get('test:redis-cache:missing')).toBe('null');
101
+ });
102
+ it('空值不应该写入一级缓存', async () => {
103
+ await cache.set('missing', null);
104
+ expect(cache.size).toBe(0);
105
+ });
106
+ it('deleteByPrefix 应该只删除本前缀下的键', async () => {
107
+ const otherPrefix = new RedisCache({ prefix: 'test:other-prefix:', redisClient: redis });
108
+ await otherPrefix.deleteByPrefix('');
109
+ await cache.set('user:1', 1);
110
+ await cache.set('user:2', 2);
111
+ await cache.set('role:1', 3);
112
+ await otherPrefix.set('user:1', 99);
113
+ const deleted = await cache.deleteByPrefix('user:');
114
+ expect(deleted).toBe(2);
115
+ expect(await cache.get('user:1')).toBeNull();
116
+ expect(await cache.get('role:1')).toBe(3);
117
+ expect(await otherPrefix.get('user:1')).toBe(99);
118
+ await otherPrefix.deleteByPrefix('');
119
+ await otherPrefix.dispose();
120
+ });
121
+ it('deleteByPrefix 应该同时清空一级缓存', async () => {
122
+ await cache.set('user:1', 1);
123
+ expect(cache.size).toBe(1);
124
+ await cache.deleteByPrefix('user:');
125
+ expect(cache.size).toBe(0);
126
+ expect(await cache.get('user:1')).toBeNull();
127
+ });
128
+ it('clear 应该同时清理一二级缓存', async () => {
129
+ await cache.set('user:1', 1);
130
+ cache.clear();
131
+ expect(cache.size).toBe(0);
132
+ // clear 内部的二级缓存清理是异步的,等待其落地
133
+ await vi.waitFor(async () => {
134
+ expect(await redis.get('test:redis-cache:user:1')).toBeNull();
135
+ });
136
+ });
137
+ it('delete 应该同时删除一二级缓存', async () => {
138
+ await cache.set('user:1', 1);
139
+ await other.get('user:1'); // 让另一个实例也有一级缓存
140
+ await cache.delete('user:1');
141
+ expect(await cache.get('user:1')).toBeNull();
142
+ expect(await redis.get('test:redis-cache:user:1')).toBeNull();
143
+ });
144
+ it('Redis 不可用时应安静降级而不抛错', async () => {
145
+ redis.disconnect();
146
+ await expect(cache.get('anything')).resolves.toBeNull();
147
+ await expect(cache.set('anything', 1)).resolves.toBeUndefined();
148
+ await expect(cache.delete('anything')).resolves.toBeUndefined();
149
+ await expect(cache.deleteByPrefix('')).resolves.toBe(0);
150
+ });
151
+ it('Redis 不可用时 getOrSet 仍应返回 loader 结果', async () => {
152
+ redis.disconnect();
153
+ await expect(cache.getOrSet('k', async () => 'value')).resolves.toBe('value');
154
+ });
155
+ });
156
+ });
@@ -19,7 +19,7 @@ export declare function queryParamsSchema<F extends ZodType, E extends ZodEnum>(
19
19
  ASC: "ASC";
20
20
  DESC: "DESC";
21
21
  }>;
22
- }, z.core.$strip>> | z.ZodUndefined;
22
+ }, z.core.$strip>> | z.ZodOptional<z.ZodUndefined>;
23
23
  }, z.core.$strip>;
24
24
  /**
25
25
  * 查询结果 Schema
@@ -56,7 +56,7 @@ export declare function listResultSchema<T extends ZodType>(t: T): z.ZodObject<{
56
56
  */
57
57
  export declare function rangeQueryParamsSchema<F extends ZodType, E extends ZodEnum>(f: F, e?: E): z.ZodObject<{
58
58
  filter: F;
59
- sort: z.ZodUndefined | z.ZodArray<z.ZodObject<{
59
+ sort: z.ZodOptional<z.ZodUndefined> | z.ZodArray<z.ZodObject<{
60
60
  key: E;
61
61
  order: z.ZodEnum<{
62
62
  ASC: "ASC";
@@ -117,7 +117,7 @@ export declare function fuzzQueryParamsSchema<F extends ZodType, E extends ZodEn
117
117
  size: z.ZodNumber;
118
118
  index: z.ZodNumber;
119
119
  }, z.core.$strip>;
120
- sort: z.ZodUndefined | z.ZodArray<z.ZodObject<{
120
+ sort: z.ZodOptional<z.ZodUndefined> | z.ZodArray<z.ZodObject<{
121
121
  key: E;
122
122
  order: z.ZodEnum<{
123
123
  ASC: "ASC";
@@ -33,7 +33,7 @@ export function queryParamsSchema(f, e) {
33
33
  return z.object({
34
34
  filter: f.describe('过滤条件'),
35
35
  paging: _pagingSchema().describe('分页参数'),
36
- sort: e ? _sortSchema(e).describe('排序条件') : z.undefined(),
36
+ sort: e ? _sortSchema(e).describe('排序条件') : z.undefined().optional().describe('排序条件'),
37
37
  });
38
38
  }
39
39
  /**
@@ -78,7 +78,7 @@ export function listResultSchema(t) {
78
78
  export function rangeQueryParamsSchema(f, e) {
79
79
  return z.object({
80
80
  filter: f.describe('过滤条件'),
81
- sort: e ? _sortSchema(e).describe('排序条件') : z.undefined(),
81
+ sort: e ? _sortSchema(e).describe('排序条件') : z.undefined().optional().describe('排序条件'),
82
82
  range: z.tuple([z.iso.datetime(), z.iso.datetime()]).describe('时间范围,格式为 [开始时间, 结束时间]'),
83
83
  });
84
84
  }
@@ -131,7 +131,7 @@ export function fuzzQueryParamsSchema(f, e) {
131
131
  return z.object({
132
132
  filter: f.describe('模糊搜索关键词,长度1-50字符'),
133
133
  paging: _pagingSchema().describe('分页参数'),
134
- sort: e ? _sortSchema(e).describe('排序条件') : z.undefined(),
134
+ sort: e ? _sortSchema(e).describe('排序条件') : z.undefined().optional().describe('排序条件'),
135
135
  });
136
136
  }
137
137
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taicode/common-server",
3
- "version": "3.7.10",
3
+ "version": "3.7.12",
4
4
  "author": "Alain",
5
5
  "license": "ISC",
6
6
  "description": "",
@@ -27,7 +27,7 @@
27
27
  "fastify": "^5.5.0",
28
28
  "redis": "^5.11.0",
29
29
  "zod": "^4.1.0",
30
- "@taicode/common-base": "^3.7.10"
30
+ "@taicode/common-base": "^3.7.12"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.19.13",
@@ -35,7 +35,7 @@
35
35
  "redis": "^5.11.0",
36
36
  "vitest": "^3.2.4",
37
37
  "zod": "^4.3.6",
38
- "@taicode/common-base": "^3.7.10"
38
+ "@taicode/common-base": "^3.7.12"
39
39
  },
40
40
  "scripts": {
41
41
  "test": "vitest run",