befly 3.8.30 → 3.8.32

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 (47) hide show
  1. package/README.md +83 -0
  2. package/{config.ts → befly.config.ts} +26 -6
  3. package/checks/checkApp.ts +31 -1
  4. package/hooks/cors.ts +3 -3
  5. package/hooks/parser.ts +3 -3
  6. package/hooks/validator.ts +1 -1
  7. package/lib/cacheHelper.ts +0 -6
  8. package/lib/cipher.ts +2 -1
  9. package/lib/connect.ts +17 -19
  10. package/lib/jwt.ts +1 -1
  11. package/lib/logger.ts +1 -1
  12. package/lib/validator.ts +149 -384
  13. package/loader/loadHooks.ts +4 -3
  14. package/loader/loadPlugins.ts +7 -9
  15. package/main.ts +22 -36
  16. package/package.json +6 -5
  17. package/plugins/cipher.ts +1 -1
  18. package/plugins/config.ts +3 -4
  19. package/plugins/db.ts +4 -5
  20. package/plugins/jwt.ts +3 -2
  21. package/plugins/logger.ts +6 -6
  22. package/plugins/redis.ts +8 -12
  23. package/router/static.ts +3 -6
  24. package/sync/syncAll.ts +7 -12
  25. package/sync/syncApi.ts +4 -3
  26. package/sync/syncDb.ts +6 -5
  27. package/sync/syncDev.ts +9 -8
  28. package/sync/syncMenu.ts +174 -132
  29. package/tests/integration.test.ts +2 -6
  30. package/tests/redisHelper.test.ts +1 -2
  31. package/tests/validator.test.ts +611 -85
  32. package/types/befly.d.ts +7 -0
  33. package/types/cache.d.ts +73 -0
  34. package/types/common.d.ts +1 -37
  35. package/types/database.d.ts +5 -0
  36. package/types/index.ts +5 -5
  37. package/types/plugin.d.ts +1 -4
  38. package/types/redis.d.ts +37 -2
  39. package/types/table.d.ts +6 -44
  40. package/util.ts +283 -0
  41. package/tests/validator-advanced.test.ts +0 -653
  42. package/types/addon.d.ts +0 -50
  43. package/types/crypto.d.ts +0 -23
  44. package/types/jwt.d.ts +0 -99
  45. package/types/logger.d.ts +0 -13
  46. package/types/tool.d.ts +0 -67
  47. package/types/validator.d.ts +0 -43
package/sync/syncMenu.ts CHANGED
@@ -1,82 +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';
20
21
  import { DbHelper } from '../lib/dbHelper.js';
21
22
  import { RedisHelper } from '../lib/redisHelper.js';
22
23
  import { RedisKeys } from 'befly-shared/redisKeys';
23
24
  import { scanAddons, getAddonDir } from 'befly-shared/addonHelper';
24
- import { scanConfig } from 'befly-shared/scanConfig';
25
25
  import { Logger } from '../lib/logger.js';
26
26
  import { projectDir } from '../paths.js';
27
+ import { beflyConfig } from '../befly.config.js';
27
28
 
28
- import type { SyncMenuOptions, MenuConfig, BeflyOptions } from '../types/index.js';
29
+ import type { SyncMenuOptions, MenuConfig } from '../types/index.js';
29
30
 
30
31
  /**
31
- * 递归转换菜单路径
32
- * @param menu 菜单对象(会被修改)
33
- * @param transform 路径转换函数
32
+ * 清理目录名中的数字后缀
33
+ * 如:login_1 login, index_2 → index
34
34
  */
35
- function transformMenuPaths(menu: MenuConfig, transform: (path: string) => string): void {
36
- if (menu.path && menu.path.startsWith('/')) {
37
- menu.path = transform(menu.path);
38
- }
39
- menu.children?.forEach((child) => transformMenuPaths(child, transform));
35
+ function cleanDirName(name: string): string {
36
+ return name.replace(/_\d+$/, '');
40
37
  }
41
38
 
42
39
  /**
43
- * addon 菜单的 path 添加前缀
44
- * 规则:
45
- * 1. 所有路径必须以 / 开头
46
- * 2. 所有路径都添加 /addon/{addonName} 前缀(包括根路径 /)
47
- * 3. 递归处理所有层级的子菜单
48
- * 4. 项目菜单不添加前缀
40
+ * 扫描 views 目录,构建菜单树
41
+ * @param viewsDir views 目录路径
42
+ * @param prefix 路径前缀(addon 前缀)
43
+ * @param parentPath 父级路径
44
+ * @returns 菜单数组
49
45
  */
50
- function addAddonPrefix(menus: MenuConfig[], addonName: string): MenuConfig[] {
51
- return menus.map((menu) => {
52
- const cloned = cloneDeep(menu);
53
- transformMenuPaths(cloned, (path) => `/addon/${addonName}${path}`);
54
- return cloned;
55
- });
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;
56
110
  }
57
111
 
58
112
  /**
59
113
  * 合并菜单配置
60
- * 优先级:项目 package.json > addon package.json
61
- * 支持三级菜单结构:父级、子级、孙级
114
+ * 支持无限层级菜单结构
62
115
  */
63
- function mergeMenuConfigs(allMenus: Array<{ menus: MenuConfig[]; addonName: string }>): MenuConfig[] {
64
- /**
65
- * 递归合并指定层级的菜单(限制最多3层)
66
- * @param menus 待合并的菜单数组
67
- * @param depth 当前深度(1=父级, 2=子级, 3=孙级)
68
- * @returns 合并后的菜单数组
69
- */
70
- function mergeLevel(menus: MenuConfig[], depth: number = 1): MenuConfig[] {
71
- const menuMap = new Map<string, MenuConfig>();
116
+ function mergeMenuConfigs(allMenus: Array<{ menus: MenuConfig[]; source: string }>): MenuConfig[] {
117
+ const menuMap = new Map<string, MenuConfig>();
72
118
 
119
+ for (const { menus } of allMenus) {
73
120
  for (const menu of menus) {
74
121
  if (!menu.path) continue;
75
122
 
76
123
  const existing = menuMap.get(menu.path);
77
124
  if (existing) {
78
125
  // 合并子菜单
79
- if (menu.children?.length > 0) {
126
+ if (menu.children && menu.children.length > 0) {
80
127
  existing.children = existing.children || [];
81
128
  existing.children.push(...menu.children);
82
129
  }
@@ -84,73 +131,67 @@ function mergeMenuConfigs(allMenus: Array<{ menus: MenuConfig[]; addonName: stri
84
131
  menuMap.set(menu.path, { ...menu });
85
132
  }
86
133
  }
134
+ }
87
135
 
88
- // 递归处理子菜单(限制最多3层)
89
- if (depth < 3) {
90
- for (const menu of menuMap.values()) {
91
- if (menu.children?.length > 0) {
92
- menu.children = mergeLevel(menu.children, depth + 1);
93
- }
94
- }
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;
95
149
  }
96
150
 
97
- return Array.from(menuMap.values());
98
- }
151
+ const filtered = { ...menu };
152
+
153
+ // 递归过滤子菜单
154
+ if (filtered.children && filtered.children.length > 0) {
155
+ filtered.children = filterHiddenMenus(filtered.children, hiddenSet);
156
+ }
99
157
 
100
- // 收集所有菜单(扁平化)
101
- const allFlatMenus = allMenus.flatMap(({ menus }) => menus);
158
+ result.push(filtered);
159
+ }
102
160
 
103
- return mergeLevel(allFlatMenus, 1);
161
+ return result;
104
162
  }
105
163
 
106
164
  /**
107
- * 收集配置文件中所有菜单的 path(最多3级)
108
- * 子级菜单使用独立路径
165
+ * 收集所有菜单的 path(递归收集所有层级)
109
166
  */
110
167
  function collectPaths(menus: MenuConfig[]): Set<string> {
111
168
  const paths = new Set<string>();
112
169
 
113
- for (const menu of menus) {
114
- if (menu.path) {
115
- paths.add(menu.path);
116
- }
117
- if (menu.children && menu.children.length > 0) {
118
- for (const child of menu.children) {
119
- if (child.path) {
120
- paths.add(child.path);
121
- }
122
- // 第三层菜单
123
- if (child.children && child.children.length > 0) {
124
- for (const grandChild of child.children) {
125
- if (grandChild.path) {
126
- paths.add(grandChild.path);
127
- }
128
- }
129
- }
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);
130
177
  }
131
178
  }
132
179
  }
133
180
 
181
+ collect(menus);
134
182
  return paths;
135
183
  }
136
184
 
137
185
  /**
138
- * 递归同步单个菜单(限制最多3层)
139
- * @param helper 数据库帮助类
140
- * @param menu 菜单配置
141
- * @param pid 父级菜单ID
142
- * @param existingMenuMap 现有菜单映射
143
- * @param depth 当前深度(1=父级, 2=子级, 3=孙级)
144
- * @returns 菜单ID
186
+ * 递归同步单个菜单(无层级限制)
145
187
  */
146
- 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> {
147
189
  const existing = existingMenuMap.get(menu.path || '');
148
190
  let menuId: number;
149
191
 
150
192
  if (existing) {
151
193
  menuId = existing.id;
152
194
 
153
- // 检查是否需要更新
154
195
  const needUpdate = existing.pid !== pid || existing.name !== menu.name || existing.sort !== (menu.sort || 0);
155
196
 
156
197
  if (needUpdate) {
@@ -176,10 +217,9 @@ async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, exi
176
217
  });
177
218
  }
178
219
 
179
- // 递归处理子菜单(限制最多3层)
180
- if (depth < 3 && menu.children?.length > 0) {
220
+ if (menu.children && menu.children.length > 0) {
181
221
  for (const child of menu.children) {
182
- await syncMenuRecursive(helper, child, menuId, existingMenuMap, depth + 1);
222
+ await syncMenuRecursive(helper, child, menuId, existingMenuMap);
183
223
  }
184
224
  }
185
225
 
@@ -187,11 +227,9 @@ async function syncMenuRecursive(helper: any, menu: MenuConfig, pid: number, exi
187
227
  }
188
228
 
189
229
  /**
190
- * 同步菜单(三层结构:父级、子级、孙级)
191
- * 子级菜单使用独立路径
230
+ * 同步菜单到数据库
192
231
  */
193
232
  async function syncMenus(helper: any, menus: MenuConfig[]): Promise<void> {
194
- // 批量查询所有现有菜单,建立 path -> menu 的映射
195
233
  const allExistingMenus = await helper.getAll({
196
234
  table: 'addon_admin_menu',
197
235
  fields: ['id', 'pid', 'name', 'path', 'sort']
@@ -234,52 +272,49 @@ async function deleteObsoleteRecords(helper: any, configPaths: Set<string>): Pro
234
272
  }
235
273
 
236
274
  /**
237
- * 加载所有菜单配置(addon + 项目)
238
- * @returns 菜单配置数组
275
+ * 加载所有菜单配置(addon views + 项目 menus.json)
239
276
  */
240
- async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; addonName: string }>> {
241
- 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 }> = [];
242
279
 
243
- // 1. 加载所有 addon 配置
280
+ // 1. 扫描所有 addon 的 views 目录
244
281
  const addonNames = scanAddons();
245
282
 
246
283
  for (const addonName of addonNames) {
247
284
  try {
248
285
  const addonDir = getAddonDir(addonName, '');
249
- const addonConfigData = await scanConfig({
250
- dirs: [addonDir],
251
- files: ['addon.config'],
252
- mode: 'first',
253
- paths: ['menus']
254
- });
255
-
256
- const addonMenus = addonConfigData?.menus || [];
257
- if (Array.isArray(addonMenus) && addonMenus.length > 0) {
258
- // 为 addon 菜单添加路径前缀
259
- const menusWithPrefix = addAddonPrefix(addonMenus, addonName);
260
- 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
+ }
261
297
  }
262
298
  } catch (error: any) {
263
- Logger.warn({ err: error, addon: addonName }, '读取 addon 配置失败');
299
+ Logger.warn({ err: error, addon: addonName }, '扫描 addon views 目录失败');
264
300
  }
265
301
  }
266
302
 
267
- // 2. 加载项目配置
268
- try {
269
- const appConfigData = await scanConfig({
270
- dirs: [projectDir],
271
- files: ['app.config'],
272
- mode: 'first',
273
- paths: ['menus']
274
- });
275
-
276
- const appMenus = appConfigData?.menus || [];
277
- if (Array.isArray(appMenus) && appMenus.length > 0) {
278
- // 项目菜单不添加前缀
279
- 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 失败');
280
317
  }
281
- } catch (error: any) {
282
- Logger.warn({ err: error }, '读取项目配置失败');
283
318
  }
284
319
 
285
320
  return allMenus;
@@ -288,39 +323,46 @@ async function loadMenuConfigs(): Promise<Array<{ menus: MenuConfig[]; addonName
288
323
  /**
289
324
  * SyncMenu 命令主函数
290
325
  */
291
- export async function syncMenuCommand(config: BeflyOptions, options: SyncMenuOptions = {}): Promise<void> {
326
+ export async function syncMenuCommand(options: SyncMenuOptions = {}): Promise<void> {
292
327
  try {
293
328
  if (options.plan) {
294
329
  Logger.debug('[计划] 同步菜单配置到数据库(plan 模式不执行)');
295
330
  return;
296
331
  }
297
332
 
298
- // 1. 加载所有菜单配置(addon + 项目)
333
+ // 1. 加载所有菜单配置
299
334
  const allMenus = await loadMenuConfigs();
300
335
 
301
336
  // 2. 合并菜单配置
302
- 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
+ }
303
345
 
304
- // 连接数据库(SQL + Redis)
305
- await Connect.connect(config);
346
+ // 连接数据库
347
+ await Connect.connect();
306
348
 
307
349
  const helper = new DbHelper({ redis: new RedisHelper() } as any, Connect.getSql());
308
350
 
309
- // 3. 检查表是否存在(addon_admin_menu 来自 addon-admin 组件)
351
+ // 3. 检查表是否存在
310
352
  const exists = await helper.tableExists('addon_admin_menu');
311
353
 
312
354
  if (!exists) {
313
- Logger.debug('表 addon_admin_menu 不存在,跳过菜单同步(需要安装 addon-admin 组件)');
355
+ Logger.debug('表 addon_admin_menu 不存在,跳过菜单同步');
314
356
  return;
315
357
  }
316
358
 
317
- // 4. 收集配置文件中所有菜单的 path
359
+ // 4. 收集所有菜单的 path
318
360
  const configPaths = collectPaths(mergedMenus);
319
361
 
320
362
  // 5. 同步菜单
321
363
  await syncMenus(helper, mergedMenus);
322
364
 
323
- // 6. 删除文件中不存在的菜单(强制删除)
365
+ // 6. 删除不存在的菜单
324
366
  await deleteObsoleteRecords(helper, configPaths);
325
367
 
326
368
  // 7. 获取最终菜单数据(用于缓存)
@@ -57,8 +57,6 @@ describe('Integration - JWT + 权限验证', () => {
57
57
 
58
58
  describe('Integration - 数据验证 + SQL 构建', () => {
59
59
  test('API 请求:验证数据 + 构建查询', () => {
60
- const validator = new Validator();
61
-
62
60
  // 1. 验证用户输入
63
61
  const userData = {
64
62
  email: 'test@example.com',
@@ -72,7 +70,7 @@ describe('Integration - 数据验证 + SQL 构建', () => {
72
70
  username: { name: '用户名', type: 'string', min: 2, max: 20 }
73
71
  };
74
72
 
75
- const validationResult = validator.validate(userData, rules, ['email', 'username']);
73
+ const validationResult = Validator.validate(userData, rules, ['email', 'username']);
76
74
  expect(validationResult.code).toBe(0);
77
75
 
78
76
  // 2. 验证通过后构建 SQL 查询
@@ -86,8 +84,6 @@ describe('Integration - 数据验证 + SQL 构建', () => {
86
84
  });
87
85
 
88
86
  test('数据插入:验证 + 字段转换 + SQL 构建', () => {
89
- const validator = new Validator();
90
-
91
87
  // 1. 验证数据
92
88
  const newUser = {
93
89
  userName: 'jane',
@@ -101,7 +97,7 @@ describe('Integration - 数据验证 + SQL 构建', () => {
101
97
  userAge: { name: '年龄', type: 'number', min: 0, max: 150 }
102
98
  };
103
99
 
104
- const validationResult = validator.validate(newUser, rules, ['userName', 'userEmail']);
100
+ const validationResult = Validator.validate(newUser, rules, ['userName', 'userEmail']);
105
101
  expect(validationResult.code).toBe(0);
106
102
 
107
103
  // 2. 字段转换(驼峰转下划线)
@@ -6,7 +6,6 @@
6
6
  import { describe, expect, it, test, beforeAll, afterAll } from 'bun:test';
7
7
  import { RedisClient } from 'bun';
8
8
 
9
- import { defaultOptions } from '../config.js';
10
9
  import { Connect } from '../lib/connect.js';
11
10
  import { RedisHelper } from '../lib/redisHelper.js';
12
11
 
@@ -14,7 +13,7 @@ let redis: RedisHelper;
14
13
 
15
14
  beforeAll(async () => {
16
15
  // 连接 Redis
17
- await Connect.connectRedis(defaultOptions.redis);
16
+ await Connect.connectRedis();
18
17
  redis = new RedisHelper();
19
18
  });
20
19