@kne/fastify-file-manager 3.0.1-alpha.3 → 3.0.3
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 +8 -0
- package/libs/controllers/index.js +526 -106
- package/libs/services/file-record.js +37 -12
- package/libs/services/folder.js +611 -0
- package/package.json +4 -2
package/index.js
CHANGED
|
@@ -22,6 +22,14 @@ module.exports = fp(
|
|
|
22
22
|
},
|
|
23
23
|
createAuthenticate: () => {
|
|
24
24
|
return [];
|
|
25
|
+
},
|
|
26
|
+
// 文件夹能力依赖 @kne/fastify-group,需先注册;设为 false 可关闭
|
|
27
|
+
enableFolder: true,
|
|
28
|
+
groupName: 'group',
|
|
29
|
+
// 上传自动挂载文件系统时的默认业务域
|
|
30
|
+
defaultFolderType: 'admin-file-system',
|
|
31
|
+
getAuthenticate: () => {
|
|
32
|
+
return [];
|
|
25
33
|
}
|
|
26
34
|
},
|
|
27
35
|
options
|
|
@@ -1,148 +1,568 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
2
|
|
|
3
|
+
const DEFAULT_FOLDER_TYPE = 'admin-file-system';
|
|
4
|
+
|
|
5
|
+
const readMultipartField = (file, name) => {
|
|
6
|
+
const field = file?.fields?.[name];
|
|
7
|
+
if (!field) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const target = Array.isArray(field) ? field[0] : field;
|
|
11
|
+
return target?.value;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const pickUploadFolderParams = (request, file, defaultType) => {
|
|
15
|
+
const path =
|
|
16
|
+
request.query?.path !== undefined
|
|
17
|
+
? request.query.path
|
|
18
|
+
: request.body?.path !== undefined
|
|
19
|
+
? request.body.path
|
|
20
|
+
: readMultipartField(file, 'path');
|
|
21
|
+
const type =
|
|
22
|
+
request.query?.type || request.body?.type || readMultipartField(file, 'type') || defaultType || DEFAULT_FOLDER_TYPE;
|
|
23
|
+
const language = request.query?.language || request.body?.language || readMultipartField(file, 'language');
|
|
24
|
+
// 未传 path 时挂到根目录
|
|
25
|
+
return { path: path === undefined ? '' : path, type, language };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const attachUploadedRecord = async ({ services, record, path, type, language }) => {
|
|
29
|
+
if (!services.folder) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!type) {
|
|
33
|
+
throw new Error('挂载文件系统时必须传入 type');
|
|
34
|
+
}
|
|
35
|
+
const parentId = await services.folder.ensurePath({ type, path, language });
|
|
36
|
+
return await services.folder.attachRecord({
|
|
37
|
+
type,
|
|
38
|
+
parentId,
|
|
39
|
+
record,
|
|
40
|
+
language,
|
|
41
|
+
linked: true
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
3
45
|
module.exports = fp(async (fastify, options) => {
|
|
4
46
|
const { services } = fastify.fileManager;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
47
|
+
const defaultFolderType = options.defaultFolderType || DEFAULT_FOLDER_TYPE;
|
|
48
|
+
|
|
49
|
+
fastify.post(
|
|
50
|
+
`${options.prefix}/upload`,
|
|
51
|
+
{
|
|
52
|
+
onRequest: options.createAuthenticate('file:write'),
|
|
53
|
+
schema: {
|
|
54
|
+
summary: '上传文件',
|
|
55
|
+
description:
|
|
56
|
+
'上传单个文件到服务器或配置的存储服务。保存成功后自动挂到文件系统(默认 type=admin-file-system,未传 path 时挂到根目录;传 path 则按路径创建并挂载)',
|
|
57
|
+
query: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
namespace: { type: 'string', description: '文件分类命名空间' },
|
|
61
|
+
path: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: '文件系统文件夹路径,如 a/b/c;未传则挂到根目录;路径不存在则自动创建'
|
|
64
|
+
},
|
|
65
|
+
type: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
description: '文件系统业务域 type,默认 admin-file-system'
|
|
68
|
+
},
|
|
69
|
+
language: { type: 'string', description: '语言' }
|
|
70
|
+
}
|
|
10
71
|
}
|
|
11
72
|
}
|
|
73
|
+
},
|
|
74
|
+
async request => {
|
|
75
|
+
const file = await request.file();
|
|
76
|
+
if (!file) {
|
|
77
|
+
throw new Error('不能获取到上传文件');
|
|
78
|
+
}
|
|
79
|
+
const record = await services.fileRecord.uploadToFileSystem({
|
|
80
|
+
file,
|
|
81
|
+
namespace: request.query.namespace || options.namespace
|
|
82
|
+
});
|
|
83
|
+
const { path, type, language } = pickUploadFolderParams(request, file, defaultFolderType);
|
|
84
|
+
const folder = await attachUploadedRecord({ services, record, path, type, language });
|
|
85
|
+
return folder ? Object.assign({}, record, { folder }) : record;
|
|
12
86
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
fastify.post(
|
|
90
|
+
`${options.prefix}/uploadUrl`,
|
|
91
|
+
{
|
|
92
|
+
onRequest: options.createAuthenticate('file:write'),
|
|
93
|
+
schema: {
|
|
94
|
+
summary: '上传URL文件',
|
|
95
|
+
description:
|
|
96
|
+
'上传单个文件到服务器或配置的存储服务。保存成功后自动挂到文件系统(默认 type=admin-file-system,未传 path 时挂到根目录;传 path 则按路径创建并挂载)',
|
|
97
|
+
body: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: {
|
|
100
|
+
url: { type: 'string', description: '文件url' },
|
|
101
|
+
namespace: { type: 'string', description: '文件分类命名空间' },
|
|
102
|
+
path: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
description: '文件系统文件夹路径,如 a/b/c;未传则挂到根目录;路径不存在则自动创建'
|
|
105
|
+
},
|
|
106
|
+
type: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
description: '文件系统业务域 type,默认 admin-file-system'
|
|
109
|
+
},
|
|
110
|
+
language: { type: 'string', description: '语言' }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
async request => {
|
|
116
|
+
const record = await services.fileRecord.uploadFromUrl({
|
|
117
|
+
url: request.body.url,
|
|
118
|
+
namespace: request.body.namespace || options.namespace
|
|
119
|
+
});
|
|
120
|
+
const { path, type, language } = pickUploadFolderParams(request, null, defaultFolderType);
|
|
121
|
+
const folder = await attachUploadedRecord({ services, record, path, type, language });
|
|
122
|
+
return folder ? Object.assign({}, record, { folder }) : record;
|
|
17
123
|
}
|
|
18
|
-
|
|
19
|
-
file, namespace: request.query.namespace || options.namespace
|
|
20
|
-
});
|
|
21
|
-
});
|
|
124
|
+
);
|
|
22
125
|
|
|
23
|
-
fastify.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
126
|
+
fastify.get(
|
|
127
|
+
`${options.prefix}/file-url/:id`,
|
|
128
|
+
{
|
|
129
|
+
onRequest: options.createAuthenticate('file:read'),
|
|
130
|
+
schema: {
|
|
131
|
+
summary: '获取文件url',
|
|
132
|
+
description: '获取文件url',
|
|
133
|
+
params: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
required: ['id'],
|
|
136
|
+
properties: {
|
|
137
|
+
id: { type: 'string', description: '文件id' }
|
|
138
|
+
}
|
|
29
139
|
}
|
|
30
140
|
}
|
|
141
|
+
},
|
|
142
|
+
async request => {
|
|
143
|
+
const { id } = request.params;
|
|
144
|
+
return await services.fileRecord.getFileUrl({ id });
|
|
31
145
|
}
|
|
32
|
-
|
|
33
|
-
return await services.fileRecord.uploadFromUrl({
|
|
34
|
-
url: request.body.url, namespace: request.body.namespace || options.namespace
|
|
35
|
-
});
|
|
36
|
-
});
|
|
146
|
+
);
|
|
37
147
|
|
|
38
|
-
fastify.get(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
148
|
+
fastify.get(
|
|
149
|
+
`${options.prefix}/file-id/:id`,
|
|
150
|
+
{
|
|
151
|
+
onRequest: options.createAuthenticate('file:read'),
|
|
152
|
+
schema: {
|
|
153
|
+
summary: '获取文件信息',
|
|
154
|
+
description: '获取文件信息',
|
|
155
|
+
query: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {
|
|
158
|
+
attachment: { type: 'boolean', description: '是否下载' },
|
|
159
|
+
filename: { type: 'string', description: '下载文件名' }
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
params: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
required: ['id'],
|
|
165
|
+
properties: {
|
|
166
|
+
id: { type: 'string', description: '文件id' }
|
|
167
|
+
}
|
|
43
168
|
}
|
|
44
169
|
}
|
|
170
|
+
},
|
|
171
|
+
async (request, reply) => {
|
|
172
|
+
const { id } = request.params;
|
|
173
|
+
const { attachment, filename: targetFilename } = request.query;
|
|
174
|
+
const { filePath, targetFile, filename, mimetype, ...props } = await services.fileRecord.getFileInfo({
|
|
175
|
+
id
|
|
176
|
+
});
|
|
177
|
+
if (targetFile) {
|
|
178
|
+
const outputFilename = encodeURIComponent(targetFilename || filename);
|
|
179
|
+
reply.header('Content-Type', mimetype);
|
|
180
|
+
reply.header('Content-Disposition', attachment ? `attachment; filename="${outputFilename}"` : `filename="${outputFilename}"`);
|
|
181
|
+
return reply.send(targetFile);
|
|
182
|
+
}
|
|
183
|
+
return attachment ? reply.download(filePath, targetFilename || filename) : reply.sendFile(filePath);
|
|
45
184
|
}
|
|
46
|
-
|
|
47
|
-
const { id } = request.params;
|
|
48
|
-
return await services.fileRecord.getFileUrl({ id });
|
|
49
|
-
});
|
|
185
|
+
);
|
|
50
186
|
|
|
51
|
-
fastify.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
187
|
+
fastify.post(
|
|
188
|
+
`${options.prefix}/file-list`,
|
|
189
|
+
{
|
|
190
|
+
onRequest: options.createAuthenticate('file:mange'),
|
|
191
|
+
schema: {
|
|
192
|
+
summary: '获取文件列表',
|
|
193
|
+
description: '查询指定命名空间下的文件列表',
|
|
194
|
+
body: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
perPage: { type: 'number', description: '每页数量' },
|
|
198
|
+
currentPage: { type: 'number', description: '当前页数' },
|
|
199
|
+
filter: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {
|
|
202
|
+
namespace: { type: 'string', description: '文件分类命名空间' },
|
|
203
|
+
size: { type: 'array', items: { type: 'number' }, description: '文件大小' },
|
|
204
|
+
filename: { type: 'string', description: '文件名' }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
57
208
|
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async request => {
|
|
212
|
+
const { filter, perPage, currentPage } = Object.assign(
|
|
213
|
+
{},
|
|
214
|
+
{
|
|
215
|
+
perPage: 20,
|
|
216
|
+
currentPage: 1
|
|
217
|
+
},
|
|
218
|
+
request.body
|
|
219
|
+
);
|
|
220
|
+
return await services.fileRecord.getFileList({
|
|
221
|
+
filter,
|
|
222
|
+
perPage,
|
|
223
|
+
currentPage
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Replace file
|
|
229
|
+
|
|
230
|
+
fastify.post(
|
|
231
|
+
`${options.prefix}/replace-file`,
|
|
232
|
+
{
|
|
233
|
+
onRequest: options.createAuthenticate('file:mange'),
|
|
234
|
+
schema: {
|
|
235
|
+
summary: '替换文件',
|
|
236
|
+
description: '替换文件',
|
|
237
|
+
query: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
id: { type: 'string', description: '文件id' }
|
|
241
|
+
}
|
|
61
242
|
}
|
|
62
243
|
}
|
|
244
|
+
},
|
|
245
|
+
async request => {
|
|
246
|
+
const file = await request.file();
|
|
247
|
+
if (!file) {
|
|
248
|
+
throw new Error('不能获取到上传文件');
|
|
249
|
+
}
|
|
250
|
+
return await services.fileRecord.uploadToFileSystem({ id: request.query.id, file });
|
|
63
251
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
fastify.post(
|
|
255
|
+
`${options.prefix}/rename-file`,
|
|
256
|
+
{
|
|
257
|
+
onRequest: options.createAuthenticate('file:mange'),
|
|
258
|
+
schema: {
|
|
259
|
+
summary: '重命名文件',
|
|
260
|
+
description: '重命名文件',
|
|
261
|
+
body: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
properties: {
|
|
264
|
+
id: { type: 'string', description: '文件id' },
|
|
265
|
+
filename: { type: 'string', description: '新文件名' }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
async request => {
|
|
271
|
+
await services.fileRecord.renameFile(request.body);
|
|
272
|
+
return {};
|
|
75
273
|
}
|
|
76
|
-
|
|
77
|
-
});
|
|
274
|
+
);
|
|
78
275
|
|
|
79
|
-
fastify.post(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
276
|
+
fastify.post(
|
|
277
|
+
`${options.prefix}/delete-files`,
|
|
278
|
+
{
|
|
279
|
+
onRequest: options.createAuthenticate('file:mange'),
|
|
280
|
+
schema: {
|
|
281
|
+
summary: '删除文件',
|
|
282
|
+
description: '删除文件',
|
|
283
|
+
body: {
|
|
284
|
+
type: 'object',
|
|
285
|
+
required: ['ids'],
|
|
286
|
+
properties: {
|
|
287
|
+
ids: { type: 'array', items: { type: 'string' }, description: '文件id列表' }
|
|
91
288
|
}
|
|
92
289
|
}
|
|
93
290
|
}
|
|
291
|
+
},
|
|
292
|
+
async request => {
|
|
293
|
+
const { ids } = request.body;
|
|
294
|
+
await services.fileRecord.deleteFiles({ ids });
|
|
295
|
+
return {};
|
|
94
296
|
}
|
|
95
|
-
|
|
96
|
-
const { filter, perPage, currentPage } = Object.assign({}, {
|
|
97
|
-
perPage: 20, currentPage: 1
|
|
98
|
-
}, request.body);
|
|
99
|
-
return await services.fileRecord.getFileList({
|
|
100
|
-
filter, perPage, currentPage
|
|
101
|
-
});
|
|
102
|
-
});
|
|
297
|
+
);
|
|
103
298
|
|
|
104
|
-
|
|
299
|
+
fastify.post(
|
|
300
|
+
`${options.prefix}/download-files`,
|
|
301
|
+
{
|
|
302
|
+
onRequest: options.createAuthenticate('file:mange'),
|
|
303
|
+
schema: {
|
|
304
|
+
summary: '批量下载压缩包',
|
|
305
|
+
description: '将选中文件打包为 zip 并下载',
|
|
306
|
+
body: {
|
|
307
|
+
type: 'object',
|
|
308
|
+
required: ['ids'],
|
|
309
|
+
properties: {
|
|
310
|
+
ids: { type: 'array', items: { type: 'string' }, description: '文件id列表' },
|
|
311
|
+
filename: { type: 'string', description: '下载压缩包文件名,默认 files.zip' }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
async (request, reply) => {
|
|
317
|
+
const { ids, filename } = request.body;
|
|
318
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
319
|
+
throw new Error('请选择要下载的文件');
|
|
320
|
+
}
|
|
321
|
+
const compressStream = await services.fileRecord.getCompressFileStream({ ids, type: 'zip' });
|
|
322
|
+
const outputFilename = encodeURIComponent(filename || 'files.zip');
|
|
323
|
+
reply.header('Content-Type', 'application/zip');
|
|
324
|
+
reply.header('Content-Disposition', `attachment; filename="${outputFilename}"`);
|
|
325
|
+
return reply.send(compressStream);
|
|
326
|
+
}
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
if (options.enableFolder === false || !services.folder) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const getFolderAuthenticate = action => {
|
|
334
|
+
if (typeof options.getAuthenticate === 'function') {
|
|
335
|
+
return options.getAuthenticate(action);
|
|
336
|
+
}
|
|
337
|
+
return [];
|
|
338
|
+
};
|
|
105
339
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
340
|
+
const withDefaultFolderType = source => async request => {
|
|
341
|
+
const current = request[source] || {};
|
|
342
|
+
if (!current.type) {
|
|
343
|
+
request[source] = Object.assign({}, current, { type: defaultFolderType });
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const folderOnRequest = (action, source) => {
|
|
348
|
+
const auth = getFolderAuthenticate(action);
|
|
349
|
+
const hooks = [withDefaultFolderType(source)].concat(Array.isArray(auth) ? auth : [auth].filter(Boolean));
|
|
350
|
+
return hooks;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
fastify.get(
|
|
354
|
+
`${options.prefix}/folder/tree`,
|
|
355
|
+
{
|
|
356
|
+
onRequest: folderOnRequest('read', 'query'),
|
|
357
|
+
schema: {
|
|
358
|
+
summary: '获取文件夹树',
|
|
359
|
+
description: '基于 fastify-group 获取指定 type 的文件树;未传 type 时使用 defaultFolderType',
|
|
360
|
+
query: {
|
|
361
|
+
type: 'object',
|
|
362
|
+
properties: {
|
|
363
|
+
type: { type: 'string', description: '业务域类型,默认 admin-file-system' },
|
|
364
|
+
language: { type: 'string', description: '语言' }
|
|
365
|
+
}
|
|
111
366
|
}
|
|
112
367
|
}
|
|
368
|
+
},
|
|
369
|
+
async request => {
|
|
370
|
+
return await services.folder.getTree(request.query);
|
|
113
371
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
fastify.post(
|
|
375
|
+
`${options.prefix}/folder/mkdir`,
|
|
376
|
+
{
|
|
377
|
+
onRequest: getFolderAuthenticate('write'),
|
|
378
|
+
schema: {
|
|
379
|
+
summary: '新建文件夹',
|
|
380
|
+
description: '在指定 type 下创建文件夹节点',
|
|
381
|
+
body: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
required: ['type', 'name'],
|
|
384
|
+
properties: {
|
|
385
|
+
type: { type: 'string', description: '业务域类型' },
|
|
386
|
+
name: { type: 'string', description: '文件夹名称' },
|
|
387
|
+
parentId: { type: 'string', description: '父文件夹 id' },
|
|
388
|
+
language: { type: 'string', description: '语言' }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
async request => {
|
|
394
|
+
return await services.folder.mkdir(request.body);
|
|
118
395
|
}
|
|
119
|
-
|
|
120
|
-
});
|
|
396
|
+
);
|
|
121
397
|
|
|
122
|
-
fastify.post(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
398
|
+
fastify.post(
|
|
399
|
+
`${options.prefix}/folder/upload`,
|
|
400
|
+
{
|
|
401
|
+
onRequest: getFolderAuthenticate('write'),
|
|
402
|
+
schema: {
|
|
403
|
+
summary: '上传文件到文件夹',
|
|
404
|
+
description: '上传文件实体并在 group 中创建文件节点',
|
|
405
|
+
query: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
required: ['type'],
|
|
408
|
+
properties: {
|
|
409
|
+
type: { type: 'string', description: '业务域类型' },
|
|
410
|
+
parentId: { type: 'string', description: '父文件夹 id' },
|
|
411
|
+
namespace: { type: 'string', description: '文件命名空间' },
|
|
412
|
+
language: { type: 'string', description: '语言' }
|
|
413
|
+
}
|
|
127
414
|
}
|
|
128
415
|
}
|
|
416
|
+
},
|
|
417
|
+
async request => {
|
|
418
|
+
const file = await request.file();
|
|
419
|
+
if (!file) {
|
|
420
|
+
throw new Error('不能获取到上传文件');
|
|
421
|
+
}
|
|
422
|
+
return await services.folder.upload({
|
|
423
|
+
type: request.query.type,
|
|
424
|
+
parentId: request.query.parentId,
|
|
425
|
+
namespace: request.query.namespace,
|
|
426
|
+
language: request.query.language,
|
|
427
|
+
file
|
|
428
|
+
});
|
|
129
429
|
}
|
|
130
|
-
|
|
131
|
-
await services.fileRecord.renameFile(request.body);
|
|
132
|
-
return {};
|
|
133
|
-
});
|
|
430
|
+
);
|
|
134
431
|
|
|
135
|
-
fastify.post(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
432
|
+
fastify.post(
|
|
433
|
+
`${options.prefix}/folder/remove`,
|
|
434
|
+
{
|
|
435
|
+
onRequest: getFolderAuthenticate('delete'),
|
|
436
|
+
schema: {
|
|
437
|
+
summary: '删除文件夹节点',
|
|
438
|
+
description: '递归删除节点及其子孙;文件节点同步删除 file-record',
|
|
439
|
+
body: {
|
|
440
|
+
type: 'object',
|
|
441
|
+
required: ['type', 'id'],
|
|
442
|
+
properties: {
|
|
443
|
+
type: { type: 'string', description: '业务域类型' },
|
|
444
|
+
id: { type: 'string', description: '节点 id' },
|
|
445
|
+
language: { type: 'string', description: '语言' }
|
|
446
|
+
}
|
|
140
447
|
}
|
|
141
448
|
}
|
|
449
|
+
},
|
|
450
|
+
async request => {
|
|
451
|
+
return await services.folder.remove(request.body);
|
|
142
452
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
fastify.post(
|
|
456
|
+
`${options.prefix}/folder/move`,
|
|
457
|
+
{
|
|
458
|
+
onRequest: getFolderAuthenticate('write'),
|
|
459
|
+
schema: {
|
|
460
|
+
summary: '移动文件夹节点',
|
|
461
|
+
description: '将一个或多个文件/文件夹节点移动到目标文件夹(parentId 为空表示根目录)',
|
|
462
|
+
body: {
|
|
463
|
+
type: 'object',
|
|
464
|
+
required: ['type', 'ids'],
|
|
465
|
+
properties: {
|
|
466
|
+
type: { type: 'string', description: '业务域类型' },
|
|
467
|
+
ids: {
|
|
468
|
+
type: 'array',
|
|
469
|
+
items: { type: 'string' },
|
|
470
|
+
minItems: 1,
|
|
471
|
+
description: '要移动的节点 id 列表'
|
|
472
|
+
},
|
|
473
|
+
parentId: { type: ['string', 'null'], description: '目标父文件夹 id,空为根目录' },
|
|
474
|
+
language: { type: 'string', description: '语言' }
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
async request => {
|
|
480
|
+
return await services.folder.move(request.body);
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
fastify.post(
|
|
485
|
+
`${options.prefix}/folder/copy`,
|
|
486
|
+
{
|
|
487
|
+
onRequest: getFolderAuthenticate('write'),
|
|
488
|
+
schema: {
|
|
489
|
+
summary: '复制文件夹节点',
|
|
490
|
+
description:
|
|
491
|
+
'将一个或多个文件/文件夹节点复制到目标文件夹(parentId 为空表示根目录);文件以 linked 节点挂载,文件夹递归复制',
|
|
492
|
+
body: {
|
|
493
|
+
type: 'object',
|
|
494
|
+
required: ['type', 'ids'],
|
|
495
|
+
properties: {
|
|
496
|
+
type: { type: 'string', description: '业务域类型' },
|
|
497
|
+
ids: {
|
|
498
|
+
type: 'array',
|
|
499
|
+
items: { type: 'string' },
|
|
500
|
+
minItems: 1,
|
|
501
|
+
description: '要复制的节点 id 列表'
|
|
502
|
+
},
|
|
503
|
+
parentId: { type: ['string', 'null'], description: '目标父文件夹 id,空为根目录' },
|
|
504
|
+
language: { type: 'string', description: '语言' }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
async request => {
|
|
510
|
+
return await services.folder.copy(request.body);
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
fastify.post(
|
|
515
|
+
`${options.prefix}/folder/rename`,
|
|
516
|
+
{
|
|
517
|
+
onRequest: getFolderAuthenticate('write'),
|
|
518
|
+
schema: {
|
|
519
|
+
summary: '重命名文件夹节点',
|
|
520
|
+
description: '重命名文件或文件夹节点;文件节点同步更新 file-record 文件名',
|
|
521
|
+
body: {
|
|
522
|
+
type: 'object',
|
|
523
|
+
required: ['type', 'id', 'name'],
|
|
524
|
+
properties: {
|
|
525
|
+
type: { type: 'string', description: '业务域类型' },
|
|
526
|
+
id: { type: 'string', description: '节点 id' },
|
|
527
|
+
name: { type: 'string', description: '新名称' },
|
|
528
|
+
language: { type: 'string', description: '语言' }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
async request => {
|
|
534
|
+
return await services.folder.rename(request.body);
|
|
535
|
+
}
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
fastify.post(
|
|
539
|
+
`${options.prefix}/folder/add-files`,
|
|
540
|
+
{
|
|
541
|
+
onRequest: getFolderAuthenticate('write'),
|
|
542
|
+
preHandler: withDefaultFolderType('body'),
|
|
543
|
+
schema: {
|
|
544
|
+
summary: '将已有文件挂到文件夹树',
|
|
545
|
+
description:
|
|
546
|
+
'把文件库中已有文件以 linked 节点形式加入指定文件夹(parentId 为空表示根目录);删除该类节点不会删除文件库实体;未传 type 时使用 defaultFolderType',
|
|
547
|
+
body: {
|
|
548
|
+
type: 'object',
|
|
549
|
+
required: ['ids'],
|
|
550
|
+
properties: {
|
|
551
|
+
type: { type: 'string', description: '业务域类型,默认 admin-file-system' },
|
|
552
|
+
ids: {
|
|
553
|
+
type: 'array',
|
|
554
|
+
items: { type: 'string' },
|
|
555
|
+
minItems: 1,
|
|
556
|
+
description: '文件库 file-record id 列表'
|
|
557
|
+
},
|
|
558
|
+
parentId: { type: ['string', 'null'], description: '目标父文件夹 id,空为根目录' },
|
|
559
|
+
language: { type: 'string', description: '语言' }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
async request => {
|
|
565
|
+
return await services.folder.addFiles(request.body);
|
|
566
|
+
}
|
|
567
|
+
);
|
|
148
568
|
});
|
|
@@ -15,10 +15,28 @@ module.exports = fp(async (fastify, fastifyOptions) => {
|
|
|
15
15
|
|
|
16
16
|
const sanitizeFilename = filename => filename.replace(/[\\/:*?"<>|\0]/g, '_');
|
|
17
17
|
|
|
18
|
+
const isUuid = value =>
|
|
19
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || ''));
|
|
20
|
+
|
|
18
21
|
const detail = async ({ id, uuid, namespace }) => {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
const key = String(id || uuid || '')
|
|
23
|
+
.split('?')[0]
|
|
24
|
+
.trim();
|
|
25
|
+
if (!key) {
|
|
26
|
+
throw new Error('文件不存在');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let file = null;
|
|
30
|
+
// uuid 列是 UUID 类型:非 uuid 字符串会直接抛 invalid input syntax,不能先查再 fallback
|
|
31
|
+
if (isUuid(key)) {
|
|
32
|
+
file = await models.fileRecord.findOne({
|
|
33
|
+
where: { uuid: key }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// 兼容历史节点把主键写进 options.fileId / 接口传主键的情况
|
|
37
|
+
if (!file) {
|
|
38
|
+
file = await models.fileRecord.findByPk(key);
|
|
39
|
+
}
|
|
22
40
|
|
|
23
41
|
if (!file) {
|
|
24
42
|
throw new Error('文件不存在');
|
|
@@ -381,15 +399,22 @@ module.exports = fp(async (fastify, fastifyOptions) => {
|
|
|
381
399
|
await fs.mkdir(tmpPath);
|
|
382
400
|
const files = [];
|
|
383
401
|
for (const file of fileList) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
402
|
+
try {
|
|
403
|
+
const fileStream = await getFileReadStream(file);
|
|
404
|
+
const filepath = path.resolve(tmpPath, file.filename);
|
|
405
|
+
const writeStream = fs.createWriteStream(filepath);
|
|
406
|
+
fileStream.pipe(writeStream);
|
|
407
|
+
await new Promise((resolve, reject) => {
|
|
408
|
+
writeStream.on('finish', resolve);
|
|
409
|
+
writeStream.on('error', reject);
|
|
410
|
+
});
|
|
411
|
+
files.push(filepath);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
console.warn(`skip invalid file: ${file.uuid}`, error.message);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (files.length === 0) {
|
|
417
|
+
throw new Error('No valid files to compress');
|
|
393
418
|
}
|
|
394
419
|
const compressStream = new compressing[type].Stream();
|
|
395
420
|
files.forEach(filepath => {
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
const fp = require('fastify-plugin');
|
|
2
|
+
|
|
3
|
+
module.exports = fp(async (fastify, fastifyOptions) => {
|
|
4
|
+
if (fastifyOptions.enableFolder === false) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const groupName = fastifyOptions.groupName || 'group';
|
|
9
|
+
const group = fastify[groupName];
|
|
10
|
+
if (!group?.services) {
|
|
11
|
+
throw new Error(`文件夹功能需要先注册 @kne/fastify-group(fastify.${groupName})`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { services } = fastify.fileManager;
|
|
15
|
+
const groupServices = group.services;
|
|
16
|
+
|
|
17
|
+
const getNodeKind = node => {
|
|
18
|
+
const kind = node?.options?.kind;
|
|
19
|
+
if (kind === 'file' || kind === 'folder') {
|
|
20
|
+
return kind;
|
|
21
|
+
}
|
|
22
|
+
return node?.options?.fileId ? 'file' : 'folder';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isUuidLike = value =>
|
|
26
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || ''));
|
|
27
|
+
|
|
28
|
+
const resolveRecordFileId = record => {
|
|
29
|
+
if (!record) {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
const uuid = typeof record.getDataValue === 'function' ? record.getDataValue('uuid') : record.uuid;
|
|
33
|
+
if (uuid) {
|
|
34
|
+
return String(uuid);
|
|
35
|
+
}
|
|
36
|
+
const id = typeof record.getDataValue === 'function' ? record.getDataValue('id') : record.id;
|
|
37
|
+
return String(id || '');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const findChildByName = async ({ type, parentId, name, language, tenantId }) => {
|
|
41
|
+
const where = Object.assign(
|
|
42
|
+
{ type, parentId: parentId || null, name },
|
|
43
|
+
language ? { language } : {},
|
|
44
|
+
tenantId != null ? { tenantId } : {}
|
|
45
|
+
);
|
|
46
|
+
const node = await group.models.tag.findOne({ where });
|
|
47
|
+
return node ? (typeof node.get === 'function' ? node.get({ plain: true }) : node) : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const assertUniqueSiblingName = async ({ type, parentId, name, language, tenantId, excludeId }) => {
|
|
51
|
+
const existing = await findChildByName({ type, parentId: parentId || null, name, language, tenantId });
|
|
52
|
+
if (existing && String(existing.id) !== String(excludeId || '')) {
|
|
53
|
+
throw new Error(`同一文件夹下已存在同名文件或文件夹:${name}`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const collectFileIds = nodes => {
|
|
58
|
+
const ids = [];
|
|
59
|
+
const walk = list => {
|
|
60
|
+
(list || []).forEach(node => {
|
|
61
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
62
|
+
if (getNodeKind(plain) === 'file' && plain.options?.fileId) {
|
|
63
|
+
ids.push(plain.options.fileId);
|
|
64
|
+
}
|
|
65
|
+
if (plain.children?.length) {
|
|
66
|
+
walk(plain.children);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
walk(nodes);
|
|
71
|
+
return ids;
|
|
72
|
+
};
|
|
73
|
+
|
|
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))];
|
|
81
|
+
const fileMap = new Map();
|
|
82
|
+
await Promise.all(
|
|
83
|
+
uniqueIds.map(async id => {
|
|
84
|
+
try {
|
|
85
|
+
const file = await services.fileRecord.getFileInstance({ id });
|
|
86
|
+
fileMap.set(String(id), file);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
// 文件记录缺失时仍返回树节点
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
|
|
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
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (plain.children?.length) {
|
|
114
|
+
plain.children = plain.children.map(mapNode);
|
|
115
|
+
}
|
|
116
|
+
return plain;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (tree || []).map(mapNode);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const getTree = async ({ type, language, tenantId }) => {
|
|
123
|
+
if (!type) {
|
|
124
|
+
throw new Error('必须传入类型');
|
|
125
|
+
}
|
|
126
|
+
const tree = await groupServices.groupList({ type, language, output: 'tree', tenantId });
|
|
127
|
+
return enrichTree(tree);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const mkdir = async ({ type, name, parentId, language, tenantId }) => {
|
|
131
|
+
if (!type) {
|
|
132
|
+
throw new Error('必须传入类型');
|
|
133
|
+
}
|
|
134
|
+
if (!name) {
|
|
135
|
+
throw new Error('必须传入文件夹名称');
|
|
136
|
+
}
|
|
137
|
+
await assertUniqueSiblingName({ type, parentId: parentId || null, name, language, tenantId });
|
|
138
|
+
const tag = await groupServices.save({
|
|
139
|
+
type,
|
|
140
|
+
name,
|
|
141
|
+
parentId: parentId || null,
|
|
142
|
+
language,
|
|
143
|
+
tenantId,
|
|
144
|
+
options: { kind: 'folder' }
|
|
145
|
+
});
|
|
146
|
+
return typeof tag.get === 'function' ? tag.get({ plain: true }) : tag;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const upload = async ({ type, parentId, file, namespace, language, tenantId }) => {
|
|
150
|
+
if (!type) {
|
|
151
|
+
throw new Error('必须传入类型');
|
|
152
|
+
}
|
|
153
|
+
if (!file) {
|
|
154
|
+
throw new Error('不能获取到上传文件');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const targetParentId = parentId || null;
|
|
158
|
+
await assertUniqueSiblingName({
|
|
159
|
+
type,
|
|
160
|
+
parentId: targetParentId,
|
|
161
|
+
name: file.filename,
|
|
162
|
+
language,
|
|
163
|
+
tenantId
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const record = await services.fileRecord.uploadToFileSystem({
|
|
167
|
+
file,
|
|
168
|
+
namespace: namespace || fastifyOptions.namespace
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const tag = await groupServices.save({
|
|
172
|
+
type,
|
|
173
|
+
name: record.filename,
|
|
174
|
+
parentId: targetParentId,
|
|
175
|
+
language,
|
|
176
|
+
tenantId,
|
|
177
|
+
options: {
|
|
178
|
+
kind: 'file',
|
|
179
|
+
fileId: resolveRecordFileId(record),
|
|
180
|
+
size: record.size,
|
|
181
|
+
mimetype: record.mimetype
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const plain = typeof tag.get === 'function' ? tag.get({ plain: true }) : tag;
|
|
186
|
+
return Object.assign({}, plain, { file: record });
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const remove = async ({ type, id, language, tenantId }) => {
|
|
190
|
+
if (!type) {
|
|
191
|
+
throw new Error('必须传入类型');
|
|
192
|
+
}
|
|
193
|
+
if (!id) {
|
|
194
|
+
throw new Error('必须传入节点 id');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const root = await groupServices.detail({ id, type, language, tenantId });
|
|
198
|
+
if (!root) {
|
|
199
|
+
throw new Error('节点不存在');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const descendantIds = await groupServices.getDescendantIds({ id, type, language, tenantId });
|
|
203
|
+
const fileIds = [];
|
|
204
|
+
|
|
205
|
+
for (const nodeId of descendantIds) {
|
|
206
|
+
const node = await groupServices.detail({ id: nodeId, type, language, tenantId });
|
|
207
|
+
if (!node) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
211
|
+
// linked 节点只删树,不删文件库实体
|
|
212
|
+
if (getNodeKind(plain) === 'file' && plain.options?.fileId && plain.options?.linked !== true) {
|
|
213
|
+
fileIds.push(plain.options.fileId);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (fileIds.length > 0) {
|
|
218
|
+
await services.fileRecord.deleteFiles({ ids: [...new Set(fileIds)] });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 先删子孙再删根,避免残留
|
|
222
|
+
const orderedIds = descendantIds.slice().reverse();
|
|
223
|
+
for (const nodeId of orderedIds) {
|
|
224
|
+
await groupServices.remove({ id: nodeId, type, language, tenantId });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const move = async ({ type, ids, parentId, language, tenantId }) => {
|
|
231
|
+
if (!type) {
|
|
232
|
+
throw new Error('必须传入类型');
|
|
233
|
+
}
|
|
234
|
+
const idList = Array.isArray(ids) ? ids.filter(Boolean) : ids ? [ids] : [];
|
|
235
|
+
if (idList.length === 0) {
|
|
236
|
+
throw new Error('必须传入节点 id');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const targetParentId = parentId || null;
|
|
240
|
+
if (targetParentId) {
|
|
241
|
+
const parent = await groupServices.detail({ id: targetParentId, type, language, tenantId });
|
|
242
|
+
if (!parent) {
|
|
243
|
+
throw new Error('目标文件夹不存在');
|
|
244
|
+
}
|
|
245
|
+
const parentPlain = typeof parent.get === 'function' ? parent.get({ plain: true }) : parent;
|
|
246
|
+
if (getNodeKind(parentPlain) !== 'folder') {
|
|
247
|
+
throw new Error('只能移动到文件夹');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const results = [];
|
|
252
|
+
for (const id of idList) {
|
|
253
|
+
const node = await groupServices.detail({ id, type, language, tenantId });
|
|
254
|
+
if (!node) {
|
|
255
|
+
throw new Error(`节点不存在: ${id}`);
|
|
256
|
+
}
|
|
257
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
258
|
+
await assertUniqueSiblingName({
|
|
259
|
+
type,
|
|
260
|
+
parentId: targetParentId,
|
|
261
|
+
name: plain.name,
|
|
262
|
+
language,
|
|
263
|
+
tenantId,
|
|
264
|
+
excludeId: plain.id
|
|
265
|
+
});
|
|
266
|
+
const tag = await groupServices.save({
|
|
267
|
+
id,
|
|
268
|
+
type,
|
|
269
|
+
parentId: targetParentId,
|
|
270
|
+
language,
|
|
271
|
+
tenantId
|
|
272
|
+
});
|
|
273
|
+
results.push(typeof tag.get === 'function' ? tag.get({ plain: true }) : tag);
|
|
274
|
+
}
|
|
275
|
+
return results;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const listDirectChildren = async ({ type, parentId, language, tenantId }) => {
|
|
279
|
+
const where = Object.assign(
|
|
280
|
+
{ type, parentId: parentId || null },
|
|
281
|
+
language ? { language } : {},
|
|
282
|
+
tenantId != null ? { tenantId } : {}
|
|
283
|
+
);
|
|
284
|
+
const rows = await group.models.tag.findAll({ where });
|
|
285
|
+
return rows.map(item => (typeof item.get === 'function' ? item.get({ plain: true }) : item));
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const copyNodeRecursive = async ({ type, sourceId, parentId, language, tenantId }) => {
|
|
289
|
+
const node = await groupServices.detail({ id: String(sourceId), type, language, tenantId });
|
|
290
|
+
if (!node) {
|
|
291
|
+
throw new Error(`节点不存在: ${sourceId}`);
|
|
292
|
+
}
|
|
293
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
294
|
+
await assertUniqueSiblingName({
|
|
295
|
+
type,
|
|
296
|
+
parentId: parentId || null,
|
|
297
|
+
name: plain.name,
|
|
298
|
+
language,
|
|
299
|
+
tenantId
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (getNodeKind(plain) === 'folder') {
|
|
303
|
+
const created = await mkdir({
|
|
304
|
+
type,
|
|
305
|
+
name: plain.name,
|
|
306
|
+
parentId: parentId || null,
|
|
307
|
+
language,
|
|
308
|
+
tenantId
|
|
309
|
+
});
|
|
310
|
+
const children = await listDirectChildren({ type, parentId: plain.id, language, tenantId });
|
|
311
|
+
for (const child of children) {
|
|
312
|
+
await copyNodeRecursive({
|
|
313
|
+
type,
|
|
314
|
+
sourceId: child.id,
|
|
315
|
+
parentId: created.id,
|
|
316
|
+
language,
|
|
317
|
+
tenantId
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return created;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const fileId = plain.options?.fileId;
|
|
324
|
+
if (!fileId) {
|
|
325
|
+
throw new Error(`文件节点缺少 fileId: ${plain.name}`);
|
|
326
|
+
}
|
|
327
|
+
const tag = await groupServices.save({
|
|
328
|
+
type,
|
|
329
|
+
name: plain.name,
|
|
330
|
+
parentId: parentId || null,
|
|
331
|
+
language,
|
|
332
|
+
tenantId,
|
|
333
|
+
options: {
|
|
334
|
+
kind: 'file',
|
|
335
|
+
fileId,
|
|
336
|
+
size: plain.options?.size,
|
|
337
|
+
mimetype: plain.options?.mimetype,
|
|
338
|
+
linked: true
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
return typeof tag.get === 'function' ? tag.get({ plain: true }) : tag;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const copy = async ({ type, ids, parentId, language, tenantId }) => {
|
|
345
|
+
if (!type) {
|
|
346
|
+
throw new Error('必须传入类型');
|
|
347
|
+
}
|
|
348
|
+
const idList = Array.isArray(ids) ? ids.filter(Boolean) : ids ? [ids] : [];
|
|
349
|
+
if (idList.length === 0) {
|
|
350
|
+
throw new Error('必须传入节点 id');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const targetParentId = parentId || null;
|
|
354
|
+
if (targetParentId) {
|
|
355
|
+
const parent = await groupServices.detail({ id: targetParentId, type, language, tenantId });
|
|
356
|
+
if (!parent) {
|
|
357
|
+
throw new Error('目标文件夹不存在');
|
|
358
|
+
}
|
|
359
|
+
const parentPlain = typeof parent.get === 'function' ? parent.get({ plain: true }) : parent;
|
|
360
|
+
if (getNodeKind(parentPlain) !== 'folder') {
|
|
361
|
+
throw new Error('只能复制到文件夹');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const results = [];
|
|
366
|
+
for (const id of idList) {
|
|
367
|
+
if (targetParentId) {
|
|
368
|
+
const descendantIds = await groupServices.getDescendantIds({
|
|
369
|
+
id: String(id),
|
|
370
|
+
type,
|
|
371
|
+
language,
|
|
372
|
+
tenantId
|
|
373
|
+
});
|
|
374
|
+
if (descendantIds.map(String).includes(String(targetParentId))) {
|
|
375
|
+
throw new Error('不能复制到自身或其子文件夹');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
results.push(
|
|
379
|
+
await copyNodeRecursive({
|
|
380
|
+
type,
|
|
381
|
+
sourceId: id,
|
|
382
|
+
parentId: targetParentId,
|
|
383
|
+
language,
|
|
384
|
+
tenantId
|
|
385
|
+
})
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
return results;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const rename = async ({ type, id, name, language, tenantId }) => {
|
|
392
|
+
if (!type) {
|
|
393
|
+
throw new Error('必须传入类型');
|
|
394
|
+
}
|
|
395
|
+
if (!id) {
|
|
396
|
+
throw new Error('必须传入节点 id');
|
|
397
|
+
}
|
|
398
|
+
if (!name) {
|
|
399
|
+
throw new Error('必须传入名称');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const node = await groupServices.detail({ id: String(id), type, language, tenantId });
|
|
403
|
+
if (!node) {
|
|
404
|
+
throw new Error('节点不存在');
|
|
405
|
+
}
|
|
406
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
407
|
+
await assertUniqueSiblingName({
|
|
408
|
+
type,
|
|
409
|
+
parentId: plain.parentId || null,
|
|
410
|
+
name,
|
|
411
|
+
language,
|
|
412
|
+
tenantId,
|
|
413
|
+
excludeId: plain.id
|
|
414
|
+
});
|
|
415
|
+
// 直接 update,避免 group.save 在查不到 id 时误走创建
|
|
416
|
+
await node.update({ name });
|
|
417
|
+
const tag = await groupServices.detail({ id: String(id), type, language, tenantId });
|
|
418
|
+
|
|
419
|
+
// linked 节点只改树显示名,不改文件库实体
|
|
420
|
+
if (getNodeKind(plain) === 'file' && plain.options?.fileId && plain.options?.linked !== true) {
|
|
421
|
+
try {
|
|
422
|
+
await services.fileRecord.renameFile({ id: plain.options.fileId, filename: name });
|
|
423
|
+
} catch (e) {
|
|
424
|
+
// 树节点已改名;文件记录缺失时不阻断
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return typeof tag.get === 'function' ? tag.get({ plain: true }) : tag || Object.assign({}, plain, { name });
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const normalizePathSegments = folderPath =>
|
|
432
|
+
String(folderPath || '')
|
|
433
|
+
.replace(/\\/g, '/')
|
|
434
|
+
.split('/')
|
|
435
|
+
.map(segment => segment.trim())
|
|
436
|
+
.filter(segment => segment && segment !== '.' && segment !== '..');
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 按路径逐级确保文件夹存在(不存在则创建),返回叶子文件夹 id;空路径返回 null(根目录)
|
|
440
|
+
*/
|
|
441
|
+
const ensurePath = async ({ type, path: folderPath, language, tenantId }) => {
|
|
442
|
+
if (!type) {
|
|
443
|
+
throw new Error('必须传入类型');
|
|
444
|
+
}
|
|
445
|
+
const segments = normalizePathSegments(folderPath);
|
|
446
|
+
let parentId = null;
|
|
447
|
+
for (const name of segments) {
|
|
448
|
+
const existing = await findChildByName({ type, parentId, name, language, tenantId });
|
|
449
|
+
if (existing) {
|
|
450
|
+
if (getNodeKind(existing) !== 'folder') {
|
|
451
|
+
throw new Error(`路径冲突:${name} 不是文件夹`);
|
|
452
|
+
}
|
|
453
|
+
parentId = existing.id;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const created = await mkdir({ type, name, parentId, language, tenantId });
|
|
457
|
+
parentId = created.id;
|
|
458
|
+
}
|
|
459
|
+
return parentId;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 将已上传的文件记录挂到文件夹树(默认 linked,删除树节点不删文件实体)
|
|
464
|
+
*/
|
|
465
|
+
const attachRecord = async ({ type, parentId, record, language, tenantId, linked = true }) => {
|
|
466
|
+
if (!type) {
|
|
467
|
+
throw new Error('必须传入类型');
|
|
468
|
+
}
|
|
469
|
+
if (!record) {
|
|
470
|
+
throw new Error('必须传入文件记录');
|
|
471
|
+
}
|
|
472
|
+
const targetParentId = parentId || null;
|
|
473
|
+
if (targetParentId) {
|
|
474
|
+
const parent = await groupServices.detail({ id: targetParentId, type, language, tenantId });
|
|
475
|
+
if (!parent) {
|
|
476
|
+
throw new Error('目标文件夹不存在');
|
|
477
|
+
}
|
|
478
|
+
const parentPlain = typeof parent.get === 'function' ? parent.get({ plain: true }) : parent;
|
|
479
|
+
if (getNodeKind(parentPlain) !== 'folder') {
|
|
480
|
+
throw new Error('只能加入到文件夹');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const linkedFileId = resolveRecordFileId(record);
|
|
485
|
+
const siblingWhere = Object.assign(
|
|
486
|
+
{ type, parentId: targetParentId },
|
|
487
|
+
language ? { language } : {},
|
|
488
|
+
tenantId != null ? { tenantId } : {}
|
|
489
|
+
);
|
|
490
|
+
const siblings = await group.models.tag.findAll({ where: siblingWhere });
|
|
491
|
+
const alreadyMounted = siblings.some(item => {
|
|
492
|
+
const plain = typeof item.get === 'function' ? item.get({ plain: true }) : item;
|
|
493
|
+
return getNodeKind(plain) === 'file' && String(plain.options?.fileId) === linkedFileId;
|
|
494
|
+
});
|
|
495
|
+
if (alreadyMounted) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
await assertUniqueSiblingName({
|
|
500
|
+
type,
|
|
501
|
+
parentId: targetParentId,
|
|
502
|
+
name: record.filename,
|
|
503
|
+
language,
|
|
504
|
+
tenantId
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const tag = await groupServices.save({
|
|
508
|
+
type,
|
|
509
|
+
name: record.filename,
|
|
510
|
+
parentId: targetParentId,
|
|
511
|
+
language,
|
|
512
|
+
tenantId,
|
|
513
|
+
options: {
|
|
514
|
+
kind: 'file',
|
|
515
|
+
fileId: linkedFileId,
|
|
516
|
+
size: record.size,
|
|
517
|
+
mimetype: record.mimetype,
|
|
518
|
+
...(linked ? { linked: true } : {})
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
return typeof tag.get === 'function' ? tag.get({ plain: true }) : tag;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const addFiles = async ({ type, parentId, ids, language, tenantId }) => {
|
|
525
|
+
if (!type) {
|
|
526
|
+
throw new Error('必须传入类型');
|
|
527
|
+
}
|
|
528
|
+
const idList = [...new Set((Array.isArray(ids) ? ids : ids ? [ids] : []).filter(Boolean).map(String))];
|
|
529
|
+
if (idList.length === 0) {
|
|
530
|
+
throw new Error('必须传入文件 id');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const targetParentId = parentId || null;
|
|
534
|
+
if (targetParentId) {
|
|
535
|
+
const parent = await groupServices.detail({ id: targetParentId, type, language, tenantId });
|
|
536
|
+
if (!parent) {
|
|
537
|
+
throw new Error('目标文件夹不存在');
|
|
538
|
+
}
|
|
539
|
+
const parentPlain = typeof parent.get === 'function' ? parent.get({ plain: true }) : parent;
|
|
540
|
+
if (getNodeKind(parentPlain) !== 'folder') {
|
|
541
|
+
throw new Error('只能加入到文件夹');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 同目录下已挂载的 fileId(兼容历史存主键的数据)
|
|
546
|
+
const siblingWhere = Object.assign(
|
|
547
|
+
{ type, parentId: targetParentId },
|
|
548
|
+
language ? { language } : {},
|
|
549
|
+
tenantId != null ? { tenantId } : {}
|
|
550
|
+
);
|
|
551
|
+
const siblings = await group.models.tag.findAll({ where: siblingWhere });
|
|
552
|
+
const existingFileIds = new Set();
|
|
553
|
+
const existingNames = new Set();
|
|
554
|
+
siblings.forEach(item => {
|
|
555
|
+
const plain = typeof item.get === 'function' ? item.get({ plain: true }) : item;
|
|
556
|
+
if (plain.name) {
|
|
557
|
+
existingNames.add(String(plain.name));
|
|
558
|
+
}
|
|
559
|
+
if (getNodeKind(plain) === 'file' && plain.options?.fileId != null) {
|
|
560
|
+
existingFileIds.add(String(plain.options.fileId));
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const results = [];
|
|
565
|
+
for (const fileId of idList) {
|
|
566
|
+
const record = await services.fileRecord.getFileInstance({ id: fileId });
|
|
567
|
+
// 列表入参多为 uuid;若是历史主键则从记录上取 uuid,禁止把雪花主键写入 options.fileId
|
|
568
|
+
const linkedFileId = isUuidLike(fileId) ? String(fileId) : resolveRecordFileId(record);
|
|
569
|
+
// 已在目标目录则跳过,避免重复挂载
|
|
570
|
+
if (existingFileIds.has(linkedFileId) || existingFileIds.has(String(record.id)) || existingFileIds.has(String(fileId))) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (existingNames.has(String(record.filename))) {
|
|
574
|
+
throw new Error(`同一文件夹下已存在同名文件或文件夹:${record.filename}`);
|
|
575
|
+
}
|
|
576
|
+
const tag = await groupServices.save({
|
|
577
|
+
type,
|
|
578
|
+
name: record.filename,
|
|
579
|
+
parentId: targetParentId,
|
|
580
|
+
language,
|
|
581
|
+
tenantId,
|
|
582
|
+
options: {
|
|
583
|
+
kind: 'file',
|
|
584
|
+
fileId: linkedFileId,
|
|
585
|
+
size: record.size,
|
|
586
|
+
mimetype: record.mimetype,
|
|
587
|
+
linked: true
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
existingFileIds.add(linkedFileId);
|
|
591
|
+
existingNames.add(String(record.filename));
|
|
592
|
+
results.push(typeof tag.get === 'function' ? tag.get({ plain: true }) : tag);
|
|
593
|
+
}
|
|
594
|
+
return results;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
Object.assign(services, {
|
|
598
|
+
folder: {
|
|
599
|
+
getTree,
|
|
600
|
+
mkdir,
|
|
601
|
+
upload,
|
|
602
|
+
remove,
|
|
603
|
+
move,
|
|
604
|
+
copy,
|
|
605
|
+
rename,
|
|
606
|
+
addFiles,
|
|
607
|
+
ensurePath,
|
|
608
|
+
attachRecord
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
});
|
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.3",
|
|
4
4
|
"description": "用于管理静态文件上传查看等",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@fastify/env": "^5.0.2",
|
|
37
37
|
"@kne/fastify-aliyun": "^1.1.1",
|
|
38
|
-
"@kne/fastify-
|
|
38
|
+
"@kne/fastify-group": "^0.2.5",
|
|
39
|
+
"@kne/fastify-sequelize": "^3.2.0",
|
|
39
40
|
"fastify": "^5.3.2",
|
|
40
41
|
"husky": "^9.0.11",
|
|
41
42
|
"mocha": "^11.7.5",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"sqlite3": "^5.1.7"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
48
|
+
"@kne/fastify-group": "*",
|
|
47
49
|
"@kne/fastify-namespace": "*",
|
|
48
50
|
"@kne/fastify-sequelize": "*",
|
|
49
51
|
"fastify-plugin": ">=5"
|