befly 3.8.29 → 3.8.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +91 -6
  2. package/checks/checkApi.ts +2 -1
  3. package/checks/checkApp.ts +31 -1
  4. package/checks/checkTable.ts +3 -2
  5. package/hooks/cors.ts +3 -3
  6. package/hooks/parser.ts +8 -6
  7. package/hooks/permission.ts +12 -5
  8. package/hooks/validator.ts +1 -1
  9. package/lib/cacheHelper.ts +73 -65
  10. package/lib/cipher.ts +2 -1
  11. package/lib/connect.ts +23 -52
  12. package/lib/dbHelper.ts +14 -11
  13. package/lib/jwt.ts +58 -437
  14. package/lib/logger.ts +76 -197
  15. package/lib/redisHelper.ts +163 -1
  16. package/lib/sqlBuilder.ts +2 -1
  17. package/lib/validator.ts +150 -384
  18. package/loader/loadApis.ts +4 -7
  19. package/loader/loadHooks.ts +6 -5
  20. package/loader/loadPlugins.ts +11 -13
  21. package/main.ts +26 -53
  22. package/package.json +10 -8
  23. package/paths.ts +0 -6
  24. package/plugins/cipher.ts +1 -1
  25. package/plugins/config.ts +3 -4
  26. package/plugins/db.ts +6 -7
  27. package/plugins/jwt.ts +7 -6
  28. package/plugins/logger.ts +6 -6
  29. package/plugins/redis.ts +9 -13
  30. package/router/api.ts +2 -2
  31. package/router/static.ts +4 -8
  32. package/sync/syncAll.ts +8 -13
  33. package/sync/syncApi.ts +14 -10
  34. package/sync/syncDb/apply.ts +1 -2
  35. package/sync/syncDb.ts +12 -15
  36. package/sync/syncDev.ts +19 -56
  37. package/sync/syncMenu.ts +182 -137
  38. package/tests/cacheHelper.test.ts +327 -0
  39. package/tests/dbHelper-columns.test.ts +5 -20
  40. package/tests/dbHelper-execute.test.ts +14 -68
  41. package/tests/fields-redis-cache.test.ts +5 -3
  42. package/tests/integration.test.ts +17 -32
  43. package/tests/jwt.test.ts +36 -94
  44. package/tests/logger.test.ts +32 -34
  45. package/tests/redisHelper.test.ts +271 -2
  46. package/tests/redisKeys.test.ts +76 -0
  47. package/tests/sync-connection.test.ts +0 -6
  48. package/tests/syncDb-constants.test.ts +12 -12
  49. package/tests/util.test.ts +5 -1
  50. package/tests/validator.test.ts +611 -85
  51. package/types/befly.d.ts +9 -15
  52. package/types/cache.d.ts +73 -0
  53. package/types/common.d.ts +10 -128
  54. package/types/database.d.ts +221 -5
  55. package/types/index.ts +6 -5
  56. package/types/plugin.d.ts +1 -4
  57. package/types/redis.d.ts +37 -2
  58. package/types/table.d.ts +175 -0
  59. package/config.ts +0 -70
  60. package/hooks/_rateLimit.ts +0 -64
  61. package/lib/regexAliases.ts +0 -59
  62. package/lib/xml.ts +0 -383
  63. package/tests/validator-advanced.test.ts +0 -653
  64. package/tests/xml.test.ts +0 -101
  65. package/types/addon.d.ts +0 -50
  66. package/types/crypto.d.ts +0 -23
  67. package/types/jwt.d.ts +0 -99
  68. package/types/logger.d.ts +0 -43
  69. package/types/tool.d.ts +0 -67
  70. package/types/validator.d.ts +0 -43
package/sync/syncMenu.ts CHANGED
@@ -1,79 +1,129 @@
1
1
  /**
2
2
  * SyncMenu 命令 - 同步菜单数据到数据库
3
- * 说明:根据配置文件增量同步菜单数据(最多3级:父级、子级、孙级)
3
+ * 说明:扫描 addon 的 views 目录和项目的 menus.json,同步菜单数据
4
4
  *
5
5
  * 流程:
6
- * 1. 扫描所有 addon 的 addon.config.js/ts 配置文件
7
- * 2. 扫描项目根目录的 app.config.js/ts 配置文件
8
- * 3. 项目的 app.config 优先级最高,可以覆盖 addon 的菜单配置
9
- * 4. 文件不存在或格式错误时默认为空数组
10
- * 5. 根据菜单的 path 字段检查是否存在
11
- * 6. 存在则更新其他字段(name、sort、type、pid)
12
- * 7. 不存在则新增菜单记录
13
- * 8. 强制删除配置中不存在的菜单记录
6
+ * 1. 扫描所有 addon 的 views 目录下的 meta.json 文件
7
+ * 2. 根据目录层级构建菜单树(无层级限制)
8
+ * 3. 读取项目的 menus.json 文件(手动配置的菜单)
9
+ * 4. 根据菜单的 path 字段检查是否存在
10
+ * 5. 存在则更新其他字段(name、sort、pid)
11
+ * 6. 不存在则新增菜单记录
12
+ * 7. 强制删除配置中不存在的菜单记录
14
13
  * 注:state 字段由框架自动管理(1=正常,2=禁用,0=删除)
15
14
  */
16
15
 
16
+ import { existsSync } from 'node:fs';
17
+ import { readdir, readFile } from 'node:fs/promises';
17
18
  import { join } from 'pathe';
18
- import { cloneDeep } from 'es-toolkit';
19
+
19
20
  import { Connect } from '../lib/connect.js';
21
+ import { DbHelper } from '../lib/dbHelper.js';
20
22
  import { RedisHelper } from '../lib/redisHelper.js';
21
- import { scanAddons, getAddonDir, scanConfig } from 'befly-util';
23
+ import { RedisKeys } from 'befly-shared/redisKeys';
24
+ import { scanAddons, getAddonDir } from 'befly-shared/addonHelper';
22
25
  import { Logger } from '../lib/logger.js';
23
26
  import { projectDir } from '../paths.js';
27
+ import { beflyConfig } from '../befly.config.js';
24
28
 
25
- import type { SyncMenuOptions, MenuConfig, BeflyOptions } from '../types/index.js';
29
+ import type { SyncMenuOptions, MenuConfig } from '../types/index.js';
26
30
 
27
31
  /**
28
- * 递归转换菜单路径
29
- * @param menu 菜单对象(会被修改)
30
- * @param transform 路径转换函数
32
+ * 清理目录名中的数字后缀
33
+ * 如:login_1 login, index_2 → index
31
34
  */
32
- function transformMenuPaths(menu: MenuConfig, transform: (path: string) => string): void {
33
- if (menu.path && menu.path.startsWith('/')) {
34
- menu.path = transform(menu.path);
35
- }
36
- menu.children?.forEach((child) => transformMenuPaths(child, transform));
35
+ function cleanDirName(name: string): string {
36
+ return name.replace(/_\d+$/, '');
37
37
  }
38
38
 
39
39
  /**
40
- * addon 菜单的 path 添加前缀
41
- * 规则:
42
- * 1. 所有路径必须以 / 开头
43
- * 2. 所有路径都添加 /addon/{addonName} 前缀(包括根路径 /)
44
- * 3. 递归处理所有层级的子菜单
45
- * 4. 项目菜单不添加前缀
40
+ * 扫描 views 目录,构建菜单树
41
+ * @param viewsDir views 目录路径
42
+ * @param prefix 路径前缀(addon 前缀)
43
+ * @param parentPath 父级路径
44
+ * @returns 菜单数组
46
45
  */
47
- function addAddonPrefix(menus: MenuConfig[], addonName: string): MenuConfig[] {
48
- return menus.map((menu) => {
49
- const cloned = cloneDeep(menu);
50
- transformMenuPaths(cloned, (path) => `/addon/${addonName}${path}`);
51
- return cloned;
52
- });
46
+ async function scanViewsDir(viewsDir: string, prefix: string, parentPath: string = ''): Promise<MenuConfig[]> {
47
+ if (!existsSync(viewsDir)) {
48
+ return [];
49
+ }
50
+
51
+ const menus: MenuConfig[] = [];
52
+ const entries = await readdir(viewsDir, { withFileTypes: true });
53
+
54
+ for (const entry of entries) {
55
+ // 只处理目录,忽略 components 目录
56
+ if (!entry.isDirectory() || entry.name === 'components') {
57
+ continue;
58
+ }
59
+
60
+ const dirPath = join(viewsDir, entry.name);
61
+ const metaPath = join(dirPath, 'meta.json');
62
+
63
+ // 没有 meta.json 的目录不处理
64
+ if (!existsSync(metaPath)) {
65
+ continue;
66
+ }
67
+
68
+ // 读取 meta.json
69
+ let meta: { name: string; order?: number };
70
+ try {
71
+ const content = await readFile(metaPath, 'utf-8');
72
+ meta = JSON.parse(content);
73
+ } catch (error: any) {
74
+ Logger.warn({ err: error, path: metaPath }, '读取 meta.json 失败');
75
+ continue;
76
+ }
77
+
78
+ // 计算路径:清理数字后缀,index 目录特殊处理
79
+ const cleanName = cleanDirName(entry.name);
80
+ let menuPath: string;
81
+ if (cleanName === 'index') {
82
+ // index 目录路径为父级路径,根级别则为 /
83
+ menuPath = parentPath || '/';
84
+ } else {
85
+ menuPath = parentPath ? `${parentPath}/${cleanName}` : `/${cleanName}`;
86
+ }
87
+
88
+ // 添加 addon 前缀
89
+ const fullPath = prefix ? `${prefix}${menuPath}` : menuPath;
90
+
91
+ const menu: MenuConfig = {
92
+ name: meta.name,
93
+ path: fullPath,
94
+ sort: meta.order || 100
95
+ };
96
+
97
+ // 递归扫描子目录
98
+ const children = await scanViewsDir(dirPath, prefix, menuPath);
99
+ if (children.length > 0) {
100
+ menu.children = children;
101
+ }
102
+
103
+ menus.push(menu);
104
+ }
105
+
106
+ // 按 sort 排序
107
+ menus.sort((a, b) => (a.sort || 100) - (b.sort || 100));
108
+
109
+ return menus;
53
110
  }
54
111
 
55
112
  /**
56
113
  * 合并菜单配置
57
- * 优先级:项目 package.json > addon package.json
58
- * 支持三级菜单结构:父级、子级、孙级
114
+ * 支持无限层级菜单结构
59
115
  */
60
- function mergeMenuConfigs(allMenus: Array<{ menus: MenuConfig[]; addonName: string }>): MenuConfig[] {
61
- /**
62
- * 递归合并指定层级的菜单(限制最多3层)
63
- * @param menus 待合并的菜单数组
64
- * @param depth 当前深度(1=父级, 2=子级, 3=孙级)
65
- * @returns 合并后的菜单数组
66
- */
67
- function mergeLevel(menus: MenuConfig[], depth: number = 1): MenuConfig[] {
68
- const menuMap = new Map<string, MenuConfig>();
116
+ function mergeMenuConfigs(allMenus: Array<{ menus: MenuConfig[]; source: string }>): MenuConfig[] {
117
+ const menuMap = new Map<string, MenuConfig>();
69
118
 
119
+ for (const { menus } of allMenus) {
70
120
  for (const menu of menus) {
71
121
  if (!menu.path) continue;
72
122
 
73
123
  const existing = menuMap.get(menu.path);
74
124
  if (existing) {
75
125
  // 合并子菜单
76
- if (menu.children?.length > 0) {
126
+ if (menu.children && menu.children.length > 0) {
77
127
  existing.children = existing.children || [];
78
128
  existing.children.push(...menu.children);
79
129
  }
@@ -81,73 +131,67 @@ function mergeMenuConfigs(allMenus: Array<{ menus: MenuConfig[]; addonName: stri
81
131
  menuMap.set(menu.path, { ...menu });
82
132
  }
83
133
  }
134
+ }
84
135
 
85
- // 递归处理子菜单(限制最多3层)
86
- if (depth < 3) {
87
- for (const menu of menuMap.values()) {
88
- if (menu.children?.length > 0) {
89
- menu.children = mergeLevel(menu.children, depth + 1);
90
- }
91
- }
136
+ return Array.from(menuMap.values());
137
+ }
138
+
139
+ /**
140
+ * 过滤隐藏的菜单(递归处理子菜单)
141
+ */
142
+ function filterHiddenMenus(menus: MenuConfig[], hiddenSet: Set<string>): MenuConfig[] {
143
+ const result: MenuConfig[] = [];
144
+
145
+ for (const menu of menus) {
146
+ // 如果菜单在隐藏列表中,跳过
147
+ if (menu.path && hiddenSet.has(menu.path)) {
148
+ continue;
92
149
  }
93
150
 
94
- return Array.from(menuMap.values());
95
- }
151
+ const filtered = { ...menu };
152
+
153
+ // 递归过滤子菜单
154
+ if (filtered.children && filtered.children.length > 0) {
155
+ filtered.children = filterHiddenMenus(filtered.children, hiddenSet);
156
+ }
96
157
 
97
- // 收集所有菜单(扁平化)
98
- const allFlatMenus = allMenus.flatMap(({ menus }) => menus);
158
+ result.push(filtered);
159
+ }
99
160
 
100
- return mergeLevel(allFlatMenus, 1);
161
+ return result;
101
162
  }
102
163
 
103
164
  /**
104
- * 收集配置文件中所有菜单的 path(最多3级)
105
- * 子级菜单使用独立路径
165
+ * 收集所有菜单的 path(递归收集所有层级)
106
166
  */
107
167
  function collectPaths(menus: MenuConfig[]): Set<string> {
108
168
  const paths = new Set<string>();
109
169
 
110
- for (const menu of menus) {
111
- if (menu.path) {
112
- paths.add(menu.path);
113
- }
114
- if (menu.children && menu.children.length > 0) {
115
- for (const child of menu.children) {
116
- if (child.path) {
117
- paths.add(child.path);
118
- }
119
- // 第三层菜单
120
- if (child.children && child.children.length > 0) {
121
- for (const grandChild of child.children) {
122
- if (grandChild.path) {
123
- paths.add(grandChild.path);
124
- }
125
- }
126
- }
170
+ function collect(items: MenuConfig[]): void {
171
+ for (const menu of items) {
172
+ if (menu.path) {
173
+ paths.add(menu.path);
174
+ }
175
+ if (menu.children && menu.children.length > 0) {
176
+ collect(menu.children);
127
177
  }
128
178
  }
129
179
  }
130
180
 
181
+ collect(menus);
131
182
  return paths;
132
183
  }
133
184
 
134
185
  /**
135
- * 递归同步单个菜单(限制最多3层)
136
- * @param helper 数据库帮助类
137
- * @param menu 菜单配置
138
- * @param pid 父级菜单ID
139
- * @param existingMenuMap 现有菜单映射
140
- * @param depth 当前深度(1=父级, 2=子级, 3=孙级)
141
- * @returns 菜单ID
186
+ * 递归同步单个菜单(无层级限制)
142
187
  */
143
- async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, existingMenuMap: Map<string, any>, depth: number = 1): Promise<number> {
188
+ async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, existingMenuMap: Map<string, any>): Promise<number> {
144
189
  const existing = existingMenuMap.get(menu.path || '');
145
190
  let menuId: number;
146
191
 
147
192
  if (existing) {
148
193
  menuId = existing.id;
149
194
 
150
- // 检查是否需要更新
151
195
  const needUpdate = existing.pid !== pid || existing.name !== menu.name || existing.sort !== (menu.sort || 0);
152
196
 
153
197
  if (needUpdate) {
@@ -173,10 +217,9 @@ async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, exi
173
217
  });
174
218
  }
175
219
 
176
- // 递归处理子菜单(限制最多3层)
177
- if (depth < 3 && menu.children?.length > 0) {
220
+ if (menu.children && menu.children.length > 0) {
178
221
  for (const child of menu.children) {
179
- await syncMenuRecursive(helper, child, menuId, existingMenuMap, depth + 1);
222
+ await syncMenuRecursive(helper, child, menuId, existingMenuMap);
180
223
  }
181
224
  }
182
225
 
@@ -184,11 +227,9 @@ async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, exi
184
227
  }
185
228
 
186
229
  /**
187
- * 同步菜单(三层结构:父级、子级、孙级)
188
- * 子级菜单使用独立路径
230
+ * 同步菜单到数据库
189
231
  */
190
232
  async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
191
- // 批量查询所有现有菜单,建立 path -> menu 的映射
192
233
  const allExistingMenus = await helper.getAll({
193
234
  table: 'addon_admin_menu',
194
235
  fields: ['id', 'pid', 'name', 'path', 'sort']
@@ -204,7 +245,7 @@ async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
204
245
  try {
205
246
  await syncMenuRecursive(helper, menu, 0, existingMenuMap, 1);
206
247
  } catch (error: any) {
207
- Logger.error(`同步菜单 "${menu.name}" 失败`, error.message || String(error));
248
+ Logger.error({ err: error, menu: menu.name }, '同步菜单失败');
208
249
  throw error;
209
250
  }
210
251
  }
@@ -231,52 +272,49 @@ async function deleteObsoleteRecords(helper: any, configPaths: Set<string>): Pro
231
272
  }
232
273
 
233
274
  /**
234
- * 加载所有菜单配置(addon + 项目)
235
- * @returns 菜单配置数组
275
+ * 加载所有菜单配置(addon views + 项目 menus.json)
236
276
  */
237
- async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; addonName: string }>> {
238
- const allMenus: Array<{ menus: MenuConfig[]; addonName: string }> = [];
277
+ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; source: string }>> {
278
+ const allMenus: Array<{ menus: MenuConfig[]; source: string }> = [];
239
279
 
240
- // 1. 加载所有 addon 配置
280
+ // 1. 扫描所有 addon 的 views 目录
241
281
  const addonNames = scanAddons();
242
282
 
243
283
  for (const addonName of addonNames) {
244
284
  try {
245
285
  const addonDir = getAddonDir(addonName, '');
246
- const addonConfigData = await scanConfig({
247
- dirs: [addonDir],
248
- files: ['addon.config'],
249
- mode: 'first',
250
- paths: ['menus']
251
- });
252
-
253
- const addonMenus = addonConfigData?.menus || [];
254
- if (Array.isArray(addonMenus) && addonMenus.length > 0) {
255
- // 为 addon 菜单添加路径前缀
256
- const menusWithPrefix = addAddonPrefix(addonMenus, addonName);
257
- allMenus.push({ menus: menusWithPrefix, addonName: addonName });
286
+ const viewsDir = join(addonDir, 'views');
287
+
288
+ if (existsSync(viewsDir)) {
289
+ const prefix = `/addon/${addonName}`;
290
+ const menus = await scanViewsDir(viewsDir, prefix);
291
+ if (menus.length > 0) {
292
+ allMenus.push({
293
+ menus: menus,
294
+ source: `addon:${addonName}`
295
+ });
296
+ }
258
297
  }
259
298
  } catch (error: any) {
260
- Logger.warn(`读取 addon 配置失败 ${addonName}: ${error.message}`);
299
+ Logger.warn({ err: error, addon: addonName }, '扫描 addon views 目录失败');
261
300
  }
262
301
  }
263
302
 
264
- // 2. 加载项目配置
265
- try {
266
- const appConfigData = await scanConfig({
267
- dirs: [projectDir],
268
- files: ['app.config'],
269
- mode: 'first',
270
- paths: ['menus']
271
- });
272
-
273
- const appMenus = appConfigData?.menus || [];
274
- if (Array.isArray(appMenus) && appMenus.length > 0) {
275
- // 项目菜单不添加前缀
276
- allMenus.push({ menus: appMenus, addonName: 'app' });
303
+ // 2. 读取项目的 menus.json
304
+ const menusJsonPath = join(projectDir, 'menus.json');
305
+ if (existsSync(menusJsonPath)) {
306
+ try {
307
+ const content = await readFile(menusJsonPath, 'utf-8');
308
+ const projectMenus = JSON.parse(content);
309
+ if (Array.isArray(projectMenus) && projectMenus.length > 0) {
310
+ allMenus.push({
311
+ menus: projectMenus,
312
+ source: 'project'
313
+ });
314
+ }
315
+ } catch (error: any) {
316
+ Logger.warn({ err: error }, '读取项目 menus.json 失败');
277
317
  }
278
- } catch (error: any) {
279
- Logger.warn(`读取项目配置失败: ${error.message}`);
280
318
  }
281
319
 
282
320
  return allMenus;
@@ -285,39 +323,46 @@ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; addonName
285
323
  /**
286
324
  * SyncMenu 命令主函数
287
325
  */
288
- export async function syncMenuCommand(config: BeflyOptions, options: SyncMenuOptions = {}): Promise<void> {
326
+ export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<void> {
289
327
  try {
290
328
  if (options.plan) {
291
329
  Logger.debug('[计划] 同步菜单配置到数据库(plan 模式不执行)');
292
330
  return;
293
331
  }
294
332
 
295
- // 1. 加载所有菜单配置(addon + 项目)
333
+ // 1. 加载所有菜单配置
296
334
  const allMenus = await loadMenuConfigs();
297
335
 
298
336
  // 2. 合并菜单配置
299
- const mergedMenus = mergeMenuConfigs(allMenus);
337
+ let mergedMenus = mergeMenuConfigs(allMenus);
338
+
339
+ // 3. 过滤隐藏菜单(根据 hiddenMenus 配置)
340
+ const hiddenMenus = (beflyConfig as any).hiddenMenus || [];
341
+ if (Array.isArray(hiddenMenus) && hiddenMenus.length > 0) {
342
+ const hiddenSet = new Set(hiddenMenus);
343
+ mergedMenus = filterHiddenMenus(mergedMenus, hiddenSet);
344
+ }
300
345
 
301
- // 连接数据库(SQL + Redis)
302
- await Connect.connect(config);
346
+ // 连接数据库
347
+ await Connect.connect();
303
348
 
304
- const helper = Connect.getDbHelper();
349
+ const helper = new DbHelper({ redis: new RedisHelper() } as any, Connect.getSql());
305
350
 
306
- // 3. 检查表是否存在(addon_admin_menu 来自 addon-admin 组件)
351
+ // 3. 检查表是否存在
307
352
  const exists = await helper.tableExists('addon_admin_menu');
308
353
 
309
354
  if (!exists) {
310
- Logger.debug('表 addon_admin_menu 不存在,跳过菜单同步(需要安装 addon-admin 组件)');
355
+ Logger.debug('表 addon_admin_menu 不存在,跳过菜单同步');
311
356
  return;
312
357
  }
313
358
 
314
- // 4. 收集配置文件中所有菜单的 path
359
+ // 4. 收集所有菜单的 path
315
360
  const configPaths = collectPaths(mergedMenus);
316
361
 
317
362
  // 5. 同步菜单
318
363
  await syncMenus(helper, mergedMenus);
319
364
 
320
- // 6. 删除文件中不存在的菜单(强制删除)
365
+ // 6. 删除不存在的菜单
321
366
  await deleteObsoleteRecords(helper, configPaths);
322
367
 
323
368
  // 7. 获取最终菜单数据(用于缓存)
@@ -330,12 +375,12 @@ export async function syncMenuCommand(config: BeflyOptions, options: SyncMenuOpt
330
375
  // 8. 缓存菜单数据到 Redis
331
376
  try {
332
377
  const redisHelper = new RedisHelper();
333
- await redisHelper.setObject('menus:all', allMenusData);
378
+ await redisHelper.setObject(RedisKeys.menusAll(), allMenusData);
334
379
  } catch (error: any) {
335
- Logger.warn(`Redis 缓存菜单数据失败: ${error.message}`);
380
+ Logger.warn({ err: error }, 'Redis 缓存菜单数据失败');
336
381
  }
337
382
  } catch (error: any) {
338
- Logger.error('菜单同步失败', error);
383
+ Logger.error({ err: error }, '菜单同步失败');
339
384
  throw error;
340
385
  } finally {
341
386
  await Connect.disconnect();