@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 +21 -2
- package/libs/controllers/index.js +43 -2
- package/libs/services/folder.js +154 -41
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -23,9 +23,9 @@ module.exports = fp(
|
|
|
23
23
|
createAuthenticate: () => {
|
|
24
24
|
return [];
|
|
25
25
|
},
|
|
26
|
-
//
|
|
26
|
+
// 文件夹能力;插件内部自行注册 @kne/fastify-group,无需宿主再挂一份
|
|
27
27
|
enableFolder: true,
|
|
28
|
-
groupName: '
|
|
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: '
|
|
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
|
{
|
package/libs/services/folder.js
CHANGED
|
@@ -5,10 +5,10 @@ module.exports = fp(async (fastify, fastifyOptions) => {
|
|
|
5
5
|
return;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
const groupName = fastifyOptions.groupName || '
|
|
8
|
+
const groupName = fastifyOptions.groupName || 'file-manager-folder';
|
|
9
9
|
const group = fastify[groupName];
|
|
10
10
|
if (!group?.services) {
|
|
11
|
-
throw new Error(
|
|
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
|
|
75
|
-
const
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
+
"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
|
-
"
|
|
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",
|