befly 3.16.5 → 3.16.7

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/dist/index.js CHANGED
@@ -29,7 +29,6 @@ import { SyncTable } from "./sync/syncTable";
29
29
  import { CoreError, isCoreError } from "./types/coreError";
30
30
  // 工具
31
31
  import { calcPerfTime } from "./utils/calcPerfTime";
32
- import { getProcessRole } from "./utils/processInfo";
33
32
  import { scanSources } from "./utils/scanSources";
34
33
  /**
35
34
  * Befly 框架核心类
@@ -97,20 +96,65 @@ export class Befly {
97
96
  // 启动期依赖完整性检查:避免 sync 阶段出现 undefined 调用
98
97
  // 注意:这里不做兼容别名(例如 dbHelper=db),要求上下文必须注入标准字段。
99
98
  this.assertStartContextReady();
100
- // 5. 自动同步 (仅主进程执行,避免集群模式下重复执行)
101
- await new SyncTable(this.context).run(tables);
102
- await syncApi(this.context, apis);
103
- await syncMenu(this.context, checkedMenus);
104
- const devEmail = this.config.devEmail;
105
- const devPassword = this.config.devPassword;
106
- if (typeof devEmail === "string" && devEmail.length > 0 && typeof devPassword === "string" && devPassword.length > 0) {
107
- await syncDev(this.context, { devEmail: devEmail, devPassword: devPassword });
99
+ // 5. 自动同步(包含删除差集/写缓存等强副作用)
100
+ // 多进程/多实例场景下必须避免并发执行:使用 Redis 分布式锁保证全局单点。
101
+ const syncLockKey = "sync:lock";
102
+ const syncReadyKey = "sync:ready";
103
+ const syncLockTtlMs = 10 * 60 * 1000;
104
+ const syncWaitReadyMaxMs = 120 * 1000;
105
+ const syncWaitPollMs = 250;
106
+ const syncToken = `${process.pid}:${Bun.nanoseconds()}`;
107
+ const ctx = this.context;
108
+ const acquired = await ctx.redis.tryAcquireLock(syncLockKey, syncToken, syncLockTtlMs);
109
+ if (acquired) {
110
+ Logger.info({ key: syncLockKey, msg: "✅ 已获取启动同步锁,将执行自动同步" });
111
+ // 清空 ready:避免其他实例读取到上一次同步的 ready 而误判当前同步已完成
112
+ await ctx.redis.del(syncReadyKey);
113
+ try {
114
+ await new SyncTable(ctx).run(tables);
115
+ await syncApi(ctx, apis);
116
+ await syncMenu(ctx, checkedMenus);
117
+ const devEmail = this.config.devEmail;
118
+ const devPassword = this.config.devPassword;
119
+ if (typeof devEmail === "string" && devEmail.length > 0 && typeof devPassword === "string" && devPassword.length > 0) {
120
+ await syncDev(ctx, { devEmail: devEmail, devPassword: devPassword });
121
+ }
122
+ else {
123
+ Logger.debug("跳过 syncDev:未配置 devEmail/devPassword");
124
+ }
125
+ // 缓存同步:统一在所有 DB 同步完成后执行
126
+ await syncCache(ctx);
127
+ // 写入 ready:通知其他实例可继续启动(保留 1 小时,下一次同步会先清空)
128
+ await ctx.redis.setString(syncReadyKey, String(Date.now()), 60 * 60);
129
+ }
130
+ finally {
131
+ const released = await ctx.redis.releaseLock(syncLockKey, syncToken);
132
+ if (!released) {
133
+ Logger.warn({ key: syncLockKey, msg: "同步锁未能主动释放(将依赖 TTL 自动释放)" });
134
+ }
135
+ }
108
136
  }
109
137
  else {
110
- Logger.debug("跳过 syncDev:未配置 devEmail/devPassword");
138
+ Logger.info({ key: syncLockKey, msg: "启动同步锁被占用:等待同步完成标记" });
139
+ const waitStart = Date.now();
140
+ while (true) {
141
+ const ready = await ctx.redis.getString(syncReadyKey);
142
+ if (typeof ready === "string" && ready.length > 0) {
143
+ Logger.info({ key: syncReadyKey, msg: "✅ 检测到同步完成标记,将跳过自动同步" });
144
+ break;
145
+ }
146
+ const elapsed = Date.now() - waitStart;
147
+ if (elapsed >= syncWaitReadyMaxMs) {
148
+ throw new CoreError({
149
+ kind: "runtime",
150
+ message: `启动等待同步完成超时(${syncWaitReadyMaxMs}ms):请检查是否有实例卡在同步阶段或 Redis 锁 TTL 过长`,
151
+ logged: true,
152
+ meta: { subsystem: "start", operation: "waitSyncReady" }
153
+ });
154
+ }
155
+ await Bun.sleep(syncWaitPollMs);
156
+ }
111
157
  }
112
- // 缓存同步:统一在所有同步完成后执行(cacheApis + cacheMenus + rebuildRoleApiPermissions)
113
- await syncCache(this.context);
114
158
  // 3. 加载钩子
115
159
  this.hooks = await loadHooks(hooks);
116
160
  // 4. 加载所有 API
@@ -152,10 +196,7 @@ export class Befly {
152
196
  }
153
197
  });
154
198
  const finalStartupTime = calcPerfTime(serverStartTime);
155
- const processRole = getProcessRole(env);
156
- const roleLabel = processRole.role === "primary" ? "主进程" : `工作进程 #${processRole.instanceId}`;
157
- const envLabel = processRole.env === "standalone" ? "" : ` [${processRole.env}]`;
158
- Logger.info(`${this.config.appName} 启动成功! (${roleLabel}${envLabel})`);
199
+ Logger.info(`${this.config.appName} 启动成功!`);
159
200
  Logger.info(`服务器启动耗时: ${finalStartupTime}`);
160
201
  Logger.info(`服务器监听地址: ${server.url}`);
161
202
  // 注意:作为库代码,这里不注册 SIGINT/SIGTERM 处理器,也不调用 process.exit。
@@ -37,7 +37,9 @@ export declare class CacheHelper {
37
37
  private redis;
38
38
  constructor(deps: CacheHelperDeps);
39
39
  private assertApiPathname;
40
+ private assertMenuPathname;
40
41
  private assertApiPathList;
42
+ private assertMenuPathList;
41
43
  /**
42
44
  * 缓存所有接口到 Redis
43
45
  */
@@ -52,12 +54,24 @@ export declare class CacheHelper {
52
54
  * - 极简方案:每个角色一个 Set,直接覆盖更新(DEL + SADD)
53
55
  */
54
56
  rebuildRoleApiPermissions(): Promise<void>;
57
+ /**
58
+ * 缓存所有角色的菜单权限到 Redis
59
+ * 全量重建:清理所有角色菜单权限缓存并重建
60
+ * - 每个角色一个 Set,直接覆盖更新(DEL + SADD)
61
+ */
62
+ rebuildRoleMenuPermissions(): Promise<void>;
55
63
  /**
56
64
  * 增量刷新单个角色的接口权限缓存
57
65
  * - apiIds 为空数组:仅清理缓存(防止残留)
58
66
  * - apiIds 非空:使用 $in 最小查询,DEL 后 SADD
59
67
  */
60
68
  refreshRoleApiPermissions(roleCode: string, apiPaths: string[]): Promise<void>;
69
+ /**
70
+ * 增量刷新单个角色的菜单权限缓存
71
+ * - menuPaths 为空数组:仅清理缓存(防止残留)
72
+ * - menuPaths 非空:DEL 后 SADD
73
+ */
74
+ refreshRoleMenuPermissions(roleCode: string, menuPaths: string[]): Promise<void>;
61
75
  /**
62
76
  * 缓存所有数据
63
77
  */
@@ -78,6 +92,12 @@ export declare class CacheHelper {
78
92
  * @returns 接口路径列表
79
93
  */
80
94
  getRolePermissions(roleCode: string): Promise<string[]>;
95
+ /**
96
+ * 获取角色的菜单权限
97
+ * @param roleCode - 角色代码
98
+ * @returns 菜单路径列表(menu.path)
99
+ */
100
+ getRoleMenuPermissions(roleCode: string): Promise<string[]>;
81
101
  /**
82
102
  * 检查角色是否有指定接口权限
83
103
  * @param roleCode - 角色代码
@@ -85,11 +105,24 @@ export declare class CacheHelper {
85
105
  * @returns 是否有权限
86
106
  */
87
107
  checkRolePermission(roleCode: string, apiPath: string): Promise<boolean>;
108
+ /**
109
+ * 检查角色是否有指定菜单权限
110
+ * @param roleCode - 角色代码
111
+ * @param menuPath - 菜单路径(menu.path)
112
+ * @returns 是否有权限
113
+ */
114
+ checkRoleMenuPermission(roleCode: string, menuPath: string): Promise<boolean>;
88
115
  /**
89
116
  * 删除角色的接口权限缓存
90
117
  * @param roleCode - 角色代码
91
118
  * @returns 是否删除成功
92
119
  */
93
120
  deleteRolePermissions(roleCode: string): Promise<boolean>;
121
+ /**
122
+ * 删除角色的菜单权限缓存
123
+ * @param roleCode - 角色代码
124
+ * @returns 是否删除成功
125
+ */
126
+ deleteRoleMenuPermissions(roleCode: string): Promise<boolean>;
94
127
  }
95
128
  export {};
@@ -34,6 +34,25 @@ export class CacheHelper {
34
34
  }
35
35
  return trimmed;
36
36
  }
37
+ assertMenuPathname(value, errorPrefix) {
38
+ if (typeof value !== "string") {
39
+ throw new Error(`${errorPrefix} 必须是字符串`);
40
+ }
41
+ const trimmed = value.trim();
42
+ if (!trimmed) {
43
+ throw new Error(`${errorPrefix} 不允许为空字符串`);
44
+ }
45
+ if (!trimmed.startsWith("/")) {
46
+ throw new Error(`${errorPrefix} 必须是 pathname(以 / 开头)`);
47
+ }
48
+ if (trimmed.includes(" ")) {
49
+ throw new Error(`${errorPrefix} 不允许包含空格`);
50
+ }
51
+ if (trimmed.length > 1 && trimmed.endsWith("/")) {
52
+ throw new Error(`${errorPrefix} 不允许以 / 结尾`);
53
+ }
54
+ return trimmed;
55
+ }
37
56
  assertApiPathList(value, roleCode) {
38
57
  if (value === null || value === undefined)
39
58
  return [];
@@ -64,6 +83,35 @@ export class CacheHelper {
64
83
  }
65
84
  return out;
66
85
  }
86
+ assertMenuPathList(value, roleCode) {
87
+ if (value === null || value === undefined)
88
+ return [];
89
+ let list = value;
90
+ // 兼容历史/手工数据:menus 可能被存成 JSON 字符串或 "null"
91
+ if (typeof list === "string") {
92
+ const trimmed = list.trim();
93
+ if (trimmed === "" || trimmed === "null") {
94
+ return [];
95
+ }
96
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
97
+ try {
98
+ list = JSON.parse(trimmed);
99
+ }
100
+ catch {
101
+ throw new Error(`角色菜单权限数据不合法:addon_admin_role.menus JSON 解析失败,roleCode=${roleCode}`);
102
+ }
103
+ }
104
+ }
105
+ if (!Array.isArray(list)) {
106
+ const typeLabel = typeof list;
107
+ throw new Error(`角色菜单权限数据不合法:addon_admin_role.menus 必须是字符串数组或 JSON 数组字符串,roleCode=${roleCode},type=${typeLabel}`);
108
+ }
109
+ const out = [];
110
+ for (const item of list) {
111
+ out.push(this.assertMenuPathname(item, `角色菜单权限数据不合法:addon_admin_role.menus 元素,roleCode=${roleCode}`));
112
+ }
113
+ return out;
114
+ }
67
115
  /**
68
116
  * 缓存所有接口到 Redis
69
117
  */
@@ -175,6 +223,66 @@ export class CacheHelper {
175
223
  });
176
224
  }
177
225
  }
226
+ /**
227
+ * 缓存所有角色的菜单权限到 Redis
228
+ * 全量重建:清理所有角色菜单权限缓存并重建
229
+ * - 每个角色一个 Set,直接覆盖更新(DEL + SADD)
230
+ */
231
+ async rebuildRoleMenuPermissions() {
232
+ try {
233
+ // 检查表是否存在
234
+ const roleTableExists = await this.db.tableExists("addon_admin_role");
235
+ if (!roleTableExists.data) {
236
+ Logger.warn("⚠️ 角色表不存在,跳过角色菜单权限缓存");
237
+ return;
238
+ }
239
+ // 查询所有角色(仅取必要字段)
240
+ const roles = await this.db.getAll({
241
+ table: "addon_admin_role",
242
+ fields: ["code", "menus"]
243
+ });
244
+ const roleMenuPathsMap = new Map();
245
+ for (const role of roles.data.lists) {
246
+ if (!role?.code)
247
+ continue;
248
+ const menuPaths = this.assertMenuPathList(role.menus, role.code);
249
+ roleMenuPathsMap.set(role.code, menuPaths);
250
+ }
251
+ const roleCodes = Array.from(roleMenuPathsMap.keys());
252
+ if (roleCodes.length === 0) {
253
+ Logger.info("✅ 没有需要缓存的角色菜单权限");
254
+ return;
255
+ }
256
+ // 清理所有角色的缓存 key(保证幂等)
257
+ const roleKeys = roleCodes.map((code) => CacheKeys.roleMenus(code));
258
+ await this.redis.delBatch(roleKeys);
259
+ // 批量写入新缓存(只写入非空权限)
260
+ const items = [];
261
+ for (const roleCode of roleCodes) {
262
+ const menuPaths = roleMenuPathsMap.get(roleCode) || [];
263
+ const members = Array.from(new Set(menuPaths)).sort();
264
+ if (members.length > 0) {
265
+ items.push({ key: CacheKeys.roleMenus(roleCode), members: members });
266
+ }
267
+ }
268
+ if (items.length > 0) {
269
+ await this.redis.saddBatch(items);
270
+ }
271
+ }
272
+ catch (error) {
273
+ Logger.error({ err: error, msg: "⚠️ 角色菜单权限缓存异常(将阻断启动)" });
274
+ throw new CoreError({
275
+ kind: "runtime",
276
+ message: "⚠️ 角色菜单权限缓存异常(将阻断启动)",
277
+ logged: true,
278
+ cause: error,
279
+ meta: {
280
+ subsystem: "cache",
281
+ operation: "rebuildRoleMenuPermissions"
282
+ }
283
+ });
284
+ }
285
+ }
178
286
  /**
179
287
  * 增量刷新单个角色的接口权限缓存
180
288
  * - apiIds 为空数组:仅清理缓存(防止残留)
@@ -200,6 +308,31 @@ export class CacheHelper {
200
308
  await this.redis.sadd(roleKey, members);
201
309
  }
202
310
  }
311
+ /**
312
+ * 增量刷新单个角色的菜单权限缓存
313
+ * - menuPaths 为空数组:仅清理缓存(防止残留)
314
+ * - menuPaths 非空:DEL 后 SADD
315
+ */
316
+ async refreshRoleMenuPermissions(roleCode, menuPaths) {
317
+ if (!roleCode || typeof roleCode !== "string") {
318
+ throw new Error("roleCode 必须是非空字符串");
319
+ }
320
+ if (!Array.isArray(menuPaths)) {
321
+ throw new Error("menuPaths 必须是数组");
322
+ }
323
+ const normalizedPaths = menuPaths.map((p) => this.assertMenuPathname(p, `refreshRoleMenuPermissions: menuPaths 元素,roleCode=${roleCode}`));
324
+ const roleKey = CacheKeys.roleMenus(roleCode);
325
+ // 空数组短路:保证清理残留
326
+ if (normalizedPaths.length === 0) {
327
+ await this.redis.del(roleKey);
328
+ return;
329
+ }
330
+ const members = Array.from(new Set(normalizedPaths));
331
+ await this.redis.del(roleKey);
332
+ if (members.length > 0) {
333
+ await this.redis.sadd(roleKey, members);
334
+ }
335
+ }
203
336
  /**
204
337
  * 缓存所有数据
205
338
  */
@@ -210,6 +343,8 @@ export class CacheHelper {
210
343
  await this.cacheMenus();
211
344
  // 3. 缓存角色权限
212
345
  await this.rebuildRoleApiPermissions();
346
+ // 4. 缓存角色菜单权限
347
+ await this.rebuildRoleMenuPermissions();
213
348
  }
214
349
  /**
215
350
  * 获取缓存的所有接口
@@ -254,6 +389,21 @@ export class CacheHelper {
254
389
  return [];
255
390
  }
256
391
  }
392
+ /**
393
+ * 获取角色的菜单权限
394
+ * @param roleCode - 角色代码
395
+ * @returns 菜单路径列表(menu.path)
396
+ */
397
+ async getRoleMenuPermissions(roleCode) {
398
+ try {
399
+ const permissions = await this.redis.smembers(CacheKeys.roleMenus(roleCode));
400
+ return permissions || [];
401
+ }
402
+ catch (error) {
403
+ Logger.error({ err: error, roleCode: roleCode, msg: "获取角色菜单权限缓存失败" });
404
+ return [];
405
+ }
406
+ }
257
407
  /**
258
408
  * 检查角色是否有指定接口权限
259
409
  * @param roleCode - 角色代码
@@ -270,6 +420,22 @@ export class CacheHelper {
270
420
  return false;
271
421
  }
272
422
  }
423
+ /**
424
+ * 检查角色是否有指定菜单权限
425
+ * @param roleCode - 角色代码
426
+ * @param menuPath - 菜单路径(menu.path)
427
+ * @returns 是否有权限
428
+ */
429
+ async checkRoleMenuPermission(roleCode, menuPath) {
430
+ try {
431
+ const pathname = this.assertMenuPathname(menuPath, "checkRoleMenuPermission: menuPath");
432
+ return await this.redis.sismember(CacheKeys.roleMenus(roleCode), pathname);
433
+ }
434
+ catch (error) {
435
+ Logger.error({ err: error, roleCode: roleCode, msg: "检查角色菜单权限失败" });
436
+ return false;
437
+ }
438
+ }
273
439
  /**
274
440
  * 删除角色的接口权限缓存
275
441
  * @param roleCode - 角色代码
@@ -289,4 +455,23 @@ export class CacheHelper {
289
455
  return false;
290
456
  }
291
457
  }
458
+ /**
459
+ * 删除角色的菜单权限缓存
460
+ * @param roleCode - 角色代码
461
+ * @returns 是否删除成功
462
+ */
463
+ async deleteRoleMenuPermissions(roleCode) {
464
+ try {
465
+ const result = await this.redis.del(CacheKeys.roleMenus(roleCode));
466
+ if (result > 0) {
467
+ Logger.info(`✅ 已删除角色 ${roleCode} 的菜单权限缓存`);
468
+ return true;
469
+ }
470
+ return false;
471
+ }
472
+ catch (error) {
473
+ Logger.error({ err: error, roleCode: roleCode, msg: "删除角色菜单权限缓存失败" });
474
+ return false;
475
+ }
476
+ }
292
477
  }
@@ -12,6 +12,12 @@ export declare class CacheKeys {
12
12
  static menusAll(): string;
13
13
  /** 角色信息缓存(完整角色对象) */
14
14
  static roleInfo(roleCode: string): string;
15
+ /**
16
+ * 角色菜单权限缓存(Set 集合)
17
+ * - key: role:menus:${roleCode}
18
+ * - member: menu.path(例如 /permission/role)
19
+ */
20
+ static roleMenus(roleCode: string): string;
15
21
  /**
16
22
  * 角色接口权限缓存(Set 集合)
17
23
  * - key: role:apis:${roleCode}
@@ -18,6 +18,14 @@ export class CacheKeys {
18
18
  static roleInfo(roleCode) {
19
19
  return `role:info:${roleCode}`;
20
20
  }
21
+ /**
22
+ * 角色菜单权限缓存(Set 集合)
23
+ * - key: role:menus:${roleCode}
24
+ * - member: menu.path(例如 /permission/role)
25
+ */
26
+ static roleMenus(roleCode) {
27
+ return `role:menus:${roleCode}`;
28
+ }
21
29
  /**
22
30
  * 角色接口权限缓存(Set 集合)
23
31
  * - key: role:apis:${roleCode}
@@ -49,6 +49,14 @@ export declare class RedisHelper {
49
49
  * @param ttl - 过期时间(秒)
50
50
  */
51
51
  setString(key: string, value: string, ttl?: number | null): Promise<string | null>;
52
+ /**
53
+ * 尝试获取分布式锁(SET key token NX PX ttlMs)
54
+ */
55
+ tryAcquireLock(key: string, token: string, ttlMs: number): Promise<boolean>;
56
+ /**
57
+ * 释放分布式锁(原子校验 token 后删除)
58
+ */
59
+ releaseLock(key: string, token: string): Promise<boolean>;
52
60
  /**
53
61
  * 获取字符串值
54
62
  * @param key - 键名
@@ -150,6 +150,63 @@ export class RedisHelper {
150
150
  return null;
151
151
  }
152
152
  }
153
+ /**
154
+ * 尝试获取分布式锁(SET key token NX PX ttlMs)
155
+ */
156
+ async tryAcquireLock(key, token, ttlMs) {
157
+ try {
158
+ if (!key || typeof key !== "string") {
159
+ throw new Error("tryAcquireLock: key 必须是非空字符串");
160
+ }
161
+ if (!token || typeof token !== "string") {
162
+ throw new Error("tryAcquireLock: token 必须是非空字符串");
163
+ }
164
+ if (!(typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0)) {
165
+ throw new Error("tryAcquireLock: ttlMs 必须是正数");
166
+ }
167
+ const pkey = `${this.prefix}${key}`;
168
+ const startTime = Date.now();
169
+ const ttlMsString = String(Math.floor(ttlMs));
170
+ const res = await this.client.set(pkey, token, "NX", "PX", ttlMsString);
171
+ const duration = Date.now() - startTime;
172
+ this.logSlow("SET NX PX", pkey, duration, { ttlMs: ttlMs });
173
+ return res === "OK";
174
+ }
175
+ catch (error) {
176
+ Logger.error({ err: error, msg: "Redis tryAcquireLock 错误" });
177
+ return false;
178
+ }
179
+ }
180
+ /**
181
+ * 释放分布式锁(原子校验 token 后删除)
182
+ */
183
+ async releaseLock(key, token) {
184
+ try {
185
+ if (!key || typeof key !== "string") {
186
+ throw new Error("releaseLock: key 必须是非空字符串");
187
+ }
188
+ if (!token || typeof token !== "string") {
189
+ throw new Error("releaseLock: token 必须是非空字符串");
190
+ }
191
+ const pkey = `${this.prefix}${key}`;
192
+ // 注意:Bun.RedisClient 当前未暴露 Lua/EVAL 等原子 compare-and-del API。
193
+ // 这里使用 get + del 的最小窗口实现;理论上存在极端竞态(TTL 恰好过期并被他人抢占)。
194
+ // 对启动期同步场景来说风险可接受;同时锁本身带 TTL,最坏情况下依赖 TTL 自动释放。
195
+ const startTime = Date.now();
196
+ const current = await this.client.get(pkey);
197
+ if (current !== token) {
198
+ return false;
199
+ }
200
+ const deleted = await this.client.del(pkey);
201
+ const duration = Date.now() - startTime;
202
+ this.logSlow("GET+DEL", pkey, duration);
203
+ return deleted > 0;
204
+ }
205
+ catch (error) {
206
+ Logger.error({ err: error, msg: "Redis releaseLock 错误" });
207
+ return false;
208
+ }
209
+ }
153
210
  /**
154
211
  * 获取字符串值
155
212
  * @param key - 键名
@@ -298,7 +355,9 @@ export class RedisHelper {
298
355
  const startTime = Date.now();
299
356
  const res = await this.client.sadd(pkey, ...members);
300
357
  const duration = Date.now() - startTime;
301
- this.logSlow("SADD", pkey, duration, { membersCount: members.length });
358
+ this.logSlow("SADD", pkey, duration, {
359
+ membersCount: members.length
360
+ });
302
361
  return res;
303
362
  }
304
363
  catch (error) {
@@ -9,4 +9,6 @@ export async function syncCache(ctx) {
9
9
  await ctx.cache.cacheMenus();
10
10
  // 3) 重建角色权限缓存(严格模式下要求 role.apis 必须为 pathname 字符串数组)
11
11
  await ctx.cache.rebuildRoleApiPermissions();
12
+ // 4) 重建角色菜单权限缓存(role.menus 为 menu.path 字符串数组)
13
+ await ctx.cache.rebuildRoleMenuPermissions();
12
14
  }
@@ -20,10 +20,18 @@ export interface CacheHelper {
20
20
  * 全量重建所有角色的接口权限缓存
21
21
  */
22
22
  rebuildRoleApiPermissions(): Promise<void>;
23
+ /**
24
+ * 全量重建所有角色的菜单权限缓存
25
+ */
26
+ rebuildRoleMenuPermissions(): Promise<void>;
23
27
  /**
24
28
  * 增量刷新单个角色的接口权限缓存
25
29
  */
26
30
  refreshRoleApiPermissions(roleCode: string, apiPaths: string[]): Promise<void>;
31
+ /**
32
+ * 增量刷新单个角色的菜单权限缓存
33
+ */
34
+ refreshRoleMenuPermissions(roleCode: string, menuPaths: string[]): Promise<void>;
27
35
  /**
28
36
  * 缓存所有数据(接口、菜单、角色权限)
29
37
  */
@@ -44,6 +52,12 @@ export interface CacheHelper {
44
52
  * @returns 接口路径列表
45
53
  */
46
54
  getRolePermissions(roleCode: string): Promise<string[]>;
55
+ /**
56
+ * 获取角色的菜单权限
57
+ * @param roleCode - 角色代码
58
+ * @returns 菜单路径列表(menu.path)
59
+ */
60
+ getRoleMenuPermissions(roleCode: string): Promise<string[]>;
47
61
  /**
48
62
  * 检查角色是否有指定接口权限
49
63
  * @param roleCode - 角色代码
@@ -51,12 +65,25 @@ export interface CacheHelper {
51
65
  * @returns 是否有权限
52
66
  */
53
67
  checkRolePermission(roleCode: string, apiPath: string): Promise<boolean>;
68
+ /**
69
+ * 检查角色是否有指定菜单权限
70
+ * @param roleCode - 角色代码
71
+ * @param menuPath - 菜单路径(menu.path)
72
+ * @returns 是否有权限
73
+ */
74
+ checkRoleMenuPermission(roleCode: string, menuPath: string): Promise<boolean>;
54
75
  /**
55
76
  * 删除角色的接口权限缓存
56
77
  * @param roleCode - 角色代码
57
78
  * @returns 是否删除成功
58
79
  */
59
80
  deleteRolePermissions(roleCode: string): Promise<boolean>;
81
+ /**
82
+ * 删除角色的菜单权限缓存
83
+ * @param roleCode - 角色代码
84
+ * @returns 是否删除成功
85
+ */
86
+ deleteRoleMenuPermissions(roleCode: string): Promise<boolean>;
60
87
  }
61
88
  /**
62
89
  * CacheHelper 构造函数类型
@@ -61,6 +61,16 @@ export interface RedisHelper {
61
61
  seconds: number;
62
62
  }>): Promise<number>;
63
63
  ping(): Promise<string>;
64
+ /**
65
+ * 尝试获取分布式锁(SET key value NX PX ttlMs)
66
+ * @returns true=获取成功;false=未获取到(被占用)
67
+ */
68
+ tryAcquireLock(key: string, token: string, ttlMs: number): Promise<boolean>;
69
+ /**
70
+ * 释放分布式锁(仅当 value==token 时删除)
71
+ * @returns true=释放成功;false=未释放(锁不存在/不归属/实现不支持)
72
+ */
73
+ releaseLock(key: string, token: string): Promise<boolean>;
64
74
  }
65
75
  /**
66
76
  * RedisHelper 构造函数类型
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "befly",
3
- "version": "3.16.5",
4
- "gitHead": "3e615600f8069fb7b34939f2361df811129331a0",
3
+ "version": "3.16.7",
4
+ "gitHead": "adac857f86168208b2e5359dc039a66cb34b50a9",
5
5
  "private": false,
6
6
  "description": "Befly - 为 Bun 专属打造的 TypeScript API 接口框架核心引擎",
7
7
  "keywords": [
@@ -58,7 +58,7 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "fast-jwt": "^6.1.0",
61
- "fast-xml-parser": "^5.3.3",
61
+ "fast-xml-parser": "^5.3.4",
62
62
  "pathe": "^2.0.3"
63
63
  },
64
64
  "engines": {