@kne/fastify-file-manager 3.0.1 → 3.0.4
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 +21 -3
- package/libs/services/folder.js +654 -0
- package/package.json +4 -2
|
@@ -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('文件不存在');
|
|
@@ -0,0 +1,654 @@
|
|
|
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
|
+
/** 重名时在扩展名前加 [_时间戳],如 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
|
+
|
|
88
|
+
const assertUniqueSiblingName = async ({ type, parentId, name, language, tenantId, excludeId }) => {
|
|
89
|
+
const existing = await findChildByName({ type, parentId: parentId || null, name, language, tenantId });
|
|
90
|
+
if (existing && String(existing.id) !== String(excludeId || '')) {
|
|
91
|
+
throw new Error(`同一文件夹下已存在同名文件或文件夹:${name}`);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const collectFileIds = nodes => {
|
|
96
|
+
const ids = [];
|
|
97
|
+
const walk = list => {
|
|
98
|
+
(list || []).forEach(node => {
|
|
99
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
100
|
+
if (getNodeKind(plain) === 'file' && plain.options?.fileId) {
|
|
101
|
+
ids.push(plain.options.fileId);
|
|
102
|
+
}
|
|
103
|
+
if (plain.children?.length) {
|
|
104
|
+
walk(plain.children);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
walk(nodes);
|
|
109
|
+
return ids;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const enrichTree = async tree => {
|
|
113
|
+
const fileIds = collectFileIds(tree);
|
|
114
|
+
if (fileIds.length === 0) {
|
|
115
|
+
return tree;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const uniqueIds = [...new Set(fileIds.map(String))];
|
|
119
|
+
const fileMap = new Map();
|
|
120
|
+
await Promise.all(
|
|
121
|
+
uniqueIds.map(async id => {
|
|
122
|
+
try {
|
|
123
|
+
const file = await services.fileRecord.getFileInstance({ id });
|
|
124
|
+
fileMap.set(String(id), file);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// 文件记录缺失时仍返回树节点
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const mapNode = node => {
|
|
132
|
+
const plain = { ...node };
|
|
133
|
+
const kind = getNodeKind(plain);
|
|
134
|
+
plain.options = Object.assign({}, plain.options, { kind });
|
|
135
|
+
if (kind === 'file' && plain.options.fileId) {
|
|
136
|
+
const file = fileMap.get(String(plain.options.fileId));
|
|
137
|
+
if (file) {
|
|
138
|
+
plain.options = Object.assign({}, plain.options, {
|
|
139
|
+
size: file.size,
|
|
140
|
+
mimetype: file.mimetype,
|
|
141
|
+
filename: file.filename
|
|
142
|
+
});
|
|
143
|
+
if (file.createdAt) {
|
|
144
|
+
plain.createdAt = file.createdAt;
|
|
145
|
+
}
|
|
146
|
+
if (file.updatedAt) {
|
|
147
|
+
plain.updatedAt = file.updatedAt;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (plain.children?.length) {
|
|
152
|
+
plain.children = plain.children.map(mapNode);
|
|
153
|
+
}
|
|
154
|
+
return plain;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return (tree || []).map(mapNode);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const getTree = async ({ type, language, tenantId }) => {
|
|
161
|
+
if (!type) {
|
|
162
|
+
throw new Error('必须传入类型');
|
|
163
|
+
}
|
|
164
|
+
const tree = await groupServices.groupList({ type, language, output: 'tree', tenantId });
|
|
165
|
+
return enrichTree(tree);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const mkdir = async ({ type, name, parentId, language, tenantId }) => {
|
|
169
|
+
if (!type) {
|
|
170
|
+
throw new Error('必须传入类型');
|
|
171
|
+
}
|
|
172
|
+
if (!name) {
|
|
173
|
+
throw new Error('必须传入文件夹名称');
|
|
174
|
+
}
|
|
175
|
+
await assertUniqueSiblingName({ type, parentId: parentId || null, name, language, tenantId });
|
|
176
|
+
const tag = await groupServices.save({
|
|
177
|
+
type,
|
|
178
|
+
name,
|
|
179
|
+
parentId: parentId || null,
|
|
180
|
+
language,
|
|
181
|
+
tenantId,
|
|
182
|
+
options: { kind: 'folder' }
|
|
183
|
+
});
|
|
184
|
+
return typeof tag.get === 'function' ? tag.get({ plain: true }) : tag;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const upload = async ({ type, parentId, file, namespace, language, tenantId }) => {
|
|
188
|
+
if (!type) {
|
|
189
|
+
throw new Error('必须传入类型');
|
|
190
|
+
}
|
|
191
|
+
if (!file) {
|
|
192
|
+
throw new Error('不能获取到上传文件');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const targetParentId = parentId || null;
|
|
196
|
+
const uniqueName = await allocateUniqueSiblingName({
|
|
197
|
+
type,
|
|
198
|
+
parentId: targetParentId,
|
|
199
|
+
name: file.filename,
|
|
200
|
+
language,
|
|
201
|
+
tenantId
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const record = await services.fileRecord.uploadToFileSystem({
|
|
205
|
+
file: Object.assign({}, file, { filename: uniqueName }),
|
|
206
|
+
namespace: namespace || fastifyOptions.namespace
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const tag = await groupServices.save({
|
|
210
|
+
type,
|
|
211
|
+
name: record.filename,
|
|
212
|
+
parentId: targetParentId,
|
|
213
|
+
language,
|
|
214
|
+
tenantId,
|
|
215
|
+
options: {
|
|
216
|
+
kind: 'file',
|
|
217
|
+
fileId: resolveRecordFileId(record),
|
|
218
|
+
size: record.size,
|
|
219
|
+
mimetype: record.mimetype
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const plain = typeof tag.get === 'function' ? tag.get({ plain: true }) : tag;
|
|
224
|
+
return Object.assign({}, plain, { file: record });
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const remove = async ({ type, id, language, tenantId }) => {
|
|
228
|
+
if (!type) {
|
|
229
|
+
throw new Error('必须传入类型');
|
|
230
|
+
}
|
|
231
|
+
if (!id) {
|
|
232
|
+
throw new Error('必须传入节点 id');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const root = await groupServices.detail({ id, type, language, tenantId });
|
|
236
|
+
if (!root) {
|
|
237
|
+
throw new Error('节点不存在');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const descendantIds = await groupServices.getDescendantIds({ id, type, language, tenantId });
|
|
241
|
+
const fileIds = [];
|
|
242
|
+
|
|
243
|
+
for (const nodeId of descendantIds) {
|
|
244
|
+
const node = await groupServices.detail({ id: nodeId, type, language, tenantId });
|
|
245
|
+
if (!node) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
249
|
+
// linked 节点只删树,不删文件库实体
|
|
250
|
+
if (getNodeKind(plain) === 'file' && plain.options?.fileId && plain.options?.linked !== true) {
|
|
251
|
+
fileIds.push(plain.options.fileId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (fileIds.length > 0) {
|
|
256
|
+
await services.fileRecord.deleteFiles({ ids: [...new Set(fileIds)] });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 先删子孙再删根,避免残留
|
|
260
|
+
const orderedIds = descendantIds.slice().reverse();
|
|
261
|
+
for (const nodeId of orderedIds) {
|
|
262
|
+
await groupServices.remove({ id: nodeId, type, language, tenantId });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {};
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const move = async ({ type, ids, parentId, language, tenantId }) => {
|
|
269
|
+
if (!type) {
|
|
270
|
+
throw new Error('必须传入类型');
|
|
271
|
+
}
|
|
272
|
+
const idList = Array.isArray(ids) ? ids.filter(Boolean) : ids ? [ids] : [];
|
|
273
|
+
if (idList.length === 0) {
|
|
274
|
+
throw new Error('必须传入节点 id');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const targetParentId = parentId || null;
|
|
278
|
+
if (targetParentId) {
|
|
279
|
+
const parent = await groupServices.detail({ id: targetParentId, type, language, tenantId });
|
|
280
|
+
if (!parent) {
|
|
281
|
+
throw new Error('目标文件夹不存在');
|
|
282
|
+
}
|
|
283
|
+
const parentPlain = typeof parent.get === 'function' ? parent.get({ plain: true }) : parent;
|
|
284
|
+
if (getNodeKind(parentPlain) !== 'folder') {
|
|
285
|
+
throw new Error('只能移动到文件夹');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const results = [];
|
|
290
|
+
for (const id of idList) {
|
|
291
|
+
const node = await groupServices.detail({ id, type, language, tenantId });
|
|
292
|
+
if (!node) {
|
|
293
|
+
throw new Error(`节点不存在: ${id}`);
|
|
294
|
+
}
|
|
295
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
296
|
+
await assertUniqueSiblingName({
|
|
297
|
+
type,
|
|
298
|
+
parentId: targetParentId,
|
|
299
|
+
name: plain.name,
|
|
300
|
+
language,
|
|
301
|
+
tenantId,
|
|
302
|
+
excludeId: plain.id
|
|
303
|
+
});
|
|
304
|
+
const tag = await groupServices.save({
|
|
305
|
+
id,
|
|
306
|
+
type,
|
|
307
|
+
parentId: targetParentId,
|
|
308
|
+
language,
|
|
309
|
+
tenantId
|
|
310
|
+
});
|
|
311
|
+
results.push(typeof tag.get === 'function' ? tag.get({ plain: true }) : tag);
|
|
312
|
+
}
|
|
313
|
+
return results;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const listDirectChildren = async ({ type, parentId, language, tenantId }) => {
|
|
317
|
+
const where = Object.assign(
|
|
318
|
+
{ type, parentId: parentId || null },
|
|
319
|
+
language ? { language } : {},
|
|
320
|
+
tenantId != null ? { tenantId } : {}
|
|
321
|
+
);
|
|
322
|
+
const rows = await group.models.tag.findAll({ where });
|
|
323
|
+
return rows.map(item => (typeof item.get === 'function' ? item.get({ plain: true }) : item));
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const copyNodeRecursive = async ({ type, sourceId, parentId, language, tenantId }) => {
|
|
327
|
+
const node = await groupServices.detail({ id: String(sourceId), type, language, tenantId });
|
|
328
|
+
if (!node) {
|
|
329
|
+
throw new Error(`节点不存在: ${sourceId}`);
|
|
330
|
+
}
|
|
331
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
332
|
+
await assertUniqueSiblingName({
|
|
333
|
+
type,
|
|
334
|
+
parentId: parentId || null,
|
|
335
|
+
name: plain.name,
|
|
336
|
+
language,
|
|
337
|
+
tenantId
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (getNodeKind(plain) === 'folder') {
|
|
341
|
+
const created = await mkdir({
|
|
342
|
+
type,
|
|
343
|
+
name: plain.name,
|
|
344
|
+
parentId: parentId || null,
|
|
345
|
+
language,
|
|
346
|
+
tenantId
|
|
347
|
+
});
|
|
348
|
+
const children = await listDirectChildren({ type, parentId: plain.id, language, tenantId });
|
|
349
|
+
for (const child of children) {
|
|
350
|
+
await copyNodeRecursive({
|
|
351
|
+
type,
|
|
352
|
+
sourceId: child.id,
|
|
353
|
+
parentId: created.id,
|
|
354
|
+
language,
|
|
355
|
+
tenantId
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return created;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const fileId = plain.options?.fileId;
|
|
362
|
+
if (!fileId) {
|
|
363
|
+
throw new Error(`文件节点缺少 fileId: ${plain.name}`);
|
|
364
|
+
}
|
|
365
|
+
const tag = await groupServices.save({
|
|
366
|
+
type,
|
|
367
|
+
name: plain.name,
|
|
368
|
+
parentId: parentId || null,
|
|
369
|
+
language,
|
|
370
|
+
tenantId,
|
|
371
|
+
options: {
|
|
372
|
+
kind: 'file',
|
|
373
|
+
fileId,
|
|
374
|
+
size: plain.options?.size,
|
|
375
|
+
mimetype: plain.options?.mimetype,
|
|
376
|
+
linked: true
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
return typeof tag.get === 'function' ? tag.get({ plain: true }) : tag;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const copy = async ({ type, ids, parentId, language, tenantId }) => {
|
|
383
|
+
if (!type) {
|
|
384
|
+
throw new Error('必须传入类型');
|
|
385
|
+
}
|
|
386
|
+
const idList = Array.isArray(ids) ? ids.filter(Boolean) : ids ? [ids] : [];
|
|
387
|
+
if (idList.length === 0) {
|
|
388
|
+
throw new Error('必须传入节点 id');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const targetParentId = parentId || null;
|
|
392
|
+
if (targetParentId) {
|
|
393
|
+
const parent = await groupServices.detail({ id: targetParentId, type, language, tenantId });
|
|
394
|
+
if (!parent) {
|
|
395
|
+
throw new Error('目标文件夹不存在');
|
|
396
|
+
}
|
|
397
|
+
const parentPlain = typeof parent.get === 'function' ? parent.get({ plain: true }) : parent;
|
|
398
|
+
if (getNodeKind(parentPlain) !== 'folder') {
|
|
399
|
+
throw new Error('只能复制到文件夹');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const results = [];
|
|
404
|
+
for (const id of idList) {
|
|
405
|
+
if (targetParentId) {
|
|
406
|
+
const descendantIds = await groupServices.getDescendantIds({
|
|
407
|
+
id: String(id),
|
|
408
|
+
type,
|
|
409
|
+
language,
|
|
410
|
+
tenantId
|
|
411
|
+
});
|
|
412
|
+
if (descendantIds.map(String).includes(String(targetParentId))) {
|
|
413
|
+
throw new Error('不能复制到自身或其子文件夹');
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
results.push(
|
|
417
|
+
await copyNodeRecursive({
|
|
418
|
+
type,
|
|
419
|
+
sourceId: id,
|
|
420
|
+
parentId: targetParentId,
|
|
421
|
+
language,
|
|
422
|
+
tenantId
|
|
423
|
+
})
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return results;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const rename = async ({ type, id, name, language, tenantId }) => {
|
|
430
|
+
if (!type) {
|
|
431
|
+
throw new Error('必须传入类型');
|
|
432
|
+
}
|
|
433
|
+
if (!id) {
|
|
434
|
+
throw new Error('必须传入节点 id');
|
|
435
|
+
}
|
|
436
|
+
if (!name) {
|
|
437
|
+
throw new Error('必须传入名称');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const node = await groupServices.detail({ id: String(id), type, language, tenantId });
|
|
441
|
+
if (!node) {
|
|
442
|
+
throw new Error('节点不存在');
|
|
443
|
+
}
|
|
444
|
+
const plain = typeof node.get === 'function' ? node.get({ plain: true }) : node;
|
|
445
|
+
await assertUniqueSiblingName({
|
|
446
|
+
type,
|
|
447
|
+
parentId: plain.parentId || null,
|
|
448
|
+
name,
|
|
449
|
+
language,
|
|
450
|
+
tenantId,
|
|
451
|
+
excludeId: plain.id
|
|
452
|
+
});
|
|
453
|
+
// 直接 update,避免 group.save 在查不到 id 时误走创建
|
|
454
|
+
await node.update({ name });
|
|
455
|
+
const tag = await groupServices.detail({ id: String(id), type, language, tenantId });
|
|
456
|
+
|
|
457
|
+
// linked 节点只改树显示名,不改文件库实体
|
|
458
|
+
if (getNodeKind(plain) === 'file' && plain.options?.fileId && plain.options?.linked !== true) {
|
|
459
|
+
try {
|
|
460
|
+
await services.fileRecord.renameFile({ id: plain.options.fileId, filename: name });
|
|
461
|
+
} catch (e) {
|
|
462
|
+
// 树节点已改名;文件记录缺失时不阻断
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return typeof tag.get === 'function' ? tag.get({ plain: true }) : tag || Object.assign({}, plain, { name });
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const normalizePathSegments = folderPath =>
|
|
470
|
+
String(folderPath || '')
|
|
471
|
+
.replace(/\\/g, '/')
|
|
472
|
+
.split('/')
|
|
473
|
+
.map(segment => segment.trim())
|
|
474
|
+
.filter(segment => segment && segment !== '.' && segment !== '..');
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* 按路径逐级确保文件夹存在(不存在则创建),返回叶子文件夹 id;空路径返回 null(根目录)
|
|
478
|
+
*/
|
|
479
|
+
const ensurePath = async ({ type, path: folderPath, language, tenantId }) => {
|
|
480
|
+
if (!type) {
|
|
481
|
+
throw new Error('必须传入类型');
|
|
482
|
+
}
|
|
483
|
+
const segments = normalizePathSegments(folderPath);
|
|
484
|
+
let parentId = null;
|
|
485
|
+
for (const name of segments) {
|
|
486
|
+
const existing = await findChildByName({ type, parentId, name, language, tenantId });
|
|
487
|
+
if (existing) {
|
|
488
|
+
if (getNodeKind(existing) !== 'folder') {
|
|
489
|
+
throw new Error(`路径冲突:${name} 不是文件夹`);
|
|
490
|
+
}
|
|
491
|
+
parentId = existing.id;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const created = await mkdir({ type, name, parentId, language, tenantId });
|
|
495
|
+
parentId = created.id;
|
|
496
|
+
}
|
|
497
|
+
return parentId;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 将已上传的文件记录挂到文件夹树(默认 linked,删除树节点不删文件实体)
|
|
502
|
+
*/
|
|
503
|
+
const attachRecord = async ({ type, parentId, record, language, tenantId, linked = true }) => {
|
|
504
|
+
if (!type) {
|
|
505
|
+
throw new Error('必须传入类型');
|
|
506
|
+
}
|
|
507
|
+
if (!record) {
|
|
508
|
+
throw new Error('必须传入文件记录');
|
|
509
|
+
}
|
|
510
|
+
const targetParentId = parentId || null;
|
|
511
|
+
if (targetParentId) {
|
|
512
|
+
const parent = await groupServices.detail({ id: targetParentId, type, language, tenantId });
|
|
513
|
+
if (!parent) {
|
|
514
|
+
throw new Error('目标文件夹不存在');
|
|
515
|
+
}
|
|
516
|
+
const parentPlain = typeof parent.get === 'function' ? parent.get({ plain: true }) : parent;
|
|
517
|
+
if (getNodeKind(parentPlain) !== 'folder') {
|
|
518
|
+
throw new Error('只能加入到文件夹');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const linkedFileId = resolveRecordFileId(record);
|
|
523
|
+
const siblingWhere = Object.assign(
|
|
524
|
+
{ type, parentId: targetParentId },
|
|
525
|
+
language ? { language } : {},
|
|
526
|
+
tenantId != null ? { tenantId } : {}
|
|
527
|
+
);
|
|
528
|
+
const siblings = await group.models.tag.findAll({ where: siblingWhere });
|
|
529
|
+
const alreadyMounted = siblings.some(item => {
|
|
530
|
+
const plain = typeof item.get === 'function' ? item.get({ plain: true }) : item;
|
|
531
|
+
return getNodeKind(plain) === 'file' && String(plain.options?.fileId) === linkedFileId;
|
|
532
|
+
});
|
|
533
|
+
if (alreadyMounted) {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const uniqueName = await allocateUniqueSiblingName({
|
|
538
|
+
type,
|
|
539
|
+
parentId: targetParentId,
|
|
540
|
+
name: record.filename,
|
|
541
|
+
language,
|
|
542
|
+
tenantId
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const tag = await groupServices.save({
|
|
546
|
+
type,
|
|
547
|
+
name: uniqueName,
|
|
548
|
+
parentId: targetParentId,
|
|
549
|
+
language,
|
|
550
|
+
tenantId,
|
|
551
|
+
options: {
|
|
552
|
+
kind: 'file',
|
|
553
|
+
fileId: linkedFileId,
|
|
554
|
+
size: record.size,
|
|
555
|
+
mimetype: record.mimetype,
|
|
556
|
+
...(linked ? { linked: true } : {})
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
return typeof tag.get === 'function' ? tag.get({ plain: true }) : tag;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const addFiles = async ({ type, parentId, ids, language, tenantId }) => {
|
|
563
|
+
if (!type) {
|
|
564
|
+
throw new Error('必须传入类型');
|
|
565
|
+
}
|
|
566
|
+
const idList = [...new Set((Array.isArray(ids) ? ids : ids ? [ids] : []).filter(Boolean).map(String))];
|
|
567
|
+
if (idList.length === 0) {
|
|
568
|
+
throw new Error('必须传入文件 id');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const targetParentId = parentId || null;
|
|
572
|
+
if (targetParentId) {
|
|
573
|
+
const parent = await groupServices.detail({ id: targetParentId, type, language, tenantId });
|
|
574
|
+
if (!parent) {
|
|
575
|
+
throw new Error('目标文件夹不存在');
|
|
576
|
+
}
|
|
577
|
+
const parentPlain = typeof parent.get === 'function' ? parent.get({ plain: true }) : parent;
|
|
578
|
+
if (getNodeKind(parentPlain) !== 'folder') {
|
|
579
|
+
throw new Error('只能加入到文件夹');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// 同目录下已挂载的 fileId(兼容历史存主键的数据)
|
|
584
|
+
const siblingWhere = Object.assign(
|
|
585
|
+
{ type, parentId: targetParentId },
|
|
586
|
+
language ? { language } : {},
|
|
587
|
+
tenantId != null ? { tenantId } : {}
|
|
588
|
+
);
|
|
589
|
+
const siblings = await group.models.tag.findAll({ where: siblingWhere });
|
|
590
|
+
const existingFileIds = new Set();
|
|
591
|
+
const existingNames = new Set();
|
|
592
|
+
siblings.forEach(item => {
|
|
593
|
+
const plain = typeof item.get === 'function' ? item.get({ plain: true }) : item;
|
|
594
|
+
if (plain.name) {
|
|
595
|
+
existingNames.add(String(plain.name));
|
|
596
|
+
}
|
|
597
|
+
if (getNodeKind(plain) === 'file' && plain.options?.fileId != null) {
|
|
598
|
+
existingFileIds.add(String(plain.options.fileId));
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const results = [];
|
|
603
|
+
for (const fileId of idList) {
|
|
604
|
+
const record = await services.fileRecord.getFileInstance({ id: fileId });
|
|
605
|
+
// 列表入参多为 uuid;若是历史主键则从记录上取 uuid,禁止把雪花主键写入 options.fileId
|
|
606
|
+
const linkedFileId = isUuidLike(fileId) ? String(fileId) : resolveRecordFileId(record);
|
|
607
|
+
// 已在目标目录则跳过,避免重复挂载
|
|
608
|
+
if (existingFileIds.has(linkedFileId) || existingFileIds.has(String(record.id)) || existingFileIds.has(String(fileId))) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const uniqueName = await allocateUniqueSiblingName({
|
|
612
|
+
type,
|
|
613
|
+
parentId: targetParentId,
|
|
614
|
+
name: record.filename,
|
|
615
|
+
language,
|
|
616
|
+
tenantId,
|
|
617
|
+
existingNames
|
|
618
|
+
});
|
|
619
|
+
const tag = await groupServices.save({
|
|
620
|
+
type,
|
|
621
|
+
name: uniqueName,
|
|
622
|
+
parentId: targetParentId,
|
|
623
|
+
language,
|
|
624
|
+
tenantId,
|
|
625
|
+
options: {
|
|
626
|
+
kind: 'file',
|
|
627
|
+
fileId: linkedFileId,
|
|
628
|
+
size: record.size,
|
|
629
|
+
mimetype: record.mimetype,
|
|
630
|
+
linked: true
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
existingFileIds.add(linkedFileId);
|
|
634
|
+
existingNames.add(String(uniqueName));
|
|
635
|
+
results.push(typeof tag.get === 'function' ? tag.get({ plain: true }) : tag);
|
|
636
|
+
}
|
|
637
|
+
return results;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
Object.assign(services, {
|
|
641
|
+
folder: {
|
|
642
|
+
getTree,
|
|
643
|
+
mkdir,
|
|
644
|
+
upload,
|
|
645
|
+
remove,
|
|
646
|
+
move,
|
|
647
|
+
copy,
|
|
648
|
+
rename,
|
|
649
|
+
addFiles,
|
|
650
|
+
ensurePath,
|
|
651
|
+
attachRecord
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
});
|
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.4",
|
|
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"
|