befly 3.24.18 → 3.24.19

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.
@@ -0,0 +1,245 @@
1
+ import picomatch from "picomatch";
2
+
3
+ import { isValidPositiveInt } from "../utils/is.js";
4
+ import { ErrorResponse } from "../utils/response.js";
5
+
6
+ const RATE_LIMIT_ENABLED_VALUES = new Set([1, true, "1", "true"]);
7
+
8
+ function isRateLimitEnabled(value) {
9
+ return RATE_LIMIT_ENABLED_VALUES.has(value);
10
+ }
11
+
12
+ function toPositiveInt(value, fallbackValue) {
13
+ const num = Number(value);
14
+
15
+ if (!Number.isFinite(num) || num <= 0) {
16
+ return fallbackValue;
17
+ }
18
+
19
+ return Math.floor(num);
20
+ }
21
+
22
+ function toSafeString(value, fallbackValue) {
23
+ if (typeof value !== "string") {
24
+ return fallbackValue;
25
+ }
26
+
27
+ const trimmed = value.trim();
28
+
29
+ if (!trimmed) {
30
+ return fallbackValue;
31
+ }
32
+
33
+ return trimmed;
34
+ }
35
+
36
+ function toRuleMethods(rule) {
37
+ if (Array.isArray(rule?.methods)) {
38
+ const values = [];
39
+
40
+ for (const method of rule.methods) {
41
+ const text = toSafeString(method, "");
42
+ if (!text) {
43
+ continue;
44
+ }
45
+
46
+ values.push(text.toUpperCase());
47
+ }
48
+
49
+ return values;
50
+ }
51
+
52
+ if (typeof rule?.method === "string") {
53
+ const value = toSafeString(rule.method, "");
54
+ if (value) {
55
+ return [value.toUpperCase()];
56
+ }
57
+ }
58
+
59
+ return [];
60
+ }
61
+
62
+ function normalizeRule(rawRule, defaultLimit, defaultWindow, defaultKeyType) {
63
+ const rule = rawRule && typeof rawRule === "object" ? rawRule : {};
64
+ const pathPattern = toSafeString(rule.path || rule.apiPath || rule.route, "");
65
+ const methods = toRuleMethods(rule);
66
+
67
+ return {
68
+ pathPattern: pathPattern,
69
+ methods: methods,
70
+ limit: toPositiveInt(rule.limit, defaultLimit),
71
+ window: toPositiveInt(rule.window, defaultWindow),
72
+ keyType: toSafeString(rule.key, defaultKeyType)
73
+ };
74
+ }
75
+
76
+ function createMatcher(pattern) {
77
+ try {
78
+ return picomatch(pattern);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function normalizeConfig(config) {
85
+ const defaultLimit = toPositiveInt(config?.defaultLimit, 1000);
86
+ const defaultWindow = toPositiveInt(config?.defaultWindow, 60);
87
+ const defaultKeyType = toSafeString(config?.key, "ip");
88
+
89
+ const skipMatchers = [];
90
+ const skipRoutes = Array.isArray(config?.skipRoutes) ? config.skipRoutes : [];
91
+
92
+ for (const route of skipRoutes) {
93
+ const pattern = toSafeString(route, "");
94
+ if (!pattern) {
95
+ continue;
96
+ }
97
+
98
+ const matcher = createMatcher(pattern);
99
+ if (!matcher) {
100
+ continue;
101
+ }
102
+
103
+ skipMatchers.push(matcher);
104
+ }
105
+
106
+ const rules = [];
107
+ const rawRules = Array.isArray(config?.rules) ? config.rules : [];
108
+
109
+ for (const rawRule of rawRules) {
110
+ const normalizedRule = normalizeRule(rawRule, defaultLimit, defaultWindow, defaultKeyType);
111
+
112
+ if (!normalizedRule.pathPattern) {
113
+ continue;
114
+ }
115
+
116
+ const matcher = createMatcher(normalizedRule.pathPattern);
117
+ if (!matcher) {
118
+ continue;
119
+ }
120
+
121
+ rules.push({
122
+ matcher: matcher,
123
+ methods: normalizedRule.methods,
124
+ limit: normalizedRule.limit,
125
+ window: normalizedRule.window,
126
+ keyType: normalizedRule.keyType
127
+ });
128
+ }
129
+
130
+ return {
131
+ enabled: isRateLimitEnabled(config?.enable),
132
+ defaultLimit: defaultLimit,
133
+ defaultWindow: defaultWindow,
134
+ defaultKeyType: defaultKeyType,
135
+ skipMatchers: skipMatchers,
136
+ rules: rules
137
+ };
138
+ }
139
+
140
+ function resolveScopeByKeyType(keyType, ctx) {
141
+ if (keyType === "userId") {
142
+ if (isValidPositiveInt(ctx.userId)) {
143
+ return `user:${ctx.userId}`;
144
+ }
145
+
146
+ return `ip:${ctx.ip || "unknown"}`;
147
+ }
148
+
149
+ if (keyType === "roleCode") {
150
+ return `role:${ctx.roleCode || "guest"}`;
151
+ }
152
+
153
+ if (keyType === "apiPath") {
154
+ return `path:${ctx.apiPath || "unknown"}`;
155
+ }
156
+
157
+ if (keyType === "global") {
158
+ return "global";
159
+ }
160
+
161
+ return `ip:${ctx.ip || "unknown"}`;
162
+ }
163
+
164
+ function resolveRateRule(normalized, ctx) {
165
+ for (const rule of normalized.rules) {
166
+ if (!rule.matcher(ctx.apiPath)) {
167
+ continue;
168
+ }
169
+
170
+ if (rule.methods.length > 0 && !rule.methods.includes(ctx.method)) {
171
+ continue;
172
+ }
173
+
174
+ return {
175
+ limit: rule.limit,
176
+ window: rule.window,
177
+ keyType: rule.keyType
178
+ };
179
+ }
180
+
181
+ return {
182
+ limit: normalized.defaultLimit,
183
+ window: normalized.defaultWindow,
184
+ keyType: normalized.defaultKeyType
185
+ };
186
+ }
187
+
188
+ function shouldSkipRateLimit(normalized, ctx) {
189
+ for (const matcher of normalized.skipMatchers) {
190
+ if (matcher(ctx.apiPath)) {
191
+ return true;
192
+ }
193
+ }
194
+
195
+ return false;
196
+ }
197
+
198
+ function buildRateLimitRedisKey(ctx, keyType, scope) {
199
+ return `rate_limit:${ctx.apiPath}:${ctx.method}:${keyType}:${scope}`;
200
+ }
201
+
202
+ export default {
203
+ order: 1.5,
204
+ handler: async (befly, ctx) => {
205
+ const normalized = normalizeConfig(befly?.config?.rateLimit);
206
+
207
+ if (!normalized.enabled) {
208
+ return;
209
+ }
210
+
211
+ if (!befly?.redis || typeof befly.redis.incrWithExpire !== "function") {
212
+ return;
213
+ }
214
+
215
+ if (shouldSkipRateLimit(normalized, ctx)) {
216
+ return;
217
+ }
218
+
219
+ const rateRule = resolveRateRule(normalized, ctx);
220
+ const scope = resolveScopeByKeyType(rateRule.keyType, ctx);
221
+ const redisKey = buildRateLimitRedisKey(ctx, rateRule.keyType, scope);
222
+ const current = await befly.redis.incrWithExpire(redisKey, rateRule.window);
223
+
224
+ if (current <= rateRule.limit) {
225
+ return;
226
+ }
227
+
228
+ ctx.corsHeaders = ctx.corsHeaders || {};
229
+ ctx.corsHeaders["Retry-After"] = String(rateRule.window);
230
+ ctx.response = ErrorResponse(
231
+ ctx,
232
+ "请求过于频繁,请稍后再试",
233
+ 1,
234
+ null,
235
+ {
236
+ limit: rateRule.limit,
237
+ window: rateRule.window,
238
+ current: current,
239
+ apiPath: ctx.apiPath,
240
+ key: rateRule.keyType
241
+ },
242
+ "rateLimit"
243
+ );
244
+ }
245
+ };
@@ -15,6 +15,95 @@ export class CacheHelper {
15
15
  this.redis = deps.redis;
16
16
  }
17
17
 
18
+ getRoleCacheVersionKey(kind) {
19
+ return `role:${kind}:activeVersion`;
20
+ }
21
+
22
+ getRoleCacheCodesKey(kind, version) {
23
+ return `role:${kind}:v:${version}:codes`;
24
+ }
25
+
26
+ getRoleCacheItemKey(kind, roleCode, version) {
27
+ if (!version) {
28
+ return `role:${kind}:${roleCode}`;
29
+ }
30
+
31
+ return `role:${kind}:v:${version}:${roleCode}`;
32
+ }
33
+
34
+ async getRoleCacheActiveVersion(kind) {
35
+ const versionKey = this.getRoleCacheVersionKey(kind);
36
+ const version = await this.redis.getString(versionKey);
37
+
38
+ if (!isNonEmptyString(version)) {
39
+ return "";
40
+ }
41
+
42
+ return version;
43
+ }
44
+
45
+ async getRoleCacheReadKey(kind, roleCode) {
46
+ const version = await this.getRoleCacheActiveVersion(kind);
47
+ return this.getRoleCacheItemKey(kind, roleCode, version);
48
+ }
49
+
50
+ async rebuildRoleCache(kind, roleValuesMap) {
51
+ const roleCodes = Array.from(roleValuesMap.keys());
52
+
53
+ if (roleCodes.length === 0) {
54
+ Logger.info(`✅ 没有需要缓存的角色 ${kind}`);
55
+ return;
56
+ }
57
+
58
+ const oldVersion = await this.getRoleCacheActiveVersion(kind);
59
+ let nextVersion = String(Date.now());
60
+
61
+ if (nextVersion === oldVersion) {
62
+ nextVersion = `${nextVersion}_1`;
63
+ }
64
+ const nextCodesKey = this.getRoleCacheCodesKey(kind, nextVersion);
65
+ const nextItems = [];
66
+
67
+ for (const roleCode of roleCodes) {
68
+ const values = roleValuesMap.get(roleCode) || [];
69
+ const members = Array.from(new Set(values)).toSorted();
70
+
71
+ if (members.length === 0) {
72
+ continue;
73
+ }
74
+
75
+ nextItems.push({
76
+ key: this.getRoleCacheItemKey(kind, roleCode, nextVersion),
77
+ members: members
78
+ });
79
+ }
80
+
81
+ await this.redis.del(nextCodesKey);
82
+ await this.redis.sadd(nextCodesKey, roleCodes);
83
+
84
+ if (nextItems.length > 0) {
85
+ await this.redis.saddBatch(nextItems);
86
+ }
87
+
88
+ await this.redis.setString(this.getRoleCacheVersionKey(kind), nextVersion);
89
+
90
+ if (!oldVersion || oldVersion === nextVersion) {
91
+ return;
92
+ }
93
+
94
+ const oldCodesKey = this.getRoleCacheCodesKey(kind, oldVersion);
95
+ const oldRoleCodes = await this.redis.smembers(oldCodesKey);
96
+ const cleanupKeys = [oldCodesKey];
97
+
98
+ for (const roleCode of oldRoleCodes) {
99
+ cleanupKeys.push(this.getRoleCacheItemKey(kind, roleCode, oldVersion));
100
+ }
101
+
102
+ if (cleanupKeys.length > 0) {
103
+ await this.redis.delBatch(cleanupKeys);
104
+ }
105
+ }
106
+
18
107
  /**
19
108
  * 缓存所有接口到 Redis
20
109
  */
@@ -95,33 +184,7 @@ export class CacheHelper {
95
184
  roleApiPathsMap.set(role.code, role.apis);
96
185
  }
97
186
 
98
- const roleCodes = Array.from(roleApiPathsMap.keys());
99
- if (roleCodes.length === 0) {
100
- Logger.info("✅ 没有需要缓存的角色权限");
101
- return;
102
- }
103
-
104
- // 清理所有角色的缓存 key(保证幂等)
105
- const roleKeys = roleCodes.map((code) => `role:apis:${code}`);
106
- await this.redis.delBatch(roleKeys);
107
-
108
- // 批量写入新缓存(只写入非空权限)
109
- const items = [];
110
-
111
- for (const roleCode of roleCodes) {
112
- const apiPaths = roleApiPathsMap.get(roleCode) || [];
113
- const members = Array.from(new Set(apiPaths)).toSorted();
114
-
115
- if (members.length > 0) {
116
- items.push({ key: `role:apis:${roleCode}`, members: members });
117
- }
118
- }
119
-
120
- if (items.length > 0) {
121
- await this.redis.saddBatch(items);
122
- }
123
-
124
- // 极简方案不做版本/ready/meta:重建完成即生效
187
+ await this.rebuildRoleCache("apis", roleApiPathsMap);
125
188
  } catch (error) {
126
189
  Logger.error("⚠️ 角色权限缓存异常(将阻断启动)", error);
127
190
  throw new Error("⚠️ 角色权限缓存异常(将阻断启动)", {
@@ -153,31 +216,7 @@ export class CacheHelper {
153
216
  roleMenuPathsMap.set(role.code, role.menus);
154
217
  }
155
218
 
156
- const roleCodes = Array.from(roleMenuPathsMap.keys());
157
- if (roleCodes.length === 0) {
158
- Logger.info("✅ 没有需要缓存的角色菜单权限");
159
- return;
160
- }
161
-
162
- // 清理所有角色的缓存 key(保证幂等)
163
- const roleKeys = roleCodes.map((code) => `role:menus:${code}`);
164
- await this.redis.delBatch(roleKeys);
165
-
166
- // 批量写入新缓存(只写入非空权限)
167
- const items = [];
168
-
169
- for (const roleCode of roleCodes) {
170
- const menuPaths = roleMenuPathsMap.get(roleCode) || [];
171
- const members = Array.from(new Set(menuPaths)).toSorted();
172
-
173
- if (members.length > 0) {
174
- items.push({ key: `role:menus:${roleCode}`, members: members });
175
- }
176
- }
177
-
178
- if (items.length > 0) {
179
- await this.redis.saddBatch(items);
180
- }
219
+ await this.rebuildRoleCache("menus", roleMenuPathsMap);
181
220
  } catch (error) {
182
221
  Logger.error("⚠️ 角色菜单权限缓存异常(将阻断启动)", error);
183
222
  throw new Error("⚠️ 角色菜单权限缓存异常(将阻断启动)", {
@@ -212,7 +251,7 @@ export class CacheHelper {
212
251
  });
213
252
  }
214
253
 
215
- const roleKey = `role:apis:${roleCode}`;
254
+ const roleKey = await this.getRoleCacheReadKey("apis", roleCode);
216
255
 
217
256
  // 空数组短路:保证清理残留
218
257
  if (apiPaths.length === 0) {
@@ -251,7 +290,7 @@ export class CacheHelper {
251
290
  });
252
291
  }
253
292
 
254
- const roleKey = `role:menus:${roleCode}`;
293
+ const roleKey = await this.getRoleCacheReadKey("menus", roleCode);
255
294
 
256
295
  // 空数组短路:保证清理残留
257
296
  if (menuPaths.length === 0) {
@@ -329,7 +368,8 @@ export class CacheHelper {
329
368
  */
330
369
  async getRoleApis(roleCode) {
331
370
  try {
332
- const permissions = await this.redis.smembers(`role:apis:${roleCode}`);
371
+ const roleKey = await this.getRoleCacheReadKey("apis", roleCode);
372
+ const permissions = await this.redis.smembers(roleKey);
333
373
  return permissions || [];
334
374
  } catch (error) {
335
375
  Logger.error("获取角色权限缓存失败", error, { roleCode: roleCode });
@@ -350,7 +390,8 @@ export class CacheHelper {
350
390
  */
351
391
  async getRoleMenus(roleCode) {
352
392
  try {
353
- const permissions = await this.redis.smembers(`role:menus:${roleCode}`);
393
+ const roleKey = await this.getRoleCacheReadKey("menus", roleCode);
394
+ const permissions = await this.redis.smembers(roleKey);
354
395
  return permissions || [];
355
396
  } catch (error) {
356
397
  Logger.error("获取角色菜单权限缓存失败", error, { roleCode: roleCode });
@@ -374,7 +415,8 @@ export class CacheHelper {
374
415
  try {
375
416
  if (isNullable(apiPath)) return false;
376
417
  const value = isString(apiPath) ? apiPath : String(apiPath);
377
- return await this.redis.sismember(`role:apis:${roleCode}`, value);
418
+ const roleKey = await this.getRoleCacheReadKey("apis", roleCode);
419
+ return await this.redis.sismember(roleKey, value);
378
420
  } catch (error) {
379
421
  Logger.error("检查角色权限失败", error, { roleCode: roleCode });
380
422
  throw new Error("检查角色权限失败", {
@@ -397,7 +439,8 @@ export class CacheHelper {
397
439
  try {
398
440
  if (isNullable(menuPath)) return false;
399
441
  const value = isString(menuPath) ? menuPath : String(menuPath);
400
- return await this.redis.sismember(`role:menus:${roleCode}`, value);
442
+ const roleKey = await this.getRoleCacheReadKey("menus", roleCode);
443
+ return await this.redis.sismember(roleKey, value);
401
444
  } catch (error) {
402
445
  Logger.error("检查角色菜单权限失败", error, { roleCode: roleCode });
403
446
  throw new Error("检查角色菜单权限失败", {
@@ -417,7 +460,8 @@ export class CacheHelper {
417
460
  */
418
461
  async deleteRoleApis(roleCode) {
419
462
  try {
420
- const result = await this.redis.del(`role:apis:${roleCode}`);
463
+ const roleReadKey = await this.getRoleCacheReadKey("apis", roleCode);
464
+ const result = await this.redis.delBatch([`role:apis:${roleCode}`, roleReadKey]);
421
465
  if (result > 0) {
422
466
  Logger.info(`✅ 已删除角色 ${roleCode} 的权限缓存`);
423
467
  return true;
@@ -442,7 +486,8 @@ export class CacheHelper {
442
486
  */
443
487
  async deleteRoleMenus(roleCode) {
444
488
  try {
445
- const result = await this.redis.del(`role:menus:${roleCode}`);
489
+ const roleReadKey = await this.getRoleCacheReadKey("menus", roleCode);
490
+ const result = await this.redis.delBatch([`role:menus:${roleCode}`, roleReadKey]);
446
491
  if (result > 0) {
447
492
  Logger.info(`✅ 已删除角色 ${roleCode} 的菜单权限缓存`);
448
493
  return true;
@@ -380,6 +380,74 @@ export class RedisHelper {
380
380
  }
381
381
  }
382
382
 
383
+ /**
384
+ * 向有序集合添加成员
385
+ * @param key - 键名
386
+ * @param items - [{ score, member }] 数组
387
+ * @returns 成功添加的成员数量
388
+ */
389
+ async zadd(key, items) {
390
+ try {
391
+ if (items.length === 0) {
392
+ return 0;
393
+ }
394
+
395
+ const pkey = `${this.prefix}${key}`;
396
+ const args = [pkey];
397
+
398
+ for (const item of items) {
399
+ const score = Number(item.score);
400
+ const member = String(item.member);
401
+
402
+ if (!Number.isFinite(score) || member.length === 0) {
403
+ continue;
404
+ }
405
+
406
+ args.push(score);
407
+ args.push(member);
408
+ }
409
+
410
+ if (args.length <= 1) {
411
+ return 0;
412
+ }
413
+
414
+ return await this.client.zadd.apply(this.client, args);
415
+ } catch (error) {
416
+ Logger.error("Redis zadd 错误", error);
417
+ return 0;
418
+ }
419
+ }
420
+
421
+ /**
422
+ * 获取有序集合成员数量
423
+ * @param key - 键名
424
+ */
425
+ async zcard(key) {
426
+ try {
427
+ const pkey = `${this.prefix}${key}`;
428
+ return await this.client.zcard(pkey);
429
+ } catch (error) {
430
+ Logger.error("Redis zcard 错误", error);
431
+ return 0;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * 按 score 区间删除有序集合成员
437
+ * @param key - 键名
438
+ * @param min - 最小分值
439
+ * @param max - 最大分值
440
+ */
441
+ async zremrangebyscore(key, min, max) {
442
+ try {
443
+ const pkey = `${this.prefix}${key}`;
444
+ return await this.client.zremrangebyscore(pkey, min, max);
445
+ } catch (error) {
446
+ Logger.error("Redis zremrangebyscore 错误", error);
447
+ return 0;
448
+ }
449
+ }
450
+
383
451
  /**
384
452
  * 删除键
385
453
  * @param key - 键名