import { basename, extname } from 'node:path'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { isEmpty } from 'lodash'; import * as qiniu from 'qiniu'; import { auth, conf, rs } from 'qiniu'; import { ConfigKeyPaths } from '~/config'; import { NETDISK_COPY_SUFFIX, NETDISK_DELIMITER, NETDISK_HANDLE_MAX_ITEM, NETDISK_LIMIT } from '~/constants/oss.constant'; import { AccountInfo } from '~/modules/user/user.model'; import { UserService } from '~/modules/user/user.service'; import { generateRandomValue } from '~/utils'; import { SFileInfo, SFileInfoDetail, SFileList } from './manage.class'; import { FileOpItem } from './manage.dto'; @Injectable() export class QiNiuNetDiskManageService { private config: conf.ConfigOptions; private mac: auth.digest.Mac; private bucketManager: rs.BucketManager; private get qiniuConfig() { return this.configService.get('oss', { infer: true }); } constructor( private configService: ConfigService, private userService: UserService ) { this.mac = new qiniu.auth.digest.Mac(this.qiniuConfig.accessKey, this.qiniuConfig.secretKey); this.config = new qiniu.conf.Config({ zone: this.qiniuConfig.zone }); // bucket manager this.bucketManager = new qiniu.rs.BucketManager(this.mac, this.config); } /** * 获取文件列表 * @param prefix 当前文件夹路径,搜索模式下会被忽略 * @param marker 下一页标识 * @returns iFileListResult */ async getFileList(prefix = '', marker = '', skey = ''): Promise { // 是否需要搜索 const searching = !isEmpty(skey); return new Promise((resolve, reject) => { this.bucketManager.listPrefix( this.qiniuConfig.bucket, { prefix: searching ? '' : prefix, limit: NETDISK_LIMIT, delimiter: searching ? '' : NETDISK_DELIMITER, marker }, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { // 如果这个nextMarker不为空,那么还有未列举完毕的文件列表,下次调用listPrefix的时候, // 指定options里面的marker为这个值 const fileList: SFileInfo[] = []; // 处理目录,但只有非搜索模式下可用 if (!searching && !isEmpty(respBody.commonPrefixes)) { // dir for (const dirPath of respBody.commonPrefixes) { const name = (dirPath as string).substr(0, dirPath.length - 1).replace(prefix, ''); if (isEmpty(skey) || name.includes(skey)) { fileList.push({ name: (dirPath as string).substr(0, dirPath.length - 1).replace(prefix, ''), type: 'dir', id: generateRandomValue(10) }); } } } // handle items if (!isEmpty(respBody.items)) { // file for (const item of respBody.items) { // 搜索模式下处理 if (searching) { const pathList: string[] = item.key.split(NETDISK_DELIMITER); // dir is empty stirng, file is key string const name = pathList.pop(); if ( item.key.endsWith(NETDISK_DELIMITER) && pathList[pathList.length - 1].includes(skey) ) { // 结果是目录 const ditName = pathList.pop(); fileList.push({ id: generateRandomValue(10), name: ditName, type: 'dir', belongTo: pathList.join(NETDISK_DELIMITER) }); } else if (name.includes(skey)) { // 文件 fileList.push({ id: generateRandomValue(10), name, type: 'file', fsize: item.fsize, mimeType: item.mimeType, putTime: new Date(Number.parseInt(item.putTime) / 10000), belongTo: pathList.join(NETDISK_DELIMITER) }); } } else { // 正常获取列表 const fileKey = item.key.replace(prefix, '') as string; if (!isEmpty(fileKey)) { fileList.push({ id: generateRandomValue(10), name: fileKey, type: 'file', fsize: item.fsize, mimeType: item.mimeType, putTime: new Date(Number.parseInt(item.putTime) / 10000) }); } } } } resolve({ list: fileList, marker: respBody.marker || null }); } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } } ); }); } /** * 获取文件信息 */ async getFileInfo(name: string, path: string): Promise { return new Promise((resolve, reject) => { this.bucketManager.stat( this.qiniuConfig.bucket, `${path}${name}`, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { const detailInfo: SFileInfoDetail = { fsize: respBody.fsize, hash: respBody.hash, md5: respBody.md5, mimeType: respBody.mimeType.split('/x-qn-meta')[0], putTime: new Date(Number.parseInt(respBody.putTime) / 10000), type: respBody.type, uploader: '', mark: respBody?.['x-qn-meta']?.['!mark'] ?? '' }; if (!respBody.endUser) { resolve(detailInfo); } else { this.userService .getAccountInfo(Number.parseInt(respBody.endUser)) .then((user: AccountInfo) => { if (isEmpty(user)) { resolve(detailInfo); } else { detailInfo.uploader = user.username; resolve(detailInfo); } }); } } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } } ); }); } /** * 修改文件MimeType */ async changeFileHeaders( name: string, path: string, headers: { [k: string]: string } ): Promise { return new Promise((resolve, reject) => { this.bucketManager.changeHeaders( this.qiniuConfig.bucket, `${path}${name}`, headers, (err, _, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { resolve(); } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } } ); }); } /** * 创建文件夹 * @returns true创建成功 */ async createDir(dirName: string): Promise { const safeDirName = dirName.endsWith('/') ? dirName : `${dirName}/`; return new Promise((resolve, reject) => { // 上传一个空文件以用于显示文件夹效果 const formUploader = new qiniu.form_up.FormUploader(this.config); const putExtra = new qiniu.form_up.PutExtra(); formUploader.put( this.createUploadToken(''), safeDirName, ' ', putExtra, (respErr, respBody, respInfo) => { if (respErr) { reject(respErr); return; } if (respInfo.statusCode === 200) { resolve(); } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } } ); }); } /** * 检查文件是否存在,同可检查目录 */ async checkFileExist(filePath: string): Promise { return new Promise((resolve, reject) => { // fix path end must a / // 检测文件夹是否存在 this.bucketManager.stat(this.qiniuConfig.bucket, filePath, (respErr, respBody, respInfo) => { if (respErr) { reject(respErr); return; } if (respInfo.statusCode === 200) { // 文件夹存在 resolve(true); } else if (respInfo.statusCode === 612) { // 文件夹不存在 resolve(false); } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } }); }); } /** * 创建Upload Token, 默认过期时间一小时 * @returns upload token */ createUploadToken(endUser: string): string { const policy = new qiniu.rs.PutPolicy({ scope: this.qiniuConfig.bucket, insertOnly: 1, fsizeLimit: 1024 ** 2 * 10, endUser }); const uploadToken = policy.uploadToken(this.mac); return uploadToken; } /** * 重命名文件 * @param dir 文件路径 * @param name 文件名称 */ async renameFile(dir: string, name: string, toName: string): Promise { const fileName = `${dir}${name}`; const toFileName = `${dir}${toName}`; const op = { force: true }; return new Promise((resolve, reject) => { this.bucketManager.move( this.qiniuConfig.bucket, fileName, this.qiniuConfig.bucket, toFileName, op, (err, respBody, respInfo) => { if (err) { reject(err); } else { if (respInfo.statusCode === 200) { resolve(); } else { reject( new Error( `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}` ) ); } } } ); }); } /** * 移动文件 */ async moveFile(dir: string, toDir: string, name: string): Promise { const fileName = `${dir}${name}`; const toFileName = `${toDir}${name}`; const op = { force: true }; return new Promise((resolve, reject) => { this.bucketManager.move( this.qiniuConfig.bucket, fileName, this.qiniuConfig.bucket, toFileName, op, (err, respBody, respInfo) => { if (err) { reject(err); } else { if (respInfo.statusCode === 200) { resolve(); } else { reject( new Error( `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}` ) ); } } } ); }); } /** * 复制文件 */ async copyFile(dir: string, toDir: string, name: string): Promise { const fileName = `${dir}${name}`; // 拼接文件名 const ext = extname(name); const bn = basename(name, ext); const toFileName = `${toDir}${bn}${NETDISK_COPY_SUFFIX}${ext}`; const op = { force: true }; return new Promise((resolve, reject) => { this.bucketManager.copy( this.qiniuConfig.bucket, fileName, this.qiniuConfig.bucket, toFileName, op, (err, respBody, respInfo) => { if (err) { reject(err); } else { if (respInfo.statusCode === 200) { resolve(); } else { reject( new Error( `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}` ) ); } } } ); }); } /** * 重命名文件夹 */ async renameDir(path: string, name: string, toName: string): Promise { const dirName = `${path}${name}`; const toDirName = `${path}${toName}`; let hasFile = true; let marker = ''; const op = { force: true }; const bucketName = this.qiniuConfig.bucket; while (hasFile) { await new Promise((resolve, reject) => { // 列举当前目录下的所有文件 this.bucketManager.listPrefix( this.qiniuConfig.bucket, { prefix: dirName, limit: NETDISK_HANDLE_MAX_ITEM, marker }, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { const moveOperations = respBody.items.map(item => { const { key } = item; const destKey = key.replace(dirName, toDirName); return qiniu.rs.moveOp(bucketName, key, bucketName, destKey, op); }); this.bucketManager.batch(moveOperations, (err2, respBody2, respInfo2) => { if (err2) { reject(err2); return; } if (respInfo2.statusCode === 200) { if (isEmpty(respBody.marker)) hasFile = false; else marker = respBody.marker; resolve(); } else { reject( new Error( `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}` ) ); } }); } else { reject( new Error( `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}` ) ); } } ); }); } } /** * 获取七牛下载的文件url链接 * @param key 文件路径 * @returns 连接 */ getDownloadLink(key: string): string { if (this.qiniuConfig.access === 'public') { return this.bucketManager.publicDownloadUrl(this.qiniuConfig.domain, key); } else if (this.qiniuConfig.access === 'private') { return this.bucketManager.privateDownloadUrl( this.qiniuConfig.domain, key, Date.now() / 1000 + 36000 ); } throw new Error('qiniu config access type not support'); } /** * 删除文件 * @param dir 删除的文件夹目录 * @param name 文件名 */ async deleteFile(dir: string, name: string): Promise { return new Promise((resolve, reject) => { this.bucketManager.delete( this.qiniuConfig.bucket, `${dir}${name}`, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { resolve(); } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } } ); }); } /** * 删除文件夹 * @param dir 文件夹所在的上级目录 * @param name 文件目录名称 */ async deleteMultiFileOrDir(fileList: FileOpItem[], dir: string): Promise { const files = fileList.filter(item => item.type === 'file'); if (files.length > 0) { // 批处理文件 const copyOperations = files.map(item => { const fileName = `${dir}${item.name}`; return qiniu.rs.deleteOp(this.qiniuConfig.bucket, fileName); }); await new Promise((resolve, reject) => { this.bucketManager.batch(copyOperations, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { resolve(); } else if (respInfo.statusCode === 298) { reject(new Error('操作异常,但部分文件夹删除成功')); } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } }); }); } // 处理文件夹 const dirs = fileList.filter(item => item.type === 'dir'); if (dirs.length > 0) { // 处理文件夹的复制 for (let i = 0; i < dirs.length; i++) { const dirName = `${dir}${dirs[i].name}/`; let hasFile = true; let marker = ''; while (hasFile) { await new Promise((resolve, reject) => { // 列举当前目录下的所有文件 this.bucketManager.listPrefix( this.qiniuConfig.bucket, { prefix: dirName, limit: NETDISK_HANDLE_MAX_ITEM, marker }, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { const moveOperations = respBody.items.map(item => { const { key } = item; return qiniu.rs.deleteOp(this.qiniuConfig.bucket, key); }); this.bucketManager.batch(moveOperations, (err2, respBody2, respInfo2) => { if (err2) { reject(err2); return; } if (respInfo2.statusCode === 200) { if (isEmpty(respBody.marker)) hasFile = false; else marker = respBody.marker; resolve(); } else { reject( new Error( `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}` ) ); } }); } else { reject( new Error( `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}` ) ); } } ); }); } } } } /** * 复制文件,含文件夹 */ async copyMultiFileOrDir(fileList: FileOpItem[], dir: string, toDir: string): Promise { const files = fileList.filter(item => item.type === 'file'); const op = { force: true }; if (files.length > 0) { // 批处理文件 const copyOperations = files.map(item => { const fileName = `${dir}${item.name}`; // 拼接文件名 const ext = extname(item.name); const bn = basename(item.name, ext); const toFileName = `${toDir}${bn}${NETDISK_COPY_SUFFIX}${ext}`; return qiniu.rs.copyOp( this.qiniuConfig.bucket, fileName, this.qiniuConfig.bucket, toFileName, op ); }); await new Promise((resolve, reject) => { this.bucketManager.batch(copyOperations, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { resolve(); } else if (respInfo.statusCode === 298) { reject(new Error('操作异常,但部分文件夹删除成功')); } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } }); }); } // 处理文件夹 const dirs = fileList.filter(item => item.type === 'dir'); if (dirs.length > 0) { // 处理文件夹的复制 for (let i = 0; i < dirs.length; i++) { const dirName = `${dir}${dirs[i].name}/`; const copyDirName = `${toDir}${dirs[i].name}${NETDISK_COPY_SUFFIX}/`; let hasFile = true; let marker = ''; while (hasFile) { await new Promise((resolve, reject) => { // 列举当前目录下的所有文件 this.bucketManager.listPrefix( this.qiniuConfig.bucket, { prefix: dirName, limit: NETDISK_HANDLE_MAX_ITEM, marker }, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { const moveOperations = respBody.items.map(item => { const { key } = item; const destKey = key.replace(dirName, copyDirName); return qiniu.rs.copyOp( this.qiniuConfig.bucket, key, this.qiniuConfig.bucket, destKey, op ); }); this.bucketManager.batch(moveOperations, (err2, respBody2, respInfo2) => { if (err2) { reject(err2); return; } if (respInfo2.statusCode === 200) { if (isEmpty(respBody.marker)) hasFile = false; else marker = respBody.marker; resolve(); } else { reject( new Error( `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}` ) ); } }); } else { reject( new Error( `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}` ) ); } } ); }); } } } } /** * 移动文件,含文件夹 */ async moveMultiFileOrDir(fileList: FileOpItem[], dir: string, toDir: string): Promise { const files = fileList.filter(item => item.type === 'file'); const op = { force: true }; if (files.length > 0) { // 批处理文件 const copyOperations = files.map(item => { const fileName = `${dir}${item.name}`; const toFileName = `${toDir}${item.name}`; return qiniu.rs.moveOp( this.qiniuConfig.bucket, fileName, this.qiniuConfig.bucket, toFileName, op ); }); await new Promise((resolve, reject) => { this.bucketManager.batch(copyOperations, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { resolve(); } else if (respInfo.statusCode === 298) { reject(new Error('操作异常,但部分文件夹删除成功')); } else { reject( new Error(`Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}`) ); } }); }); } // 处理文件夹 const dirs = fileList.filter(item => item.type === 'dir'); if (dirs.length > 0) { // 处理文件夹的复制 for (let i = 0; i < dirs.length; i++) { const dirName = `${dir}${dirs[i].name}/`; const toDirName = `${toDir}${dirs[i].name}/`; // 移动的目录不是是自己 if (toDirName.startsWith(dirName)) continue; let hasFile = true; let marker = ''; while (hasFile) { await new Promise((resolve, reject) => { // 列举当前目录下的所有文件 this.bucketManager.listPrefix( this.qiniuConfig.bucket, { prefix: dirName, limit: NETDISK_HANDLE_MAX_ITEM, marker }, (err, respBody, respInfo) => { if (err) { reject(err); return; } if (respInfo.statusCode === 200) { const moveOperations = respBody.items.map(item => { const { key } = item; const destKey = key.replace(dirName, toDirName); return qiniu.rs.moveOp( this.qiniuConfig.bucket, key, this.qiniuConfig.bucket, destKey, op ); }); this.bucketManager.batch(moveOperations, (err2, respBody2, respInfo2) => { if (err2) { reject(err2); return; } if (respInfo2.statusCode === 200) { if (isEmpty(respBody.marker)) hasFile = false; else marker = respBody.marker; resolve(); } else { reject( new Error( `Qiniu Error Code: ${respInfo2.statusCode}, Info: ${respInfo2.statusMessage}` ) ); } }); } else { reject( new Error( `Qiniu Error Code: ${respInfo.statusCode}, Info: ${respInfo.statusMessage}` ) ); } } ); }); } } } } }