refactor(plugin-file-manager): change storage type api and add plugin api (#6246)

* refactor(plugin-file-manager): change storage type api and add plugin api

* fix(plugin-file-manager): fix test cases
This commit is contained in:
Junyi 2025-02-21 22:22:16 +08:00 committed by mytharcher
parent 2ac2b9a81c
commit f8a5999903
13 changed files with 318 additions and 201 deletions

View File

@ -52,88 +52,6 @@ describe('action', () => {
describe('create / upload', () => {
describe('default storage', () => {
it('should be create file record', async () => {
const Plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer;
const model = await Plugin.createFileRecord({
collectionName: 'attachments',
filePath: path.resolve(__dirname, './files/text.txt'),
});
const matcher = {
title: 'text',
extname: '.txt',
path: '',
// size: 13,
meta: {},
storageId: 1,
};
expect(model.toJSON()).toMatchObject(matcher);
});
it('should be local2 storage', async () => {
const storage = await StorageRepo.create({
values: {
name: 'local2',
type: STORAGE_TYPE_LOCAL,
baseUrl: DEFAULT_LOCAL_BASE_URL,
rules: {
size: 1024,
},
paranoid: true,
},
});
const Plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer;
const model = await Plugin.createFileRecord({
collectionName: 'attachments',
storageName: 'local2',
filePath: path.resolve(__dirname, './files/text.txt'),
});
const matcher = {
title: 'text',
extname: '.txt',
path: '',
// size: 13,
meta: {},
storageId: storage.id,
};
expect(model.toJSON()).toMatchObject(matcher);
});
it('should be custom values', async () => {
const Plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer;
const model = await Plugin.createFileRecord({
collectionName: 'attachments',
filePath: path.resolve(__dirname, './files/text.txt'),
values: {
size: 22,
},
});
const matcher = {
title: 'text',
extname: '.txt',
path: '',
size: 22,
meta: {},
storageId: 1,
};
expect(model.toJSON()).toMatchObject(matcher);
});
it('should be upload file', async () => {
const Plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer;
const data = await Plugin.uploadFile({
filePath: path.resolve(__dirname, './files/text.txt'),
documentRoot: 'storage/backups/test',
});
const matcher = {
title: 'text',
extname: '.txt',
path: '',
meta: {},
storageId: 1,
};
expect(data).toMatchObject(matcher);
});
it('upload file should be ok', async () => {
const { body } = await agent.resource('attachments').create({
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),

View File

@ -0,0 +1,156 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import path from 'path';
import { getApp } from '.';
import PluginFileManagerServer from '../server';
import { STORAGE_TYPE_LOCAL, FILE_FIELD_NAME } from '../../constants';
const { LOCAL_STORAGE_BASE_URL, LOCAL_STORAGE_DEST = 'storage/uploads', APP_PORT = '13000' } = process.env;
const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `/storage/uploads`;
describe('file manager > server', () => {
let app;
let agent;
let db;
let plugin: PluginFileManagerServer;
let StorageRepo;
let AttachmentRepo;
let local;
beforeEach(async () => {
app = await getApp();
agent = app.agent();
db = app.db;
plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer;
AttachmentRepo = db.getCollection('attachments').repository;
StorageRepo = db.getCollection('storages').repository;
local = await StorageRepo.findOne({
filter: {
type: STORAGE_TYPE_LOCAL,
},
});
});
afterEach(async () => {
await app.destroy();
});
describe('api', () => {
describe('createFileRecord', () => {
it('should be create file record', async () => {
const model = await plugin.createFileRecord({
collectionName: 'attachments',
filePath: path.resolve(__dirname, './files/text.txt'),
});
const matcher = {
title: 'text',
extname: '.txt',
path: '',
// size: 13,
meta: {},
storageId: 1,
};
expect(model.toJSON()).toMatchObject(matcher);
});
it('should be local2 storage', async () => {
const storage = await StorageRepo.create({
values: {
name: 'local2',
type: STORAGE_TYPE_LOCAL,
baseUrl: DEFAULT_LOCAL_BASE_URL,
rules: {
size: 1024,
},
paranoid: true,
},
});
const model = await plugin.createFileRecord({
collectionName: 'attachments',
storageName: 'local2',
filePath: path.resolve(__dirname, './files/text.txt'),
});
const matcher = {
title: 'text',
extname: '.txt',
path: '',
// size: 13,
meta: {},
storageId: storage.id,
};
expect(model.toJSON()).toMatchObject(matcher);
});
it('should be custom values', async () => {
const model = await plugin.createFileRecord({
collectionName: 'attachments',
filePath: path.resolve(__dirname, './files/text.txt'),
values: {
size: 22,
},
});
const matcher = {
title: 'text',
extname: '.txt',
path: '',
size: 22,
meta: {},
storageId: 1,
};
expect(model.toJSON()).toMatchObject(matcher);
});
});
describe('uploadFile', () => {
it('should be upload file', async () => {
const data = await plugin.uploadFile({
filePath: path.resolve(__dirname, './files/text.txt'),
documentRoot: 'storage/backups/test',
});
const matcher = {
title: 'text',
extname: '.txt',
path: '',
meta: {},
storageId: 1,
};
expect(data).toMatchObject(matcher);
});
});
describe('getFileURL', () => {
it('local attachment without env', async () => {
const { body } = await agent.resource('attachments').create({
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),
});
const url = await plugin.getFileURL(body.data);
expect(url).toBe(`${process.env.APP_PUBLIC_PATH?.replace(/\/$/g, '') || ''}${body.data.url}`);
});
it('local attachment with env', async () => {
const originalPath = process.env.APP_PUBLIC_PATH;
process.env.APP_PUBLIC_PATH = 'http://localhost';
const { body } = await agent.resource('attachments').create({
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),
});
const url = await plugin.getFileURL(body.data);
expect(url).toBe(`http://localhost${body.data.url}`);
process.env.APP_PUBLIC_PATH = originalPath;
});
});
});
});

View File

@ -13,6 +13,7 @@ import AliOSSStorage from '../../storages/ali-oss';
import { FILE_FIELD_NAME } from '../../../constants';
import { getApp, requestFile } from '..';
import { Database } from '@nocobase/database';
import PluginFileManagerServer from '../../server';
const itif = process.env.ALI_OSS_ACCESS_KEY_SECRET ? it : it.skip;
@ -23,19 +24,20 @@ describe('storage:ali-oss', () => {
let AttachmentRepo;
let StorageRepo;
let storage;
const aliossStorage = new AliOSSStorage();
let plugin: PluginFileManagerServer;
beforeEach(async () => {
app = await getApp();
agent = app.agent();
db = app.db;
plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer;
AttachmentRepo = db.getCollection('attachments').repository;
StorageRepo = db.getCollection('storages').repository;
storage = await StorageRepo.create({
values: {
...aliossStorage.defaults(),
...AliOSSStorage.defaults(),
name: 'ali-oss',
default: true,
path: 'test/path',
@ -107,7 +109,7 @@ describe('storage:ali-oss', () => {
itif('destroy record should not delete file when paranoid', async () => {
const paranoidStorage = await StorageRepo.create({
values: {
...aliossStorage.defaults(),
...AliOSSStorage.defaults(),
name: 'ali-oss-2',
path: 'test/nocobase',
paranoid: true,
@ -134,4 +136,29 @@ describe('storage:ali-oss', () => {
expect(content2.status).toBe(200);
});
});
describe('plugin api', () => {
itif('getFileURL', async () => {
const options = AliOSSStorage.defaults();
await StorageRepo.create({
values: {
...options,
name: 'ali-oss-2',
path: 'test/nocobase',
default: true,
},
});
const { body } = await agent.resource('attachments').create({
[FILE_FIELD_NAME]: path.resolve(__dirname, '../files/text.txt'),
});
const url = plugin.getFileURL(body.data);
expect(url).toBe(`${options.baseUrl}/${body.data.path}/${body.data.filename}`);
// 通过 url 是否能正确访问
const content1 = await requestFile(url, agent);
expect(content1.text).toBe('Hello world!\n');
});
});
});

View File

@ -23,7 +23,6 @@ describe('storage:s3', () => {
let AttachmentRepo;
let StorageRepo;
let storage;
const s3Storage = new S3Storage();
beforeEach(async () => {
app = await getApp();
@ -35,7 +34,7 @@ describe('storage:s3', () => {
storage = await StorageRepo.create({
values: {
...s3Storage.defaults(),
...S3Storage.defaults(),
name: 's3',
default: true,
path: 'test/path',
@ -106,7 +105,7 @@ describe('storage:s3', () => {
itif('destroy record should not delete file when paranoid', async () => {
const paranoidStorage = await StorageRepo.create({
values: {
...s3Storage.defaults(),
...S3Storage.defaults(),
name: 's3-2',
path: 'test/nocobase',
paranoid: true,

View File

@ -21,7 +21,6 @@ describe('storage:tx-cos', () => {
let agent;
let db: Database;
let storage;
const txStorage = new TXCOSStorage();
beforeEach(async () => {
app = await getApp();
@ -30,7 +29,7 @@ describe('storage:tx-cos', () => {
const Storage = db.getCollection('storages').model;
storage = await Storage.create({
...txStorage.defaults(),
...TXCOSStorage.defaults(),
name: 'tx-cos',
default: true,
path: 'test/path',

View File

@ -20,6 +20,7 @@ import {
LIMIT_FILES,
} from '../../constants';
import * as Rules from '../rules';
import { StorageClassType } from '../storages';
// TODO(optimize): 需要优化错误处理,计算失败后需要抛出对应错误,以便程序处理
function getFileFilter(storage) {
@ -39,8 +40,8 @@ export function getFileData(ctx: Context) {
return ctx.throw(400, 'file validation failed');
}
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
const { [storageConfig.filenameKey || 'filename']: name } = file;
const StorageType = ctx.app.pm.get(Plugin).storageTypes.get(storage.type) as StorageClassType;
const { [StorageType.filenameKey || 'filename']: name } = file;
// make compatible filename across cloud service (with path)
const filename = Path.basename(name);
const extname = Path.extname(filename);
@ -48,6 +49,8 @@ export function getFileData(ctx: Context) {
const baseUrl = storage.baseUrl.replace(/\/+$/, '');
const pathname = [path, filename].filter(Boolean).join('/');
const storageInstance = new StorageType(storage);
return {
title: Buffer.from(file.originalname, 'latin1').toString('utf8').replace(extname, ''),
filename,
@ -61,7 +64,7 @@ export function getFileData(ctx: Context) {
// @ts-ignore
meta: ctx.request.body,
storageId: storage.id,
...(storageConfig.getFileData ? storageConfig.getFileData(file) : {}),
...(storageInstance.getFileData ? storageInstance.getFileData(file) : {}),
};
}
@ -72,11 +75,12 @@ async function multipart(ctx: Context, next: Next) {
return ctx.throw(500);
}
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
if (!storageConfig) {
const StorageType = ctx.app.pm.get(Plugin).storageTypes.get(storage.type) as StorageClassType;
if (!StorageType) {
ctx.logger.error(`[file-manager] storage type "${storage.type}" is not defined`);
return ctx.throw(500);
}
const storageInstance = new StorageType(storage);
const multerOptions = {
fileFilter: getFileFilter(storage),
@ -84,7 +88,7 @@ async function multipart(ctx: Context, next: Next) {
// 每次只允许提交一个文件
files: LIMIT_FILES,
},
storage: storageConfig.make(storage),
storage: storageInstance.make(),
};
multerOptions.limits['fileSize'] = Math.min(
Math.max(FILE_SIZE_LIMIT_MIN, storage.rules.size ?? FILE_SIZE_LIMIT_DEFAULT),

View File

@ -10,7 +10,7 @@
import { StorageEngine } from 'multer';
export * from '../constants';
export { AttachmentModel, default, IStorage, PluginFileManagerServer, StorageModel } from './server';
export { AttachmentModel, default, PluginFileManagerServer, StorageModel } from './server';
export { StorageType } from './storages';

View File

@ -19,7 +19,7 @@ import { FileModel } from './FileModel';
import initActions from './actions';
import { getFileData } from './actions/attachments';
import { AttachmentInterface } from './interfaces/attachment-interface';
import { AttachmentModel, IStorage, StorageModel } from './storages';
import { AttachmentModel, StorageClassType, StorageModel, StorageType } from './storages';
import StorageTypeAliOss from './storages/ali-oss';
import StorageTypeLocal from './storages/local';
import StorageTypeS3 from './storages/s3';
@ -53,7 +53,7 @@ export type UploadFileOptions = {
};
export class PluginFileManagerServer extends Plugin {
storageTypes = new Registry<IStorage>();
storageTypes = new Registry<StorageClassType>();
storagesCache = new Map<number, StorageModel>();
afterDestroy = async (record: Model, options) => {
@ -66,15 +66,16 @@ export class PluginFileManagerServer extends Plugin {
if (storage?.paranoid) {
return;
}
const storageConfig = this.storageTypes.get(storage.type);
const result = await storageConfig.delete(storage, [record as unknown as AttachmentModel]);
const Type = this.storageTypes.get(storage.type);
const storageConfig = new Type(storage);
const result = await storageConfig.delete([record as unknown as AttachmentModel]);
if (!result[0]) {
throw new FileDeleteError('Failed to delete file', record);
}
};
registerStorageType(type: string, options: IStorage) {
this.storageTypes.register(type, options);
registerStorageType(type: string, Type: StorageClassType) {
this.storageTypes.register(type, Type);
}
async createFileRecord(options: FileRecordOptions) {
@ -98,21 +99,15 @@ export class PluginFileManagerServer extends Plugin {
const storageRepository = this.db.getRepository('storages');
let storageInstance;
if (storageName) {
storageInstance = await storageRepository.findOne({
filter: {
name: storageName,
},
});
}
if (!storageInstance) {
storageInstance = await storageRepository.findOne({
filter: {
default: true,
},
});
}
storageInstance = await storageRepository.findOne({
filter: storageName
? {
name: storageName,
}
: {
default: true,
},
});
const fileStream = fs.createReadStream(filePath);
@ -126,13 +121,14 @@ export class PluginFileManagerServer extends Plugin {
storageInstance.options['documentRoot'] = documentRoot;
}
const storageConfig = this.storageTypes.get(storageInstance.type);
const storageType = this.storageTypes.get(storageInstance.type);
const storage = new storageType(storageInstance);
if (!storageConfig) {
if (!storage) {
throw new Error(`[file-manager] storage type "${storageInstance.type}" is not defined`);
}
const engine = storageConfig.make(storageInstance);
const engine = storage.make();
const file = {
originalname: basename(filePath),
@ -166,14 +162,14 @@ export class PluginFileManagerServer extends Plugin {
}
async install() {
const defaultStorageConfig = this.storageTypes.get(DEFAULT_STORAGE_TYPE);
const defaultStorageType = this.storageTypes.get(DEFAULT_STORAGE_TYPE);
if (defaultStorageConfig) {
if (defaultStorageType) {
const Storage = this.db.getCollection('storages');
if (
await Storage.repository.findOne({
filter: {
name: defaultStorageConfig.defaults().name,
name: defaultStorageType.defaults().name,
},
})
) {
@ -181,7 +177,7 @@ export class PluginFileManagerServer extends Plugin {
}
await Storage.repository.create({
values: {
...defaultStorageConfig.defaults(),
...defaultStorageType.defaults(),
type: DEFAULT_STORAGE_TYPE,
default: true,
},
@ -219,10 +215,10 @@ export class PluginFileManagerServer extends Plugin {
async load() {
this.db.on('afterDestroy', this.afterDestroy);
this.storageTypes.register(STORAGE_TYPE_LOCAL, new StorageTypeLocal());
this.storageTypes.register(STORAGE_TYPE_ALI_OSS, new StorageTypeAliOss());
this.storageTypes.register(STORAGE_TYPE_S3, new StorageTypeS3());
this.storageTypes.register(STORAGE_TYPE_TX_COS, new StorageTypeTxCos());
this.storageTypes.register(STORAGE_TYPE_LOCAL, StorageTypeLocal);
this.storageTypes.register(STORAGE_TYPE_ALI_OSS, StorageTypeAliOss);
this.storageTypes.register(STORAGE_TYPE_S3, StorageTypeS3);
this.storageTypes.register(STORAGE_TYPE_TX_COS, StorageTypeTxCos);
const Storage = this.db.getModel('storages');
Storage.afterSave((m, { transaction }) => {
@ -284,6 +280,12 @@ export class PluginFileManagerServer extends Plugin {
this.app.db.interfaceManager.registerInterfaceType('attachment', AttachmentInterface);
}
getFileURL(file: AttachmentModel) {
const storage = this.storagesCache.get(file.storageId);
const storageType = this.storageTypes.get(storage.type);
return new storageType(storage).getFileURL(file);
}
}
export default PluginFileManagerServer;

View File

@ -12,14 +12,7 @@ import { STORAGE_TYPE_ALI_OSS } from '../../constants';
import { cloudFilenameGetter, getFileKey } from '../utils';
export default class extends StorageType {
make(storage) {
const createAliOssStorage = require('multer-aliyun-oss');
return new createAliOssStorage({
config: storage.options,
filename: cloudFilenameGetter(storage),
});
}
defaults() {
static defaults() {
return {
title: '阿里云对象存储',
type: STORAGE_TYPE_ALI_OSS,
@ -33,8 +26,16 @@ export default class extends StorageType {
},
};
}
async delete(storage, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const { client } = this.make(storage);
make() {
const createAliOssStorage = require('multer-aliyun-oss');
return new createAliOssStorage({
config: this.storage.options,
filename: cloudFilenameGetter(this.storage),
});
}
async delete(records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const { client } = this.make();
const { deleted } = await client.deleteMulti(records.map(getFileKey));
return [deleted.length, records.filter((record) => !deleted.find((item) => item.Key === getFileKey(record)))];
}

View File

@ -27,21 +27,23 @@ export interface AttachmentModel {
title: string;
filename: string;
path: string;
url: string;
storageId: number;
}
export interface IStorage {
filenameKey?: string;
middleware?(app: Application): void;
export abstract class StorageType {
static defaults(): StorageModel {
return {} as StorageModel;
}
static filenameKey?: string;
constructor(public storage: StorageModel) {}
abstract make(): StorageEngine;
abstract delete(records: AttachmentModel[]): [number, AttachmentModel[]] | Promise<[number, AttachmentModel[]]>;
getFileData?(file: { [key: string]: any }): { [key: string]: any };
make(storage: StorageModel): StorageEngine;
defaults(): StorageModel;
delete(storage: StorageModel, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]>;
}
export abstract class StorageType implements IStorage {
abstract make(storage: StorageModel): StorageEngine;
abstract delete(storage: StorageModel, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]>;
defaults(): StorageModel {
return {} as any;
getFileURL(file: AttachmentModel): string | Promise<string> {
return file.url;
}
}
export type StorageClassType = { new (storage: StorageModel): StorageType } & typeof StorageType;

View File

@ -22,16 +22,7 @@ function getDocumentRoot(storage): string {
}
export default class extends StorageType {
make(storage) {
return multer.diskStorage({
destination: function (req, file, cb) {
const destPath = path.join(getDocumentRoot(storage), storage.path);
mkdirp(destPath, (err: Error | null) => cb(err, destPath));
},
filename: getFilename,
});
}
defaults() {
static defaults() {
return {
title: 'Local storage',
type: STORAGE_TYPE_LOCAL,
@ -45,8 +36,18 @@ export default class extends StorageType {
},
};
}
async delete(storage, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const documentRoot = getDocumentRoot(storage);
make() {
return multer.diskStorage({
destination: (req, file, cb) => {
const destPath = path.join(getDocumentRoot(this.storage), this.storage.path);
mkdirp(destPath, (err: Error | null) => cb(err, destPath));
},
filename: getFilename,
});
}
async delete(records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const documentRoot = getDocumentRoot(this.storage);
let count = 0;
const undeleted = [];
await records.reduce(
@ -70,4 +71,7 @@ export default class extends StorageType {
return [count, undeleted];
}
getFileURL(file: AttachmentModel) {
return process.env.APP_PUBLIC_PATH ? `${process.env.APP_PUBLIC_PATH.replace(/\/$/g, '')}${file.url}` : file.url;
}
}

View File

@ -12,11 +12,27 @@ import { STORAGE_TYPE_S3 } from '../../constants';
import { cloudFilenameGetter, getFileKey } from '../utils';
export default class extends StorageType {
filenameKey = 'key';
make(storage) {
static defaults() {
return {
title: 'AWS S3',
name: 'aws-s3',
type: STORAGE_TYPE_S3,
baseUrl: process.env.AWS_S3_STORAGE_BASE_URL,
options: {
region: process.env.AWS_S3_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
bucket: process.env.AWS_S3_BUCKET,
},
};
}
static filenameKey = 'key';
make() {
const { S3Client } = require('@aws-sdk/client-s3');
const multerS3 = require('multer-s3');
const { accessKeyId, secretAccessKey, bucket, acl = 'public-read', ...options } = storage.options;
const { accessKeyId, secretAccessKey, bucket, acl = 'public-read', ...options } = this.storage.options;
if (options.endpoint) {
options.forcePathStyle = true;
} else {
@ -42,29 +58,16 @@ export default class extends StorageType {
multerS3.AUTO_CONTENT_TYPE(req, file, cb);
},
key: cloudFilenameGetter(storage),
key: cloudFilenameGetter(this.storage),
});
}
defaults() {
return {
title: 'AWS S3',
name: 'aws-s3',
type: STORAGE_TYPE_S3,
baseUrl: process.env.AWS_S3_STORAGE_BASE_URL,
options: {
region: process.env.AWS_S3_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
bucket: process.env.AWS_S3_BUCKET,
},
};
}
async delete(storage, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
async delete(records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const { DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const { s3 } = this.make(storage);
const { s3 } = this.make();
const { Deleted } = await s3.send(
new DeleteObjectsCommand({
Bucket: storage.options.bucket,
Bucket: this.storage.options.bucket,
Delete: {
Objects: records.map((record) => ({ Key: getFileKey(record) })),
},

View File

@ -14,18 +14,7 @@ import { STORAGE_TYPE_TX_COS } from '../../constants';
import { getFilename, getFileKey } from '../utils';
export default class extends StorageType {
filenameKey = 'url';
make(storage) {
const createTxCosStorage = require('multer-cos');
return new createTxCosStorage({
cos: {
...storage.options,
dir: (storage.path ?? '').replace(/\/+$/, ''),
},
filename: getFilename,
});
}
defaults() {
static defaults() {
return {
title: '腾讯云对象存储',
type: STORAGE_TYPE_TX_COS,
@ -39,11 +28,24 @@ export default class extends StorageType {
},
};
}
async delete(storage, records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const { cos } = this.make(storage);
static filenameKey = 'url';
make() {
const createTxCosStorage = require('multer-cos');
return new createTxCosStorage({
cos: {
...this.storage.options,
dir: (this.storage.path ?? '').replace(/\/+$/, ''),
},
filename: getFilename,
});
}
async delete(records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
const { cos } = this.make();
const { Deleted } = await promisify(cos.deleteMultipleObject).call(cos, {
Region: storage.options.Region,
Bucket: storage.options.Bucket,
Region: this.storage.options.Region,
Bucket: this.storage.options.Bucket,
Objects: records.map((record) => ({ Key: getFileKey(record) })),
});
return [Deleted.length, records.filter((record) => !Deleted.find((item) => item.Key === getFileKey(record)))];