@kne/fastify-file-manager 3.0.3 → 3.0.6

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/index.js CHANGED
@@ -23,9 +23,9 @@ module.exports = fp(
23
23
  createAuthenticate: () => {
24
24
  return [];
25
25
  },
26
- // 文件夹能力依赖 @kne/fastify-group,需先注册;设为 false 可关闭
26
+ // 文件夹能力;插件内部自行注册 @kne/fastify-group,无需宿主再挂一份
27
27
  enableFolder: true,
28
- groupName: 'group',
28
+ groupName: 'file-manager-folder',
29
29
  // 上传自动挂载文件系统时的默认业务域
30
30
  defaultFolderType: 'admin-file-system',
31
31
  getAuthenticate: () => {
@@ -35,6 +35,25 @@ module.exports = fp(
35
35
  options
36
36
  );
37
37
  await fs.ensureDir(options.root);
38
+
39
+ if (options.enableFolder !== false) {
40
+ const denyGroupHttp = () => [
41
+ async () => {
42
+ const err = new Error('Forbidden');
43
+ err.statusCode = 403;
44
+ throw err;
45
+ }
46
+ ];
47
+ await fastify.register(require('@kne/fastify-group'), {
48
+ name: options.groupName,
49
+ // 表名 = prefix + snakeCase(name + Model),默认 t_ + file_manager_folder_tag
50
+ dbTableNamePrefix: options.groupDbTableNamePrefix || 't_',
51
+ // 仅使用 group 的 models/services;HTTP API 一律 403,业务走 folder/*
52
+ prefix: options.groupPrefix || `${options.prefix}/folder-group`,
53
+ getAuthenticate: denyGroupHttp
54
+ });
55
+ }
56
+
38
57
  fastify.register(require('@fastify/multipart'), options.multipart);
39
58
  fastify.register(require('@kne/fastify-namespace'), {
40
59
  name: 'fileManager',
@@ -356,12 +356,13 @@ module.exports = fp(async (fastify, options) => {
356
356
  onRequest: folderOnRequest('read', 'query'),
357
357
  schema: {
358
358
  summary: '获取文件夹树',
359
- description: '基于 fastify-group 获取指定 type 的文件树;未传 type 时使用 defaultFolderType',
359
+ description: '获取指定 type 的文件树;kind=folder 时仅返回文件夹节点;未传 type 时使用 defaultFolderType',
360
360
  query: {
361
361
  type: 'object',
362
362
  properties: {
363
363
  type: { type: 'string', description: '业务域类型,默认 admin-file-system' },
364
- language: { type: 'string', description: '语言' }
364
+ language: { type: 'string', description: '语言' },
365
+ kind: { type: 'string', description: '传 folder 时仅返回文件夹树' }
365
366
  }
366
367
  }
367
368
  }
@@ -371,6 +372,46 @@ module.exports = fp(async (fastify, options) => {
371
372
  }
372
373
  );
373
374
 
375
+ fastify.post(
376
+ `${options.prefix}/folder/list`,
377
+ {
378
+ onRequest: folderOnRequest('read', 'body'),
379
+ schema: {
380
+ summary: '分页获取目录子节点',
381
+ description: '按 parentId 分页列出直接子节点;文件夹优先,再按名称排序;未传 type 时使用 defaultFolderType',
382
+ body: {
383
+ type: 'object',
384
+ properties: {
385
+ type: { type: 'string', description: '业务域类型,默认 admin-file-system' },
386
+ parentId: { type: 'string', description: '父文件夹 id,根目录可不传' },
387
+ currentPage: { type: 'number', description: '当前页数' },
388
+ perPage: { type: 'number', description: '每页数量' },
389
+ keyword: { type: 'string', description: '按名称模糊搜索' },
390
+ language: { type: 'string', description: '语言' }
391
+ }
392
+ }
393
+ }
394
+ },
395
+ async request => {
396
+ const { type, parentId, currentPage, perPage, keyword, language } = Object.assign(
397
+ {},
398
+ {
399
+ currentPage: 1,
400
+ perPage: 20
401
+ },
402
+ request.body
403
+ );
404
+ return await services.folder.getList({
405
+ type,
406
+ parentId,
407
+ currentPage,
408
+ perPage,
409
+ keyword,
410
+ language
411
+ });
412
+ }
413
+ );
414
+
374
415
  fastify.post(
375
416
  `${options.prefix}/folder/mkdir`,
376
417
  {
@@ -5,10 +5,10 @@ module.exports = fp(async (fastify, fastifyOptions) => {
5
5
  return;
6
6
  }
7
7
 
8
- const groupName = fastifyOptions.groupName || 'group';
8
+ const groupName = fastifyOptions.groupName || 'file-manager-folder';
9
9
  const group = fastify[groupName];
10
10
  if (!group?.services) {
11
- throw new Error(`文件夹功能需要先注册 @kne/fastify-group(fastify.${groupName})`);
11
+ throw new Error(`文件夹功能未就绪:缺少内部 group 命名空间 fastify['${groupName}']`);
12
12
  }
13
13
 
14
14
  const { services } = fastify.fileManager;
@@ -47,6 +47,44 @@ module.exports = fp(async (fastify, fastifyOptions) => {
47
47
  return node ? (typeof node.get === 'function' ? node.get({ plain: true }) : node) : null;
48
48
  };
49
49
 
50
+ /** 重名时在扩展名前加 [_时间戳],如 avatar.png → avatar[_1712345678901].png */
51
+ const withTimestampSuffix = name => {
52
+ const str = String(name || 'file');
53
+ const lastDot = str.lastIndexOf('.');
54
+ const hasExt = lastDot > 0 && lastDot < str.length - 1;
55
+ const base = hasExt ? str.slice(0, lastDot) : str;
56
+ const ext = hasExt ? str.slice(lastDot) : '';
57
+ return `${base}[_${Date.now()}]${ext}`;
58
+ };
59
+
60
+ const allocateUniqueSiblingName = async ({ type, parentId, name, language, tenantId, existingNames }) => {
61
+ const taken = async candidate => {
62
+ if (existingNames && existingNames.has(String(candidate))) {
63
+ return true;
64
+ }
65
+ const existing = await findChildByName({
66
+ type,
67
+ parentId: parentId || null,
68
+ name: candidate,
69
+ language,
70
+ tenantId
71
+ });
72
+ return !!existing;
73
+ };
74
+
75
+ let candidate = String(name || 'file');
76
+ if (!(await taken(candidate))) {
77
+ return candidate;
78
+ }
79
+ for (let i = 0; i < 5; i++) {
80
+ candidate = withTimestampSuffix(name);
81
+ if (!(await taken(candidate))) {
82
+ return candidate;
83
+ }
84
+ }
85
+ return withTimestampSuffix(`${name}_${Math.random().toString(36).slice(2, 8)}`);
86
+ };
87
+
50
88
  const assertUniqueSiblingName = async ({ type, parentId, name, language, tenantId, excludeId }) => {
51
89
  const existing = await findChildByName({ type, parentId: parentId || null, name, language, tenantId });
52
90
  if (existing && String(existing.id) !== String(excludeId || '')) {
@@ -71,13 +109,8 @@ module.exports = fp(async (fastify, fastifyOptions) => {
71
109
  return ids;
72
110
  };
73
111
 
74
- const enrichTree = async tree => {
75
- const fileIds = collectFileIds(tree);
76
- if (fileIds.length === 0) {
77
- return tree;
78
- }
79
-
80
- const uniqueIds = [...new Set(fileIds.map(String))];
112
+ const loadFileMap = async fileIds => {
113
+ const uniqueIds = [...new Set((fileIds || []).map(String).filter(Boolean))];
81
114
  const fileMap = new Map();
82
115
  await Promise.all(
83
116
  uniqueIds.map(async id => {
@@ -85,33 +118,44 @@ module.exports = fp(async (fastify, fastifyOptions) => {
85
118
  const file = await services.fileRecord.getFileInstance({ id });
86
119
  fileMap.set(String(id), file);
87
120
  } catch (e) {
88
- // 文件记录缺失时仍返回树节点
121
+ // 文件记录缺失时仍返回节点
89
122
  }
90
123
  })
91
124
  );
125
+ return fileMap;
126
+ };
92
127
 
93
- const mapNode = node => {
94
- const plain = { ...node };
95
- const kind = getNodeKind(plain);
96
- plain.options = Object.assign({}, plain.options, { kind });
97
- if (kind === 'file' && plain.options.fileId) {
98
- const file = fileMap.get(String(plain.options.fileId));
99
- if (file) {
100
- plain.options = Object.assign({}, plain.options, {
101
- size: file.size,
102
- mimetype: file.mimetype,
103
- filename: file.filename
104
- });
105
- if (file.createdAt) {
106
- plain.createdAt = file.createdAt;
107
- }
108
- if (file.updatedAt) {
109
- plain.updatedAt = file.updatedAt;
110
- }
128
+ const applyFileMeta = (node, fileMap) => {
129
+ const plain = { ...node };
130
+ const kind = getNodeKind(plain);
131
+ plain.options = Object.assign({}, plain.options, { kind });
132
+ if (kind === 'file' && plain.options.fileId) {
133
+ const file = fileMap.get(String(plain.options.fileId));
134
+ if (file) {
135
+ plain.options = Object.assign({}, plain.options, {
136
+ size: file.size,
137
+ mimetype: file.mimetype,
138
+ filename: file.filename
139
+ });
140
+ if (file.createdAt) {
141
+ plain.createdAt = file.createdAt;
142
+ }
143
+ if (file.updatedAt) {
144
+ plain.updatedAt = file.updatedAt;
111
145
  }
112
146
  }
113
- if (plain.children?.length) {
114
- plain.children = plain.children.map(mapNode);
147
+ }
148
+ return plain;
149
+ };
150
+
151
+ const enrichTree = async tree => {
152
+ const fileIds = collectFileIds(tree);
153
+ const fileMap = fileIds.length === 0 ? new Map() : await loadFileMap(fileIds);
154
+
155
+ const mapNode = node => {
156
+ const plain = applyFileMeta(node, fileMap);
157
+ if (node.children?.length) {
158
+ plain.children = node.children.map(mapNode);
115
159
  }
116
160
  return plain;
117
161
  };
@@ -119,12 +163,75 @@ module.exports = fp(async (fastify, fastifyOptions) => {
119
163
  return (tree || []).map(mapNode);
120
164
  };
121
165
 
122
- const getTree = async ({ type, language, tenantId }) => {
166
+ const enrichNodes = async nodes => {
167
+ const list = nodes || [];
168
+ const fileIds = list
169
+ .filter(node => getNodeKind(node) === 'file' && node.options?.fileId)
170
+ .map(node => node.options.fileId);
171
+ const fileMap = await loadFileMap(fileIds);
172
+ return list.map(node => applyFileMeta(node, fileMap));
173
+ };
174
+
175
+ const filterFoldersOnly = tree =>
176
+ (tree || [])
177
+ .filter(node => getNodeKind(node) === 'folder')
178
+ .map(node => {
179
+ const plain = { ...node };
180
+ plain.options = Object.assign({}, plain.options, { kind: 'folder' });
181
+ plain.children = filterFoldersOnly(node.children);
182
+ return plain;
183
+ });
184
+
185
+ const sortSiblingNodes = (left, right) => {
186
+ const leftIsFolder = getNodeKind(left) === 'folder' ? 0 : 1;
187
+ const rightIsFolder = getNodeKind(right) === 'folder' ? 0 : 1;
188
+ if (leftIsFolder !== rightIsFolder) {
189
+ return leftIsFolder - rightIsFolder;
190
+ }
191
+ return String(left.name || '').localeCompare(String(right.name || ''), undefined, {
192
+ numeric: true,
193
+ sensitivity: 'base'
194
+ });
195
+ };
196
+
197
+ const getTree = async ({ type, language, tenantId, kind }) => {
123
198
  if (!type) {
124
199
  throw new Error('必须传入类型');
125
200
  }
126
201
  const tree = await groupServices.groupList({ type, language, output: 'tree', tenantId });
127
- return enrichTree(tree);
202
+ const enriched = await enrichTree(tree);
203
+ if (kind === 'folder' || kind === 'foldersOnly') {
204
+ return filterFoldersOnly(enriched);
205
+ }
206
+ return enriched;
207
+ };
208
+
209
+ const getList = async ({ type, parentId, currentPage = 1, perPage = 20, keyword, language, tenantId }) => {
210
+ if (!type) {
211
+ throw new Error('必须传入类型');
212
+ }
213
+ const { Op } = fastify.sequelize.Sequelize;
214
+ const page = Math.max(1, Number(currentPage) || 1);
215
+ const size = Math.min(200, Math.max(1, Number(perPage) || 20));
216
+ const where = Object.assign(
217
+ { type, parentId: parentId || null },
218
+ language ? { language } : {},
219
+ tenantId != null ? { tenantId } : {}
220
+ );
221
+ const trimmedKeyword = String(keyword || '').trim();
222
+ if (trimmedKeyword) {
223
+ where.name = { [Op.like]: `%${trimmedKeyword}%` };
224
+ }
225
+
226
+ const rows = await group.models.tag.findAll({ where });
227
+ const plainRows = rows
228
+ .map(item => (typeof item.get === 'function' ? item.get({ plain: true }) : item))
229
+ .sort(sortSiblingNodes);
230
+ const totalCount = plainRows.length;
231
+ const start = (page - 1) * size;
232
+ const pageRows = plainRows.slice(start, start + size);
233
+ const pageData = await enrichNodes(pageRows);
234
+ return { pageData, totalCount };
128
235
  };
129
236
 
130
237
  const mkdir = async ({ type, name, parentId, language, tenantId }) => {
@@ -155,7 +262,7 @@ module.exports = fp(async (fastify, fastifyOptions) => {
155
262
  }
156
263
 
157
264
  const targetParentId = parentId || null;
158
- await assertUniqueSiblingName({
265
+ const uniqueName = await allocateUniqueSiblingName({
159
266
  type,
160
267
  parentId: targetParentId,
161
268
  name: file.filename,
@@ -164,7 +271,7 @@ module.exports = fp(async (fastify, fastifyOptions) => {
164
271
  });
165
272
 
166
273
  const record = await services.fileRecord.uploadToFileSystem({
167
- file,
274
+ file: Object.assign({}, file, { filename: uniqueName }),
168
275
  namespace: namespace || fastifyOptions.namespace
169
276
  });
170
277
 
@@ -496,7 +603,7 @@ module.exports = fp(async (fastify, fastifyOptions) => {
496
603
  return null;
497
604
  }
498
605
 
499
- await assertUniqueSiblingName({
606
+ const uniqueName = await allocateUniqueSiblingName({
500
607
  type,
501
608
  parentId: targetParentId,
502
609
  name: record.filename,
@@ -506,7 +613,7 @@ module.exports = fp(async (fastify, fastifyOptions) => {
506
613
 
507
614
  const tag = await groupServices.save({
508
615
  type,
509
- name: record.filename,
616
+ name: uniqueName,
510
617
  parentId: targetParentId,
511
618
  language,
512
619
  tenantId,
@@ -570,12 +677,17 @@ module.exports = fp(async (fastify, fastifyOptions) => {
570
677
  if (existingFileIds.has(linkedFileId) || existingFileIds.has(String(record.id)) || existingFileIds.has(String(fileId))) {
571
678
  continue;
572
679
  }
573
- if (existingNames.has(String(record.filename))) {
574
- throw new Error(`同一文件夹下已存在同名文件或文件夹:${record.filename}`);
575
- }
576
- const tag = await groupServices.save({
680
+ const uniqueName = await allocateUniqueSiblingName({
577
681
  type,
682
+ parentId: targetParentId,
578
683
  name: record.filename,
684
+ language,
685
+ tenantId,
686
+ existingNames
687
+ });
688
+ const tag = await groupServices.save({
689
+ type,
690
+ name: uniqueName,
579
691
  parentId: targetParentId,
580
692
  language,
581
693
  tenantId,
@@ -588,7 +700,7 @@ module.exports = fp(async (fastify, fastifyOptions) => {
588
700
  }
589
701
  });
590
702
  existingFileIds.add(linkedFileId);
591
- existingNames.add(String(record.filename));
703
+ existingNames.add(String(uniqueName));
592
704
  results.push(typeof tag.get === 'function' ? tag.get({ plain: true }) : tag);
593
705
  }
594
706
  return results;
@@ -597,6 +709,7 @@ module.exports = fp(async (fastify, fastifyOptions) => {
597
709
  Object.assign(services, {
598
710
  folder: {
599
711
  getTree,
712
+ getList,
600
713
  mkdir,
601
714
  upload,
602
715
  remove,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-file-manager",
3
- "version": "3.0.3",
3
+ "version": "3.0.6",
4
4
  "description": "用于管理静态文件上传查看等",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -45,7 +45,6 @@
45
45
  "sqlite3": "^5.1.7"
46
46
  },
47
47
  "peerDependencies": {
48
- "@kne/fastify-group": "*",
49
48
  "@kne/fastify-namespace": "*",
50
49
  "@kne/fastify-sequelize": "*",
51
50
  "fastify-plugin": ">=5"
@@ -53,7 +52,8 @@
53
52
  "dependencies": {
54
53
  "@fastify/multipart": "^9.0.3",
55
54
  "@fastify/static": "^8.1.1",
56
- "compressing": "^2.0.0",
55
+ "@kne/fastify-group": "^0.2.5",
56
+ "compressing": "^2.1.1",
57
57
  "fs-extra": "^11.2.0",
58
58
  "glob": "^13.0.0",
59
59
  "http-errors": "^2.0.0",