chanjs 2.7.2 → 2.7.3
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/App.js +232 -16
- package/base/Aop.js +20 -3
- package/base/Container.js +80 -3
- package/base/Controller.js +38 -9
- package/base/Database.js +50 -0
- package/base/Event.js +12 -0
- package/base/{Service.js → Repository.js} +644 -539
- package/common/api.js +18 -8
- package/common/code.js +25 -15
- package/common/email.js +98 -17
- package/common/index.js +1 -1
- package/config/code.js +138 -82
- package/global/index.js +1 -1
- package/helper/index.js +43 -41
- package/index.js +19 -6
- package/loader/index.js +6 -0
- package/{helper → loader}/loader.js +41 -27
- package/middleware/compress.js +185 -0
- package/middleware/cors.js +36 -24
- package/middleware/header.js +5 -10
- package/middleware/index.js +1 -0
- package/middleware/log.js +27 -3
- package/middleware/setBody.js +9 -1
- package/middleware/static.js +2 -1
- package/middleware/template.js +139 -4
- package/middleware/waf.js +136 -76
- package/package.json +2 -3
- package/realtime/index.js +7 -0
- package/realtime/sse.js +424 -0
- package/realtime/websocket.js +540 -0
- package/response/index.js +12 -0
- package/response/response.js +258 -0
- package/schedule/index.js +6 -0
- package/schedule/schedule.js +491 -0
- package/{helper → security}/checker.js +23 -8
- package/security/index.js +14 -0
- package/{helper → security}/jwt.js +175 -107
- package/security/keywords.js +179 -0
- package/security/rate-limit.js +105 -0
- package/security/sign.js +210 -0
- package/security/xss-filter.js +63 -0
- package/storage/cache.js +258 -0
- package/storage/index.js +9 -0
- package/storage/redis.js +258 -0
- package/storage/store.js +266 -0
- package/{helper → utils}/file.js +106 -15
- package/{helper → utils}/filter.js +2 -1
- package/{helper → utils}/html.js +19 -1
- package/utils/index.js +34 -0
- package/{helper → utils}/ip.js +25 -16
- package/utils/request.js +172 -0
- package/{helper → utils}/time.js +1 -1
- package/utils/tree.js +121 -0
- package/common/category.js +0 -22
- package/common/sms.js +0 -104
- package/extend/art-template.js +0 -129
- package/extend/index.js +0 -6
- package/global/global.js +0 -63
- package/helper/cache.js +0 -187
- package/helper/keywords.js +0 -132
- package/helper/rate-limit.js +0 -116
- package/helper/request.js +0 -47
- package/helper/response.js +0 -180
- package/helper/sign.js +0 -96
- package/helper/tree.js +0 -77
- package/helper/xss-filter.js +0 -42
- /package/{helper → utils}/data-parse.js +0 -0
package/security/sign.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 加密和签名工具函数
|
|
5
|
+
* 提供安全的 AES-256-GCM 加密解密和 HMAC-SHA256 数据签名
|
|
6
|
+
*
|
|
7
|
+
* 安全改进:
|
|
8
|
+
* 1. 弃用 CryptoJS(默认用 EVP_BytesToKey 派生密钥,安全性不足)
|
|
9
|
+
* 2. 改用 Node.js 原生 crypto 模块,AES-256-GCM 提供机密性 + 完整性
|
|
10
|
+
* 3. 密钥派生用 scrypt(抗暴力破解,NIST 推荐)
|
|
11
|
+
* 4. verifySign 用 crypto.timingSafeEqual 替换 ===,防止时序攻击
|
|
12
|
+
*
|
|
13
|
+
* 兼容性说明:
|
|
14
|
+
* - 新版 AES 密文格式:base64(iv:tag:ciphertext),与 CryptoJS 不兼容
|
|
15
|
+
* - 旧版 Cookie 失效后业务层 catch 会自动重新生成(Share.js 已处理)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 密钥派生缓存(避免每次加解密都派生一遍)
|
|
20
|
+
* 改为 LRU:命中时先 delete 再 set,刷新访问顺序
|
|
21
|
+
*/
|
|
22
|
+
const keyCache = new Map();
|
|
23
|
+
const KEY_CACHE_MAX = 100;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 从用户密钥派生固定长度的 AES-256 密钥
|
|
27
|
+
* 用 scrypt 算法,参数选择 N=16384, r=8, p=1,约 100ms 派生时间,抗暴力破解
|
|
28
|
+
*
|
|
29
|
+
* 安全改进:
|
|
30
|
+
* - salt 优先用 AES_SALT 环境变量(部署期固定),不再从 secret 推导
|
|
31
|
+
* - 未设置 AES_SALT 时降级到原行为并打印警告
|
|
32
|
+
* @private
|
|
33
|
+
*/
|
|
34
|
+
function deriveKey(secret) {
|
|
35
|
+
if (!secret || typeof secret !== 'string') return null;
|
|
36
|
+
if (keyCache.has(secret)) {
|
|
37
|
+
// LRU 命中:先 delete 再 set,刷新到 Map 末尾
|
|
38
|
+
const cached = keyCache.get(secret);
|
|
39
|
+
keyCache.delete(secret);
|
|
40
|
+
keyCache.set(secret, cached);
|
|
41
|
+
return cached;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// salt 优先用 AES_SALT 环境变量;未配置时降级到 secret 哈希
|
|
45
|
+
const aesSalt = process.env.AES_SALT;
|
|
46
|
+
let salt;
|
|
47
|
+
if (aesSalt) {
|
|
48
|
+
salt = crypto.createHash('sha256').update(aesSalt).digest();
|
|
49
|
+
} else {
|
|
50
|
+
if (!deriveKey._warned) {
|
|
51
|
+
console.warn('[安全警告] 未配置 AES_SALT 环境变量,回退到基于 secret 的 salt,建议设置 AES_SALT');
|
|
52
|
+
deriveKey._warned = true;
|
|
53
|
+
}
|
|
54
|
+
salt = crypto.createHash('sha256').update(secret).digest();
|
|
55
|
+
}
|
|
56
|
+
const key = crypto.scryptSync(secret, salt, 32); // AES-256 需要 32 字节密钥
|
|
57
|
+
|
|
58
|
+
// LRU 缓存上限:超过时淘汰 Map 头部(最久未访问)
|
|
59
|
+
if (keyCache.size >= KEY_CACHE_MAX) {
|
|
60
|
+
const oldestKey = keyCache.keys().next().value;
|
|
61
|
+
if (oldestKey !== undefined) keyCache.delete(oldestKey);
|
|
62
|
+
}
|
|
63
|
+
keyCache.set(secret, key);
|
|
64
|
+
return key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* AES 加密(AES-256-GCM)
|
|
69
|
+
* @param {Object|string} obj - 要加密的对象或字符串
|
|
70
|
+
* @param {string} key - 加密密钥(用户提供的密钥,会通过 scrypt 派生)
|
|
71
|
+
* @returns {string|null} 加密后的字符串(base64 编码,格式:iv:tag:ciphertext),失败时返回 null
|
|
72
|
+
* @description
|
|
73
|
+
* 使用 AES-256-GCM 算法加密数据
|
|
74
|
+
* - GCM 模式提供机密性 + 完整性校验
|
|
75
|
+
* - 每次加密生成随机 IV(12 字节),防止相同明文产生相同密文
|
|
76
|
+
* - 输出格式:base64(iv + tag + ciphertext)
|
|
77
|
+
* @example
|
|
78
|
+
* const encrypted = aesEncrypt({ id: 1, name: '张三' }, 'my-secret-key');
|
|
79
|
+
*/
|
|
80
|
+
export const aesEncrypt = (obj, key) => {
|
|
81
|
+
if (!key) {
|
|
82
|
+
console.error('[安全错误] AES_SALT 必须配置');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const derivedKey = deriveKey(key);
|
|
87
|
+
if (!derivedKey) return null;
|
|
88
|
+
|
|
89
|
+
// 随机 IV(12 字节,GCM 推荐长度)
|
|
90
|
+
const iv = crypto.randomBytes(12);
|
|
91
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv);
|
|
92
|
+
|
|
93
|
+
const plaintext = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
|
94
|
+
const encrypted = Buffer.concat([
|
|
95
|
+
cipher.update(plaintext, 'utf8'),
|
|
96
|
+
cipher.final(),
|
|
97
|
+
]);
|
|
98
|
+
const tag = cipher.getAuthTag(); // 16 字节认证标签
|
|
99
|
+
|
|
100
|
+
// 格式:base64(iv + tag + ciphertext)
|
|
101
|
+
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error("AES加密失败:", error.message);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* AES 解密(AES-256-GCM)
|
|
110
|
+
* @param {string} str - 要解密的字符串(base64 编码,格式:iv:tag:ciphertext)
|
|
111
|
+
* @param {string} key - 解密密钥
|
|
112
|
+
* @returns {string|null} 解密后的字符串,失败时返回 null
|
|
113
|
+
* @description
|
|
114
|
+
* 使用 AES-256-GCM 算法解密数据
|
|
115
|
+
* - 自动校验 GCM 认证标签,密文被篡改会抛错
|
|
116
|
+
* - 与 aesEncrypt 配对使用
|
|
117
|
+
* @example
|
|
118
|
+
* const decrypted = aesDecrypt(encryptedStr, 'my-secret-key');
|
|
119
|
+
*/
|
|
120
|
+
export const aesDecrypt = (str, key) => {
|
|
121
|
+
if (!key) {
|
|
122
|
+
console.error('[安全错误] AES_SALT 必须配置');
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const derivedKey = deriveKey(key);
|
|
127
|
+
if (!derivedKey) return null;
|
|
128
|
+
|
|
129
|
+
const data = Buffer.from(str, 'base64');
|
|
130
|
+
// 前 12 字节是 IV,接下来 16 字节是 GCM tag,剩下是密文
|
|
131
|
+
if (data.length < 28) return null; // 12 + 16 最小长度
|
|
132
|
+
const iv = data.subarray(0, 12);
|
|
133
|
+
const tag = data.subarray(12, 28);
|
|
134
|
+
const ciphertext = data.subarray(28);
|
|
135
|
+
|
|
136
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv);
|
|
137
|
+
decipher.setAuthTag(tag);
|
|
138
|
+
|
|
139
|
+
const decrypted = Buffer.concat([
|
|
140
|
+
decipher.update(ciphertext),
|
|
141
|
+
decipher.final(),
|
|
142
|
+
]);
|
|
143
|
+
return decrypted.toString('utf8');
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error("AES解密失败:", error.message);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 数据签名(HMAC-SHA256)
|
|
152
|
+
* @param {Object} data - 要签名的数据对象
|
|
153
|
+
* @param {string} secret - 签名密钥
|
|
154
|
+
* @returns {string|null} 签名字符串(hex 编码),失败时返回 null
|
|
155
|
+
* @description
|
|
156
|
+
* 使用 HMAC-SHA256 算法对数据进行签名
|
|
157
|
+
* 原生 crypto 实现,与 CryptoJS.HmacSHA256 输出格式兼容(hex 字符串)
|
|
158
|
+
* @example
|
|
159
|
+
* const signature = signData({ userId: 123 }, 'my-sign-key');
|
|
160
|
+
*/
|
|
161
|
+
export function signData(data, secret) {
|
|
162
|
+
if (!secret) {
|
|
163
|
+
console.error('[安全错误] 签名密钥必须配置');
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
168
|
+
hmac.update(JSON.stringify(data));
|
|
169
|
+
return hmac.digest('hex');
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error("数据签名失败:", error.message);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 验证数据签名
|
|
178
|
+
* @param {Object} data - 原始数据对象
|
|
179
|
+
* @param {string} signature - 待验证的签名
|
|
180
|
+
* @param {string} secret - 签名密钥
|
|
181
|
+
* @returns {boolean} 签名是否有效
|
|
182
|
+
* @description
|
|
183
|
+
* 重新计算签名并使用 crypto.timingSafeEqual 进行常量时间比对
|
|
184
|
+
* 防止时序攻击:攻击者无法通过响应时间推断签名前缀
|
|
185
|
+
*
|
|
186
|
+
* 安全要点:
|
|
187
|
+
* 1. 两边长度不同直接返回 false(不进入 timingSafeEqual,避免越界)
|
|
188
|
+
* 2. 长度相同才用 timingSafeEqual 进行常量时间比较
|
|
189
|
+
* 3. 返回值不抛异常,只返回 true/false
|
|
190
|
+
* @example
|
|
191
|
+
* const isValid = verifySign({ userId: 123 }, signature, 'my-sign-key');
|
|
192
|
+
*/
|
|
193
|
+
export function verifySign(data, signature, secret) {
|
|
194
|
+
try {
|
|
195
|
+
const computedSign = signData(data, secret);
|
|
196
|
+
if (!computedSign || !signature) return false;
|
|
197
|
+
|
|
198
|
+
// 长度不同直接返回 false(timingSafeEqual 要求两边等长)
|
|
199
|
+
if (computedSign.length !== signature.length) return false;
|
|
200
|
+
|
|
201
|
+
// 常量时间比较,防止时序攻击
|
|
202
|
+
return crypto.timingSafeEqual(
|
|
203
|
+
Buffer.from(computedSign),
|
|
204
|
+
Buffer.from(String(signature))
|
|
205
|
+
);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error("签名验证失败:", error.message);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import xss from 'xss';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* XSS 过滤工具
|
|
5
|
+
* 提供跨站脚本攻击过滤功能
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 过滤 XSS 攻击代码
|
|
10
|
+
* @param {*} data - 要过滤的数据,可以是字符串、数组或对象
|
|
11
|
+
* @returns {*} 过滤后的数据
|
|
12
|
+
* @description
|
|
13
|
+
* 递归过滤数据中的所有字符串值
|
|
14
|
+
* 使用 xss 库清除危险的 HTML 和 JavaScript 代码
|
|
15
|
+
* 支持字符串、数组和对象的递归处理
|
|
16
|
+
*
|
|
17
|
+
* 安全加固(P2 #22):
|
|
18
|
+
* 用 WeakSet 跟踪已访问对象,避免循环引用导致栈溢出
|
|
19
|
+
* - 首次遇到对象时加入 visited
|
|
20
|
+
* - 递归处理子属性时传递 visited
|
|
21
|
+
* - 遇到已访问对象直接返回(断开循环)
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const clean = filterXSS({
|
|
25
|
+
* name: '<script>alert(1)</script>',
|
|
26
|
+
* items: ['<img src=x onerror=alert(1)>']
|
|
27
|
+
* });
|
|
28
|
+
*/
|
|
29
|
+
export function filterXSS(data, visited) {
|
|
30
|
+
// 字符串:xss 过滤
|
|
31
|
+
if (typeof data === 'string') {
|
|
32
|
+
return xss(data);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 数组:递归处理每个元素
|
|
36
|
+
if (Array.isArray(data)) {
|
|
37
|
+
// 数组本身不加入 visited(数组循环引用极少见,且会破坏数组的正常处理)
|
|
38
|
+
// 只跟踪对象的循环引用
|
|
39
|
+
return data.map(item => filterXSS(item, visited));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 对象:递归处理每个属性
|
|
43
|
+
if (data && typeof data === 'object') {
|
|
44
|
+
// 循环引用检测
|
|
45
|
+
if (!visited) visited = new WeakSet();
|
|
46
|
+
if (visited.has(data)) {
|
|
47
|
+
// 已访问过,返回空对象断开循环(保留类型一致性)
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
visited.add(data);
|
|
51
|
+
|
|
52
|
+
const result = {};
|
|
53
|
+
for (const key in data) {
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
55
|
+
result[key] = filterXSS(data[key], visited);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 其他类型(number / boolean / null / undefined / function):原样返回
|
|
62
|
+
return data;
|
|
63
|
+
}
|
package/storage/cache.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* incr 默认 TTL(毫秒)
|
|
3
|
+
* 用于 incr 新建 key 时自动附加过期,避免永久占用内存
|
|
4
|
+
* 与 redis.js 的 DEFAULT_INCR_TTL 保持一致
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_INCR_TTL = 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* LRU(最近最少使用)缓存类
|
|
10
|
+
* @class Cache
|
|
11
|
+
* @description
|
|
12
|
+
* 基于内存的 LRU 缓存,支持 TTL 和自动过期清理。
|
|
13
|
+
* 改进点:
|
|
14
|
+
* 1. 单 Map 实现 LRU(删除再插入保证顺序),避免双 Map 开销
|
|
15
|
+
* 2. 修复 TTL 续期 bug:get 时仅刷新 LRU 顺序,不再续期 TTL(避免热点 key 永不过期)
|
|
16
|
+
* 3. 默认上限 10 万条(约 20MB),可配置
|
|
17
|
+
* 4. 惰性批量清理:set 时按频率触发过期清理,不使用定时器
|
|
18
|
+
* 5. incr/incrAndExpire:自增不刷新 TTL(与 Redis 行为对齐)
|
|
19
|
+
* @example
|
|
20
|
+
* const cache = new Cache({ maxSize: 100000, defaultTTL: 60000 });
|
|
21
|
+
* cache.set('user:123', { name: '张三' }, 30000);
|
|
22
|
+
* const user = cache.get('user:123');
|
|
23
|
+
*/
|
|
24
|
+
class Cache {
|
|
25
|
+
/**
|
|
26
|
+
* 创建缓存实例
|
|
27
|
+
* @param {Object} [options={}] - 配置选项
|
|
28
|
+
* @param {number} [options.maxSize=100000] - 最大缓存条目数,默认 10 万条约 20MB
|
|
29
|
+
* @param {number} [options.defaultTTL=300000] - 默认过期时间(毫秒),默认 5 分钟
|
|
30
|
+
*/
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.map = new Map();
|
|
33
|
+
this.maxSize = options.maxSize || 100000;
|
|
34
|
+
this.defaultTTL = options.defaultTTL || 5 * 60 * 1000;
|
|
35
|
+
// 惰性清理控制
|
|
36
|
+
this._lastCleanup = Date.now();
|
|
37
|
+
this._cleanupInterval = 10 * 1000; // 最少间隔 10 秒
|
|
38
|
+
this._cleanupBatch = 200; // 每次最多检查 200 条
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 设置缓存值
|
|
43
|
+
* @param {string} key - 缓存键
|
|
44
|
+
* @param {*} value - 要缓存的值
|
|
45
|
+
* @param {number} [ttl=this.defaultTTL] - 过期时间(毫秒)
|
|
46
|
+
*/
|
|
47
|
+
set(key, value, ttl = this.defaultTTL) {
|
|
48
|
+
this._maybeCleanup();
|
|
49
|
+
|
|
50
|
+
// 达到上限且是新 key → 淘汰 Map 头部(最久未访问)
|
|
51
|
+
if (this.map.size >= this.maxSize && !this.map.has(key)) {
|
|
52
|
+
this._evictLRU();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 先删除再插入,保证 Map 末尾是最新访问
|
|
56
|
+
this.map.delete(key);
|
|
57
|
+
this.map.set(key, { value, expireAt: Date.now() + ttl });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 获取缓存值
|
|
62
|
+
* @param {string} key - 缓存键
|
|
63
|
+
* @returns {*} 缓存的值,如果键不存在或已过期则返回 null
|
|
64
|
+
* @description
|
|
65
|
+
* 仅刷新 LRU 顺序,不续期 TTL(修复旧版 bug)
|
|
66
|
+
*/
|
|
67
|
+
get(key) {
|
|
68
|
+
const item = this.map.get(key);
|
|
69
|
+
if (!item) return null;
|
|
70
|
+
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (now > item.expireAt) {
|
|
73
|
+
this.map.delete(key);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// LRU:删除再插入,刷新到 Map 末尾
|
|
78
|
+
this.map.delete(key);
|
|
79
|
+
this.map.set(key, item);
|
|
80
|
+
return item.value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 删除缓存条目
|
|
85
|
+
*/
|
|
86
|
+
del(key) {
|
|
87
|
+
return this.map.delete(key);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 清空所有缓存
|
|
92
|
+
*/
|
|
93
|
+
clear() {
|
|
94
|
+
this.map.clear();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 检查键是否存在
|
|
99
|
+
* 与 get 行为对齐:命中也刷新 LRU 访问顺序
|
|
100
|
+
* 避免高频 has 查询的 key 被提前淘汰
|
|
101
|
+
*/
|
|
102
|
+
has(key) {
|
|
103
|
+
const item = this.map.get(key);
|
|
104
|
+
if (!item) return false;
|
|
105
|
+
if (Date.now() > item.expireAt) {
|
|
106
|
+
this.map.delete(key);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
// LRU:删除再插入,刷新到末尾
|
|
110
|
+
this.map.delete(key);
|
|
111
|
+
this.map.set(key, item);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 获取当前缓存大小(触发一次完整清理)
|
|
117
|
+
*/
|
|
118
|
+
size() {
|
|
119
|
+
this._cleanupAll();
|
|
120
|
+
return this.map.size;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 自增,新建 key 自动附加默认 TTL
|
|
125
|
+
* @param {string} key - 缓存键
|
|
126
|
+
* @param {number} [ttlMs=DEFAULT_INCR_TTL] - 新建 key 时的 TTL(毫秒)
|
|
127
|
+
* @returns {number} 自增后的值
|
|
128
|
+
* @description
|
|
129
|
+
* - 新建 key:设置值为 1,TTL = ttlMs
|
|
130
|
+
* - 已存在 key:值 +1,**不刷新 TTL**(与 Redis INCR 行为对齐)
|
|
131
|
+
*
|
|
132
|
+
* 重要:自增不会续期 TTL,这是有意设计。
|
|
133
|
+
* Rate Limit 场景下,每个 IP 的窗口由首次请求决定,后续请求只递增不续期。
|
|
134
|
+
*/
|
|
135
|
+
incr(key, ttlMs = DEFAULT_INCR_TTL) {
|
|
136
|
+
this._maybeCleanup();
|
|
137
|
+
const item = this.map.get(key);
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
|
|
140
|
+
if (!item || now > item.expireAt) {
|
|
141
|
+
// 新建 key,设置默认 TTL
|
|
142
|
+
if (this.map.size >= this.maxSize && !this.map.has(key)) {
|
|
143
|
+
this._evictLRU();
|
|
144
|
+
}
|
|
145
|
+
this.map.set(key, { value: 1, expireAt: now + ttlMs });
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 已存在:自增,**不刷新 TTL**(保持原 expireAt)
|
|
150
|
+
item.value = (typeof item.value === 'number' ? item.value : 0) + 1;
|
|
151
|
+
return item.value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 自增并首次设置过期时间
|
|
156
|
+
* @param {string} key - 缓存键
|
|
157
|
+
* @param {number} ttlMs - TTL 毫秒
|
|
158
|
+
* @returns {number} 自增后的值
|
|
159
|
+
* @description
|
|
160
|
+
* 与 incr 区别:新建时用调用方指定的 ttlMs,而非默认 TTL
|
|
161
|
+
* - 新建 key:设置值为 1,TTL = ttlMs
|
|
162
|
+
* - 已存在 key:值 +1,**不刷新 TTL**
|
|
163
|
+
*
|
|
164
|
+
* 用于 Rate Limit 场景:第一次访问设置窗口 TTL,后续访问只递增不续期
|
|
165
|
+
*/
|
|
166
|
+
incrAndExpire(key, ttlMs) {
|
|
167
|
+
this._maybeCleanup();
|
|
168
|
+
const item = this.map.get(key);
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
|
|
171
|
+
if (!item || now > item.expireAt) {
|
|
172
|
+
// 新建 key,设置指定 TTL
|
|
173
|
+
if (this.map.size >= this.maxSize && !this.map.has(key)) {
|
|
174
|
+
this._evictLRU();
|
|
175
|
+
}
|
|
176
|
+
this.map.set(key, { value: 1, expireAt: now + ttlMs });
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 已存在:自增,**不刷新 TTL**
|
|
181
|
+
item.value = (typeof item.value === 'number' ? item.value : 0) + 1;
|
|
182
|
+
return item.value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 刷新过期时间
|
|
187
|
+
* @param {string} key - 缓存键
|
|
188
|
+
* @param {number} ttlMs - TTL 毫秒
|
|
189
|
+
* @returns {boolean} 是否设置成功
|
|
190
|
+
*/
|
|
191
|
+
expire(key, ttlMs) {
|
|
192
|
+
const item = this.map.get(key);
|
|
193
|
+
if (!item || Date.now() > item.expireAt) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
item.expireAt = Date.now() + ttlMs;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 循环淘汰最久未使用的条目,直到 size < maxSize
|
|
202
|
+
* 修复旧版只淘汰 1 条导致批量插入超出上限的问题
|
|
203
|
+
* Map 的 keys().next() 返回最早插入的 key(O(1))
|
|
204
|
+
* @private
|
|
205
|
+
*/
|
|
206
|
+
_evictLRU() {
|
|
207
|
+
while (this.map.size >= this.maxSize) {
|
|
208
|
+
const oldestKey = this.map.keys().next().value;
|
|
209
|
+
if (oldestKey === undefined) break;
|
|
210
|
+
this.map.delete(oldestKey);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 惰性批量清理过期项
|
|
216
|
+
* 控制频率:每 10 秒最多清理一次
|
|
217
|
+
* 控制单轮清理量:每轮最多 1000 条,避免大缓存全量遍历阻塞事件循环
|
|
218
|
+
*
|
|
219
|
+
* 修复说明:
|
|
220
|
+
* 旧版在遇到第一个未过期条目时 break,假设 Map 按过期时间有序插入。
|
|
221
|
+
* 实际上 Map 按插入顺序遍历,与过期时间无关。
|
|
222
|
+
* 后插入但 TTL 短的条目可能先过期,导致旧版无法清理被前面长 TTL 条目挡住的过期条目。
|
|
223
|
+
* 现改为分批清理(每轮最多 1000 条),剩余的会在下次触发时继续清理。
|
|
224
|
+
* @private
|
|
225
|
+
*/
|
|
226
|
+
_maybeCleanup() {
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
if (now - this._lastCleanup < this._cleanupInterval) return;
|
|
229
|
+
this._lastCleanup = now;
|
|
230
|
+
|
|
231
|
+
// 分批清理,单轮最多 1000 条,避免大缓存全量遍历阻塞事件循环
|
|
232
|
+
let count = 0;
|
|
233
|
+
for (const [key, item] of this.map) {
|
|
234
|
+
if (now > item.expireAt) {
|
|
235
|
+
this.map.delete(key);
|
|
236
|
+
}
|
|
237
|
+
if (++count >= 1000) break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 完整清理所有过期项
|
|
243
|
+
* 仅在调用 size() 时触发
|
|
244
|
+
* @private
|
|
245
|
+
*/
|
|
246
|
+
_cleanupAll() {
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
for (const [key, item] of this.map) {
|
|
249
|
+
if (now > item.expireAt) {
|
|
250
|
+
this.map.delete(key);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export { DEFAULT_INCR_TTL };
|
|
257
|
+
export const cache = new Cache();
|
|
258
|
+
export default Cache;
|
package/storage/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 存储模块 - 缓存/Redis/存储适配层
|
|
3
|
+
* - cache: 同步内存缓存(LRU + TTL)
|
|
4
|
+
* - redis: 异步 Redis 后端
|
|
5
|
+
* - store: 统一异步 API,自动选 Redis/内存 + 降级兜底
|
|
6
|
+
*/
|
|
7
|
+
export { cache, DEFAULT_INCR_TTL } from "./cache.js";
|
|
8
|
+
export { default as RedisBackend } from "./redis.js";
|
|
9
|
+
export { store } from "./store.js";
|