analyzerPerField = new HashMap<>();
+ analyzerPerField.put(LuceneService.FIELD_CONTENT_NGRAM, ngramAnalyzer);
+ analyzerPerField.put(LuceneService.FIELD_FILENAME_NGRAM, ngramAnalyzer);
+ analyzerPerField.put(LuceneService.FIELD_TAG_NAME_NGRAM, ngramAnalyzer);
+ // 如果其他字段也需要特定分析器,在这里添加
+
+ // PerFieldAnalyzerWrapper 允许为特定字段指定分析器,其他字段使用默认分析器
+ PerFieldAnalyzerWrapper wrapperAnalyzer = new PerFieldAnalyzerWrapper(analyzer, analyzerPerField);
+
+ IndexWriterConfig indexWriterConfig = new IndexWriterConfig(wrapperAnalyzer);
+ indexWriterConfig.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
+ this.indexWriterInstance = new IndexWriter(directory, indexWriterConfig);
+ return this.indexWriterInstance;
+ }
+
+ /**
+ * SearcherManager管理
+ *
+ * @return SearcherManager
+ */
+ @Bean
+ public SearcherManager searcherManager(IndexWriter indexWriter) throws IOException {
+ this.searcherManagerInstance = new SearcherManager(indexWriter, false, false, new SearcherFactory());
+ controlledRealTimeReopenThread = new ControlledRealTimeReopenThread<>(indexWriter, searcherManagerInstance,
+ 5.0, 0.025);
+ controlledRealTimeReopenThread.setDaemon(true);
+ //线程名称
+ controlledRealTimeReopenThread.setName("NRTReopenThread");
+ // 开启线程
+ controlledRealTimeReopenThread.start();
+ return searcherManagerInstance;
+ }
+
+ @PreDestroy
+ public void destroy() {
+ log.info("Shutting down Lucene components...");
+
+ // 1. 关闭 NRT Reopen 线程 (它会尝试关闭 SearcherManager)
+ if (this.controlledRealTimeReopenThread != null) {
+ try {
+ log.debug("Closing ControlledRealTimeReopenThread...");
+ this.controlledRealTimeReopenThread.close(); // 这个close()会等待线程结束
+ log.info("ControlledRealTimeReopenThread closed.");
+ } catch (Exception e) { // InterruptedException or IOException
+ log.error("Error closing ControlledRealTimeReopenThread: {}", e.getMessage(), e);
+ }
+ }
+
+ // 2. SearcherManager 可能已经被 cRTReopenThead 关闭,但作为保险再检查一次
+ // 实际上,SearcherManager.close() 也会关闭它内部的 IndexReader,
+ // 但通常不会关闭外部传入的 IndexWriter。
+ if (this.searcherManagerInstance != null) {
+ // SearcherManager 的 close() 通常由 cRTReopenThead.close() 间接触发,
+ // 但如果 cRTReopenThead 未成功初始化或启动,这里可以作为后备。
+ // 然而,重复关闭可能导致问题,所以最好依赖 cRTReopenThead 的关闭。
+ // 如果 cRTReopenThead.close() 确保了 SearcherManager 关闭,这里可以不显式调用。
+ log.debug("SearcherManager should have been closed by NRTReopenThread.");
+ }
+
+ // 3. 最重要:关闭 IndexWriter
+ // 这会提交任何挂起的更改,释放写锁,并关闭文件句柄。
+ if (this.indexWriterInstance != null && this.indexWriterInstance.isOpen()) {
+ try {
+ log.info("Closing IndexWriter...");
+ this.indexWriterInstance.close();
+ log.info("IndexWriter closed.");
+ } catch (IOException e) {
+ log.error("Error closing IndexWriter: {}", e.getMessage(), e);
+ }
+ }
+ log.info("Lucene components shutdown complete.");
+ }
+}
+
diff --git a/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java b/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java
new file mode 100644
index 0000000..6a08e6e
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java
@@ -0,0 +1,698 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.CharsetDetector;
+import cn.hutool.core.io.FileTypeUtil;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileIndex;
+import com.jmal.clouddisk.model.FileIntroVO;
+import com.jmal.clouddisk.model.Tag;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.jmal.clouddisk.service.impl.CommonFileService;
+import com.jmal.clouddisk.util.FileContentTypeUtils;
+import com.jmal.clouddisk.util.HashUtil;
+import com.jmal.clouddisk.util.MyFileUtils;
+import com.mongodb.client.AggregateIterable;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.lucene.document.*;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.Term;
+import org.mozilla.universalchardet.UniversalDetector;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.stereotype.Service;
+
+import java.io.*;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static com.jmal.clouddisk.service.impl.CommonFileService.COLLECTION_NAME;
+
+/**
+ * @author jmal
+ *
+ * 优化索引
+ * indexWriter.forceMerge(1);
+ * indexWriter.commit();
+ * @Description LuceneService
+ * @Date 2021/4/27 4:44 下午
+ */
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class LuceneService {
+
+ private final FileProperties fileProperties;
+ private final MongoTemplate mongoTemplate;
+ private final IndexWriter indexWriter;
+ private final IUserService userService;
+ private final ReadContentService readContentService;
+ private final RebuildIndexTaskService rebuildIndexTaskService;
+ private final SearchFileService searchFileService;
+ private final EtagService esTagService;
+
+ public static final String MONGO_INDEX_FIELD = "index";
+
+ /**
+ * 创建索引线程池
+ */
+ private ExecutorService executorCreateIndexService;
+ /**
+ * 更新索引内容线程池
+ */
+ private ExecutorService executorUpdateContentIndexService;
+ /**
+ * 更新大文件索引内容线程池
+ */
+ private ExecutorService executorUpdateBigContentIndexService;
+
+ /**
+ * 定时任务线程池
+ */
+ ScheduledExecutorService scheduler;
+
+ /**
+ * 新建索引文件缓冲队列大小
+ */
+ private static final int CREATE_INDEX_QUEUE_SIZE = 40960;
+
+ /**
+ * 新建索引文件缓冲队列
+ */
+ private ArrayBlockingQueue indexFileQueue;
+
+ /**
+ * 新建索引文件内容缓冲队列
+ */
+ private ArrayBlockingQueue indexFileContentQueue;
+
+ private final AtomicBoolean processingUnIndexedScheduled = new AtomicBoolean(false);
+
+ public static final String FIELD_CONTENT_NGRAM = "content_ngram";
+ public static final String FIELD_FILENAME_NGRAM = "filename_ngram";
+ public static final String FIELD_TAG_NAME_NGRAM = "tagName_ngram";
+
+ public static final String FIELD_CONTENT_FUZZY = "content";
+ public static final String FIELD_FILENAME_FUZZY = "filename";
+ public static final String FIELD_TAG_NAME_FUZZY = "tagName";
+
+ // 定义分段大小
+ private static final int CHUNK_SIZE_CHARS = 1024; // 例如,每 1KB 左右一个块,或者按行数
+ private static final int CHUNK_OVERLAP_CHARS = 7; // 段落间的重叠字符数,防止边界切割问题 (NGramTokenFilter本身可能处理部分边界,但显式重叠更保险)
+
+ public static final int BYTES_PER_MB = 1024 * 1024;
+ private static final long MEMORY_PER_SMALL_THREAD_MB = 500;
+ private static final long MEMORY_PER_BIG_THREAD_MB = 4096;
+
+ @PostConstruct
+ public void init() {
+ if (executorCreateIndexService == null) {
+ int processors = Runtime.getRuntime().availableProcessors() - 2;
+ executorCreateIndexService = ThreadUtil.newFixedExecutor(Math.max(processors, 3), 100, "createIndexFileTask", true);
+ }
+ // 获取jvm可用内存
+ long maxMemory = Runtime.getRuntime().maxMemory();
+ if (executorUpdateContentIndexService == null) {
+ // 获取可用处理器数量
+ int smallProcessors = Runtime.getRuntime().availableProcessors() - 3;
+ // 设置线程数, 假设每个线程占用内存为500M
+ int maxSmallProcessors = Math.toIntExact((maxMemory / BYTES_PER_MB) / MEMORY_PER_SMALL_THREAD_MB);
+ if (smallProcessors > maxSmallProcessors) {
+ smallProcessors = maxSmallProcessors;
+ }
+ smallProcessors = Math.max(smallProcessors, 1);
+ executorUpdateContentIndexService = ThreadUtil.newFixedExecutor(smallProcessors, 20, "updateContentIndexTask", true);
+ }
+ if (executorUpdateBigContentIndexService == null) {
+ // 设置线程数, 假设每个线程占用内存为4G
+ int bigProcessors = Math.toIntExact((maxMemory / BYTES_PER_MB) / MEMORY_PER_BIG_THREAD_MB);
+ bigProcessors = Math.max(bigProcessors, 1);
+ executorUpdateBigContentIndexService = ThreadUtil.newFixedExecutor(bigProcessors, 100, "updateBigContentIndexTask", true);
+ }
+ log.info("NGRAM_MAX_CONTENT_LENGTH_MB:{}, NGRAM_MIN_SIZE: {}, ngramMaxSize: {}", fileProperties.getNgramMaxContentLengthMB(), fileProperties.getNgramMinSize(), fileProperties.getNgramMaxSize());
+ }
+
+ /**
+ * 推送至新建索引文件缓存队列
+ *
+ * @param fileId fileId
+ */
+ public void pushCreateIndexQueue(String fileId) {
+ if (CharSequenceUtil.isBlank(fileId)) {
+ return;
+ }
+ try {
+ getIndexFileQueue().put(fileId);
+ } catch (InterruptedException e) {
+ log.error("推送新建索引队列失败, fileId: {}, {}", fileId, e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * 推送至新建索引文件缓存队列
+ *
+ * @param fileId fileId
+ */
+ public void pushCreateIndexContentQueue(String fileId) {
+ if (CharSequenceUtil.isBlank(fileId)) {
+ return;
+ }
+ try {
+ getIndexFileContentQueue().put(fileId);
+ } catch (InterruptedException e) {
+ log.error("推送新建索引内容队列失败, fileId: {}, {}", fileId, e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private ArrayBlockingQueue getIndexFileQueue() {
+ if (indexFileQueue == null) {
+ indexFileQueue = new ArrayBlockingQueue<>(CREATE_INDEX_QUEUE_SIZE);
+ if (scheduler == null) {
+ scheduler = new ScheduledThreadPoolExecutor(1, ThreadUtil.createThreadFactory("luceneScheduler"));
+ }
+ createIndexFileTask();
+ }
+ return indexFileQueue;
+ }
+
+ private ArrayBlockingQueue getIndexFileContentQueue() {
+ if (indexFileContentQueue == null) {
+ indexFileContentQueue = new ArrayBlockingQueue<>(CREATE_INDEX_QUEUE_SIZE);
+ }
+ return indexFileContentQueue;
+ }
+
+ /**
+ * 新建索引文件任务
+ */
+ private void createIndexFileTask() {
+ getIndexFileQueue();
+ getIndexFileContentQueue();
+ scheduler.scheduleAtFixedRate(() -> {
+ try {
+ List fileIdList = new ArrayList<>(indexFileQueue.size());
+ List toBeIndexedFileIdList = new ArrayList<>(indexFileContentQueue.size());
+ executorCreateIndexService.execute(() -> {
+ // 添加待索引标记
+ indexFileContentQueue.drainTo(toBeIndexedFileIdList);
+ addToBeIndexedFlagOfDoc(toBeIndexedFileIdList);
+
+ // 添加不带文件内容的索引
+ indexFileQueue.drainTo(fileIdList);
+ if (!fileIdList.isEmpty()) {
+ createIndexFiles(fileIdList);
+ }
+ });
+ // 更新任务进度
+ rebuildIndexTaskService.updateTaskProgress();
+ } catch (Exception e) {
+ log.error("创建索引失败", e);
+ }
+ }, 0, 1000, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * 创建索引
+ *
+ * @param fileIdList fileIdList
+ */
+ private void createIndexFiles(List fileIdList) {
+ // 提取出fileIdList
+ List fileIntroVOList = searchFileService.getFileIntroVOs(fileIdList);
+ for (FileIntroVO fileIntroVO : fileIntroVOList) {
+ rebuildIndexTaskService.incrementNotIndexTaskSize();
+ updateIndex(false, fileIntroVO);
+ }
+ rebuildIndexTaskService.removeDeletedFlag(fileIdList);
+ }
+
+ private void updateIndex(boolean readContent, FileIntroVO fileIntroVO) {
+ try {
+ File file = getFileByFileIntroVO(fileIntroVO);
+ boolean isContent = checkFileContent(file);
+ if (!readContent && isContent) {
+ pushCreateIndexContentQueue(fileIntroVO.getId());
+ }
+ if (!readContent && !isContent) {
+ rebuildIndexTaskService.incrementIndexedTaskSize();
+ }
+ FileIndex fileIndex = new FileIndex(file, fileIntroVO);
+ fileIndex.setTagName(getTagName(fileIntroVO));
+ setFileIndex(fileIndex);
+ if (readContent) {
+ String sha256 = HashUtil.sha256(file);
+ try (StringWriter contentWriter = new StringWriter()) {
+ readFileContent(file, fileIntroVO.getId(), contentWriter);
+ String fullContent = contentWriter.toString();
+
+ if (CharSequenceUtil.isBlank(fullContent)) {
+ updateIndexStatus(fileIntroVO, IndexStatus.INDEXED);
+ return;
+ }
+ // 调用改造后的 updateIndexDocument
+ updateIndexDocument(indexWriter, fileIndex, fullContent, fileIndex.getFileIndexHash(sha256));
+ startProcessFilesToBeIndexed();
+ }
+ } else {
+ updateIndexDocument(indexWriter, fileIndex, null, fileIndex.getFileIndexHash(null));
+ }
+ log.debug("添加索引, filepath: {}", file.getAbsoluteFile());
+ } catch (Exception e) {
+ log.warn("updateIndexError: {}", e.getMessage(), e);
+ } finally {
+ updateIndexStatus(fileIntroVO, IndexStatus.INDEXED);
+ if (readContent) {
+ rebuildIndexTaskService.incrementIndexedTaskSize();
+ }
+ }
+ }
+
+ /**
+ * 更新索引状态
+ *
+ * @param fileIntroVO fileIntroVO
+ * @param indexStatus IndexStatus
+ */
+ private void updateIndexStatus(FileIntroVO fileIntroVO, IndexStatus indexStatus) {
+ Query query = new Query();
+ query.addCriteria(Criteria.where("_id").is(fileIntroVO.getId()));
+ Update update = new Update();
+ update.set(MONGO_INDEX_FIELD, indexStatus.getStatus());
+ mongoTemplate.updateFirst(query, update, COLLECTION_NAME);
+
+ // set etag
+ String username = userService.getUserNameById(fileIntroVO.getUserId());
+ esTagService.updateFileEtagAsync(username, getFileByFileIntroVO(fileIntroVO));
+ }
+
+ private File getFileByFileIntroVO(FileIntroVO fileIntroVO) {
+ String username = userService.getUserNameById(fileIntroVO.getUserId());
+ return Paths.get(fileProperties.getRootDir(), username, fileIntroVO.getPath(), fileIntroVO.getName()).toFile();
+ }
+
+ private String getTagName(FileIntroVO fileIntroVO) {
+ if (fileIntroVO != null && fileIntroVO.getTags() != null && !fileIntroVO.getTags().isEmpty()) {
+ return fileIntroVO.getTags().stream().map(Tag::getName).reduce((a, b) -> a + " " + b).orElse("");
+ }
+ return null;
+ }
+
+ /**
+ * 构建FileIndex
+ *
+ * @param fileIndex FileIndex
+ */
+ public void setFileIndex(FileIndex fileIndex) {
+ File file = fileIndex.getFile();
+ if (!FileUtil.exist(fileIndex.getFile())) {
+ return;
+ }
+ fileIndex.setIsFolder(file.isDirectory());
+ fileIndex.setName(file.getName());
+ try {
+ fileIndex.setModified(Files.getLastModifiedTime(file.toPath()).toMillis());
+ } catch (IOException e) {
+ log.error("获取文件修改时间失败, file: {}, {}", file.getAbsolutePath(), e.getMessage(), e);
+ }
+ fileIndex.setSize(file.length());
+ setType(file, fileIndex);
+ }
+
+ private void setType(File file, FileIndex fileIndex) {
+ String fileName = file.getName();
+ String suffix = MyFileUtils.extName(fileName);
+ fileIndex.setType(Constants.OTHER);
+ if (CharSequenceUtil.isBlank(suffix)) {
+ fileIndex.setType(Constants.OTHER);
+ return;
+ }
+ String contentType = FileContentTypeUtils.getContentType(suffix);
+ if (CharSequenceUtil.isBlank(contentType)) {
+ fileIndex.setType(Constants.OTHER);
+ return;
+ }
+ if (contentType.startsWith(Constants.CONTENT_TYPE_IMAGE)) {
+ fileIndex.setType(Constants.CONTENT_TYPE_IMAGE);
+ }
+ if (contentType.startsWith(Constants.VIDEO)) {
+ fileIndex.setType(Constants.VIDEO);
+ }
+ if (contentType.startsWith(Constants.AUDIO)) {
+ fileIndex.setType(Constants.AUDIO);
+ }
+ if (fileProperties.getDocument().contains(suffix)) {
+ fileIndex.setType(Constants.DOCUMENT);
+ }
+ }
+
+ private void readFileContent(File file, String fileId, Writer writer) {
+ String charset = null;
+ try {
+ if (file == null || !file.isFile() || file.length() < 1) {
+ return;
+ }
+ String type = FileTypeUtil.getType(file).toLowerCase();
+ switch (type) {
+ case "pdf" -> readContentService.readPdfContent(file, fileId, writer);
+ case "dwg" -> readContentService.dwg2mxweb(file, fileId);
+ case "epub" -> readContentService.readEpubContent(file, fileId, writer);
+ case "ppt", "pptx" -> readContentService.readPPTContent(file, writer);
+ case "doc", "docx" -> readContentService.readWordContent(file, writer);
+ case "xls", "xlsx" -> readContentService.readExcelContent(file, writer);
+ default -> {
+ if (fileProperties.getSimText().contains(type)) {
+ charset = UniversalDetector.detectCharset(file);
+ if (CharSequenceUtil.isBlank(charset)) {
+ charset = String.valueOf(CharsetDetector.detect(file, StandardCharsets.UTF_8));
+ }
+ if (CharSequenceUtil.isBlank(charset)) {
+ return;
+ }
+ // 对于纯文本,可以流式读取
+ try (Reader reader = new InputStreamReader(new FileInputStream(file), Charset.forName(charset))) {
+ reader.transferTo(writer);
+ }
+ } else {
+ String contentType = CommonFileService.getContentType(file, FileContentTypeUtils.getContentType(file, MyFileUtils.extName(file.getName())));
+ if (contentType.contains("charset=utf-8")) {
+ try (Reader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) {
+ reader.transferTo(writer);
+ }
+ }
+ }
+ }
+ }
+ } catch (UnsupportedCharsetException unsupportedCharsetException) {
+ log.warn("不支持的字符集, charset: {}, file: {}, {}", file.getAbsolutePath(), charset, unsupportedCharsetException.getMessage());
+ } catch (Exception e) {
+ log.error("读取文件内容失败, file: {}, {}", file.getAbsolutePath(), e.getMessage(), e);
+ }
+ }
+
+ private boolean checkFileContent(File file) {
+ try {
+ if (file == null) {
+ return false;
+ }
+ if (!file.isFile() || file.length() < 1) {
+ return false;
+ }
+ String type = FileTypeUtil.getType(file).toLowerCase();
+ if (MyFileUtils.hasContentFile(type)) {
+ return true;
+ }
+ if (fileProperties.getSimText().contains(type)) {
+ return true;
+ }
+ return MyFileUtils.hasCharset(file);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+
+ public void deleteIndexDocuments(List fileIds) {
+ try {
+ for (String fileId : fileIds) {
+ Term term = new Term("id", fileId);
+ indexWriter.deleteDocuments(term);
+ }
+ indexWriter.commit();
+ } catch (IOException e) {
+ log.error("删除索引失败, fileIds: {}, {}", fileIds, e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 添加/更新索引
+ *
+ * @param indexWriter indexWriter
+ * @param fileIndex FileIndex
+ * @param fullContent fullContent
+ * @param fileIndexHash fileIndexHash 唯一索引标识
+ */
+ public void updateIndexDocument(IndexWriter indexWriter, FileIndex fileIndex, String fullContent, String fileIndexHash) {
+ String fileId = fileIndex.getFileId();
+ try {
+ if (searchFileService.existsSha256(fileIndexHash)) {
+ return;
+ }
+ String fileName = (fileIndex.getName() == null ? "" : fileIndex.getName()) + " " + fileIndex.getRemark();
+ String tagName = fileIndex.getTagName();
+ Boolean isFolder = fileIndex.getIsFolder();
+ Boolean isFavorite = fileIndex.getIsFavorite();
+ String path = fileIndex.getPath();
+ Document newDocument = new Document();
+ newDocument.add(new StringField("id", fileId, Field.Store.YES));
+ newDocument.add(new StringField(IUserService.USER_ID, fileIndex.getUserId(), Field.Store.NO));
+
+ newDocument.add(new StringField(Constants.ETAG, fileIndexHash, Field.Store.NO));
+
+ if (fileIndex.getType() != null) {
+ newDocument.add(new StringField("type", fileIndex.getType(), Field.Store.NO));
+ }
+ if (CharSequenceUtil.isNotBlank(fileName)) {
+ newDocument.add(new Field(FIELD_FILENAME_NGRAM, fileName, TextField.TYPE_NOT_STORED));
+ newDocument.add(new TextField(FIELD_FILENAME_FUZZY, fileName, Field.Store.NO));
+ }
+ if (isFolder != null) {
+ newDocument.add(new IntPoint(Constants.IS_FOLDER, isFolder ? 1 : 0));
+ }
+ if (isFavorite != null) {
+ newDocument.add(new IntPoint(Constants.IS_FAVORITE, isFavorite ? 1 : 0));
+ }
+ if (path != null) {
+ newDocument.add(new StringField(Constants.PATH_FIELD, path, Field.Store.NO));
+ }
+ if (CharSequenceUtil.isNotBlank(tagName)) {
+ newDocument.add(new Field(FIELD_TAG_NAME_NGRAM, tagName, TextField.TYPE_NOT_STORED));
+ newDocument.add(new TextField(FIELD_TAG_NAME_FUZZY, tagName, Field.Store.NO));
+ }
+ if (CharSequenceUtil.isNotBlank(fullContent)) {
+ newDocument.add(new TextField(FIELD_CONTENT_FUZZY, fullContent, Field.Store.NO));
+ }
+ if (CharSequenceUtil.isNotBlank(fullContent) && fileProperties.getExactSearch()) {
+ String contentForNgram;
+ // 将完整内容字符串转换为内存中的输入流
+ try (InputStream contentStream = new ByteArrayInputStream(fullContent.getBytes(StandardCharsets.UTF_8))) {
+ // 调用内存高效的流式截断方法
+ contentForNgram = getContentForNgram(contentStream, fileIndex);
+ } catch (IOException e) {
+ log.error("Error creating ByteArrayInputStream: {}", e.getMessage(), e);
+ return;
+ }
+
+ if (CharSequenceUtil.isNotBlank(contentForNgram)) {
+ List chunks = segmentContent(contentForNgram);
+ for (String chunk : chunks) {
+ if (CharSequenceUtil.isNotBlank(chunk)) {
+ newDocument.add(new Field(FIELD_CONTENT_NGRAM, chunk, TextField.TYPE_NOT_STORED));
+ }
+ }
+ }
+ }
+
+ if (fileIndex.getModified() != null) {
+ newDocument.add(new NumericDocValuesField("modified", fileIndex.getModified()));
+ }
+ if (fileIndex.getSize() != null) {
+ newDocument.add(new NumericDocValuesField("size", fileIndex.getSize()));
+ }
+ indexWriter.updateDocument(new Term("id", fileId), newDocument);
+ } catch (IOException e) {
+ log.error("更新索引失败, fileId: {}, {}", fileId, e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 从输入流中高效地截取用于N-Gram的内容,避免OOM。
+ *
+ * @param contentStream 内容的输入流
+ * @param fileIndex 文件元数据,用于日志记录
+ * @return 截断后的UTF-8字符串
+ * @throws IOException 读取流异常
+ */
+ private String getContentForNgram(InputStream contentStream, FileIndex fileIndex) throws IOException {
+ int maxContentLengthInBytes = fileProperties.getNgramMaxContentLength();
+ byte[] buffer = new byte[8192]; // 8KB的读取缓冲区
+ int bytesRead;
+
+ // 使用 ByteArrayOutputStream 来存储截取的结果
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream(maxContentLengthInBytes)) {
+ while ((bytesRead = contentStream.read(buffer)) != -1) {
+ int remainingCapacity = maxContentLengthInBytes - baos.size();
+ if (bytesRead > remainingCapacity) {
+ baos.write(buffer, 0, remainingCapacity);
+ log.warn("内容已截断以进行N-Gram索引(截断至 {} 字节), 文件大小: {}MB 文件: {}", maxContentLengthInBytes, fileIndex.getSize() / BYTES_PER_MB, Paths.get(fileIndex.getPath(), fileIndex.getName()));
+ break; // 已达到上限
+ }
+ baos.write(buffer, 0, bytesRead);
+ }
+
+ byte[] contentBytes = baos.toByteArray();
+
+ // 进行UTF-8字符边界检查,防止乱码
+ int effectiveLength = contentBytes.length;
+ while (effectiveLength > 0 && (contentBytes[effectiveLength - 1] & 0xC0) == 0x80) {
+ effectiveLength--;
+ }
+
+ if (effectiveLength == 0) {
+ return "";
+ }
+
+ return new String(contentBytes, 0, effectiveLength, StandardCharsets.UTF_8);
+ }
+ }
+
+ private List segmentContent(String content) {
+ List chunks = new ArrayList<>();
+ if (content == null || content.isEmpty()) {
+ return chunks;
+ }
+ int length = content.length();
+ if (length <= CHUNK_SIZE_CHARS) { // 如果内容本身小于或等于块大小,则作为一个块
+ chunks.add(content);
+ return chunks;
+ }
+
+ // 确保步进大于0
+ int step = CHUNK_SIZE_CHARS - CHUNK_OVERLAP_CHARS;
+
+ for (int i = 0; i < length; i += step) {
+ int end = Math.min(i + CHUNK_SIZE_CHARS, length);
+ chunks.add(content.substring(i, end));
+ if (end == length) {
+ break; // 已到达内容末尾
+ }
+ }
+ return chunks;
+ }
+
+ public FileIntroVO getFileIntroVO(String fileId) {
+ Query query = new Query();
+ query.addCriteria(Criteria.where("_id").is(fileId));
+ query.fields().include("id", "name", "userId", "path", "isFolder", "isFavorite", "remark", "tags", "etag", "size");
+ return mongoTemplate.findOne(query, FileIntroVO.class, COLLECTION_NAME);
+ }
+
+ /**
+ * 添加待索引标记
+ */
+ private void addToBeIndexedFlagOfDoc(List fielIdList) {
+ if (fielIdList.isEmpty()) {
+ return;
+ }
+ Query query = new Query();
+ query.addCriteria(Criteria.where("_id").in(fielIdList));
+ Update update = new Update();
+ update.set(MONGO_INDEX_FIELD, IndexStatus.NOT_INDEX.getStatus());
+ mongoTemplate.updateMulti(query, update, COLLECTION_NAME);
+ // 添加待索引标记
+ startProcessFilesToBeIndexed();
+ }
+
+ /**
+ * 处理待索引文件
+ */
+ public void processFilesToBeIndexed() throws IOException {
+ boolean run = true;
+ log.debug("开始处理待索引文件");
+ while (run) {
+ if (!hasUnIndexFile()) {
+ log.debug("待索引文件处理完成");
+ rebuildIndexTaskService.rebuildingIndexCompleted();
+ indexWriter.commit();
+ run = false;
+ }
+ List pipeline = Arrays.asList(new org.bson.Document("$match", new org.bson.Document(MONGO_INDEX_FIELD, IndexStatus.NOT_INDEX.getStatus())), new org.bson.Document("$project", new org.bson.Document("_id", 1)), new org.bson.Document("$limit", 8));
+ AggregateIterable aggregateIterable = mongoTemplate.getCollection(COLLECTION_NAME).aggregate(pipeline);
+ for (org.bson.Document document : aggregateIterable) {
+ String fileId = document.getObjectId("_id").toHexString();
+ FileIntroVO fileIntroVO = getFileIntroVO(fileId);
+ if (fileIntroVO != null) {
+ // 处理待索引文件
+ updateIndexStatus(fileIntroVO, IndexStatus.INDEXING);
+ processFileThreaded(fileIntroVO);
+ }
+ }
+ }
+ }
+
+ /**
+ * 是否存在待索引文件
+ */
+ private boolean hasUnIndexFile() {
+ Query query = new Query();
+ query.addCriteria(Criteria.where(MONGO_INDEX_FIELD).is(IndexStatus.NOT_INDEX.getStatus()));
+ long count = mongoTemplate.count(query, COLLECTION_NAME);
+ return count > 0;
+ }
+
+ private void processFileThreaded(FileIntroVO fileIntroVO) {
+ long size = fileIntroVO.getSize();
+
+ if (RebuildIndexTaskService.isSyncFile()) {
+ // 单线程处理
+ updateIndex(true, fileIntroVO);
+ } else {
+ // 根据文件大小选择多线程处理
+ if (size > 10 * 1024 * 1024) {
+ // 大文件,使用特定线程池处理
+ executorUpdateBigContentIndexService.execute(() -> updateIndex(true, fileIntroVO));
+ } else {
+ // 小文件,使用普通线程池处理
+ executorUpdateContentIndexService.execute(() -> updateIndex(true, fileIntroVO));
+ }
+ }
+ }
+
+ private void startProcessFilesToBeIndexed() {
+ if (processingUnIndexedScheduled.compareAndSet(false, true)) {
+ executorCreateIndexService.execute(() -> {
+ try {
+ processFilesToBeIndexed();
+ } catch (IOException e) {
+ log.error("处理待索引文件内容失败", e);
+ } finally {
+ processingUnIndexedScheduled.set(false);
+ }
+ });
+ }
+ }
+
+ @PreDestroy
+ public void destroy() {
+ if (executorCreateIndexService != null) {
+ executorCreateIndexService.shutdown();
+ }
+ if (executorUpdateContentIndexService != null) {
+ executorUpdateContentIndexService.shutdown();
+ }
+ if (executorUpdateBigContentIndexService != null) {
+ executorUpdateBigContentIndexService.shutdown();
+ }
+ if (scheduler != null) {
+ scheduler.shutdown();
+ }
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/lucene/ReadContentService.java b/src/main/java/com/jmal/clouddisk/lucene/ReadContentService.java
new file mode 100644
index 0000000..c6ccc37
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/lucene/ReadContentService.java
@@ -0,0 +1,326 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.text.CharSequenceUtil;
+import com.jmal.clouddisk.media.VideoProcessService;
+import com.jmal.clouddisk.ocr.OcrService;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.impl.CommonFileService;
+import com.jmal.clouddisk.util.FileContentUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import nl.siegmann.epublib.domain.Book;
+import nl.siegmann.epublib.domain.Resource;
+import nl.siegmann.epublib.domain.Spine;
+import nl.siegmann.epublib.epub.EpubReader;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDResources;
+import org.apache.pdfbox.pdmodel.graphics.PDXObject;
+import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.poi.hslf.usermodel.HSLFShape;
+import org.apache.poi.hslf.usermodel.HSLFSlide;
+import org.apache.poi.hslf.usermodel.HSLFSlideShow;
+import org.apache.poi.hslf.usermodel.HSLFTextShape;
+import org.apache.poi.hssf.usermodel.HSSFWorkbook;
+import org.apache.poi.hwpf.HWPFDocument;
+import org.apache.poi.hwpf.extractor.WordExtractor;
+import org.apache.poi.openxml4j.exceptions.OLE2NotOfficeXmlFileException;
+import org.apache.poi.poifs.filesystem.POIFSFileSystem;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFShape;
+import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.apache.poi.xslf.usermodel.XSLFTextShape;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.apache.poi.xwpf.usermodel.*;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.springframework.stereotype.Service;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.regex.Pattern;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ReadContentService {
+
+ private final OcrService ocrService;
+
+ public final CommonFileService commonFileService;
+
+ public final VideoProcessService videoProcessService;
+
+ /**
+ * 将 DWG 文件转换为 MXWeb 文件
+ *
+ * @param file 文件
+ * @param fileId 文件 ID
+ */
+ public void dwg2mxweb(File file, String fileId) {
+ String username = commonFileService.getUsernameByAbsolutePath(Path.of(file.getAbsolutePath()));
+ // 生成封面图像
+ if (CharSequenceUtil.isNotBlank(fileId)) {
+ String outputName = file.getName() + Constants.MXWEB_SUFFIX;
+ FileContentUtil.dwgConvert(file.getAbsolutePath(), videoProcessService.getVideoCacheDir(username, fileId), outputName);
+ }
+ }
+
+ public static boolean checkPageContent(PDDocument document, int pageIndex) throws IOException {
+ PDPage page = document.getPage(pageIndex); // 获取页面
+ if (page == null) {
+ return false;
+ }
+ // 检查是否含有图片
+ PDResources resources = page.getResources();
+ if (resources == null) {
+ return false;
+ }
+ for (COSName xObjectName : resources.getXObjectNames()) {
+ PDXObject xObject = resources.getXObject(xObjectName);
+ if (xObject instanceof PDImageXObject) {
+ // 如果找到至少一张图片,则可以提前退出
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void readPdfContent(File file, String fileId, Writer writer) {
+ try (PDDocument document = Loader.loadPDF(new RandomAccessReadBufferedFile(file))) {
+ String username = commonFileService.getUsernameByAbsolutePath(Path.of(file.getAbsolutePath()));
+
+ // 生成封面图像
+ if (CharSequenceUtil.isNotBlank(fileId)) {
+ File coverFile = FileContentUtil.pdfCoverImage(file, document, videoProcessService.getVideoCacheDir(username, fileId));
+ commonFileService.updateCoverFileDocument(fileId, coverFile);
+ }
+
+ PDFRenderer pdfRenderer = new PDFRenderer(document);
+ PDFTextStripper pdfStripper = new PDFTextStripper();
+
+ for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) { // 使用 0-based 索引
+ readPdfOfPage(file, pageIndex, pdfStripper, document, writer, pdfRenderer, username);
+ }
+ } catch (IOException e) {
+ FileContentUtil.readFailed(file, e);
+ }
+ }
+
+ private void readPdfOfPage(File file, int pageIndex, PDFTextStripper pdfStripper, PDDocument document, Writer writer, PDFRenderer pdfRenderer, String username) {
+ try {
+ int pageNumber = pageIndex + 1;
+ pdfStripper.setStartPage(pageNumber); // PDFTextStripper 使用 1-based 索引
+ pdfStripper.setEndPage(pageNumber);
+ String text = pdfStripper.getText(document).trim();
+
+ // 如果页面包含文字,添加提取的文字
+ if (!text.isEmpty()) {
+ writer.write(text);
+ }
+ // 如果页面包含图片或没有文字,则进行 OCR
+ if ((checkPageContent(document, pageIndex) || text.isEmpty()) && Boolean.TRUE.equals(ocrService.getOcrConfig().getEnable())) {
+ String ocrText = ocrService.extractPageWithOCR(file, pdfRenderer, pageIndex, document.getNumberOfPages(), username);
+ if (CharSequenceUtil.isNotBlank(ocrText)) {
+ writer.write(ocrText);
+ }
+ }
+ } catch (IOException e) {
+ log.error("提取文字失败, {}, 页数: {}", file.getName(), pageIndex, e);
+ } catch (Exception e) {
+ log.error("提取文字失败1, {}, 页数: {}", file.getName(), pageIndex, e);
+ }
+ }
+
+ public void readEpubContent(File file, String fileId, Writer writer) {
+ try (InputStream fileInputStream = new FileInputStream(file)) {
+ // 打开 EPUB 文件
+ EpubReader epubReader = new EpubReader();
+ Book book = epubReader.readEpub(fileInputStream);
+
+ // 生成封面图像
+ String username = commonFileService.getUsernameByAbsolutePath(Path.of(file.getAbsolutePath()));
+ if (CharSequenceUtil.isNotBlank(fileId)) {
+ File coverFile = FileContentUtil.epubCoverImage(book, videoProcessService.getVideoCacheDir(username, fileId));
+ commonFileService.updateCoverFileDocument(fileId, coverFile);
+ }
+
+ // 获取章节内容
+ Spine spine = book.getSpine();
+ for (int i = 0; i < spine.size(); i++) {
+ Resource resource = spine.getResource(i);
+ InputStream is = resource.getInputStream();
+ byte[] bytes = is.readAllBytes();
+ String htmlContent = new String(bytes, StandardCharsets.UTF_8);
+ // 使用 JSoup 解析 HTML 并提取纯文本
+ Document document = Jsoup.parse(htmlContent);
+ String textContent = document.text();
+ writer.write(textContent);
+ is.close();
+ }
+ } catch (IOException e) {
+ FileContentUtil.readFailed(file, e);
+ }
+ }
+
+ public void readPPTContent(File file, Writer writer) {
+ String fileName = file.getName().toLowerCase();
+
+ try (FileInputStream fis = new FileInputStream(file)) {
+ if (fileName.endsWith(".pptx")) {
+ // 读取 .pptx 文件
+ try (XMLSlideShow pptx = new XMLSlideShow(fis)) {
+ readSlides(pptx.getSlides(), writer);
+ }
+ } else if (fileName.endsWith(".ppt")) {
+ // 读取 .ppt 文件
+ try (HSLFSlideShow ppt = new HSLFSlideShow(fis)) {
+ readSlides(ppt.getSlides(), writer);
+ }
+ } else {
+ throw new IllegalArgumentException("Unsupported file format: " + fileName);
+ }
+ } catch (IOException e) {
+ FileContentUtil.readFailed(file, e);
+ }
+ }
+
+ // 通用方法读取幻灯片中的文本内容
+ private void readSlides(Iterable> slides, Writer writer) throws IOException {
+ for (Object slide : slides) {
+ if (slide instanceof XSLFSlide xslfSlide) {
+ for (XSLFShape shape : xslfSlide.getShapes()) {
+ if (shape instanceof XSLFTextShape textShape) {
+ writer.write(textShape.getText());
+ writer.write(" ");
+ }
+ }
+ } else if (slide instanceof HSLFSlide hslfSlide) {
+ for (HSLFShape shape : hslfSlide.getShapes()) {
+ if (shape instanceof HSLFTextShape textShape) {
+ writer.write(textShape.getText());
+ writer.write(" ");
+ }
+ }
+ }
+ }
+ }
+
+ public void readWordContent(File file, Writer writer) {
+ try (FileInputStream fis = new FileInputStream(file)) {
+ try {
+ // 读取 DOCX 文件
+ XWPFDocument document = new XWPFDocument(fis);
+
+ // 1. 读取段落
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ writer.write(paragraph.getText());
+ writer.write("\n");
+ }
+
+ // 2. 读取表格
+ for (XWPFTable table : document.getTables()) {
+ for (XWPFTableRow row : table.getRows()) {
+ for (XWPFTableCell cell : row.getTableCells()) {
+ writer.write(cell.getText());
+ writer.write("\t");
+ }
+ writer.write("\n");
+ }
+ }
+
+ // 3. 读取页眉
+ for (XWPFHeader header : document.getHeaderList()) {
+ writer.write(header.getText());
+ writer.write("\n");
+ }
+
+ // 4. 读取页脚
+ for (XWPFFooter footer : document.getFooterList()) {
+ writer.write(footer.getText());
+ writer.write("\n");
+ }
+ } catch (OLE2NotOfficeXmlFileException e) {
+ // 读取 DOC 文件
+ try (FileInputStream fis2 = new FileInputStream(file);
+ POIFSFileSystem poifs = new POIFSFileSystem(fis2);
+ HWPFDocument doc = new HWPFDocument(poifs)) {
+ WordExtractor extractor = new WordExtractor(doc);
+ writer.write(extractor.getText());
+ }
+ }
+ } catch (IOException e) {
+ FileContentUtil.readFailed(file, e);
+ }
+ }
+
+ // 匹配包含至少一个中文或英文字符的字符串
+ private static final Pattern TEXT_PATTERN = Pattern.compile(".*[a-zA-Z一-龥]+.*");
+
+ public void readExcelContent(File file, Writer writer) {
+ try (FileInputStream fis = new FileInputStream(file)) {
+ try (Workbook workbook = createWorkbook(file, fis)) {
+ for (Sheet sheet : workbook) {
+ readSheetContent(sheet, writer);
+ }
+ }
+ } catch (IOException e) {
+ FileContentUtil.readFailed(file, e);
+ } catch (IllegalArgumentException e) {
+ log.warn("Unsupported file format: {}", file.getName());
+ }
+ }
+
+ private Workbook createWorkbook(File file, FileInputStream fis) throws IOException {
+ if (file.getName().endsWith(".xlsx")) {
+ return new XSSFWorkbook(fis);
+ } else if (file.getName().endsWith(".xls")) {
+ return new HSSFWorkbook(fis);
+ } else {
+ throw new IllegalArgumentException("Unsupported file format");
+ }
+ }
+
+ private void readSheetContent(Sheet sheet, Writer writer) throws IOException {
+ for (Row row : sheet) {
+ for (Cell cell : row) {
+ String cellValue = getCellValueAsString(cell);
+ // 包含至少一个中文或英文字符
+ if (TEXT_PATTERN.matcher(cellValue).matches()) {
+ writer.write(cellValue);
+ writer.write(" ");
+ }
+ }
+ }
+ }
+
+ private String getCellValueAsString(Cell cell) {
+ if (cell == null) {
+ return "";
+ }
+ switch (cell.getCellType()) {
+ case STRING:
+ return cell.getStringCellValue();
+ case BOOLEAN:
+ return Boolean.toString(cell.getBooleanCellValue());
+ case NUMERIC:
+ if (DateUtil.isCellDateFormatted(cell)) {
+ return cell.getDateCellValue().toString();
+ } else {
+ return Double.toString(cell.getNumericCellValue());
+ }
+ case FORMULA:
+ return cell.getCellFormula();
+ case BLANK:
+ default:
+ return "";
+ }
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/lucene/RebuildIndexTaskService.java b/src/main/java/com/jmal/clouddisk/lucene/RebuildIndexTaskService.java
new file mode 100644
index 0000000..b14d194
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/lucene/RebuildIndexTaskService.java
@@ -0,0 +1,649 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.date.TimeInterval;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.core.util.NumberUtil;
+import cn.hutool.core.util.ReUtil;
+import cn.hutool.core.util.StrUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.ocr.OcrService;
+import com.jmal.clouddisk.service.impl.CommonFileService;
+import com.jmal.clouddisk.service.impl.MenuService;
+import com.jmal.clouddisk.service.impl.RoleService;
+import com.jmal.clouddisk.service.impl.UserServiceImpl;
+import com.jmal.clouddisk.util.ResponseResult;
+import com.jmal.clouddisk.util.ResultUtil;
+import com.jmal.clouddisk.util.ThrottleExecutor;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.PrefixQuery;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.math.RoundingMode;
+import java.nio.file.*;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 重建索引任务
+ * 重建索引分为两个步骤
+ * 1. 同步文件到数据库
+ * 2. 创建文件内容索引
+ */
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class RebuildIndexTaskService {
+
+ private final IndexWriter indexWriter;
+
+ private final MenuService menuService;
+
+ private final RoleService roleService;
+
+ private final UserServiceImpl userService;
+
+ private final FileProperties fileProperties;
+
+ private final CommonFileService commonFileService;
+
+ private final MongoTemplate mongoTemplate;
+
+ private ExecutorService syncFileVisitorService;
+
+ private SyncFileVisitor syncFileVisitor;
+
+ private double totalCount;
+
+ private final OcrService ocrService;
+
+ /**
+ * 接收消息的用户
+ */
+ private static final Set RECIPIENT = new CopyOnWriteArraySet<>();
+
+ private static final String MSG_SYNCED = "synced";
+
+ /**
+ * 待索引任务总数
+ */
+ private static final AtomicInteger NOT_INDEX_TASK_SIZE = new AtomicInteger(0);
+ /**
+ * 完成索引任务数
+ */
+ private static final AtomicInteger INDEXED_TASK_SIZE = new AtomicInteger(0);
+
+ /**
+ * 同步文件操作锁, 防止重复操作
+ */
+ private static final ReentrantLock SYNC_FILE_LOCK = new ReentrantLock();
+
+ private Runnable syncCompleteCallback;
+
+ /**
+ * 进度百分比
+ */
+ private static final Map PERCENT_MAP = new ConcurrentHashMap<>(2);
+ /**
+ * 同步进度
+ */
+ private static final String SYNC_PERCENT = "syncPercent";
+ /**
+ * 索引进度
+ */
+ private static final String INDEXING_PERCENT = "indexingPercent";
+
+ private ThrottleExecutor throttleExecutor;
+
+ private Timer DELAY_DELETE_TAG_TIMER;
+
+ private final ReentrantLock deleteDocWithDeleteFlagLock = new ReentrantLock();
+
+ @PostConstruct
+ public void init() {
+ // 启动时检测是否存在菜单,不存在则初始化
+ if (!menuService.existsMenu()) {
+ menuService.initMenus();
+ roleService.initRoles();
+ }
+ // 启动时检测是否存在lucene索引,不存在则初始化
+ if (!checkIndexExists()) {
+ doSync(userService.getCreatorUsername(), null, true);
+ }
+ // 重置索引状态
+ resetIndexStatus();
+ }
+
+ private void getSyncFileVisitorService() {
+ int processors = Runtime.getRuntime().availableProcessors() - 4;
+ if (syncFileVisitorService == null || syncFileVisitorService.isShutdown()) {
+ syncFileVisitorService = ThreadUtil.newFixedExecutor(Math.max(processors, 2), 1, "syncFileVisitor", true);
+ }
+ }
+
+ public void doSync(String username, String path, boolean isDelIndex) {
+ if (isSyncFile() || isIndexing()) {
+ return;
+ }
+ if (StrUtil.isBlank(username)) {
+ username = userService.getCreatorUsername();
+ }
+ setPercentMap(0d, 0d);
+ String finalUsername = username;
+ ThreadUtil.execute(() -> {
+ if (!SYNC_FILE_LOCK.tryLock()) {
+ return;
+ }
+ try {
+ Path canPath;
+ if (StrUtil.isBlank(path)) {
+ canPath = Paths.get(fileProperties.getRootDir());
+ } else {
+ canPath = Paths.get(path);
+ }
+ if (!Files.exists(canPath)) {
+ return;
+ }
+ getSyncFileVisitorService();
+ rebuildingIndex(finalUsername, canPath, isDelIndex);
+ } finally {
+ SYNC_FILE_LOCK.unlock();
+ setPercentMap(100d, 100d);
+ pushMessage();
+ }
+ });
+ }
+
+ /**
+ * 重建索引
+ *
+ * @param recipient 接收消息的用户
+ * @param path 要扫描的路径
+ * @param isDelIndex 是否删除索引
+ */
+ private void rebuildingIndex(String recipient, Path path, boolean isDelIndex) {
+ TimeInterval timeInterval = new TimeInterval();
+ try {
+ getRecipient(recipient);
+ restIndexedTasks();
+ if (DELAY_DELETE_TAG_TIMER != null) {
+ DELAY_DELETE_TAG_TIMER.cancel();
+ }
+ Set fileVisitOptions = EnumSet.noneOf(FileVisitOption.class);
+ fileVisitOptions.add(FileVisitOption.FOLLOW_LINKS);
+ // 先移除删除标记, 以免因为扫描路径的不同导致删除标记未移除
+ removeDeletedFlag(null);
+ // 添加删除标记, 扫描完后如果标记还在则删除
+ addDeleteFlagOfDoc(path, isDelIndex);
+ // 重置索引状态
+ resetIndexStatus();
+ FileCountVisitor fileCountVisitor = new FileCountVisitor();
+ Files.walkFileTree(path, fileVisitOptions, Integer.MAX_VALUE, fileCountVisitor);
+ totalCount = fileCountVisitor.getCount();
+ log.info("path: {}, 开始同步, 文件数: {}", path, totalCount);
+ timeInterval.start();
+ if (syncFileVisitor == null) {
+ syncFileVisitor = new SyncFileVisitor(totalCount);
+ }
+ Files.walkFileTree(path, fileVisitOptions, Integer.MAX_VALUE, syncFileVisitor);
+ // 等待线程池里所有任务完成
+ waitTaskCompleted();
+ deleteDocWithDeleteFlag();
+ } catch (IOException e) {
+ log.error("{}{}", e.getMessage(), path, e);
+ } finally {
+ setPercentMap(100d, getIndexedPercentValue());
+ syncFileVisitor = null;
+ log.info("同步完成, 耗时: {}s", timeInterval.intervalSecond());
+ }
+ }
+
+ /**
+ * 删除path下的所有索引
+ * @param path path
+ */
+ private void deleteAllIndex(String path) {
+ if (StrUtil.isBlank(path)) {
+ path = "/";
+ }
+ // 查询path下的所有索引
+ Term prefixTerm = new Term("path", path);
+ PrefixQuery prefixQuery = new PrefixQuery(prefixTerm);
+ try {
+ indexWriter.deleteDocuments(prefixQuery);
+ indexWriter.commit();
+ } catch (IOException e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ private void waitTaskCompleted() {
+ try {
+ log.info("等待同步文件完成");
+ syncFileVisitorService.shutdown();
+ // 等待线程池里所有任务完成
+ if (!syncFileVisitorService.awaitTermination(10, TimeUnit.MINUTES)) {
+ log.warn("同步文件超时, 尝试强制停止所有任务");
+ // 移除删除标记, 以免误删索引
+ removeDeletedFlag(null);
+ syncFileVisitorService.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ syncFileVisitorService.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private String getRecipient(String username) {
+ if (StrUtil.isNotBlank(username)) {
+ RECIPIENT.clear();
+ RECIPIENT.add(username);
+ }
+ return RECIPIENT.stream().findFirst().orElse(username);
+ }
+
+ private void deleteDocWithDeleteFlag() {
+ DELAY_DELETE_TAG_TIMER = new Timer();
+ DELAY_DELETE_TAG_TIMER.schedule(new TimerTask() {
+ @Override
+ public void run() {
+ if (!deleteDocWithDeleteFlagLock.tryLock()) {
+ return;
+ }
+ try {
+ commonFileService.deleteDocWithDeleteFlag();
+ } finally {
+ deleteDocWithDeleteFlagLock.unlock();
+ }
+ }
+ }, 10000);
+ }
+
+ public void onSyncComplete(Runnable callback) {
+ this.syncCompleteCallback = callback;
+ }
+
+ public void rebuildingIndexCompleted() {
+ if (!hasUnIndexedTasks() && NOT_INDEX_TASK_SIZE.get() > 0) {
+ setPercentMap(100d, 100d);
+ log.debug("重建索引完成, INDEXED_TASK_SIZE, {}, NOT_INDEX_TASK_SIZE: {}", INDEXED_TASK_SIZE, NOT_INDEX_TASK_SIZE);
+ restIndexedTasks();
+ pushMessage();
+ if (syncCompleteCallback != null) {
+ syncCompleteCallback.run();
+ }
+ }
+ }
+
+ private void restIndexedTasks() {
+ NOT_INDEX_TASK_SIZE.set(0);
+ INDEXED_TASK_SIZE.set(0);
+ totalCount = 0;
+ }
+
+ /**
+ * 增加待索引任务数
+ */
+ public void incrementNotIndexTaskSize() {
+ NOT_INDEX_TASK_SIZE.incrementAndGet();
+ }
+
+ /**
+ * 增加已索引任务数
+ */
+ public void incrementIndexedTaskSize() {
+ INDEXED_TASK_SIZE.incrementAndGet();
+ delayResetIndex();
+ }
+
+ public void delayResetIndex() {
+ synchronized (RebuildIndexTaskService.class) {
+ if (throttleExecutor == null) {
+ throttleExecutor = new ThrottleExecutor(10000);
+ }
+ }
+ throttleExecutor.schedule(this::rebuildingIndexCompleted);
+ }
+
+ private void updatePercent() {
+ setPercentMap(null, null);
+ pushMessage();
+ }
+
+ /**
+ * 是否正在同步文件
+ */
+ public static boolean isSyncFile() {
+ return SYNC_FILE_LOCK.isLocked();
+ }
+
+ /**
+ * 是否正在创建索引
+ */
+ public static boolean isIndexing() {
+ return NOT_INDEX_TASK_SIZE.get() > 0;
+ }
+
+ /**
+ * 把文件同步到数据库
+ * @param username 用户名
+ * @param path 文件相对路径
+ * @param isDelIndex 是否删除索引
+ */
+ public ResponseResult sync(String username, String path, boolean isDelIndex) {
+ if (StrUtil.isNotBlank(path)) {
+ path = Paths.get(fileProperties.getRootDir(), username, path).toString();
+ }
+ doSync(username, path, isDelIndex);
+ return ResultUtil.success();
+ }
+
+ /**
+ * 是否正在同步中
+ */
+ public Map isSync() {
+ if (PERCENT_MAP.isEmpty()) {
+ setPercentMap(100d, 100d);
+ }
+ return PERCENT_MAP;
+ }
+
+ /**
+ * 更新任务进度, 用于前端显示
+ */
+ public void updateTaskProgress() {
+ if (!isSyncFile() && !isIndexing()) {
+ return;
+ }
+ if (getRecipient(null) == null) {
+ getRecipient(userService.getCreatorUsername());
+ }
+ // 更新进度
+ updatePercent();
+ }
+
+ /**
+ * 获取同步进度
+ */
+ private double getSyncPercent() {
+ if (syncFileVisitor == null) {
+ return PERCENT_MAP.getOrDefault(SYNC_PERCENT, 100d);
+ }
+ return syncFileVisitor.getPercent();
+ }
+
+ /**
+ * 获取索引进度
+ */
+ private double getIndexedPercent() {
+ if (NOT_INDEX_TASK_SIZE.get() == 0 || isSyncFile()) {
+ return 100;
+ }
+ return getIndexedPercentValue();
+ }
+
+ private double getIndexedPercentValue() {
+ if (totalCount == 0) {
+ return 0;
+ }
+ return NumberUtil.round((double) INDEXED_TASK_SIZE.get() / totalCount * 100, 2, RoundingMode.DOWN).doubleValue();
+ }
+
+ private class SyncFileVisitor extends SimpleFileVisitor {
+
+ @Getter
+ public final double totalCount;
+
+ private final AtomicInteger processCount;
+
+ public SyncFileVisitor(double totalCount) {
+ this.totalCount = totalCount;
+ this.processCount = new AtomicInteger(0);
+ }
+
+ public double getPercent() {
+ return NumberUtil.round(processCount.get() / totalCount * 100, 2).doubleValue();
+ }
+
+ @NotNull
+ @Override
+ public FileVisitResult visitFileFailed(@NotNull Path file, @NotNull IOException exc) throws IOException {
+ log.error(exc.getMessage(), exc);
+ return super.visitFileFailed(file, exc);
+ }
+
+ @NotNull
+ @Override
+ public FileVisitResult preVisitDirectory(@NotNull Path dir, @NotNull BasicFileAttributes attrs) throws IOException {
+ // 跳过临时文件目录
+ FileVisitResult skipTempDirectory = skipTempDirectory(dir);
+ if (skipTempDirectory != null) return skipTempDirectory;
+ String username = commonFileService.getUsernameByAbsolutePath(dir);
+ processCount.incrementAndGet();
+ if (StrUtil.isBlank(username)) {
+ return super.visitFile(dir, attrs);
+ }
+ processFile(dir, username);
+ return super.preVisitDirectory(dir, attrs);
+ }
+
+ @NotNull
+ @Override
+ public FileVisitResult visitFile(Path file, @NotNull BasicFileAttributes attrs) throws IOException {
+ // 判断文件名是否在monitorIgnoreFilePrefix中
+ if (fileProperties.getMonitorIgnoreFilePrefix().stream().anyMatch(file.getFileName()::startsWith)) {
+ log.debug("忽略文件:{}", file.getFileName());
+ return super.visitFile(file, attrs);
+ }
+ processCount.incrementAndGet();
+ String username = commonFileService.getUsernameByAbsolutePath(file);
+ if (StrUtil.isBlank(username)) {
+ return super.visitFile(file, attrs);
+ }
+ processFile(file, username);
+ return super.visitFile(file, attrs);
+ }
+
+ private void processFile(Path file, String username) {
+ // 使用 RxJava 执行异步文件创建
+ syncFileVisitorService.execute(() -> createFile(username, file));
+ }
+
+ private void createFile(String username, Path file) {
+ try {
+ commonFileService.createFile(username, file.toFile(), null, null);
+ } catch (Exception e) {
+ log.error("createFile error {}{}", e.getMessage(), file, e);
+ Query query = new Query();
+ query.fields().include("_id");
+ FileDocument fileDocument = commonFileService.getFileDocument(username, file.toFile().getAbsolutePath(), query);
+ if (fileDocument != null) {
+ // 需要移除删除标记
+ removeDeletedFlag(Collections.singletonList(fileDocument.getId()));
+ }
+ }
+ }
+ }
+
+ private void setPercentMap(Double syncPercent, Double indexingPercent) {
+ if (syncPercent == null) {
+ syncPercent = getSyncPercent();
+ }
+ if (indexingPercent == null) {
+ indexingPercent = getIndexedPercent();
+ }
+ PERCENT_MAP.put(SYNC_PERCENT, syncPercent);
+ PERCENT_MAP.put(INDEXING_PERCENT, indexingPercent);
+ if (syncPercent >= 100 && indexingPercent >= 100) {
+ ocrService.setMaxConcurrentRequests(ocrService.getOcrConfig().getMaxTasks());
+ }
+ }
+
+ private void pushMessage() {
+ commonFileService.pushMessage(getRecipient(null), PERCENT_MAP, RebuildIndexTaskService.MSG_SYNCED);
+ log.debug("索引进度: {}, isSyncFile: {}, INDEXED_TASK_SIZE, {}, NOT_INDEX_TASK_SIZE: {}", PERCENT_MAP, isSyncFile(), INDEXED_TASK_SIZE.get(), NOT_INDEX_TASK_SIZE.get());
+ }
+
+ private class FileCountVisitor extends SimpleFileVisitor {
+ private final AtomicLong count = new AtomicLong(0);
+
+ public long getCount() {
+ return count.get();
+ }
+
+ @NotNull
+ @Override
+ public FileVisitResult preVisitDirectory(@NotNull Path dir, @NotNull BasicFileAttributes attrs) throws IOException {
+ // 跳过临时文件目录
+ FileVisitResult skipTempDirectory = skipTempDirectory(dir);
+ if (skipTempDirectory != null) return skipTempDirectory;
+ String username = commonFileService.getUsernameByAbsolutePath(dir);
+ count.addAndGet(1);
+ if (StrUtil.isBlank(username)) {
+ return super.visitFile(dir, attrs);
+ }
+ return super.preVisitDirectory(dir, attrs);
+ }
+
+ @NotNull
+ @Override
+ public FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException {
+ count.addAndGet(1);
+ return super.visitFile(file, attrs);
+ }
+ }
+
+ private @Nullable FileVisitResult skipTempDirectory(Path dir) {
+ // 跳过临时文件目录
+ if (dir.toFile().getName().equals(fileProperties.getChunkFileDir())) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ // 跳过lucene索引目录
+ if (dir.toFile().getName().equals(fileProperties.getLuceneIndexDir())) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ return null;
+ }
+
+ public boolean checkIndexExists() {
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where("ossFolder").exists(true));
+ long count = mongoTemplate.count(query, CommonFileService.COLLECTION_NAME);
+ long indexCount = indexWriter.getDocStats().numDocs;
+ return indexCount > count;
+ }
+
+ /**
+ * 重置索引状态, 将正在索引的文件状态重置为未索引
+ */
+ public void resetIndexStatus() {
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where(LuceneService.MONGO_INDEX_FIELD).lte(IndexStatus.INDEXING.getStatus()));
+ Update update = new Update();
+ update.unset(LuceneService.MONGO_INDEX_FIELD);
+ mongoTemplate.updateMulti(query, update, CommonFileService.COLLECTION_NAME);
+ }
+
+ /**
+ * 检查是否有未索引或正在索引的任务
+ */
+ public boolean hasUnIndexedTasks() {
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where(LuceneService.MONGO_INDEX_FIELD).lte(IndexStatus.INDEXING.getStatus()));
+ long count = mongoTemplate.count(query, CommonFileService.COLLECTION_NAME);
+ return count > 0;
+ }
+
+ /**
+ * 添加删除标记
+ */
+ private void addDeleteFlagOfDoc(Path filepath, boolean isDelIndex) {
+ if (filepath == null) {
+ return;
+ }
+ if (Files.notExists(filepath)) {
+ return;
+ }
+ if (filepath.toFile().isFile()) {
+ return;
+ }
+ String username = commonFileService.getUsernameByAbsolutePath(filepath);
+ String userId = null;
+ String path = null;
+ if (StrUtil.isNotBlank(username)) {
+ userId = userService.getUserIdByUserName(username);
+ Path relativePath = Paths.get(fileProperties.getRootDir(), username).relativize(filepath);
+ if (relativePath.getNameCount() > 0) {
+ path = "/" + relativePath + "/";
+ } else {
+ path = "/";
+ }
+ }
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where("alonePage").exists(false));
+ query.addCriteria(Criteria.where("release").exists(false));
+ query.addCriteria(Criteria.where("mountFileId").exists(false));
+ if (StrUtil.isNotBlank(userId)) {
+ query.addCriteria(Criteria.where("userId").is(userId));
+ }
+ if (StrUtil.isNotBlank(path)) {
+ query.addCriteria(Criteria.where("path").regex("^" + ReUtil.escape(path)));
+ }
+ Update update = new Update();
+ // 添加删除标记用于在之后删除
+ update.set("delete", 1);
+ mongoTemplate.updateMulti(query, update, CommonFileService.COLLECTION_NAME);
+ if (isDelIndex) {
+ // 删除索引
+ deleteAllIndex(path);
+ }
+ }
+
+ /**
+ * 移除删除标记
+ *
+ * @param fileIdList fileIdList
+ */
+ public void removeDeletedFlag(List fileIdList) {
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ if (fileIdList == null || fileIdList.isEmpty()) {
+ query.addCriteria(Criteria.where("delete").is(1));
+ } else {
+ query.addCriteria(Criteria.where("_id").in(fileIdList).and("delete").is(1));
+ }
+ Update update = new Update();
+ update.unset("delete");
+ mongoTemplate.updateMulti(query, update, CommonFileService.COLLECTION_NAME);
+ }
+
+ @PreDestroy
+ public void destroy() {
+ if (syncFileVisitorService != null) {
+ syncFileVisitorService.shutdown();
+ }
+ if (throttleExecutor != null) {
+ throttleExecutor.shutdown();
+ }
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/lucene/SearchFileService.java b/src/main/java/com/jmal/clouddisk/lucene/SearchFileService.java
new file mode 100644
index 0000000..2b48017
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/lucene/SearchFileService.java
@@ -0,0 +1,589 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.util.BooleanUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.exception.CommonException;
+import com.jmal.clouddisk.exception.ExceptionType;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.model.FileIntroVO;
+import com.jmal.clouddisk.model.TagDO;
+import com.jmal.clouddisk.model.query.SearchDTO;
+import com.jmal.clouddisk.model.query.SearchOptionHistoryDO;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.jmal.clouddisk.service.impl.TagService;
+import com.jmal.clouddisk.service.impl.UserLoginHolder;
+import com.jmal.clouddisk.util.ResponseResult;
+import com.jmal.clouddisk.util.ResultUtil;
+import com.jmal.clouddisk.util.TimeUntils;
+import com.mongodb.client.AggregateIterable;
+import io.reactivex.rxjava3.core.Completable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.IntPoint;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.index.StoredFields;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.queryparser.classic.ParseException;
+import org.apache.lucene.queryparser.classic.QueryParser;
+import org.apache.lucene.search.*;
+import org.bson.types.ObjectId;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.*;
+
+import static com.jmal.clouddisk.lucene.LuceneService.FIELD_TAG_NAME_FUZZY;
+import static com.jmal.clouddisk.service.impl.CommonFileService.COLLECTION_NAME;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class SearchFileService {
+
+ private final Analyzer analyzer;
+ private final FileProperties fileProperties;
+ private final SearcherManager searcherManager;
+ private final UserLoginHolder userLoginHolder;
+ private final TagService tagService;
+ private final MongoTemplate mongoTemplate;
+ private final IUserService userService;
+
+ public boolean existsSha256(String fileIndexHash) {
+ IndexSearcher indexSearcher = null;
+ try {
+ searcherManager.maybeRefresh();
+ indexSearcher = searcherManager.acquire();
+ Term term = new Term(Constants.ETAG, fileIndexHash);
+ Query query = new TermQuery(term);
+ TopDocs topDocs = indexSearcher.search(query, 1);
+ return topDocs.totalHits.value() > 0;
+ } catch (IOException e) {
+ log.error("检查 {} 是否存在失败", Constants.ETAG, e);
+ } finally {
+ if (indexSearcher != null) {
+ try {
+ searcherManager.release(indexSearcher);
+ } catch (IOException e) {
+ log.error("释放搜索器失败", e);
+ }
+ }
+ }
+ return false;
+ }
+
+ public ResponseResult> searchFile(SearchDTO searchDTO) {
+ String keyword = searchDTO.getKeyword();
+ if (keyword == null || keyword.trim().isEmpty() || searchDTO.getUserId() == null) {
+ return ResultUtil.success(Collections.emptyList());
+ }
+ ResponseResult> result = ResultUtil.genResult();
+ IndexSearcher indexSearcher = null;
+ try {
+ beforeQuery(searchDTO);
+ if (!searchDTO.getUserId().equals(userLoginHolder.getUserId())) {
+ Map props = new HashMap<>();
+ props.put("fileUsername", userService.getUserNameById(searchDTO.getUserId()));
+ result.setProps(props);
+ }
+
+ int pageNum = searchDTO.getPage();
+ int pageSize = searchDTO.getPageSize();
+ searcherManager.maybeRefresh();
+ List seenIds = new ArrayList<>();
+ indexSearcher = searcherManager.acquire();
+ Query query = getQuery(searchDTO);
+ Sort sort = getSort(searchDTO);
+ ScoreDoc lastScoreDoc = null;
+ if (pageNum > 1) {
+ int totalHitsToSkip = (pageNum - 1) * pageSize;
+ TopDocs topDocs = indexSearcher.search(query, totalHitsToSkip, sort);
+ if (topDocs.scoreDocs.length == totalHitsToSkip) {
+ lastScoreDoc = topDocs.scoreDocs[totalHitsToSkip - 1];
+ }
+ }
+ int count = indexSearcher.count(query);
+ TopDocs topDocs = indexSearcher.searchAfter(lastScoreDoc, query, pageSize, sort);
+ StoredFields storedFields = indexSearcher.storedFields();
+ for (ScoreDoc hit : topDocs.scoreDocs) {
+ Document doc = storedFields.document(hit.doc);
+ String id = doc.get("id");
+ seenIds.add(id);
+ }
+ List fileIntroVOList = getFileIntroVOs(seenIds);
+ long now = System.currentTimeMillis();
+ fileIntroVOList.forEach(fileIntroVO -> {
+ long updateMilli = TimeUntils.getMilli(fileIntroVO.getUpdateDate());
+ fileIntroVO.setAgoTime(now - updateMilli);
+ });
+ result.setData(fileIntroVOList);
+ result.setCount(count);
+
+ String userId = userLoginHolder.getUserId();
+ // 添加搜索历史
+ Completable.fromAction(() -> addSearchHistory(userId, searchDTO)).subscribeOn(Schedulers.io()).subscribe();
+
+ return result;
+ } catch (IOException | ParseException | java.lang.IllegalArgumentException e) {
+ log.error("搜索失败", e);
+ return result.setData(Collections.emptyList()).setCount(0);
+ } finally {
+ if (indexSearcher != null) {
+ try {
+ searcherManager.release(indexSearcher);
+ } catch (IOException e) {
+ log.error("释放搜索器失败", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * 添加搜索历史
+ *
+ * @param searchUserId searchUserId
+ * @param searchDTO searchDTO
+ */
+ private void addSearchHistory(String searchUserId, SearchDTO searchDTO) {
+ if (CharSequenceUtil.isBlank(searchUserId) || searchDTO == null) {
+ return;
+ }
+ SearchOptionHistoryDO searchOptionHistoryDO = searchDTO.toSearchOptionDO();
+ searchOptionHistoryDO.setSearchTime(System.currentTimeMillis());
+ searchOptionHistoryDO.setSearchUserId(searchUserId);
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where("keyword").is(searchDTO.getKeyword()));
+ query.addCriteria(Criteria.where("searchUserId").is(searchUserId));
+ // 删除重复的搜索记录
+ mongoTemplate.remove(query, SearchOptionHistoryDO.class);
+
+ // 删除一个月之前的搜索记录
+ org.springframework.data.mongodb.core.query.Query query1 = new org.springframework.data.mongodb.core.query.Query();
+ query1.addCriteria(Criteria.where("searchUserId").is(searchUserId));
+ query1.addCriteria(Criteria.where("searchTime").lt(System.currentTimeMillis() - 30 * 24 * 60 * 60 * 1000L));
+ mongoTemplate.remove(query1, SearchOptionHistoryDO.class);
+
+ // 插入搜索记录
+ mongoTemplate.insert(searchOptionHistoryDO);
+ }
+
+ public List recentlySearchHistory(String keyword) {
+ // 最近6条搜索记录
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where("searchUserId").is(userLoginHolder.getUserId()));
+ if (CharSequenceUtil.isNotBlank(keyword)) {
+ query.addCriteria(Criteria.where("keyword").regex(keyword));
+ }
+ query.with(org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "searchTime"));
+ query.limit(6);
+ List searchOptionHistoryDOList = mongoTemplate.find(query, SearchOptionHistoryDO.class);
+ return searchOptionHistoryDOList.stream().map(SearchOptionHistoryDO::toSearchDTO).toList();
+ }
+
+ public void deleteSearchHistory(String id) {
+ if (CharSequenceUtil.isBlank(id)) {
+ throw new CommonException(ExceptionType.MISSING_PARAMETERS);
+ }
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where("_id").is(id));
+ mongoTemplate.remove(query, SearchOptionHistoryDO.class);
+ }
+
+ public void deleteAllSearchHistory() {
+ String userId = userLoginHolder.getUserId();
+ if (CharSequenceUtil.isBlank(userId)) {
+ throw new CommonException(ExceptionType.MISSING_PARAMETERS);
+ }
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+ mongoTemplate.remove(query, SearchOptionHistoryDO.class);
+ }
+
+ /**
+ * 获取排序规则
+ *
+ * @param searchDTO searchDTO
+ * @return Sort
+ */
+ private static Sort getSort(SearchDTO searchDTO) {
+ // 如果 searchDTO 为 null,返回默认相关度倒序排序
+ if (searchDTO == null) {
+ return Sort.RELEVANCE;
+ }
+
+ String sortProp = searchDTO.getSortProp();
+ String sortOrder = searchDTO.getSortOrder();
+
+ // 如果排序属性或顺序为空,默认按相关度倒序排序
+ if (CharSequenceUtil.isBlank(sortProp) || CharSequenceUtil.isBlank(sortOrder)) {
+ return Sort.RELEVANCE;
+ }
+
+ // 确定排序方向
+ boolean reverse = "descending".equalsIgnoreCase(sortOrder);
+
+ // 根据排序属性创建对应的 SortField
+ SortField sortField;
+ switch (sortProp.toLowerCase()) {
+ case "updatedate":
+ sortField = new SortField("modified", SortField.Type.LONG, reverse);
+ break;
+ case "size":
+ sortField = new SortField("size", SortField.Type.LONG, reverse);
+ break;
+ case "name": // 显式支持相关度排序
+ // 注意:FIELD_SCORE 本身是降序的,如果要求升序需要特殊处理
+ return reverse ? Sort.RELEVANCE : new Sort(new SortField(null, SortField.Type.SCORE, true));
+ default:
+ // 对于不支持的排序字段,默认按相关度倒序
+ return Sort.RELEVANCE;
+ }
+
+ return new Sort(sortField);
+ }
+
+ /**
+ * 构建查询器
+ *
+ * @param searchDTO searchDTO
+ * @return Query
+ * @throws ParseException ParseException
+ */
+ private Query getQuery(SearchDTO searchDTO) throws ParseException {
+ // 精确搜索时,通常不希望对用户输入做过多改动
+ String exactKeyword = searchDTO.getKeyword().toLowerCase();
+
+ // 模糊搜索将关键字中的特殊字符转义
+ String fuzzyKeyword = QueryParser.escape(searchDTO.getKeyword().toLowerCase().trim());
+
+ // filename 和 tag Name 进行 NGram 匹配
+ BooleanQuery.Builder nameAndTagQueryBuilder = new BooleanQuery.Builder();
+ if (BooleanUtil.isTrue(searchDTO.getIncludeFileName())) {
+ BooleanQuery.Builder filenameBooleanQueryBuilder = getBooleanQueryFieldBuilder(LuceneService.FIELD_FILENAME_NGRAM, LuceneService.FIELD_FILENAME_FUZZY, exactKeyword, fuzzyKeyword, searchDTO);
+ nameAndTagQueryBuilder.add(filenameBooleanQueryBuilder.build(), BooleanClause.Occur.SHOULD);
+ }
+ if (BooleanUtil.isTrue(searchDTO.getIncludeTagName())) {
+ BooleanQuery.Builder tagNameBooleanQueryBuilder = getBooleanQueryFieldBuilder(LuceneService.FIELD_TAG_NAME_NGRAM, FIELD_TAG_NAME_FUZZY, exactKeyword, fuzzyKeyword, searchDTO);
+ nameAndTagQueryBuilder.add(tagNameBooleanQueryBuilder.build(), BooleanClause.Occur.SHOULD);
+ }
+
+ Query boostQuery = new BoostQuery(nameAndTagQueryBuilder.build(), 3.0f);
+
+ Query contentQuery = null;
+ if (BooleanUtil.isTrue(searchDTO.getIncludeFileContent())) {
+ BooleanQuery.Builder contentBooleanQueryBuilder = getBooleanQueryFieldBuilder(LuceneService.FIELD_CONTENT_NGRAM, LuceneService.FIELD_CONTENT_FUZZY, exactKeyword, fuzzyKeyword, searchDTO);
+ contentQuery = contentBooleanQueryBuilder.build();
+ }
+
+ // 如果keyword中有空格,将其拆分为多个关键字,这多个关键字之间是OR关系,在name或tag字段
+ Query nameAndTagQuery = null;
+ if (!BooleanUtil.isTrue(searchDTO.getExactSearch())) {
+ nameAndTagQuery = getMultipleKeywordsQuery(searchDTO, exactKeyword);
+ }
+
+ // 将正则表达式查询、短语查询和分词匹配查询组合成一个查询(OR关系)
+ BooleanQuery.Builder combinedQueryBuilder = new BooleanQuery.Builder().add(boostQuery, BooleanClause.Occur.SHOULD);
+ if (contentQuery != null) {
+ combinedQueryBuilder.add(contentQuery, BooleanClause.Occur.SHOULD);
+ }
+
+ if (nameAndTagQuery != null) {
+ combinedQueryBuilder.add(nameAndTagQuery, BooleanClause.Occur.SHOULD);
+ }
+
+ // 其他通用搜索选项
+ BooleanQuery otherQuery = getOtherOption(searchDTO);
+
+ BooleanQuery combinedQuery = combinedQueryBuilder.build();
+
+ // 创建最终查询(AND关系)
+ BooleanQuery.Builder fileQueryBuilder = new BooleanQuery.Builder().add(new TermQuery(new Term(IUserService.USER_ID, searchDTO.getSearchUserId())), BooleanClause.Occur.MUST);
+
+ if (!combinedQuery.clauses().isEmpty()) {
+ fileQueryBuilder.add(combinedQuery, BooleanClause.Occur.MUST);
+ }
+
+ if (!otherQuery.clauses().isEmpty()) {
+ fileQueryBuilder.add(otherQuery, BooleanClause.Occur.MUST);
+ }
+
+ // 添加其他不通用查询条件
+ otherQueryParams(searchDTO, fileQueryBuilder);
+
+ // or 挂载点查询
+ Query mountQuery = getMountQueryBuilder(searchDTO, combinedQuery, otherQuery);
+
+ // 构建最终查询
+ BooleanQuery.Builder finalQueryBuilder = new BooleanQuery.Builder();
+
+ // 是否仅搜索挂载文件
+ if (!searchDTO.onlySearchMount()) {
+ finalQueryBuilder.add(fileQueryBuilder.build(), BooleanClause.Occur.SHOULD);
+ }
+
+ // 是否搜索挂载文件
+ if (mountQuery != null) {
+ finalQueryBuilder.add(mountQuery, BooleanClause.Occur.SHOULD);
+ }
+
+ return finalQueryBuilder.build();
+ }
+
+ /**
+ * 创建字段查询
+ * 如果开启了精准搜索配置,且根据参数exactSearch为true,则创建精确查询;否则创建模糊查询
+ * 如果禁用精准搜索配置,则为“content”以外的字段(“filename”和“tagName”)创建模糊查询和精准查询, 两种查询使用OR组合
+ * 由于精确搜索配置导致前端没有exactSearch参数,默认使用NGram分词器索引“filename”和“tagName”或模糊查询组合查询将会有更好的效果”对于未来的维护者来说将非常有益
+ *
+ * @param fieldNameExact 精准查询字段名
+ * @param fieldNameFuzzy 模糊查询字段名
+ * @param exactKeyword 精准查询关键字
+ * @param fuzzyKeyword 模糊查询关键字
+ * @param searchDTO 查询参数
+ */
+ private BooleanQuery.Builder getBooleanQueryFieldBuilder(String fieldNameExact, String fieldNameFuzzy, String exactKeyword, String fuzzyKeyword, SearchDTO searchDTO) throws ParseException {
+ BooleanQuery.Builder booleanQueryFieldBuilder = new BooleanQuery.Builder();
+ Query fuzzyQuery = null;
+ Query exactQuery = null;
+ if (fileProperties.getExactSearch()) {
+ exactQuery = getExactQuery(fieldNameExact, exactKeyword);
+ if (!BooleanUtil.isTrue(searchDTO.getExactSearch())) {
+ fuzzyQuery = getFuzzyQuery(fieldNameFuzzy, fuzzyKeyword);
+ }
+ } else {
+ if (LuceneService.FIELD_CONTENT_FUZZY.equals(fieldNameFuzzy)) {
+ fuzzyQuery = getFuzzyQuery(fieldNameFuzzy, fuzzyKeyword);
+ } else {
+ fuzzyQuery = getFuzzyQuery(fieldNameFuzzy, fuzzyKeyword);
+ exactQuery = getExactQuery(fieldNameExact, exactKeyword);
+ }
+ }
+ if (fuzzyQuery != null) {
+ booleanQueryFieldBuilder.add(fuzzyQuery, BooleanClause.Occur.SHOULD);
+ }
+ if (exactQuery != null) {
+ booleanQueryFieldBuilder.add(exactQuery, BooleanClause.Occur.SHOULD);
+ }
+ return booleanQueryFieldBuilder;
+ }
+
+ private Query getMultipleKeywordsQuery(SearchDTO searchDTO, String exactKeyword) {
+ if (exactKeyword.contains(" ") && (BooleanUtil.isTrue(searchDTO.getIncludeFileName()) || BooleanUtil.isTrue(searchDTO.getIncludeTagName()))) {
+ BooleanQuery.Builder nameAndTagMultipleWordsQueryBuilder = new BooleanQuery.Builder();
+ for (String key : exactKeyword.split(" ")) {
+
+ if (BooleanUtil.isTrue(searchDTO.getIncludeFileName())) {
+ Query filenameQuery = getExactQuery(LuceneService.FIELD_FILENAME_NGRAM, key);
+ nameAndTagMultipleWordsQueryBuilder.add(filenameQuery, BooleanClause.Occur.SHOULD);
+ }
+ if (BooleanUtil.isTrue(searchDTO.getIncludeTagName())) {
+ Query tagNameQuery = getExactQuery(LuceneService.FIELD_TAG_NAME_NGRAM, key);
+ nameAndTagMultipleWordsQueryBuilder.add(tagNameQuery, BooleanClause.Occur.SHOULD);
+ }
+
+ }
+ return nameAndTagMultipleWordsQueryBuilder.build();
+ }
+ return null;
+ }
+
+ private Query getFuzzyQuery(String fieldNameFuzzy, String fuzzyKeyword) throws ParseException {
+ Query contentQuery;
+ QueryParser contentParser = new QueryParser(fieldNameFuzzy, analyzer);
+ contentParser.setDefaultOperator(QueryParser.Operator.AND);
+ contentQuery = contentParser.parse(QueryParser.escape(fuzzyKeyword.trim()));
+ return contentQuery;
+ }
+
+ private Query getExactQuery(String fieldName, String exactSearchTerm) {
+ Query contentQuery;
+ if (CharSequenceUtil.isBlank(exactSearchTerm)) {
+ contentQuery = new MatchNoDocsQuery();
+ } else if (exactSearchTerm.length() <= fileProperties.getNgramMaxSize()) {
+ Term term = new Term(fieldName, exactSearchTerm);
+ contentQuery = new TermQuery(term);
+ } else {
+ // 如果用户输入长度大于 maxGramSize,则分解查询字符串
+ BooleanQuery.Builder decomposedQueryBuilder = new BooleanQuery.Builder();
+ boolean hasValidSubTerms = false;
+
+ // 生成重叠的、长度为 maxGram 的子串
+ // 滑动窗口:从索引 0 开始,每次取长度为 maxGram 的子串,然后窗口向右移动一个字符
+ for (int i = 0; i <= exactSearchTerm.length() - fileProperties.getNgramMaxSize(); i++) {
+ String subTerm = exactSearchTerm.substring(i, i + fileProperties.getNgramMaxSize());
+ if (CharSequenceUtil.isNotBlank(subTerm)) { // 理论上 subTerm 不会为空,但作为防御
+ decomposedQueryBuilder.add(new TermQuery(new Term(fieldName, subTerm)), BooleanClause.Occur.MUST);
+ hasValidSubTerms = true;
+ }
+ }
+ if (hasValidSubTerms) {
+ contentQuery = decomposedQueryBuilder.build();
+ log.debug("{} 精确子串搜索 (长查询分解后): {}", fieldName, contentQuery.toString());
+ } else {
+ // 如果分解后没有有效的子查询(例如,原始字符串非常特殊或maxGram设置不当导致无法分解)
+ // 虽然理论上 exactSearchTerm.length() > maxGram 应该总能分解出至少一个,但作为防御
+ contentQuery = new MatchNoDocsQuery();
+ }
+ }
+ return contentQuery;
+ }
+
+ private static BooleanQuery getOtherOption(SearchDTO searchDTO) {
+ BooleanQuery.Builder otherOptionBuilder = new BooleanQuery.Builder();
+ // 文件类型
+ if (CharSequenceUtil.isNotBlank(searchDTO.getType())) {
+ otherOptionBuilder.add(new TermQuery(new Term("type", searchDTO.getType())), BooleanClause.Occur.MUST);
+ }
+
+ // 是否文件夹
+ if (searchDTO.getIsFolder() != null) {
+ otherOptionBuilder.add(IntPoint.newExactQuery("isFolder", searchDTO.getIsFolder() ? 1 : 0), BooleanClause.Occur.MUST);
+ }
+
+ // 更新时间范围查询
+ if (searchDTO.getModifyStart() != null || searchDTO.getModifyEnd() != null) {
+ long start = searchDTO.getModifyStart() != null ? searchDTO.getModifyStart() : Long.MIN_VALUE;
+ long end = searchDTO.getModifyEnd() != null ? searchDTO.getModifyEnd() : Long.MAX_VALUE;
+ otherOptionBuilder.add(NumericDocValuesField.newSlowRangeQuery("modified", start, end), BooleanClause.Occur.MUST);
+ }
+
+ // 文件大小范围查询
+ if (searchDTO.getSizeMin() != null || searchDTO.getSizeMax() != null) {
+ long minSize = searchDTO.getSizeMin() != null ? searchDTO.getSizeMin() : Long.MIN_VALUE;
+ long maxSize = searchDTO.getSizeMax() != null ? searchDTO.getSizeMax() : Long.MAX_VALUE;
+ otherOptionBuilder.add(NumericDocValuesField.newSlowRangeQuery("size", minSize, maxSize), BooleanClause.Occur.MUST);
+ }
+ return otherOptionBuilder.build();
+ }
+
+ /**
+ * 获取挂载点查询
+ *
+ * @param searchDTO searchDTO
+ * @param combinedQuery 共用查询
+ * @return mountQueryBuilder
+ */
+ private BooleanQuery getMountQueryBuilder(SearchDTO searchDTO, BooleanQuery combinedQuery, BooleanQuery otherQuery) {
+ BooleanQuery.Builder mountQueryBuilder;
+ if (BooleanUtil.isTrue(searchDTO.getSearchMount()) || searchDTO.onlySearchMount()) {
+ mountQueryBuilder = new BooleanQuery.Builder();
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where("userId").is(searchDTO.getUserId()));
+ query.addCriteria(Criteria.where("mountFileId").exists(true));
+ query.fields().exclude("content");
+ query.fields().exclude("video");
+ query.fields().exclude("music");
+ query.fields().exclude("contentText");
+ query.fields().exclude("exif");
+ List fileDocumentList = mongoTemplate.find(query, FileDocument.class);
+ fileDocumentList.parallelStream().forEach(fileDocument -> {
+ BooleanQuery.Builder mountQueryBuilderItem = new BooleanQuery.Builder();
+ org.springframework.data.mongodb.core.query.Query query1 = new org.springframework.data.mongodb.core.query.Query();
+ query1.fields().include("userId");
+ query1.fields().include("path");
+ query1.fields().include("name");
+ FileDocument fileBase = mongoTemplate.findById(fileDocument.getMountFileId(), FileDocument.class);
+ if (fileBase != null) {
+ // userId
+ mountQueryBuilderItem.add(new TermQuery(new Term(IUserService.USER_ID, fileBase.getUserId())), BooleanClause.Occur.MUST);
+ // path
+ mountQueryBuilderItem.add(getPathQueryBuilder(fileBase.getPath() + fileBase.getName()), BooleanClause.Occur.MUST);
+ // other
+ mountQueryBuilderItem.add(combinedQuery, BooleanClause.Occur.MUST);
+ if (!otherQuery.clauses().isEmpty()) {
+ mountQueryBuilderItem.add(otherQuery, BooleanClause.Occur.MUST);
+ }
+ }
+ mountQueryBuilder.add(mountQueryBuilderItem.build(), BooleanClause.Occur.SHOULD);
+ });
+ } else {
+ mountQueryBuilder = null;
+ }
+ return mountQueryBuilder == null ? null : mountQueryBuilder.build();
+ }
+
+ /**
+ * 查询前置处理
+ *
+ * @param searchDTO searchDTO
+ */
+ private void beforeQuery(SearchDTO searchDTO) {
+ String folder = searchDTO.getFolder();
+ if (CharSequenceUtil.isNotBlank(folder)) {
+ // 挂载点查询
+ org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
+ query.addCriteria(Criteria.where("_id").is(folder));
+ FileDocument fileDocument = mongoTemplate.findOne(query, FileDocument.class);
+ if (fileDocument != null && BooleanUtil.isFalse(searchDTO.getSearchOverall())) {
+ searchDTO.setCurrentDirectory(fileDocument.getPath() + fileDocument.getName());
+ searchDTO.setMountUserId(fileDocument.getUserId());
+ }
+ }
+ }
+
+
+ private void otherQueryParams(SearchDTO searchDTO, BooleanQuery.Builder builder) {
+ boolean queryPath = CharSequenceUtil.isNotBlank(searchDTO.getCurrentDirectory()) && searchDTO.getCurrentDirectory().length() > 1;
+
+ if (queryPath) {
+ builder.add(getPathQueryBuilder(searchDTO.getCurrentDirectory()), BooleanClause.Occur.MUST);
+ }
+
+ // 标签
+ if (searchDTO.getTagId() != null) {
+ TagDO tagDO = tagService.getTagInfo(searchDTO.getTagId());
+ if (tagDO != null) {
+ builder.add(new TermQuery(new Term(FIELD_TAG_NAME_FUZZY, tagDO.getName())), BooleanClause.Occur.MUST);
+ }
+ }
+
+ // 是否收藏
+ if (searchDTO.getIsFavorite() != null && !queryPath) {
+ builder.add(IntPoint.newExactQuery(Constants.IS_FAVORITE, searchDTO.getIsFavorite() ? 1 : 0), BooleanClause.Occur.MUST);
+ }
+ }
+
+ private static BooleanQuery getPathQueryBuilder(String path) {
+ // 检查path最后一个字符是否有/, 如果没有,则添加
+ path = path.endsWith("/") ? path : path + "/";
+ BooleanQuery.Builder pathQueryBuilder = new BooleanQuery.Builder();
+ Term pathTerm = new Term("path", path);
+ PrefixQuery prefixQuery = new PrefixQuery(pathTerm);
+ pathQueryBuilder.add(new TermQuery(pathTerm), BooleanClause.Occur.SHOULD);
+ pathQueryBuilder.add(prefixQuery, BooleanClause.Occur.SHOULD);
+ return pathQueryBuilder.build();
+ }
+
+ public List getFileIntroVOs(List fileIdList) {
+ List objectIds = fileIdList.stream()
+ .filter(ObjectId::isValid)
+ .map(ObjectId::new)
+ .toList();
+ List pipeline = Arrays.asList(new org.bson.Document("$match",
+ new org.bson.Document("_id",
+ new org.bson.Document("$in", objectIds))),
+ new org.bson.Document("$project", new org.bson.Document("order", 0L).append("contentText", 0L).append("content", 0L)),
+ new org.bson.Document("$addFields",
+ new org.bson.Document("order",
+ new org.bson.Document("$indexOfArray", Arrays.asList(objectIds, "$_id")))),
+ new org.bson.Document("$sort",
+ new org.bson.Document("order", 1L))
+ );
+
+ AggregateIterable aggregateIterable = mongoTemplate.getCollection(COLLECTION_NAME).aggregate(pipeline);
+
+ List results = new ArrayList<>();
+ for (org.bson.Document document : aggregateIterable) {
+ FileIntroVO fileIntroVO = mongoTemplate.getConverter().read(FileIntroVO.class, document);
+ results.add(fileIntroVO);
+ }
+ return results;
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/lucene/TaskProgress.java b/src/main/java/com/jmal/clouddisk/lucene/TaskProgress.java
new file mode 100644
index 0000000..6e16b90
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/lucene/TaskProgress.java
@@ -0,0 +1,42 @@
+package com.jmal.clouddisk.lucene;
+
+import lombok.Data;
+
+@Data
+public class TaskProgress {
+ /**
+ * 任务id
+ */
+ private String taskId;
+ /**
+ * 任务类型
+ */
+ private TaskType taskType;
+ /**
+ * 任务类型 taskType.getType()
+ */
+ private String type;
+ /**
+ * 任务名称
+ */
+ private String name;
+ /**
+ * 文件路径
+ */
+ private String path;
+ /**
+ * 任务状态
+ */
+ private String progress;
+ private String username;
+
+ public TaskProgress(String taskId, String username, TaskType taskType, String name, String progress) {
+ this.taskId = taskId;
+ this.username = username;
+ this.taskType = taskType;
+ this.type = taskType.getType();
+ this.name = name;
+ this.progress = progress;
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/lucene/TaskProgressService.java b/src/main/java/com/jmal/clouddisk/lucene/TaskProgressService.java
new file mode 100644
index 0000000..14e11d1
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/lucene/TaskProgressService.java
@@ -0,0 +1,117 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.service.impl.CommonFileService;
+import com.jmal.clouddisk.service.impl.UserServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentSkipListMap;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class TaskProgressService {
+
+ private final CommonFileService commonFileService;
+
+ private final UserServiceImpl userService;
+
+ private final static Map TASK_PROGRESS_MAP = new ConcurrentSkipListMap<>();
+
+ private final static String MSG_TASK_PROGRESS = "taskProgress";
+
+ private final static String MSG_TRANSCODE_STATUS = "transcodeStatus";
+
+ private String defaultUsername;
+
+ public List getTaskProgressList() {
+ return List.copyOf(TASK_PROGRESS_MAP.values());
+ }
+
+ /**
+ * 更新转码状态
+ * @param transcodeStatus 转码状态
+ */
+ public void pushTranscodeStatus(Map transcodeStatus) {
+ commonFileService.pushMessage(getDefaultUsername(), transcodeStatus, MSG_TRANSCODE_STATUS);
+ }
+
+ /**
+ * 添加任务进度
+ * @param file 文件
+ * @param taskType 任务类型
+ * @param progress 进度
+ */
+ public void addTaskProgress(File file, TaskType taskType, String progress) {
+ getDefaultUsername();
+ String taskId = getTaskId(file);
+ TaskProgress taskProgress;
+ if (checkTaskProgress(taskId)) {
+ taskProgress = getTaskProgress(taskId);
+ taskProgress.setProgress(progress);
+ } else {
+ String username = commonFileService.getUsernameByAbsolutePath(file.toPath());
+ if (username == null) {
+ return;
+ }
+ taskProgress = new TaskProgress(taskId, username, taskType, file.getName(), progress);
+ taskProgress.setPath(commonFileService.getRelativePath(username, file.getAbsolutePath(), file.getName()));
+ }
+ addTaskProgress(taskProgress);
+ }
+
+ /**
+ * 移除任务进度
+ * @param file 文件
+ */
+ public void removeTaskProgress(File file) {
+ String taskId = getTaskId(file);
+ removeTaskProgress(taskId);
+ }
+
+ private String getDefaultUsername() {
+ if (defaultUsername == null) {
+ defaultUsername = userService.getCreatorUsername();
+ }
+ return defaultUsername;
+ }
+
+ private static String getTaskId(File file) {
+ return SecureUtil.md5(file.getAbsoluteFile().toString());
+ }
+
+ private void removeTaskProgress(String taskId) {
+ if (checkTaskProgress(taskId)) {
+ TaskProgress taskProgress = getTaskProgress(taskId);
+ taskProgress.setProgress(null);
+ pushMessage(taskProgress);
+ TASK_PROGRESS_MAP.remove(taskId);
+ }
+ }
+
+ private void addTaskProgress(TaskProgress taskProgress) {
+ TASK_PROGRESS_MAP.put(taskProgress.getTaskId(), taskProgress);
+ pushMessage(taskProgress);
+ }
+
+ private boolean checkTaskProgress(String taskId) {
+ return TASK_PROGRESS_MAP.containsKey(taskId);
+ }
+
+ private TaskProgress getTaskProgress(String taskId) {
+ return TASK_PROGRESS_MAP.get(taskId);
+ }
+
+ private void pushMessage(TaskProgress taskProgress) {
+ if (!taskProgress.getUsername().equals(defaultUsername)) {
+ commonFileService.pushMessage(defaultUsername, taskProgress, MSG_TASK_PROGRESS);
+ }
+ commonFileService.pushMessage(taskProgress.getUsername(), taskProgress, MSG_TASK_PROGRESS);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/lucene/TaskType.java b/src/main/java/com/jmal/clouddisk/lucene/TaskType.java
new file mode 100644
index 0000000..3e546b3
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/lucene/TaskType.java
@@ -0,0 +1,24 @@
+package com.jmal.clouddisk.lucene;
+
+import lombok.Getter;
+
+/**
+ * 任务类型
+ */
+@Getter
+public enum TaskType {
+ /**
+ * OCR识别
+ */
+ OCR("OCR识别"),
+ /**
+ * 视频转码
+ */
+ TRANSCODE_VIDEO("视频转码");
+
+ private final String type;
+
+ TaskType(String type) {
+ this.type = type;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/FFMPEGCommand.java b/src/main/java/com/jmal/clouddisk/media/FFMPEGCommand.java
new file mode 100644
index 0000000..a5d59e4
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/FFMPEGCommand.java
@@ -0,0 +1,320 @@
+package com.jmal.clouddisk.media;
+
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.util.NumberUtil;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.jmal.clouddisk.service.Constants;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Collectors;
+
+import static com.jmal.clouddisk.util.FFMPEGUtils.printErrorInfo;
+
+@Slf4j
+public class FFMPEGCommand {
+
+ /**
+ * 缩略图宽度
+ */
+ static final int thumbnailWidth = 192;
+ /**
+ * 获取视频的分辨率和码率信息
+ *
+ * @param videoPath 视频路径
+ * @return 视频信息
+ */
+ static VideoInfo getVideoInfo(String videoPath) {
+ try {
+ ProcessBuilder processBuilder = new ProcessBuilder(
+ "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_format", "-show_streams", "-of", "json", videoPath);
+ Process process = processBuilder.start();
+ try (InputStream inputStream = process.getInputStream();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ String json = reader.lines().collect(Collectors.joining());
+ JSONObject jsonObject = JSON.parseObject(json);
+
+ // 获取视频格式信息
+ JSONObject formatObject = jsonObject.getJSONObject("format");
+ String format = formatObject.getString("format_name");
+
+ // 获取视频时长
+ int duration = Convert.toInt(formatObject.get("duration"));
+
+ // 获取视频流信息
+ JSONArray streamsArray = jsonObject.getJSONArray("streams");
+ if (!streamsArray.isEmpty()) {
+ JSONObject streamObject = streamsArray.getJSONObject(0);
+ int width = streamObject.getIntValue("width");
+ int height = streamObject.getIntValue("height");
+ int rotation = 0;
+ if (streamObject.containsKey("side_data_list")) {
+ for (int i = 0; i < streamObject.getJSONArray("side_data_list").size(); i++) {
+ JSONObject sideData = streamObject.getJSONArray("side_data_list").getJSONObject(i);
+ if (sideData.containsKey("rotation")) {
+ rotation = sideData.getIntValue("rotation");
+ break;
+ }
+ }
+ }
+ // 转换rotation
+
+ // 获取视频帧率信息
+ String frameRateStr = streamObject.getString("r_frame_rate");
+ double frameRate = parseFrameRate(frameRateStr);
+
+ int bitrate = streamObject.getIntValue("bit_rate"); // bps
+ return new VideoInfo(videoPath, width, height, format, bitrate, duration, Math.abs(rotation), frameRate);
+ }
+ }
+
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ printErrorInfo(processBuilder, process);
+ }
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return new VideoInfo();
+ }
+
+ /**
+ * 解析帧率字符串
+ *
+ * @param frameRateStr 帧率字符串(形如 "30000/1001")
+ * @return 帧率
+ */
+ static double parseFrameRate(String frameRateStr) {
+ if (frameRateStr != null && frameRateStr.contains("/")) {
+ String[] parts = frameRateStr.split("/");
+ if (parts.length == 2) {
+ try {
+ double numerator = Double.parseDouble(parts[0]);
+ double denominator = Double.parseDouble(parts[1]);
+ return NumberUtil.round(numerator / denominator, 2).doubleValue();
+ } catch (NumberFormatException e) {
+ log.error("Failed to parse frame rate: {}", frameRateStr, e);
+ }
+ }
+ }
+ return 0.0;
+ }
+
+ /**
+ * 合并缩略图
+ * @param inputPattern 输入文件名格式
+ * @param outputImage 输出图片
+ * @param columns 列数
+ * @param rows 行数
+ * @return ProcessBuilder
+ */
+ static ProcessBuilder mergeVTT(String inputPattern, String outputImage, int columns, int rows) {
+ ProcessBuilder processBuilder = new ProcessBuilder(
+ Constants.FFMPEG,
+ "-y",
+ "-i", inputPattern,
+ "-filter_complex", String.format("tile=%dx%d", columns, rows),
+ outputImage
+ );
+ processBuilder.redirectErrorStream(true);
+ return processBuilder;
+ }
+
+ /**
+ * 获取视频封面
+ * @param videoPath 视频路径
+ * @param outputPath 输出路径
+ * @param videoDuration 视频时长
+ * @return ProcessBuilder
+ */
+ static ProcessBuilder getVideoCoverProcessBuilder(String videoPath, String outputPath, int videoDuration) {
+ int targetTimestamp = (int) (videoDuration * 0.1);
+ String formattedTimestamp = VideoInfoUtil.formatTimestamp(targetTimestamp, false);
+ log.debug("\r\nvideoPath: {}, formattedTimestamp: {}", videoPath, formattedTimestamp);
+ ProcessBuilder processBuilder = new ProcessBuilder(
+ Constants.FFMPEG,
+ "-y",
+ "-ss", formattedTimestamp,
+ "-i", videoPath,
+ "-vf", String.format("scale=%s:-2", thumbnailWidth),
+ "-frames:v", "1",
+ outputPath
+ );
+ processBuilder.redirectErrorStream(true);
+ return processBuilder;
+ }
+
+ static ProcessBuilder useVideotoolbox(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern, double frameRate) {
+ return new ProcessBuilder(
+ Constants.FFMPEG,
+ "-hwaccel", "videotoolbox",
+ "-i", fileAbsolutePath.toString(),
+ "-c:v", "h264_videotoolbox",
+ "-profile:v", "main",
+ "-pix_fmt", "yuv420p",
+ "-level", "4.0",
+ "-start_number", "0",
+ "-hls_time", "10",
+ "-hls_list_size", "0",
+ "-vf", "scale=-2:" + height,
+ "-b:v", Convert.toStr(bitrate),
+ "-preset", "medium",
+ "-g", "48",
+ "-sc_threshold", "0",
+ "-r", String.format("%.2f", frameRate),
+ "-f", "hls",
+ "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(),
+ outputPath,
+ "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval),
+ thumbnailPattern
+ );
+ }
+
+ static ProcessBuilder cpuTranscoding(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, int vttInterval, String thumbnailPattern, double frameRate) {
+
+ return new ProcessBuilder(
+ Constants.FFMPEG,
+ "-i", fileAbsolutePath.toString(),
+ "-profile:v", "main",
+ "-pix_fmt", "yuv420p",
+ "-level", "4.0",
+ "-start_number", "0",
+ "-hls_time", "10",
+ "-hls_list_size", "0",
+ "-vf", "scale=-2:" + height,
+ "-b:v", Convert.toStr(bitrate),
+ "-preset", "medium",
+ "-g", "48",
+ "-sc_threshold", "0",
+ "-r", String.format("%.2f", frameRate),
+ "-f", "hls",
+ "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(),
+ outputPath,
+ "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval),
+ thumbnailPattern
+ );
+ }
+
+ static ProcessBuilder useNvencCuda(String fileId, Path fileAbsolutePath, int bitrate, int height, String videoCacheDir, String outputPath, double frameRate) {
+ // 使用CUDA硬件加速和NVENC编码器
+ return new ProcessBuilder(
+ Constants.FFMPEG,
+ "-init_hw_device", "cuda=cu:0",
+ "-filter_hw_device", "cu",
+ "-hwaccel", "cuda",
+ "-hwaccel_output_format", "cuda",
+ "-threads", "1",
+ "-autorotate", "0",
+ "-i", fileAbsolutePath.toString(),
+ "-autoscale", "0",
+ "-map_metadata", "-1",
+ "-map_chapters", "-1",
+ "-threads", "0",
+ "-map", "0:0",
+ "-map", "0:1",
+ "-map", "-0:s",
+ "-codec:v:0", "h264_nvenc",
+ "-preset", "p1",
+ "-b:v", Convert.toStr(bitrate),
+ "-maxrate", Convert.toStr(bitrate),
+ "-bufsize", "5643118",
+ "-g:v:0", "180",
+ "-keyint_min:v:0", "180",
+ "-vf", "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709,scale_cuda=-2:" + height + ":format=yuv420p",
+ "-codec:a:0", "copy",
+ "-copyts",
+ "-avoid_negative_ts", "disabled",
+ "-max_muxing_queue_size", "2048",
+ "-r", String.format("%.2f", frameRate),
+ "-f", "hls",
+ "-max_delay", "5000000",
+ "-hls_time", "3",
+ "-hls_segment_type", "mpegts",
+ "-start_number", "0",
+ "-y",
+ "-hls_segment_filename", Paths.get(videoCacheDir, fileId + "-%03d.ts").toString(),
+ "-hls_playlist_type", "vod",
+ "-hls_list_size", "0",
+ outputPath
+ );
+ }
+ static ProcessBuilder useNvencCudaVtt(Path fileAbsolutePath, int vttInterval, String thumbnailPattern) {
+ // 使用CUDA硬件加速和NVENC编码器
+ return new ProcessBuilder(
+ Constants.FFMPEG,
+ "-init_hw_device", "cuda=cu:0",
+ "-filter_hw_device", "cu",
+ "-i", fileAbsolutePath.toString(),
+ "-y",
+ "-vf", String.format("scale=%s:-2,fps=1/%d", thumbnailWidth, vttInterval),
+ "-vsync", "vfr",
+ thumbnailPattern
+ );
+ }
+
+ /**
+ * 检测是否装有Mac Apple Silicon
+ */
+ static boolean checkMacAppleSilicon() {
+ try {
+ Process process = Runtime.getRuntime().exec("ffmpeg -hwaccels");
+ BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.contains("videotoolbox")) {
+ return true;
+ }
+ }
+ } catch (IOException e) {
+ return false;
+ }
+ return false;
+ }
+
+ /**
+ * 检测是否装有NVIDIA显卡驱动
+ */
+ static boolean checkNvidiaDrive() {
+ try {
+ Process process = Runtime.getRuntime().exec("nvidia-smi");
+ BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.contains("Version")) {
+ return true;
+ }
+ }
+ } catch (IOException e) {
+ return false;
+ }
+ return false;
+ }
+
+ /**
+ * 检查是否没有ffmpeg
+ * @return true: 没有ffmpeg
+ */
+ public static boolean hasNoFFmpeg() {
+ try {
+ Process process = Runtime.getRuntime().exec("ffmpeg -version");
+ BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.contains("ffmpeg version")) {
+ return false;
+ }
+ }
+ return true;
+ } catch (IOException e) {
+ log.error(e.getMessage(), e);
+ }
+ return true;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/FFMPEGUtils.java b/src/main/java/com/jmal/clouddisk/media/FFMPEGUtils.java
new file mode 100644
index 0000000..981c4dd
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/FFMPEGUtils.java
@@ -0,0 +1,126 @@
+package com.jmal.clouddisk.media;
+
+import cn.hutool.core.util.BooleanUtil;
+import cn.hutool.core.util.NumberUtil;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.BufferedWriter;
+import java.io.FileWriter;
+import java.io.IOException;
+
+@Slf4j
+public class FFMPEGUtils {
+
+ /**
+ * h5播放器支持的视频格式
+ */
+ private static final String[] WEB_SUPPORTED_FORMATS = {"mp4", "webm", "ogg", "flv", "hls", "mkv"};
+
+ /**
+ * 获取VTT文件间隔
+ *
+ * @param videoInfo 视频信息
+ * @param vttThumbnailCount 期望的缩略图数量
+ * @return VTT文件间隔
+ */
+ static int getVttInterval(VideoInfo videoInfo, Integer vttThumbnailCount) {
+ // 期望的缩略图数量默认为60张
+ int videoDuration = videoInfo.getDuration();
+ // 计算缩略图间隔
+ if (videoDuration <= vttThumbnailCount) {
+ return 1;
+ }
+ return videoDuration / vttThumbnailCount;
+ }
+
+ /**
+ * 生成VTT文件
+ * @param videoInfo 视频信息
+ * @param interval 缩略图间隔
+ * @param vttFilePath VTT文件路径
+ * @param thumbnailImagePath 最终缩略图路径
+ */
+ static void generateVTT(VideoInfo videoInfo, int interval, String vttFilePath, String thumbnailImagePath, int columns) {
+ try (BufferedWriter writer = new BufferedWriter(new FileWriter(vttFilePath))) {
+ writer.write("WEBVTT\n\n");
+ int expectedThumbnails = videoInfo.getDuration() / interval;
+ for (int i = 0; i < expectedThumbnails; i++) {
+ String startTime = VideoInfoUtil.formatTimestamp(i * interval, true);
+ String endTime = VideoInfoUtil.formatTimestamp((i + 1) * interval, true);
+
+ int column = i % columns;
+ int row = i / columns;
+ int thumbWidth = FFMPEGCommand.thumbnailWidth;
+ double tHeight = (double) videoInfo.getHeight() / ((double) videoInfo.getWidth() / FFMPEGCommand.thumbnailWidth);
+ int thumbHeight = (int) Math.floor(tHeight);
+ int x = column * thumbWidth;
+ int y = row * thumbHeight;
+
+ writer.write(String.format("%s --> %s\n", startTime, endTime));
+ writer.write(String.format("%s#xywh=%d,%d,%d,%d\n\n", thumbnailImagePath, x, y, thumbWidth, thumbHeight));
+ }
+ } catch (IOException e) {
+ log.warn(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 判断是否需要转码
+ *
+ * @param videoInfo 视频信息
+ * @return 是否需要转码
+ */
+ static boolean needTranscode(VideoInfo videoInfo, TranscodeConfig transcodeConfig) {
+ if (BooleanUtil.isFalse(transcodeConfig.getEnable())) {
+ return false;
+ }
+ // 判断视频码率、高度、帧率是否满足转码条件
+ boolean bitrateCond = videoInfo.getBitrate() > 0 && videoInfo.getBitrate() <= transcodeConfig.getBitrateCond();
+ boolean heightCond = videoInfo.getHeight() > 0 && videoInfo.getHeight() <= transcodeConfig.getHeightCond();
+ boolean frameRateCond = videoInfo.getFrameRate() > 0 && videoInfo.getFrameRate() <= transcodeConfig.getFrameRateCond();
+ if (bitrateCond && heightCond && frameRateCond) {
+ // 都不满足, 则判断视频格式是否为HTML5 Video Player支持的格式, 不支持则需要转码
+ return !isSupportedFormat(videoInfo.getFormat());
+ }
+ return true;
+ }
+
+ /**
+ * 判断视频格式是否为HTML5 Video Player支持的格式
+ *
+ * @param format 视频格式
+ * @return 是否支持
+ */
+ static boolean isSupportedFormat(String format) {
+ // HTML5 Video Player支持的视频格式
+ String[] formatList = format.split(",");
+ for (String f : formatList) {
+ for (String supportedFormat : WEB_SUPPORTED_FORMATS) {
+ if (f.trim().equalsIgnoreCase(supportedFormat)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 获取转码进度
+ * @param videoDuration 视频时长
+ * @param line 命令输出信息
+ * @return 转码进度
+ */
+ static double getProgressStr(int videoDuration, String line) {
+ String[] parts = line.split("time=")[1].split(" ")[0].split(":");
+ int hours = Integer.parseInt(parts[0]);
+ int minutes = Integer.parseInt(parts[1]);
+ int seconds = Integer.parseInt(parts[2].split("\\.")[0]);
+ int totalSeconds = hours * 3600 + minutes * 60 + seconds;
+ // 计算转码进度百分比
+ double progress = (double) totalSeconds / videoDuration * 100;
+ if (progress > 100) {
+ progress = 100;
+ }
+ return NumberUtil.round(progress, 2).doubleValue();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/HeifUtils.java b/src/main/java/com/jmal/clouddisk/media/HeifUtils.java
new file mode 100644
index 0000000..3370d1f
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/HeifUtils.java
@@ -0,0 +1,122 @@
+package com.jmal.clouddisk.media;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.file.PathUtil;
+import com.jmal.clouddisk.service.Constants;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static com.jmal.clouddisk.util.FFMPEGUtils.getWaitingForResults;
+
+@Slf4j
+public class HeifUtils {
+
+ /**
+ * 将heic转换为jpg
+ */
+ public static String heifConvert(String inputPath, Path outputParentPath) {
+ if (!checkHeif()) {
+ return null;
+ }
+ Path filepath = Paths.get(inputPath + ".jpg");
+ String outputPath = Paths.get(outputParentPath.toString(), filepath.getFileName().toString()).toString();
+ if (FileUtil.exist(outputPath)) {
+ return outputPath;
+ }
+ try {
+ ProcessBuilder processBuilder = heifConvert(inputPath, outputPath);
+ Process process = processBuilder.start();
+ StringBuilder output = printProcessInfo(process);
+ outputPath = getWaitingForResults(outputPath, processBuilder, process);
+
+ delAuxiliaryImagePath(output);
+
+ // 打印命令 用空格连接
+ String command = String.join(" ", processBuilder.command());
+ log.info("heif-convert 执行成功, command: \r\n{}", command);
+
+ if (outputPath != null) {
+ PathUtil.move(Path.of(outputPath), filepath, true);
+ return filepath.toString();
+ }
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ return null;
+ } catch (IOException e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ /**
+ * 删除辅助图像文件
+ * @param output process输出
+ */
+ private static void delAuxiliaryImagePath(StringBuilder output) {
+ // 解析输出,查找辅助图像文件名, 并删除
+ String auxiliaryImagePath = null;
+ Pattern pattern = Pattern.compile("Auxiliary image written to (.+)");
+ Matcher matcher = pattern.matcher(output.toString());
+ if (matcher.find()) {
+ auxiliaryImagePath = matcher.group(1);
+ }
+ if (auxiliaryImagePath != null) {
+ FileUtil.del(auxiliaryImagePath);
+ }
+ }
+
+ /**
+ * 将heic转换为jpg
+ * @param filepath heic文件绝对路径
+ * @return ProcessBuilder
+ */
+ private static ProcessBuilder heifConvert(String filepath, String outputPath) {
+ ProcessBuilder processBuilder = new ProcessBuilder(
+ Constants.HEIF_CONVERT,
+ filepath,
+ outputPath
+ );
+ processBuilder.redirectErrorStream(true);
+ return processBuilder;
+ }
+
+ public static StringBuilder printProcessInfo(Process process) throws IOException {
+ StringBuilder output = new StringBuilder();
+ try (InputStream inputStream = process.getInputStream();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ output.append(line).append("\n");
+ }
+ }
+ return output;
+ }
+
+ /**
+ * 检查是否安装了heif-convert
+ */
+ private static boolean checkHeif() {
+ try {
+ Process process = Runtime.getRuntime().exec(Constants.HEIF_CONVERT);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.contains("output")) {
+ return true;
+ }
+ }
+ return true;
+ } catch (IOException e) {
+ log.error(e.getMessage(), e);
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/TranscodeConfig.java b/src/main/java/com/jmal/clouddisk/media/TranscodeConfig.java
new file mode 100644
index 0000000..ea6e994
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/TranscodeConfig.java
@@ -0,0 +1,124 @@
+package com.jmal.clouddisk.media;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import lombok.Data;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+/**
+ * 转码配置
+ */
+@Data
+@Document(collection = "transcodeConfig")
+@Valid
+@Schema
+public class TranscodeConfig {
+
+ @Schema(description = "是否启用转码, 默认开启")
+ private Boolean enable;
+
+ @Max(value = 8, message = "最大任务数不能超过8")
+ @Min(value = 1, message = "最大任务数不能小于1")
+ @Schema(description = "最大任务数, 最多同时处理的转码任务数, 默认为1")
+ private Integer maxThreads;
+
+ @Max(value = 1000000, message = "码率不能超过 1000000 kbps")
+ @Min(value = 100, message = "码率不能低于 100 kbps")
+ @Schema(description = "转码条件: 视频码率(kbps), 默认 2500 kbps, 小于该值则不转码")
+ private Integer bitrateCond;
+
+ @Max(value = 10000, message = "高度不能超过 10000")
+ @Min(value = 100, message = "高度不能低于 100")
+ @Schema(description = "转码条件: 视频高度(视频宽度, 默认随高度等比例缩放), 默认 720, 小于该值则不转码")
+ private Integer heightCond;
+
+ @Max(value = 120, message = "目标帧率不能超过 120")
+ @Min(value = 1, message = "目标帧率不能低于 1")
+ @Schema(description = "转码条件: 视频帧率, 默认 30 fps, 小于该值则不转码")
+ private Integer frameRateCond;
+
+ @Max(value = 1000000, message = "码率不能超过 1000000 kbps")
+ @Min(value = 100, message = "码率不能低于 100 kbps")
+ @Schema(description = "转码后的视频码率(kbps), 默认 2500 kbps")
+ private Integer bitrate;
+
+ @Max(value = 10000, message = "高度不能超过 10000")
+ @Min(value = 100, message = "高度不能低于 100")
+ @Schema(description = "转码后的视频高度(视频宽度, 默认随高度等比例缩放), 默认 720")
+ private Integer height;
+
+ @Max(value = 120, message = "目标帧率不能超过 120")
+ @Min(value = 1, message = "目标帧率不能低于 1")
+ @Schema(description = "转码后的视频帧率, 默认 30 fps")
+ private Double frameRate;
+
+ @Max(value = 256, message = "缩略图数量超过 256")
+ @Min(value = 1, message = "缩略图数量不能低于 1")
+ @Schema(description = "vtt缩略图数量, 默认 60 张")
+ private Integer vttThumbnailCount;
+
+ @Schema(description = "是否重新转码, 转码参数变化后对已经转码过的视频重新转码, 默认开启")
+ private Boolean isReTranscode;
+
+ public Boolean getEnable() {
+ if (enable == null)
+ return true;
+ return enable;
+ }
+
+ public Integer getHeight() {
+ if (height == null)
+ return 720;
+ return height;
+ }
+
+ public Integer getBitrate() {
+ if (bitrate == null)
+ return 2500;
+ return bitrate;
+ }
+
+ public Double getFrameRate() {
+ if (frameRate == null)
+ return 30.0;
+ return frameRate;
+ }
+
+ public Integer getMaxThreads() {
+ if (maxThreads == null)
+ return 1;
+ return maxThreads;
+ }
+
+ public Integer getBitrateCond() {
+ if (bitrateCond == null)
+ return 2500;
+ return bitrateCond;
+ }
+
+ public Integer getHeightCond() {
+ if (heightCond == null)
+ return 720;
+ return heightCond;
+ }
+
+ public Integer getFrameRateCond() {
+ if (frameRateCond == null)
+ return 30;
+ return frameRateCond;
+ }
+
+ public Integer getVttThumbnailCount() {
+ if (vttThumbnailCount == null)
+ return 60;
+ return vttThumbnailCount;
+ }
+
+ public Boolean getIsReTranscode() {
+ if (isReTranscode == null)
+ return true;
+ return isReTranscode;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/TranscodeStatus.java b/src/main/java/com/jmal/clouddisk/media/TranscodeStatus.java
new file mode 100644
index 0000000..a7337d1
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/TranscodeStatus.java
@@ -0,0 +1,29 @@
+package com.jmal.clouddisk.media;
+
+import lombok.Getter;
+
+/**
+ * 转码状态
+ */
+@Getter
+public enum TranscodeStatus {
+ /**
+ * 待转码
+ */
+ NOT_TRANSCODE(0),
+ /**
+ * 正在进行转码
+ */
+ TRANSCODING(1),
+ /**
+ * 已完成转码
+ */
+ TRANSCENDED(2);
+
+ private final int status;
+
+ TranscodeStatus(int status) {
+ this.status = status;
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/VideoInfo.java b/src/main/java/com/jmal/clouddisk/media/VideoInfo.java
new file mode 100644
index 0000000..2e9531f
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/VideoInfo.java
@@ -0,0 +1,83 @@
+package com.jmal.clouddisk.media;
+
+import cn.hutool.core.util.StrUtil;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+
+@Data
+@Slf4j
+public class VideoInfo {
+ private Integer width;
+ private Integer height;
+ private Integer bitrate;
+ private String format;
+ private Double frameRate;
+ private Integer duration;
+ private String covertPath;
+ /**
+ * 视频旋转角度
+ */
+ private Integer rotation;
+
+ /**
+ * 转码后的视频高度
+ */
+ private Integer toHeight;
+ /**
+ * 转码后的视频码率
+ */
+ private Integer toBitrate;
+ /**
+ * 转码后的视频帧率
+ */
+ private Double toFrameRate;
+
+ public VideoInfo() {
+ this.width = 0;
+ this.height = 0;
+ this.frameRate = 0.0;
+ this.format = "";
+ this.bitrate = 0;
+ this.duration = 0;
+ }
+
+ public VideoInfo(String videoPath, int width, int height, String format, int bitrate, int duration, int rotation, double frameRate) {
+ if (rotation == 90 || rotation == 270) {
+ this.width = height;
+ this.height = width;
+ } else {
+ this.width = width;
+ this.height = height;
+ }
+ this.format = format;
+ this.bitrate = bitrate;
+ this.duration = duration;
+ this.frameRate = frameRate;
+ this.rotation = rotation;
+ log.debug("\r\nvideoPath: {}, width: {}, height: {}, format: {}, bitrate: {}, duration: {}", videoPath, width, height, format, bitrate, duration);
+ }
+
+ public VideoInfoDO toVideoInfoDO() {
+ VideoInfoDO videoInfoDO = new VideoInfoDO();
+ if (this.bitrate > 0) {
+ videoInfoDO.setBitrateNum(this.bitrate);
+ videoInfoDO.setBitrate(VideoInfoUtil.convertBitrateToReadableFormat(this.bitrate));
+ }
+ if (StrUtil.isNotBlank(this.format)) {
+ videoInfoDO.setFormat(this.format);
+ }
+ if (this.duration > 0) {
+ videoInfoDO.setDurationNum(this.duration);
+ videoInfoDO.setDuration(VideoInfoUtil.formatTimestamp(this.duration, false));
+ }
+ if (this.height > 0 && this.width > 0) {
+ videoInfoDO.setHeight(this.height);
+ videoInfoDO.setWidth(this.width);
+ }
+ if (this.frameRate > 0) {
+ videoInfoDO.setFrameRate(this.frameRate);
+ }
+ return videoInfoDO;
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/VideoInfoDO.java b/src/main/java/com/jmal/clouddisk/media/VideoInfoDO.java
new file mode 100644
index 0000000..c669dbe
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/VideoInfoDO.java
@@ -0,0 +1,46 @@
+package com.jmal.clouddisk.media;
+
+import lombok.Data;
+
+@Data
+public class VideoInfoDO {
+ private Integer width;
+ private Integer height;
+ /**
+ * 视频码率
+ */
+ private String bitrate;
+ /**
+ * 视频码率数值
+ */
+ private Integer bitrateNum;
+ /**
+ * 视频格式
+ */
+ private String format;
+ /**
+ * 视频帧率
+ */
+ private Double frameRate;
+ /**
+ * 视频时长
+ */
+ private String duration;
+ /**
+ * 视频时长数值(秒)
+ */
+ private Integer durationNum;
+
+ /**
+ * 转码后的视频高度
+ */
+ private Integer toHeight;
+ /**
+ * 转码后的视频码率
+ */
+ private Integer toBitrate;
+ /**
+ * 转码后的视频帧率
+ */
+ private Double toFrameRate;
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/VideoInfoUtil.java b/src/main/java/com/jmal/clouddisk/media/VideoInfoUtil.java
new file mode 100644
index 0000000..379c356
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/VideoInfoUtil.java
@@ -0,0 +1,46 @@
+package com.jmal.clouddisk.media;
+
+public class VideoInfoUtil {
+
+ /**
+ * 将比特率转换为可读的格式
+ * @param bitrate 比特率
+ * @return 可读的比特率格式
+ */
+ public static String convertBitrateToReadableFormat(long bitrate) {
+ if (bitrate < 1000) {
+ return bitrate + " bps";
+ } else if (bitrate < 1000000) {
+ return String.format("%.2f Kbps", bitrate / 1000.0);
+ } else {
+ return String.format("%.2f Mbps", bitrate / 1000000.0);
+ }
+ }
+
+ /**
+ * 将视频时长换为可读的格式
+ * @param timestamp 视频时长(秒)
+ * @param milliseconds 是否包含毫秒
+ * @return 可读的视频时长格式
+ */
+ public static String formatTimestamp(int timestamp, boolean milliseconds) {
+ int hours = timestamp / 3600;
+ int minutes = (timestamp % 3600) / 60;
+ int seconds = timestamp % 60;
+ if (milliseconds) {
+ return String.format("%02d:%02d:%02d.000", hours, minutes, seconds);
+ } else {
+ return String.format("%02d:%02d:%02d", hours, minutes, seconds);
+ }
+ }
+
+ public static void main(String[] args) {
+ long bitrate = 200000; // 示例比特率
+ String readableBitrate = convertBitrateToReadableFormat(bitrate);
+ System.out.println("可读的比特率格式为: " + readableBitrate);
+ int duration = 3600; // 示例视频时长
+ String readableDuration = formatTimestamp(duration, false);
+ System.out.println("可读的视频时长格式为: " + readableDuration);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/media/VideoProcessService.java b/src/main/java/com/jmal/clouddisk/media/VideoProcessService.java
new file mode 100644
index 0000000..ae41144
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/media/VideoProcessService.java
@@ -0,0 +1,733 @@
+package com.jmal.clouddisk.media;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.core.util.BooleanUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.lucene.TaskProgressService;
+import com.jmal.clouddisk.lucene.TaskType;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.oss.IOssService;
+import com.jmal.clouddisk.oss.OssConfigService;
+import com.jmal.clouddisk.oss.web.WebOssService;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.jmal.clouddisk.service.impl.CommonFileService;
+import com.jmal.clouddisk.util.CaffeineUtil;
+import com.mongodb.client.AggregateIterable;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.bson.Document;
+import org.bson.conversions.Bson;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.stereotype.Service;
+
+import java.io.*;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static com.jmal.clouddisk.util.FFMPEGUtils.*;
+
+@Service
+@Lazy
+@Slf4j
+public class VideoProcessService {
+
+ @Autowired
+ private FileProperties fileProperties;
+
+ @Autowired
+ private IUserService userService;
+
+ @Autowired
+ private CommonFileService commonFileService;
+
+ @Autowired
+ private TaskProgressService taskProgressService;
+
+ @Resource
+ MongoTemplate mongoTemplate;
+
+ /**
+ * 视频转码线程池
+ */
+ private ExecutorService videoTranscodingService;
+
+ /**
+ * 视频转码任务
+ */
+ private final static Map> videoTranscodingTasks = new ConcurrentHashMap<>();
+ private final static Set processesTasks = new CopyOnWriteArraySet<>();
+
+ /**
+ * 添加转码任务线程池
+ */
+ private ExecutorService addTranscodingTaskService;
+
+ /**
+ * 正在转码的视频文件数
+ */
+ private final static AtomicInteger transcodingCount = new AtomicInteger(0);
+
+ /**
+ * 等待转码的视频文件数
+ */
+ private final static AtomicInteger waitingTranscodingCount = new AtomicInteger(0);
+
+ /**
+ * 处理待转码文件锁, 防止多次处理
+ */
+ private final ReentrantLock toBeTranscodeLock = new ReentrantLock();
+
+ private final static String TRANSCODE_VIDEO = "transcodeVideo";
+
+ @PostConstruct
+ public void init() {
+ getVideoTranscodingService();
+ addTranscodingTaskService = ThreadUtil.newFixedExecutor(8, 40960, "addTranscodingTask", true);
+ }
+
+ private void getVideoTranscodingService() {
+ TranscodeConfig transcodeConfig = getTranscodeConfig();
+ int processors = transcodeConfig.getMaxThreads();
+ synchronized (this) {
+ if (videoTranscodingService == null) {
+ createExecutor(processors);
+ return;
+ }
+ }
+ if (videoTranscodingService.isShutdown()) {
+ createExecutor(processors);
+ } else {
+ try {
+ videoTranscodingService.shutdown();
+ if (!videoTranscodingService.awaitTermination(180, TimeUnit.MINUTES)) {
+ log.warn("等待转码超时, 尝试强制停止所有转码任务");
+ videoTranscodingService.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ videoTranscodingService.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ createExecutor(processors);
+ }
+ }
+
+ private void createExecutor(int processors) {
+ waitingTranscodingCount.set(0);
+ transcodingCount.set(0);
+ videoTranscodingService = ThreadUtil.newFixedExecutor(processors, 1, "videoTranscoding", true);
+ }
+
+ /**
+ * 取消转码任务
+ */
+ public void cancelTranscodeTask() {
+ // 修改所有未转码的视频文件状态
+ Query query = new Query();
+ query.addCriteria(Criteria.where(TRANSCODE_VIDEO).is(TranscodeStatus.NOT_TRANSCODE.getStatus()));
+ Update update = new Update();
+ update.unset(TRANSCODE_VIDEO);
+ mongoTemplate.updateMulti(query, update, CommonFileService.COLLECTION_NAME);
+ // 取消process任务
+ processesTasks.forEach(p -> {
+ if (p.isAlive()) {
+ p.destroy();
+ }
+ });
+ processesTasks.clear();
+
+ // 取消转码任务
+ videoTranscodingTasks.forEach((fileId, future) -> {
+ if (!future.isDone()) {
+ future.cancel(true);
+ }
+ });
+ videoTranscodingTasks.clear();
+ videoTranscodingService.shutdownNow();
+ getVideoTranscodingService();
+ // 等待 waitingTranscodingCount < 0 且 transcodingCount < 0
+ while (waitingTranscodingCount.get() > 0 || transcodingCount.get() > 0) {
+ try {
+ TimeUnit.MILLISECONDS.sleep(50);
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ }
+ }
+ // 更新转码状态
+ taskProgressService.pushTranscodeStatus(getTranscodeStatus());
+ }
+
+ /**
+ * 设置转码配置
+ *
+ * @param config TranscodeConfig
+ */
+ public long setTranscodeConfig(TranscodeConfig config) {
+ if (config == null) {
+ return 0;
+ }
+ Query query = new Query();
+ TranscodeConfig tc = mongoTemplate.findOne(query, TranscodeConfig.class);
+ if (tc == null) {
+ mongoTemplate.save(config);
+ } else {
+ Update update = getTranscodeConfigUpdate(config);
+ mongoTemplate.updateFirst(query, update, TranscodeConfig.class);
+ if (!ObjectUtil.equals(tc.getMaxThreads(), config.getMaxThreads())) {
+ // 重新加载转码线程池
+ addTranscodingTaskService.execute(this::getVideoTranscodingService);
+ }
+ // 检查是否需要重新转码
+ if (BooleanUtil.isTrue(config.getIsReTranscode())) {
+ // 检查转码参数是否变化
+ return checkTranscodeConfigChange(config);
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * 检查转码参数是否变化
+ *
+ * @param config 新参数
+ */
+ private long checkTranscodeConfigChange(TranscodeConfig config) {
+ // 更新所有视频文件的转码状态
+ List fileIdList = getTranscodeConfigQuery(config);
+ Query query = new Query();
+ query.addCriteria(Criteria.where("_id").in(fileIdList));
+ Update update = new Update();
+ update.set(TRANSCODE_VIDEO, TranscodeStatus.NOT_TRANSCODE.getStatus());
+ UpdateResult updateResult = mongoTemplate.updateMulti(query, update, CommonFileService.COLLECTION_NAME);
+ if (updateResult.getModifiedCount() > 0) {
+ log.info("需要重新转码的数量: {}", updateResult.getModifiedCount());
+ createExecutor(config.getMaxThreads());
+ // 重新转码
+ startProcessFilesToBeIndexed();
+ return updateResult.getModifiedCount();
+ }
+ return 0;
+ }
+
+ private List getTranscodeConfigQuery(TranscodeConfig config) {
+ List pipeline = Arrays.asList(new Document("$match",
+ new Document("video",
+ new Document("$exists", true))),
+ new Document("$match",
+ new Document("$or", Arrays.asList(
+ new Document("video.height",
+ new Document("$exists", false)),
+ new Document("video.height",
+ new Document("$gt", config.getHeightCond())),
+ new Document("video.bitrateNum",
+ new Document("$gt", config.getBitrateCond() * 1000)),
+ new Document("video.frameRate",
+ new Document("$gt", config.getFrameRateCond()))))),
+ new Document("$match",
+ new Document("$or", Arrays.asList(new Document("video.toHeight",
+ new Document("$ne", config.getHeight())),
+ new Document("video.toBitrate",
+ new Document("$ne", config.getBitrate())),
+ new Document("video.toFrameRate",
+ new Document("$ne", config.getFrameRate()))))),
+ new Document("$project",
+ new Document("_id", 1L)));
+ List fileIdList = new ArrayList<>();
+ AggregateIterable aggregateIterable = mongoTemplate.getCollection(CommonFileService.COLLECTION_NAME).aggregate(pipeline);
+ for (org.bson.Document document : aggregateIterable) {
+ String fileId = document.getObjectId("_id").toHexString();
+ fileIdList.add(fileId);
+ }
+ return fileIdList;
+ }
+
+ private static @NotNull Update getTranscodeConfigUpdate(TranscodeConfig config) {
+ Update update = new Update();
+ update.set("enable", config.getEnable());
+ update.set("maxThreads", config.getMaxThreads());
+ update.set("bitrate", config.getBitrate());
+ update.set("height", config.getHeight());
+ update.set("frameRate", config.getFrameRate());
+ update.set("bitrateCond", config.getBitrateCond());
+ update.set("heightCond", config.getHeightCond());
+ update.set("frameRateCond", config.getFrameRateCond());
+ update.set("vttThumbnailCount", config.getVttThumbnailCount());
+ update.set("isReTranscode", config.getIsReTranscode());
+ return update;
+ }
+
+ public TranscodeConfig getTranscodeConfig() {
+ Query query = new Query();
+ TranscodeConfig config = mongoTemplate.findOne(query, TranscodeConfig.class);
+ if (config == null) {
+ config = new TranscodeConfig();
+ }
+ return config;
+ }
+
+ /**
+ * 设置转码状态
+ *
+ * @param fileId fileId
+ * @param transcodeStatus TranscodeStatus
+ */
+ private void updateTranscodeVideo(String fileId, TranscodeStatus transcodeStatus) {
+ // 设置未转码标记
+ Query query = new Query();
+ query.addCriteria(Criteria.where("_id").is(fileId));
+ Update update = new Update();
+ if (transcodeStatus == TranscodeStatus.TRANSCENDED) {
+ update.unset(TRANSCODE_VIDEO);
+ } else {
+ update.set(TRANSCODE_VIDEO, transcodeStatus.getStatus());
+ }
+ mongoTemplate.updateFirst(query, update, CommonFileService.COLLECTION_NAME);
+ }
+
+ public Map getTranscodeStatus() {
+ return Map.of("waitingTranscodingCount", waitingTranscodingCount.get(), "transcodingCount", transcodingCount.get());
+ }
+
+ /**
+ * 处理待转码文件
+ */
+ public void processingToBeTranscode() {
+ boolean run = true;
+ log.debug("开始处理待转码文件");
+ while (run) {
+ Query query = new Query();
+ query.addCriteria(Criteria.where(TRANSCODE_VIDEO).is(TranscodeStatus.NOT_TRANSCODE.getStatus()));
+ long count = mongoTemplate.count(query, CommonFileService.COLLECTION_NAME);
+ // 更新待转码文件数量
+ waitingTranscodingCount.set((int) count);
+ // 更新转码状态
+ taskProgressService.pushTranscodeStatus(getTranscodeStatus());
+ if (count == 0) {
+ log.debug("待转码文件处理完成");
+ run = false;
+ }
+ query.limit(1);
+ List fileDocumentList = mongoTemplate.find(query, FileDocument.class, CommonFileService.COLLECTION_NAME);
+ for (FileDocument fileDocument : fileDocumentList) {
+ String fileId = fileDocument.getId();
+ String username = userService.getUserNameById(fileDocument.getUserId());
+ String relativePath = fileDocument.getPath();
+ String fileName = fileDocument.getName();
+ updateTranscodeVideo(fileId, TranscodeStatus.TRANSCODING);
+ Future> videoTranscodingTask = videoTranscodingService.submit(() -> doConvertToM3U8(fileId, username, relativePath, fileName));
+ videoTranscodingTasks.put(fileId, videoTranscodingTask);
+ }
+ }
+ }
+
+ private void startProcessFilesToBeIndexed() {
+ addTranscodingTaskService.execute(() -> {
+ if (!toBeTranscodeLock.tryLock()) {
+ return;
+ }
+ try {
+ processingToBeTranscode();
+ } finally {
+ toBeTranscodeLock.unlock();
+ }
+ });
+ }
+
+ public void convertToM3U8(String fileId) {
+ addTranscodingTaskService.execute(() -> {
+ try {
+ TimeUnit.SECONDS.sleep(5);
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ }
+ updateTranscodeVideo(fileId, TranscodeStatus.NOT_TRANSCODE);
+ startProcessFilesToBeIndexed();
+ });
+ }
+
+ /**
+ * 转码视频
+ *
+ * @param fileId fileId
+ * @param username username
+ * @param relativePath relativePath
+ * @param fileName fileName
+ */
+ private void doConvertToM3U8(String fileId, String username, String relativePath, String fileName) {
+ try {
+ // 更新正在转码的视频文件数
+ transcodingCount.incrementAndGet();
+ waitingTranscodingCount.decrementAndGet();
+ // 更新转码状态
+ taskProgressService.pushTranscodeStatus(getTranscodeStatus());
+ // 开始转码
+ videoToM3U8(fileId, username, relativePath, fileName, false);
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (IOException e) {
+ log.error(e.getMessage(), e);
+ } finally {
+ // 更新正在转码的视频文件数
+ transcodingCount.decrementAndGet();
+ // 更新转码状态
+ taskProgressService.pushTranscodeStatus(getTranscodeStatus());
+ updateTranscodeVideo(fileId, TranscodeStatus.TRANSCENDED);
+ videoTranscodingTasks.remove(fileId);
+ }
+ }
+
+ public void deleteVideoCacheByIds(String username, List fileIds) {
+ fileIds.forEach(fileId -> deleteVideoCacheById(username, fileId));
+ }
+
+ public void deleteVideoCacheById(String username, String fileId) {
+ String videoCacheDir = getVideoCacheDir(username, fileId);
+ if (FileUtil.exist(videoCacheDir)) {
+ FileUtil.del(videoCacheDir);
+ }
+ }
+
+ public VideoInfo getVideoInfo(File videoFile) {
+ VideoInfo videoInfo = new VideoInfo();
+ if (FFMPEGCommand.hasNoFFmpeg()) {
+ return videoInfo;
+ }
+ if (!videoFile.exists()) {
+ return videoInfo;
+ }
+ videoInfo = FFMPEGCommand.getVideoInfo(videoFile.getAbsolutePath());
+ return videoInfo;
+ }
+
+ public VideoInfo getVideoCover(String fileId, String username, String relativePath, String fileName) {
+ VideoInfo videoInfo = new VideoInfo();
+ if (FFMPEGCommand.hasNoFFmpeg()) {
+ return videoInfo;
+ }
+ Path prePath = Paths.get(username, relativePath, fileName);
+ String ossPath = CaffeineUtil.getOssPath(prePath);
+ Path fileAbsolutePath = Paths.get(fileProperties.getRootDir(), username, relativePath, fileName);
+ String videoCacheDir = getVideoCacheDir(username, "");
+ // 判断fileId是否为path, 如果为path则取最后一个
+ if (fileId.contains("/")) {
+ fileId = fileId.substring(fileId.lastIndexOf("/") + 1);
+ }
+ String outputPath = Paths.get(videoCacheDir, fileId + ".png").toString();
+ videoInfo.setCovertPath(outputPath);
+ if (FileUtil.exist(outputPath)) {
+ return videoInfo;
+ }
+ try {
+ String videoPath = fileAbsolutePath.toString();
+ if (ossPath != null) {
+ IOssService ossService = OssConfigService.getOssStorageService(ossPath);
+ String objectName = WebOssService.getObjectName(prePath, ossPath, false);
+ URL url = ossService.getPresignedObjectUrl(objectName, 60);
+ if (url != null) {
+ videoPath = url.toString();
+ }
+ }
+ if (FileUtil.exist(outputPath)) {
+ return videoInfo;
+ }
+ videoInfo = FFMPEGCommand.getVideoInfo(videoPath);
+ videoInfo.setCovertPath(outputPath);
+ int videoDuration = videoInfo.getDuration();
+ ProcessBuilder processBuilder = FFMPEGCommand.getVideoCoverProcessBuilder(videoPath, outputPath, videoDuration);
+ printSuccessInfo(processBuilder);
+ // 等待处理结果
+ Process process = processBuilder.start();
+ outputPath = getWaitingForResults(outputPath, processBuilder, process);
+ if (outputPath != null) {
+ return videoInfo;
+ }
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ return videoInfo;
+ } catch (IOException e) {
+ log.error(e.getMessage(), e);
+ }
+ return videoInfo;
+ }
+
+ /**
+ * 获取视频文件缓存目录
+ *
+ * @param username username
+ * @param fileId fileId
+ * @return 视频文件缓存目录
+ */
+ public String getVideoCacheDir(String username, String fileId) {
+ // 视频文件缓存目录
+ String videoCacheDir = Paths.get(fileProperties.getRootDir(), fileProperties.getChunkFileDir(), username, fileProperties.getVideoTranscodeCache(), fileId).toString();
+ if (!FileUtil.exist(videoCacheDir)) {
+ FileUtil.mkdir(videoCacheDir);
+ }
+ return videoCacheDir;
+ }
+
+ private void videoToM3U8(String fileId, String username, String relativePath, String fileName, boolean onlyCPU) throws IOException, InterruptedException {
+ TranscodeConfig transcodeConfig = getTranscodeConfig();
+ if (BooleanUtil.isFalse(transcodeConfig.getEnable())) {
+ return;
+ }
+ Path fileAbsolutePath = Paths.get(fileProperties.getRootDir(), username, relativePath, fileName);
+ // 视频文件缓存目录
+ String videoCacheDir = getVideoCacheDir(username, fileId);
+ if (FFMPEGCommand.hasNoFFmpeg()) {
+ return;
+ }
+ String outputPath = Paths.get(videoCacheDir, fileId + ".m3u8").toString();
+ if (FileUtil.exist(outputPath) && !checkTranscodeChange(fileId, transcodeConfig)) {
+ return;
+ }
+ // 获取原始视频的分辨率和码率信息
+ VideoInfo videoInfo = FFMPEGCommand.getVideoInfo(fileAbsolutePath.toString());
+ // 判断是否需要转码
+ if (!FFMPEGUtils.needTranscode(videoInfo, transcodeConfig)) {
+ return;
+ }
+ // 如果视频的码率小于配置码率,则使用视频的原始码率
+ int SD_VIDEO_BITRATE = transcodeConfig.getBitrate() * 1000;
+ int bitrate = SD_VIDEO_BITRATE;
+ if (videoInfo.getBitrate() < SD_VIDEO_BITRATE && videoInfo.getBitrate() > 0) {
+ bitrate = videoInfo.getBitrate();
+ }
+ // 如果视频的高度小于配置高度,则使用视频的原始高度
+ int targetHeight = transcodeConfig.getHeight();
+ if (videoInfo.getHeight() < targetHeight) {
+ targetHeight = videoInfo.getHeight();
+ }
+ // 如果视频的帧率小于配置帧率,则使用视频的原始帧率
+ double frameRate = transcodeConfig.getFrameRate();
+ if (videoInfo.getFrameRate() < frameRate) {
+ frameRate = videoInfo.getFrameRate();
+ }
+
+ // 计算缩略图间隔
+ int vttInterval = FFMPEGUtils.getVttInterval(videoInfo, transcodeConfig.getVttThumbnailCount());
+ Path vttPath = Paths.get(videoCacheDir, "vtt");
+ if (!Files.exists(vttPath)) {
+ FileUtil.mkdir(vttPath);
+ }
+ String thumbnailPattern = Paths.get(vttPath.toString(), "thumb_%03d.png").toString();
+
+ ProcessBuilder processBuilder = FFMPEGCommand.cpuTranscoding(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern, frameRate);
+ if (!onlyCPU && FFMPEGCommand.checkNvidiaDrive()) {
+ log.info("use NVENC hardware acceleration");
+ processBuilder = FFMPEGCommand.useNvencCuda(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, frameRate);
+ generateVttOfNvidia(fileAbsolutePath, vttInterval, thumbnailPattern, videoInfo);
+ }
+ if (!onlyCPU && FFMPEGCommand.checkMacAppleSilicon()) {
+ log.info("use videotoolbox hardware acceleration");
+ processBuilder = FFMPEGCommand.useVideotoolbox(fileId, fileAbsolutePath, bitrate, targetHeight, videoCacheDir, outputPath, vttInterval, thumbnailPattern, frameRate);
+ }
+ processBuilder.redirectErrorStream(true);
+ Process process = processBuilder.start();
+ processesTasks.add(process);
+ boolean pushMessage = false;
+ // 第一个ts文件
+ String firstTS = fileId + "-001.ts";
+ try (InputStream inputStream = process.getInputStream();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ // 读取命令的输出信息
+ String line;
+ while ((line = reader.readLine()) != null) {
+ // 处理命令的输出信息,例如打印到控制台
+ if (line.contains("Error")) {
+ log.error(line);
+ }
+ if (line.contains(firstTS)) {
+ // 开始转码
+ // log.info("开始转码: {}", fileName);
+ startConvert(username, relativePath, fileName, fileId, transcodeConfig);
+ pushMessage = true;
+ }
+ transcodingProgress(fileAbsolutePath, videoInfo.getDuration(), line, "");
+ }
+
+ // 生成vtt缩略图
+ generateVtt(fileId, videoCacheDir, videoInfo, vttInterval, thumbnailPattern);
+
+ int exitCode = process.waitFor();
+ if (exitCode == 0) {
+ printSuccessInfo(processBuilder);
+ log.info("转码成功: {}, onlyCPU: {}", fileName, onlyCPU);
+ if (BooleanUtil.isFalse(pushMessage)) {
+ startConvert(username, relativePath, fileName, fileId, transcodeConfig);
+ }
+ } else {
+ if (!onlyCPU) {
+ videoToM3U8(fileId, username, relativePath, fileName, true);
+ }
+ printErrorInfo(processBuilder, process);
+ }
+
+ } catch (InterruptedException exception) {
+ log.warn("转码被中断: {}", fileName);
+ Thread.currentThread().interrupt();
+ } finally {
+ taskProgressService.removeTaskProgress(fileAbsolutePath.toFile());
+ processesTasks.remove(process);
+ }
+ }
+
+ private void generateVttOfNvidia(Path fileAbsolutePath, int vttInterval, String thumbnailPattern, VideoInfo videoInfo) throws IOException {
+ // 生成vtt缩略图, nvidia加速时要单独生成vtt缩略图
+ taskProgressService.addTaskProgress(fileAbsolutePath.toFile(), TaskType.TRANSCODE_VIDEO, "vtt生成中...");
+ ProcessBuilder processBuilder = FFMPEGCommand.useNvencCudaVtt(fileAbsolutePath, vttInterval, thumbnailPattern);
+ processBuilder.redirectErrorStream(true);
+ Process process = processBuilder.start();
+ processesTasks.add(process);
+ // 第一个ts文件
+ try (InputStream inputStream = process.getInputStream();
+ BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ // 读取命令的输出信息
+ String line;
+ while ((line = reader.readLine()) != null) {
+ // 处理命令的输出信息,例如打印到控制台
+ if (line.contains("Error")) {
+ log.error(line);
+ }
+ transcodingProgress(fileAbsolutePath, videoInfo.getDuration(), line, "vtt—");
+ }
+ int exitCode = process.waitFor();
+ if (exitCode == 0) {
+ printSuccessInfo(processBuilder);
+ } else {
+ printErrorInfo(processBuilder, process);
+ }
+ } catch (InterruptedException exception) {
+ log.warn("生成vtt缩略图被中断: {}", fileAbsolutePath.getFileName());
+ Thread.currentThread().interrupt();
+ } finally {
+ processesTasks.remove(process);
+ }
+ }
+
+ /**
+ * 检查转码参数是否变化
+ *
+ * @param fileId 文件id
+ * @param config 转码配置
+ * @return 是否变化
+ */
+ private boolean checkTranscodeChange(String fileId, TranscodeConfig config) {
+ Query query = new Query();
+ query.addCriteria(Criteria.where("_id").is(fileId));
+ FileDocument fileDocument = mongoTemplate.findOne(query, FileDocument.class);
+ if (fileDocument == null) {
+ return true;
+ }
+ VideoInfoDO videoInfo = fileDocument.getVideo();
+ if (videoInfo == null) {
+ return true;
+ }
+ return !Objects.equals(videoInfo.getToHeight(), config.getHeight()) || !Objects.equals(videoInfo.getToBitrate(), config.getBitrate()) || !Objects.equals(videoInfo.getToFrameRate(), config.getFrameRate());
+ }
+
+ private void generateVtt(String fileId, String videoCacheDir, VideoInfo videoInfo, int vttInterval, String thumbnailPattern) throws InterruptedException, IOException {
+ String vttFilePath = Paths.get(videoCacheDir, fileId + ".vtt").toString();
+ int columns = 10; // 合并图像的列数
+ int rows = (int) Math.ceil((double) videoInfo.getDuration() / vttInterval / columns);
+ String thumbnailImagePath = Paths.get(videoCacheDir, fileId + "-vtt.jpg").toString();
+ ProcessBuilder processBuilder = FFMPEGCommand.mergeVTT(thumbnailPattern, thumbnailImagePath, columns, rows);
+ // 等待处理结果
+ Process process = processBuilder.start();
+ processesTasks.add(process);
+ try {
+ String outputPath = getWaitingForResults(thumbnailImagePath, processBuilder, process);
+ if (outputPath != null) {
+ log.info("生成vtt缩略图成功: {}", fileId);
+ printSuccessInfo(processBuilder);
+ FFMPEGUtils.generateVTT(videoInfo, vttInterval, vttFilePath, String.format("%s-vtt.jpg", fileId), columns);
+ // 删除vtt临时文件
+ Path vttPath = Paths.get(videoCacheDir, "vtt");
+ FileUtil.del(vttPath);
+ } else {
+ log.error("生成vtt缩略图失败: {}", fileId);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ processesTasks.remove(process);
+ }
+ }
+
+ /**
+ * 解析转码进度 0
+ *
+ * @param fileAbsolutePath 视频文件绝对路径
+ * @param videoDuration 视频时长
+ * @param line 命令输出信息
+ */
+ private void transcodingProgress(Path fileAbsolutePath, int videoDuration, String line, String desc) {
+ // 解析转码进度
+ if (line.contains("time=")) {
+ try {
+ if (line.contains(":")) {
+ double progress = FFMPEGUtils.getProgressStr(videoDuration, line);
+ log.debug("{}, 转码进度: {}%", fileAbsolutePath.getFileName(), progress);
+ taskProgressService.addTaskProgress(fileAbsolutePath.toFile(), TaskType.TRANSCODE_VIDEO, desc + progress + "%");
+ }
+ } catch (Exception e) {
+ log.warn(e.getMessage(), e);
+ }
+ }
+ }
+
+ private void startConvert(String username, String relativePath, String fileName, String fileId, TranscodeConfig transcodeConfig) {
+ Query query = new Query();
+ String userId = userService.getUserIdByUserName(username);
+ query.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+ query.addCriteria(Criteria.where("path").is(relativePath));
+ query.addCriteria(Criteria.where("name").is(fileName));
+ Update update = new Update();
+ String m3u8 = Paths.get(username, fileId + ".m3u8").toString();
+ update.set("m3u8", m3u8);
+ String vtt = Paths.get(username, fileId + ".vtt").toString();
+ update.set("vtt", vtt);
+
+ update.set("video.toHeight", transcodeConfig.getHeight());
+ update.set("video.toBitrate", transcodeConfig.getBitrate());
+ update.set("video.toFrameRate", transcodeConfig.getFrameRate());
+
+ FileDocument fileDocument = mongoTemplate.findOne(query, FileDocument.class);
+ if (fileDocument == null) {
+ return;
+ }
+ mongoTemplate.upsert(query, update, FileDocument.class);
+ fileDocument.setM3u8(m3u8);
+ commonFileService.pushMessage(username, fileDocument, Constants.UPDATE_FILE);
+ }
+
+ @PreDestroy
+ private void destroy() {
+ if (videoTranscodingService != null) {
+ videoTranscodingService.shutdown();
+ }
+ if (addTranscodingTaskService != null) {
+ addTranscodingTaskService.shutdown();
+ }
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/ArchivesVO.java b/src/main/java/com/jmal/clouddisk/model/ArchivesVO.java
new file mode 100644
index 0000000..f3553f5
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/ArchivesVO.java
@@ -0,0 +1,43 @@
+package com.jmal.clouddisk.model;
+
+import com.jmal.clouddisk.util.TimeUntils;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.Date;
+
+/**
+ * @Description 归档展示
+ * @blame jmal
+ * @Date 2020/11/15 7:33 下午
+ */
+@Data
+public class ArchivesVO {
+
+ /***
+ * 归档id
+ */
+ private String id;
+ /***
+ * 归档名称
+ */
+ private String name;
+ /***
+ * 显示时间
+ */
+ private Date date;
+ /***
+ * 归档缩略名
+ */
+ private String slug;
+
+ public String date(){
+ LocalDateTime localDateTime = date.toInstant().atZone(TimeUntils.ZONE_ID).toLocalDateTime();
+ return localDateTime.format(TimeUntils.DATE_MONTH);
+ }
+
+ public String dateTime(){
+ LocalDateTime localDateTime = date.toInstant().atZone(TimeUntils.ZONE_ID).toLocalDateTime();
+ return localDateTime.format(TimeUntils.DATE_DAY);
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/ArticleDTO.java b/src/main/java/com/jmal/clouddisk/model/ArticleDTO.java
new file mode 100644
index 0000000..7e4beac
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/ArticleDTO.java
@@ -0,0 +1,53 @@
+package com.jmal.clouddisk.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+
+import jakarta.validation.Valid;
+
+/**
+ * @author jmal
+ * @Description 文章DTO
+ * @Date 2020/11/26 11:13 上午
+ */
+@Data
+@Schema
+@Valid
+public class ArticleDTO {
+
+ @Schema(name = "mark", title = "文章id")
+ String mark;
+
+ @Schema(name = "userId", title = "用户Id")
+ String userId;
+
+ @Schema(name = "isRelease", title = "是否发布")
+ Boolean isRelease;
+
+ @Schema(name = "isAlonePage", title = "是否为独立页面")
+ Boolean isAlonePage;
+
+ @Schema(name = "isDraft", title = "是否为草稿")
+ Boolean isDraft;
+
+ @Schema(name = "keyword", title = "关键字")
+ String keyword;
+
+ @Schema(name = "categoryIds", title = "分类id集合")
+ String[] categoryIds;
+
+ @Schema(name = "tagIds", title = "标签id集合")
+ String[] tagIds;
+
+ @Schema(name = "sortableProp", title = "排序字段")
+ String sortableProp;
+
+ @Schema(name = "order", title = "排序的顺序")
+ String order;
+
+ @Schema(hidden = true, name = "pageIndex", title = "当前页数")
+ Integer pageIndex;
+ @Schema(hidden = true, name = "pageSize", title = "每页条数")
+ Integer pageSize;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/ArticleParamDTO.java b/src/main/java/com/jmal/clouddisk/model/ArticleParamDTO.java
new file mode 100644
index 0000000..7cc65a9
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/ArticleParamDTO.java
@@ -0,0 +1,71 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jmal.clouddisk.util.FileNameUtils;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+
+/**
+ * @author jmal
+ * @Description 文章(添加/修改)DTO
+ * @Date 2020/12/9 10:23 上午
+ */
+@Schema
+@Data
+@Valid
+public class ArticleParamDTO {
+
+ @NotNull(message = "userId不能为空")
+ String userId;
+ @NotNull(message = "username不能为空")
+ String username;
+ @NotNull(message = "filename不能为空")
+ String filename;
+ String fileId;
+ @NotNull(message = "contentText不能为空")
+ @Schema(name = "contentText", title = "markdown内容", requiredMode = Schema.RequiredMode.REQUIRED)
+ String contentText;
+ @Schema(name = "html", title = "html内容")
+ String html;
+ @Schema(name = "currentDirectory", title = "当前目录,用户的网盘目录,如果为空则为'/'")
+ String currentDirectory;
+ @Schema(name = "updateDate", title = "更新时间")
+ LocalDateTime updateDate;
+ @Schema(name = "isAlonePage", title = "是否为独立页面")
+ Boolean isAlonePage;
+ @Schema(name = "pageSort", title = "页面顺序")
+ Integer pageSort;
+ @Schema(name = "isDraft", title = "是否为草稿")
+ Boolean isDraft;
+ @Schema(name = "isRelease", title = "是否发布")
+ Boolean isRelease;
+ @Schema(name = "cover", title = "文章封面")
+ String cover;
+ @Schema(name = "cover", title = "文章缩略名")
+ String slug;
+ @Schema(name = "categoryIds", title = "分类Id集合")
+ String[] categoryIds;
+ @Schema(name = "tagNames", title = "标签名称集合")
+ String[] tagNames;
+ @Schema(name = "uploadDate", title = "更新时间")
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime uploadDate;
+
+ public String getFilename() {
+ return FileNameUtils.safeDecode(filename);
+ }
+
+ public String getCurrentDirectory() {
+ if (currentDirectory == null || "undefined".equals(currentDirectory)) {
+ return null;
+ }
+ return FileNameUtils.safeDecode(currentDirectory);
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/ArticleVO.java b/src/main/java/com/jmal/clouddisk/model/ArticleVO.java
new file mode 100644
index 0000000..2d0023a
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/ArticleVO.java
@@ -0,0 +1,31 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * @Description 文章展示
+ * @blame jmal
+ * @Date 2020/11/15 7:33 下午
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ArticleVO extends MarkdownBaseFile {
+ /***
+ * 文件内容
+ */
+ private String contentText;
+ /***
+ * html内容
+ */
+ private String html;
+ /***
+ * 是否为独立页,适用于文档类型
+ */
+ private Boolean alonePage;
+ /***
+ * 是否可编辑
+ */
+ private Boolean editable;
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/ArticlesQueryVO.java b/src/main/java/com/jmal/clouddisk/model/ArticlesQueryVO.java
new file mode 100644
index 0000000..e126e28
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/ArticlesQueryVO.java
@@ -0,0 +1,20 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+
+/**
+ * @author jmal
+ * @Description 文章查询页简要信息
+ * @Date 2020/12/16 3:12 下午
+ */
+ @Data
+public class ArticlesQueryVO {
+ /***
+ * 文章标题名
+ */
+ String name;
+ /***
+ * 文章背景
+ */
+ String background;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/CategoryDO.java b/src/main/java/com/jmal/clouddisk/model/CategoryDO.java
new file mode 100644
index 0000000..c26f809
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/CategoryDO.java
@@ -0,0 +1,67 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import org.springframework.data.annotation.Id;
+
+import java.text.Collator;
+import java.util.Comparator;
+
+/**
+ * @author jmal
+ * @Description 类别(自定类别)
+ * @Date 2020/10/26 4:19 下午
+ */
+@Data
+public class CategoryDO implements Comparable {
+ @Id
+ private String id;
+ /***
+ * 用户Id
+ */
+ private String userId;
+ /***
+ * 分类名称
+ */
+ private String name;
+ /***
+ * 缩略名,默认为name
+ */
+ private String slug;
+ /***
+ * 父级分类Id
+ */
+ private String parentCategoryId;
+ /***
+ * 是否为默认分类
+ */
+ private Boolean isDefault;
+ /***
+ * 分类描述
+ */
+ private String desc;
+ /***
+ * 分类背景
+ */
+ String categoryBackground;
+ /***
+ * 按分类名称排序
+ * @param categoryDO Category
+ * @return int
+ */
+ @Override
+ public int compareTo(CategoryDO categoryDO) {
+ Comparator cmp = Collator.getInstance(java.util.Locale.CHINA);
+ return cmp.compare(getName(), categoryDO.getName());
+ }
+
+ /***
+ * 按类别显示的文章查询页简要信息
+ * @return 文章查询页简要信息
+ */
+ public ArticlesQueryVO toArticlesQuery(){
+ ArticlesQueryVO articlesQueryVO = new ArticlesQueryVO();
+ articlesQueryVO.setBackground(categoryBackground);
+ articlesQueryVO.setName("分类 - "+name);
+ return articlesQueryVO;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/CategoryDTO.java b/src/main/java/com/jmal/clouddisk/model/CategoryDTO.java
new file mode 100644
index 0000000..53d67d3
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/CategoryDTO.java
@@ -0,0 +1,91 @@
+package com.jmal.clouddisk.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+import org.springframework.data.annotation.Id;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import java.text.Collator;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * @Description 文件类别DTO
+ * @Author jmal
+ * @Date 2020/10/27 10:48 下午
+ */
+@Data
+@Schema
+@Valid
+public class CategoryDTO implements Comparable {
+
+ @Id
+ @Schema(hidden = true)
+ private String id;
+
+ @Schema(name = "userId", title = "用户id")
+ private String userId;
+
+ @NotNull(message = "分类名称不能为空")
+ @Schema(name = "name", title = "分类名称", example = "新建分类", requiredMode = Schema.RequiredMode.REQUIRED)
+ private String name;
+
+ /***
+ * 缩略名,默认为name
+ */
+ @Schema(name = "slug", title = "分类缩略名")
+ private String slug;
+ /***
+ * 父级分类Id
+ */
+ @Schema(name = "parentCategoryId", title = "父级分类Id")
+ private String parentCategoryId;
+ /***
+ * 子分类数
+ */
+ @Schema(hidden = true)
+ private Integer subCategorySize;
+ /***
+ * 是否为默认分类
+ */
+ @Schema(hidden = true)
+ private Boolean isDefault;
+ /***
+ * 分类描述
+ */
+ @Schema(name = "desc", title = "分类描述")
+ private String desc;
+ /***
+ * 文章数
+ */
+ @Schema(hidden = true)
+ private Integer articleNum;
+ /***
+ * 文章总数
+ */
+ @Schema(hidden = true)
+ private Integer value;
+ /***
+ * 子分类
+ */
+ @Schema(hidden = true)
+ private List children;
+
+ @Schema(name = "categoryBackground", title = "分类背景")
+ String categoryBackground;
+
+ @Schema(hidden = true, name = "categoryIdsList", title = "分类Id列表")
+ private List> categoryIdsList;
+ /***
+ * 按照分类名称来排序
+ * @param categoryDTO CategoryDTO
+ * @return int
+ */
+ @Override
+ public int compareTo(CategoryDTO categoryDTO) {
+ Comparator cmp = Collator.getInstance(java.util.Locale.CHINA);
+ return cmp.compare(getName(), categoryDTO.getName());
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/CloudSettingDO.java b/src/main/java/com/jmal/clouddisk/model/CloudSettingDO.java
new file mode 100644
index 0000000..d17c244
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/CloudSettingDO.java
@@ -0,0 +1,21 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+
+
+/**
+ * @author jmal
+ * @Description 网盘设置
+ * @Date 2022/03/26 2:45 下午
+ */
+@Data
+public class CloudSettingDO {
+ /**
+ * 网盘名称
+ */
+ private String cloudName;
+ /**
+ * 网盘logo
+ */
+ private byte[] logo;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/CloudSettingDTO.java b/src/main/java/com/jmal/clouddisk/model/CloudSettingDTO.java
new file mode 100644
index 0000000..8e64990
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/CloudSettingDTO.java
@@ -0,0 +1,23 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * @author jmal
+ * @Description 网盘设置DTO
+ * @Date 2022/03/26 2:45 下午
+ */
+@Data
+public class CloudSettingDTO {
+ /**
+ * 网盘名称
+ */
+ private String cloudName;
+ /**
+ * 网盘logo
+ */
+ private byte[] logo;
+
+ private MultipartFile file;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/CommentDO.java b/src/main/java/com/jmal/clouddisk/model/CommentDO.java
new file mode 100644
index 0000000..892a90b
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/CommentDO.java
@@ -0,0 +1,93 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.springframework.data.annotation.Id;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.validation.annotation.Validated;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+
+/**
+ * @Description 评论
+ * @Author jmal
+ * @Date 2020-04-02 10:25
+ */
+@Data
+@Validated
+public class CommentDO {
+ @Id
+ String id;
+
+ /***
+ * 文件Id
+ */
+ String fileId;
+
+ /***
+ * 评论者邮箱 必填
+ */
+ @Email(message = "邮箱格式不正确")
+ String senderEmail;
+
+ /***
+ * 评论者
+ */
+ @NotNull(message = "名称不能为空")
+ String sender;
+
+ /***
+ * 评论内容
+ */
+ @NotNull(message = "内容不能为空")
+ String content;
+
+ /***
+ * 评论时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ LocalDateTime date;
+
+ /***
+ * 评论更新时间 当该评论有新回复时会更新该时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ LocalDateTime updateDate;
+
+ /***
+ * 评论楼层
+ */
+ @NotNull(message = "评论楼层不能为空")
+ Integer floor;
+
+ /***
+ * 评论父层
+ */
+ Integer parentFloor;
+
+ /***
+ * 评论者Id 如果未登录则为空
+ */
+ String senderId;
+
+ /***
+ * 接收者
+ */
+ @NotNull(message = "接收者不能为空")
+ String receiver;
+
+ /***
+ * 接收者Id 如果该接收者评论时未登录则为空
+ */
+ String receiverId;
+
+ /***
+ * 接收者邮箱
+ */
+ @Email(message = "邮箱格式不正确")
+ String receiverEmail;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/DirectLink.java b/src/main/java/com/jmal/clouddisk/model/DirectLink.java
new file mode 100644
index 0000000..0afffcb
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/DirectLink.java
@@ -0,0 +1,38 @@
+package com.jmal.clouddisk.model;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jmal.clouddisk.service.impl.DirectLinkService;
+import lombok.Data;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
+import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+@Data
+@Document(collection = DirectLinkService.COLLECTION_NAME)
+@CompoundIndexes({
+ @CompoundIndex(name = "mark_1", def = "{'mark': 1}"),
+ @CompoundIndex(name = "fileId_1", def = "{'fileId': 1}"),
+
+})
+public class DirectLink {
+ String id;
+
+ String fileId;
+
+ String userId;
+
+ String mark;
+
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ LocalDateTime updateDate;
+
+ Long downloads;
+
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/EditTagDTO.java b/src/main/java/com/jmal/clouddisk/model/EditTagDTO.java
new file mode 100644
index 0000000..1645c66
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/EditTagDTO.java
@@ -0,0 +1,24 @@
+package com.jmal.clouddisk.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Schema
+@Valid
+public class EditTagDTO {
+ @NotNull(message = "文件id列表不能为空")
+ @Schema(description = "要修改的文件id列表")
+ List fileIds;
+
+ @NotNull(message = "标签列表不能为空")
+ @Schema(description = "于文件id列表相关联的标签列表")
+ List tagList;
+
+ @Schema(description = "要额外删除的标签id列表")
+ List removeTagIds;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/ExifInfo.java b/src/main/java/com/jmal/clouddisk/model/ExifInfo.java
new file mode 100644
index 0000000..374590e
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/ExifInfo.java
@@ -0,0 +1,103 @@
+package com.jmal.clouddisk.model;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+/**
+ * 照片EXIF信息
+ */
+@Data
+public class ExifInfo {
+ /**
+ * 设备制造商
+ */
+ private String make;
+ /**
+ * 设备型号
+ */
+ private String model;
+ /**
+ * 分辨率
+ */
+ private String resolution;
+ /**
+ * 内容创作者
+ */
+ private String software;
+ /**
+ * 内容创建时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime dateTimeOriginal;
+ /**
+ * 光圈值
+ */
+ private Double aperture;
+ /**
+ * 光圈数
+ */
+ private String fNumber;
+ /**
+ * 曝光时间
+ */
+ private Double exposureTime;
+ /**
+ * ISO感光度
+ */
+ private Integer isoEquivalent;
+ /**
+ * 曝光程序
+ */
+ private String exposureProgram;
+ /**
+ * 测光模式
+ */
+ private String meteringMode;
+ /**
+ * 白平衡
+ */
+ private String whiteBalanceMode;
+ /**
+ * 焦距
+ */
+ private Double focalLength;
+ /**
+ * 闪光灯
+ */
+ private String flash;
+ /**
+ * 经度
+ */
+ private Double longitude;
+ /**
+ * 纬度
+ */
+ private Double latitude;
+
+ public String toString() {
+ return "ExifInfo{" +
+ "make='" + make + '\'' +
+ ", model='" + model + '\'' +
+ ", resolution='" + resolution + '\'' +
+ ", software='" + software + '\'' +
+ ", dateTimeOriginal=" + dateTimeOriginal +
+ ", aperture=" + aperture +
+ ", fNumber='" + fNumber + '\'' +
+ ", exposureTime=" + exposureTime +
+ ", isoEquivalent=" + isoEquivalent +
+ ", exposureProgram='" + exposureProgram + '\'' +
+ ", meteringMode='" + meteringMode + '\'' +
+ ", whiteBalanceMode='" + whiteBalanceMode + '\'' +
+ ", focalLength=" + focalLength +
+ ", flash='" + flash + '\'' +
+ ", longitude=" + longitude +
+ ", latitude=" + latitude +
+ '}';
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/FileBase.java b/src/main/java/com/jmal/clouddisk/model/FileBase.java
new file mode 100644
index 0000000..006e469
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/FileBase.java
@@ -0,0 +1,73 @@
+package com.jmal.clouddisk.model;
+
+import com.alibaba.fastjson.annotation.JSONField;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jmal.clouddisk.util.TimeUntils;
+import lombok.Data;
+import org.springframework.data.annotation.Id;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author jmal
+ * @Description 文件模型基类
+ * @Date 2020/11/12 2:05 下午
+ */
+@Data
+public class FileBase {
+
+ @Id
+ String id;
+ /***
+ * 是否为文件夹
+ */
+ private Boolean isFolder;
+ /***
+ * 文件名称
+ */
+ private String name;
+ /***
+ * 文件MD5值
+ */
+ private String md5;
+ /***
+ * 文件大小
+ */
+ private long size;
+ /***
+ * 文件类型
+ */
+ private String contentType;
+ /***
+ * 上传时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime uploadDate;
+ /***
+ * 修改时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @JSONField(format = "yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime updateDate;
+
+ /***
+ * 格式化文件更新时间
+ * @return yyyy 年 MM 月 dd 日 HH:mm:ss
+ */
+ public String updateTime(){
+ return updateDate.format(TimeUntils.UPDATE_FORMAT_TIME);
+ }
+
+ /***
+ * 格式化文件上传时间
+ * @return yyyy 年 MM 月 dd 日
+ */
+ public String uploadTime(){
+ return uploadDate.format(TimeUntils.UPLOAD_FORMAT_TIME);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/FileDocument.java b/src/main/java/com/jmal/clouddisk/model/FileDocument.java
new file mode 100644
index 0000000..6792b10
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/FileDocument.java
@@ -0,0 +1,286 @@
+package com.jmal.clouddisk.model;
+
+import com.jmal.clouddisk.media.VideoInfoDO;
+import com.jmal.clouddisk.service.impl.FileServiceImpl;
+import lombok.Data;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * FileDocument 文件模型
+ *
+ * @author jmal
+ */
+@Data
+@Document(collection = FileServiceImpl.COLLECTION_NAME)
+@CompoundIndexes({
+ @CompoundIndex(name = "name_1", def = "{'name': 1}"),
+ @CompoundIndex(name = "size_1", def = "{'size': 1}"),
+ @CompoundIndex(name = "updateDate_1", def = "{'updateDate': 1}"),
+ @CompoundIndex(name = "path_name", def = "{'path': 1, 'name': 1}"),
+ @CompoundIndex(name = "user_md5_path", def = "{'userId': 1,'md5': 1, 'path': 1}"),
+ @CompoundIndex(name = "user_path", def = "{'userId': 1, 'path': 1}"),
+ @CompoundIndex(name = "user_path_name", def = "{'userId': 1, 'path': 1, 'name': 1}"),
+ @CompoundIndex(name = "user_isFolder_path", def = "{'userId': 1, 'isFolder': 1, 'path': 1}"),
+ @CompoundIndex(name = "user_isFolder_path_name", def = "{'userId': 1, 'isFolder': 1, 'path': 1, 'name': 1}"),
+ @CompoundIndex(name = "user_isFolder", def = "{'userId': 1, 'isFolder': 1}"),
+ @CompoundIndex(name = "user_isFavorite", def = "{'userId': 1, 'isFavorite': 1}"),
+ @CompoundIndex(name = "user_contentType", def = "{'userId': 1, 'contentType': 1}"),
+ @CompoundIndex(name = "doc_tags", def = "{'tags.tagId': 1}"),
+ @CompoundIndex(name = "process_marked_folders", def = "{ 'needsEtagUpdate': 1, 'isFolder': 1, 'lastEtagUpdateRequestAt': 1 }"),
+})
+public class FileDocument extends FileBase {
+ private String userId;
+ private String username;
+ /***
+ * 文件头像/缩略图
+ */
+ private String avatar;
+ /***
+ * 文件路径(根路径为"/")
+ */
+ private String path;
+ private String rootPath;
+ /***
+ * updateDate 距离现在的时间
+ */
+ private Long agoTime;
+ /***
+ * 显示大小
+ */
+ private String showSize;
+ /***
+ * 图片的宽度
+ */
+ private String w;
+ /***
+ * 图片的高度
+ */
+ private String h;
+ /***
+ * 文件内容
+ */
+ private byte[] content;
+
+ /***
+ * 文件内容
+ */
+ private String contentText;
+ /**
+ * 文件编码
+ */
+ private String decoder;
+ /***
+ * html内容
+ */
+ private String html;
+ /***
+ * 文件后缀名
+ */
+ private String suffix;
+ /***
+ * 文件描述
+ */
+ private String description;
+ /***
+ * 是否收藏
+ */
+ private Boolean isFavorite;
+ /**
+ * oss目录名称
+ */
+ private String ossFolder;
+ /**
+ * oss平台类型
+ */
+ private String ossPlatform;
+ /***
+ * 是否公共文件
+ */
+ private Boolean isPublic;
+ /***
+ * 是否分享
+ */
+ private Boolean isShare;
+ /***
+ * 是否分享
+ */
+ private Boolean isPrivacy;
+ private String extractionCode;
+ /***
+ * shareId
+ */
+ private String shareId;
+ /***
+ * shareBase
+ */
+ private Boolean shareBase;
+ private Boolean subShare;
+ /***
+ * 分享有效期
+ */
+ private Long expiresAt;
+ /***
+ * 音乐
+ */
+ private Music music;
+ /**
+ * 照片exif信息
+ */
+ private ExifInfo exif;
+ /**
+ * 视频信息
+ */
+ private VideoInfoDO video;
+ /**
+ * 媒体封面
+ */
+ private String mediaCover;
+ /**
+ * m3u8文件路径(相对路径)
+ */
+ private String m3u8;
+ /**
+ * vtt文件路径(相对路径)
+ */
+ private String vtt;
+ /***
+ * 是否发布,适用于文档类型
+ */
+ private Boolean release;
+ /***
+ * 是否为独立页,适用于文档类型
+ */
+ private Boolean alonePage;
+ /***
+ * 独立页排序
+ */
+ private Integer pageSort;
+ /***
+ * 是否有草稿(FileDocument对象转String)
+ */
+ private String draft;
+ /***
+ * 封面
+ */
+ private String cover;
+ /***
+ * 缩略名
+ */
+ private String slug;
+ /***
+ * 分类Id集合
+ */
+ private String[] categoryIds;
+ /***
+ * 标签Id集合
+ */
+ private String[] tagIds;
+
+ /**
+ * 标签集合
+ */
+ private List tags;
+
+ /**
+ * 挂载的文件id
+ */
+ private String mountFileId;
+
+ /**
+ * 删除标记 0:未删除 1:已删除
+ */
+ private Integer delete;
+
+ private Boolean move;
+
+ /**
+ * 是否显示封面, 缩略图保存在content中
+ */
+ private Boolean showCover;
+
+ /**
+ * 备注, 主要用于全文检索
+ */
+ private String remark;
+
+ /**
+ * 操作权限
+ */
+ private List operationPermissionList;
+
+ /**
+ * 文件的 ETag
+ */
+ private String etag;
+
+ /**
+ * ETag 更新失败次数
+ */
+ private Integer etagUpdateFailedAttempts;
+
+ /**
+ * 是否需要更新 ETag
+ */
+ private Boolean needsEtagUpdate;
+
+ /**
+ * 最后Etag更新请求时间
+ */
+ private LocalDateTime lastEtagUpdateRequestAt;
+
+ /**
+ * 最后Etag更新错误
+ */
+ private String lastEtagUpdateError;
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ final FileBase other = (FileBase) obj;
+ return Objects.equals(this.id, other.id);
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 7;
+ hash = 89 * hash + (this.id != null ? this.id.hashCode() : 0);
+ return hash;
+ }
+
+ public Trash toTrash(boolean hidden, boolean move) {
+ Trash trash = new Trash();
+ trash.setId(this.getId());
+ trash.setName(this.getName());
+ trash.setPath(this.getPath());
+ trash.setUserId(this.getUserId());
+ trash.setH(this.getH());
+ trash.setW(this.getW());
+ trash.setSuffix(this.getSuffix());
+ trash.setIsFolder(this.getIsFolder());
+ trash.setContent(this.getContent());
+ trash.setExif(this.getExif());
+ trash.setMusic(this.getMusic());
+ trash.setVideo(this.getVideo());
+ trash.setContentType(this.getContentType());
+ trash.setSize(this.getSize());
+ trash.setUploadDate(this.getUploadDate());
+ trash.setUpdateDate(this.getUpdateDate());
+ trash.setMd5(this.getMd5());
+ trash.setM3u8(this.getM3u8());
+ trash.setVtt(this.getVtt());
+ trash.setHidden(hidden);
+ trash.setMove(move);
+ return trash;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/FileIndex.java b/src/main/java/com/jmal/clouddisk/model/FileIndex.java
new file mode 100644
index 0000000..8c22391
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/FileIndex.java
@@ -0,0 +1,64 @@
+package com.jmal.clouddisk.model;
+
+import com.jmal.clouddisk.util.HashUtil;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.File;
+
+/**
+ * 创建文件索引需要的内容
+ */
+@Data
+@Accessors(chain = true)
+public class FileIndex {
+
+ public FileIndex(File file, FileIntroVO fileIntroVO) {
+ this.file = file;
+ this.userId = fileIntroVO.getUserId();
+ this.fileId = fileIntroVO.getId();
+ this.path = fileIntroVO.getPath();
+ this.isFolder = fileIntroVO.getIsFolder();
+ this.isFavorite = fileIntroVO.getIsFavorite();
+ this.remark = fileIntroVO.getRemark();
+ }
+
+
+ private String userId;
+ private String username;
+ private File file;
+ private String fileId;
+ private String name;
+ private String path;
+ private String type;
+ private String tagName;
+ private Long modified;
+ private Long size;
+ private Boolean isFolder;
+ private Boolean isFavorite;
+ private String remark;
+
+ /**
+ * 获取文件ID的哈希值, 用于唯一索引标识, 用于避免重复索引
+ * @param fileSha256 文件的SHA-256哈希值
+ */
+ public String getFileIndexHash(String fileSha256) {
+ String delimiter = "|";
+ String hashSource =
+ safe(fileId) + delimiter +
+ safe(userId) + delimiter +
+ safe(path) + delimiter +
+ safe(name) + delimiter +
+ safe(tagName) + delimiter +
+ isFavorite + delimiter +
+ safe(remark) + delimiter +
+ modified + delimiter +
+ size + delimiter +
+ safe(fileSha256);
+ return HashUtil.sha256(hashSource);
+ }
+
+ private String safe(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/FileIntroVO.java b/src/main/java/com/jmal/clouddisk/model/FileIntroVO.java
new file mode 100644
index 0000000..8fda395
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/FileIntroVO.java
@@ -0,0 +1,117 @@
+package com.jmal.clouddisk.model;
+
+import com.jmal.clouddisk.media.VideoInfoDO;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * @author jmal
+ * @Description 文件简介
+ * @Date 2020/11/12 1:54 下午
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FileIntroVO extends FileBase {
+ private String userId;
+ private String username;
+ /***
+ * 文件路径(根路径为"/")
+ */
+ private String path;
+ /***
+ * updateDate 距离现在的时间
+ */
+ private Long agoTime;
+ /***
+ * 显示大小
+ */
+ private String showSize;
+ /***
+ * 文件后缀名
+ */
+ private String suffix;
+ /***
+ * 是否收藏
+ */
+ private Boolean isFavorite;
+ /***
+ * 是否分享
+ */
+ private Boolean isShare;
+ /**
+ * oss目录名称
+ */
+ private String ossFolder;
+ /**
+ * oss平台类型
+ */
+ private String ossPlatform;
+ /***
+ * 是否为私密链接
+ */
+ private Boolean isPrivacy;
+
+ private String contentText;
+
+ private Boolean shareBase;
+ private Boolean subShare;
+ /***
+ * 分享有效期
+ */
+ private Long expiresAt;
+ /**
+ * music
+ */
+ private Music music;
+ /**
+ * 照片exif信息
+ */
+ private ExifInfo exif;
+ /**
+ * 视频信息
+ */
+ private VideoInfoDO video;
+ /**
+ * 媒体封面
+ */
+ private String mediaCover;
+ /**
+ * m3u8文件路径(相对路径)
+ */
+ private String m3u8;
+ /***
+ * 图片的宽度
+ */
+ private String w;
+ /***
+ * 图片的高度
+ */
+ private String h;
+ /***
+ * 封面
+ */
+ private String cover;
+ /***
+ * 是否显示封面, 缩略图保存在content中
+ */
+ private Boolean showCover;
+ /**
+ * 挂载的文件id
+ */
+ private String mountFileId;
+ private List tags;
+ /**
+ * 备注, 主要用于全文检索
+ */
+ private String remark;
+ /**
+ * 操作权限
+ */
+ private List operationPermissionList;
+ /**
+ * 文件的etag
+ */
+ private String etag;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/FileType.java b/src/main/java/com/jmal/clouddisk/model/FileType.java
new file mode 100644
index 0000000..167c245
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/FileType.java
@@ -0,0 +1,28 @@
+package com.jmal.clouddisk.model;
+
+/**
+ * @Description 文件类型
+ * @Author jmal
+ */
+public enum FileType {
+ /***
+ * 音频
+ */
+ audio,
+ /***
+ * 视频
+ */
+ video,
+ /***
+ * 图片
+ */
+ image,
+ /***
+ * 文本
+ */
+ text,
+ /***
+ * 文档
+ */
+ document;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/GridFSBO.java b/src/main/java/com/jmal/clouddisk/model/GridFSBO.java
new file mode 100644
index 0000000..7291fb1
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/GridFSBO.java
@@ -0,0 +1,27 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author jmal
+ * @Description GridFSDO
+ * @date 2023/5/10 16:43
+ */
+@Data
+public class GridFSBO {
+
+ String id;
+
+ String filename;
+
+ long length;
+
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ LocalDateTime uploadDate;
+
+ Metadata metadata;
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/HeartwingsDO.java b/src/main/java/com/jmal/clouddisk/model/HeartwingsDO.java
new file mode 100644
index 0000000..c0fc64b
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/HeartwingsDO.java
@@ -0,0 +1,44 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
+import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author jmal
+ * @Description 心语记录
+ * @Date 2021/3/1 10:19 上午
+ */
+@Data
+@Document(collection = "heartwings")
+@CompoundIndexes({
+ @CompoundIndex(name = "createTime_1", def = "{'createTime': 1}"),
+})
+public class HeartwingsDO {
+
+ private String id;
+
+ /***
+ * 创建者 userId
+ */
+ private String creator;
+
+ /***
+ * 创建者 username
+ */
+ private String username;
+
+ /***
+ * 心语
+ */
+ private String heartwings;
+
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/LdapConfigDO.java b/src/main/java/com/jmal/clouddisk/model/LdapConfigDO.java
new file mode 100644
index 0000000..4705ffc
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/LdapConfigDO.java
@@ -0,0 +1,71 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.security.crypto.encrypt.TextEncryptor;
+
+import java.util.List;
+
+/**
+ * @author jmal
+ * @Description LdapDO
+ * @date 2023/5/8 18:00
+ */
+@Data
+@Document(collection = "ldapConfig")
+public class LdapConfigDO {
+ String id;
+ /**
+ * 是否启用
+ */
+ Boolean enable;
+ /**
+ * ldap服务器
+ */
+ String ldapServer;
+ /**
+ * 默认角色
+ */
+ List defaultRoleList;
+ /**
+ * 管理账号密码
+ */
+ String password;
+ /**
+ * baseDN
+ */
+ String baseDN;
+ /**
+ * User DN
+ */
+ String userDN;
+ /**
+ * LDAP服务器中对应个人用户名的字段
+ */
+ String loginName;
+
+ String userId;
+
+ public LdapConfigDTO toLdapConfigDTO(TextEncryptor textEncryptor) {
+ LdapConfigDTO ldapConfigDTO = new LdapConfigDTO();
+ ldapConfigDTO.setEnable(this.enable);
+ ldapConfigDTO.setLdapServer(this.ldapServer);
+ ldapConfigDTO.setDefaultRoleList(this.defaultRoleList);
+ ldapConfigDTO.setPassword(textEncryptor.decrypt(this.password));
+ ldapConfigDTO.setBaseDN(this.baseDN);
+ ldapConfigDTO.setUserDN(this.userDN);
+ ldapConfigDTO.setLoginName(this.loginName);
+ return ldapConfigDTO;
+ }
+
+ public LdapConfigDTO toLdapConfigDTO() {
+ LdapConfigDTO ldapConfigDTO = new LdapConfigDTO();
+ ldapConfigDTO.setEnable(this.enable);
+ ldapConfigDTO.setLdapServer(this.ldapServer);
+ ldapConfigDTO.setDefaultRoleList(this.defaultRoleList);
+ ldapConfigDTO.setBaseDN(this.baseDN);
+ ldapConfigDTO.setUserDN(this.userDN);
+ ldapConfigDTO.setLoginName(this.loginName);
+ return ldapConfigDTO;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/LdapConfigDTO.java b/src/main/java/com/jmal/clouddisk/model/LdapConfigDTO.java
new file mode 100644
index 0000000..b9d3c74
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/LdapConfigDTO.java
@@ -0,0 +1,62 @@
+package com.jmal.clouddisk.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import org.springframework.security.crypto.encrypt.TextEncryptor;
+
+import java.util.List;
+
+/**
+ * @author jmal
+ * @Description LdapDO
+ * @date 2023/5/8 18:00
+ */
+@Data
+@Schema
+@Valid
+public class LdapConfigDTO {
+
+ @Schema(name = "enable", title = "是否启用", example = "true")
+ Boolean enable;
+
+ @NotNull(message = "ldap服务器不能为空")
+ @Schema(name = "ldapServer", title = "ldap服务器", example = "localhost:8389")
+ String ldapServer;
+
+ @NotNull(message = "默认角色不能为空")
+ @Schema(name = "defaultRoleList", title = "默认角色")
+ List defaultRoleList;
+
+ @NotNull(message = "账号密码不能为空")
+ @Schema(name = "password", title = "账号密码", example = "password")
+ String password;
+
+ @NotNull(message = "Base DN不能为空")
+ @Schema(name = "baseDN", title = "Base DN", example = "dc=tes,dc=com")
+ String baseDN;
+
+ @NotNull(message = "User DN不能为空")
+ @Schema(name = "userDN", title = "User DN", example = "cn=admin,dc=tes,dc=com")
+ String userDN;
+
+ @NotNull(message = "登录名不能为空")
+ @Schema(name = "loginName", title = "登录名", example = "uid")
+ String loginName;
+
+ public LdapConfigDO toLdapConfigDO(String userId, TextEncryptor textEncryptor) {
+ LdapConfigDO ldapConfigDO = new LdapConfigDO();
+ ldapConfigDO.setId("6458f8c5bb943e3cf1db5f29");
+ ldapConfigDO.setEnable(this.enable);
+ ldapConfigDO.setLdapServer(this.ldapServer);
+ ldapConfigDO.setDefaultRoleList(this.defaultRoleList);
+ ldapConfigDO.setPassword(textEncryptor.encrypt(password));
+ ldapConfigDO.setBaseDN(this.baseDN);
+ ldapConfigDO.setUserDN(this.userDN);
+ ldapConfigDO.setLoginName(this.loginName);
+ ldapConfigDO.setUserId(userId);
+ return ldapConfigDO;
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/LogOperation.java b/src/main/java/com/jmal/clouddisk/model/LogOperation.java
new file mode 100644
index 0000000..299e199
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/LogOperation.java
@@ -0,0 +1,185 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.LocalDateTime;
+
+/**
+ * @Description 操作日志
+ * @author jmal
+ */
+@Data
+@Document(collection = "log")
+@CompoundIndex(name = "createTime_1", def = "{'createTime': 1}")
+@CompoundIndex(name = "time_1", def = "{'time': 1}")
+@CompoundIndex(name = "username_1", def = "{'username': 1}")
+@CompoundIndex(name = "showName_1", def = "{'showName': 1}")
+@CompoundIndex(name = "ip_1", def = "{'ip': 1}")
+@CompoundIndex(name = "cityIp_1", def = "{'cityIp': 1}")
+@CompoundIndex(name = "url_1", def = "{'url': 1}")
+@CompoundIndex(name = "status_1", def = "{'status': 1}")
+@CompoundIndex(name = "operationModule_1", def = "{'operationModule': 1}")
+@CompoundIndex(name = "operationFun_1", def = "{'operationFun': 1}")
+@CompoundIndex(name = "deviceModel_1", def = "{'deviceModel': 1}")
+@CompoundIndex(name = "operatingSystem_1", def = "{'operatingSystem': 1}")
+@CompoundIndex(name = "type_1", def = "{'type': 1}")
+@CompoundIndex(name = "type_createTime_1", def = "{'type': 1, 'createTime': 1}")
+@CompoundIndex(name = "type_username_1", def = "{'type': 1, 'username': 1}")
+@CompoundIndex(name = "type_username_createTime_1", def = "{'type': 1, 'username': 1, 'createTime': 1}")
+@CompoundIndex(name = "fileUserId_type_1", def = "{'fileUserId': 1, 'type': 1}")
+public class LogOperation {
+ private String id;
+ /***
+ * 账号
+ */
+ private String username;
+ /***
+ * 用户名
+ */
+ private String showName;
+ /***
+ * ip地址
+ */
+ private String ip;
+ /***
+ * 操作模块
+ */
+ private String operationModule;
+ /***
+ * 操作功能
+ */
+ private String operationFun;
+ /***
+ * 请求地址
+ */
+ private String url;
+ /***
+ * 请求方式
+ */
+ private String method;
+ /***
+ * 设备型号
+ */
+ private String deviceModel;
+ /***
+ * 操作系统
+ */
+ private String operatingSystem;
+ /***
+ * 浏览器
+ */
+ private String browser;
+ /***
+ * 耗时
+ */
+ private Long time;
+ /***
+ * 状态
+ */
+ private Integer status;
+ /***
+ * 操作时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime createTime;
+ /***
+ * 备注
+ */
+ private String remarks;
+ /***
+ * 日志类型
+ */
+ private String type;
+
+ private IpInfo ipInfo;
+
+ /**
+ * 文件路径
+ */
+ private String filepath;
+
+ /**
+ * 文件所属用户
+ */
+ private String fileUserId;
+
+ @Data
+ public static class IpInfo {
+ /***
+ * 国家
+ */
+ private String country;
+ /***
+ * 区域
+ */
+ private String area;
+ /***
+ * 省份
+ */
+ private String province;
+ /***
+ * 城市
+ */
+ private String city;
+ /***
+ * 运营商
+ */
+ private String operators;
+ }
+
+ /***
+ * 日志类型
+ */
+ public enum Type {
+ /***
+ * 登录日志
+ */
+ LOGIN,
+ /***
+ * 浏览日志
+ */
+ BROWSE,
+ /***
+ * 系统操作日志
+ */
+ OPERATION,
+ /***
+ * 文件操作日志
+ */
+ OPERATION_FILE,
+ /***
+ * 文章访问日志
+ */
+ ARTICLE,
+ /***
+ * webdav
+ */
+ WEBDAV
+ }
+
+ public LogOperation clone() {
+ LogOperation logOperation = new LogOperation();
+ logOperation.setUsername(this.username);
+ logOperation.setShowName(this.showName);
+ logOperation.setIp(this.ip);
+ logOperation.setOperationModule(this.operationModule);
+ logOperation.setOperationFun(this.operationFun);
+ logOperation.setUrl(this.url);
+ logOperation.setMethod(this.method);
+ logOperation.setDeviceModel(this.deviceModel);
+ logOperation.setOperatingSystem(this.operatingSystem);
+ logOperation.setBrowser(this.browser);
+ logOperation.setTime(this.time);
+ logOperation.setStatus(this.status);
+ logOperation.setCreateTime(this.createTime);
+ logOperation.setRemarks(this.remarks);
+ logOperation.setType(this.type);
+ logOperation.setIpInfo(this.ipInfo);
+ logOperation.setFilepath(this.filepath);
+ logOperation.setFileUserId(this.fileUserId);
+ return logOperation;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/LogOperationDTO.java b/src/main/java/com/jmal/clouddisk/model/LogOperationDTO.java
new file mode 100644
index 0000000..abc227f
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/LogOperationDTO.java
@@ -0,0 +1,79 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author jmal
+ * @Description 操作日志传输对象
+ * @Date 2021/2/8 2:16 下午
+ */
+@Data
+@Schema
+public class LogOperationDTO {
+ /***
+ * 页数
+ */
+ @Schema(name = "page", title = "页数", example = "1")
+ private Integer page;
+ /**
+ * 每页条数
+ */
+ @Schema(name = "pageSize", title = "每页条数", example = "10")
+ private Integer pageSize;
+ /**
+ * 账号
+ */
+ private String username;
+ /**
+ * 用户名
+ */
+ private String showName;
+ /**
+ * avatar
+ */
+ private String avatar;
+ /**
+ * 排除账号
+ */
+ private String excludeUsername;
+ /**
+ * ip
+ */
+ private String ip;
+ /**
+ * 日志类型
+ */
+ private String type;
+ /**
+ * 要查询的排序参数
+ */
+ private String sortProp;
+ /**
+ * 要查询的排序顺序
+ */
+ private String sortOrder;
+ /**
+ * 开始时间
+ */
+ private Long startTime;
+ /**
+ * 结束时间
+ */
+ private Long endTime;
+
+ /**
+ * 操作时间
+ */
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy年MM月dd日 HH:mm:ss")
+ private LocalDateTime createTime;
+
+ /**
+ * 操作功能
+ */
+ private String operationFun;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/MarkdownBaseFile.java b/src/main/java/com/jmal/clouddisk/model/MarkdownBaseFile.java
new file mode 100644
index 0000000..742bd8f
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/MarkdownBaseFile.java
@@ -0,0 +1,52 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * @author jmal
+ * @Description MarkdownBaseFile
+ * @date 2023/5/10 13:54
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class MarkdownBaseFile extends FileBase {
+
+ private String username;
+ /***
+ * 是否发布,适用于文档类型
+ */
+ private Boolean release;
+ /***
+ * 封面
+ */
+ private String cover;
+ /***
+ * 用户头像
+ */
+ private String avatar;
+ /***
+ * 缩略名
+ */
+ private String slug;
+ /***
+ * 分类Id集合
+ */
+ private String[] categoryIds;
+
+ /***
+ * 分类集合
+ */
+ private List categories;
+ /***
+ * 标签Id集合
+ */
+ private String[] tagIds;
+ /***
+ * 标签集合
+ */
+ private List tags;
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/MarkdownVO.java b/src/main/java/com/jmal/clouddisk/model/MarkdownVO.java
new file mode 100644
index 0000000..0e5e39e
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/MarkdownVO.java
@@ -0,0 +1,25 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * @Description 文档
+ * @blame jmal
+ * @Date 2020/11/15 7:33 下午
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class MarkdownVO extends MarkdownBaseFile {
+
+ private String userId;
+ /***
+ * updateDate 距离现在的时间
+ */
+ private Long agoTime;
+ /***
+ * 是否有草稿
+ */
+ private Boolean draft;
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/Metadata.java b/src/main/java/com/jmal/clouddisk/model/Metadata.java
new file mode 100644
index 0000000..bf6c10f
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/Metadata.java
@@ -0,0 +1,16 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+
+@Data
+public class Metadata {
+ String filepath;
+ String filename;
+ String time;
+ String compression;
+ /**
+ * 操作人(username)
+ */
+ String operator;
+ Long size;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/Music.java b/src/main/java/com/jmal/clouddisk/model/Music.java
new file mode 100644
index 0000000..1a45e9f
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/Music.java
@@ -0,0 +1,26 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+
+/**
+ * @author jmal
+ */
+@Data
+public class Music {
+ /***
+ * 歌名
+ */
+ String songName;
+ /***
+ * 歌手
+ */
+ String singer;
+ /***
+ * 专辑
+ */
+ String album;
+ /***
+ * 封面
+ */
+ String coverBase64;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/OperationPermission.java b/src/main/java/com/jmal/clouddisk/model/OperationPermission.java
new file mode 100644
index 0000000..9f80fc0
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/OperationPermission.java
@@ -0,0 +1,13 @@
+package com.jmal.clouddisk.model;
+
+/**
+ * @author jmal
+ * @Description 文件操作权限
+ * @date 2023/8/10 15:19
+ */
+public enum OperationPermission {
+ DOWNLOAD,
+ UPLOAD,
+ DELETE,
+ PUT;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/OperationTips.java b/src/main/java/com/jmal/clouddisk/model/OperationTips.java
new file mode 100644
index 0000000..e62f8a7
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/OperationTips.java
@@ -0,0 +1,23 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+@Data
+@Accessors(chain = true)
+@Builder
+public class OperationTips {
+ /**
+ * 操作结果说明
+ */
+ String msg;
+ /**
+ * 操作结果状态
+ */
+ Boolean success;
+ /**
+ * 操作
+ */
+ String operation;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/Page.java b/src/main/java/com/jmal/clouddisk/model/Page.java
new file mode 100644
index 0000000..fcde10a
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/Page.java
@@ -0,0 +1,129 @@
+package com.jmal.clouddisk.model;
+import cn.hutool.core.util.PageUtil;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.ArrayList;
+
+/**
+ * @author jmal
+ * @Description 分页数据结果集
+ * @Date 2020/11/18 10:32 上午
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class Page extends ArrayList {
+
+ public static final int DEFAULT_PAGE_SIZE = 10;
+
+ /**
+ * 页码,0表示第一页
+ */
+ private int page;
+ /**
+ * 每页结果数
+ */
+ private int pageSize;
+ /**
+ * 总页数
+ */
+ private int totalPage;
+ /**
+ * 总数
+ */
+ private int total;
+
+ private T data;
+
+ //---------------------------------------------------------- Constructor start
+
+ /**
+ * 构造
+ */
+ public Page() {
+ this(0, DEFAULT_PAGE_SIZE);
+ }
+
+ /**
+ * 构造
+ *
+ * @param page 页码,0表示第一页
+ * @param pageSize 每页结果数
+ */
+ public Page(int page, int pageSize) {
+ super(pageSize <= 0 ? DEFAULT_PAGE_SIZE : pageSize);
+
+ this.page = Math.max(page, 0);
+ this.pageSize = pageSize <= 0 ? DEFAULT_PAGE_SIZE : pageSize;
+ }
+
+ /**
+ * 构造
+ *
+ * @param page 页码,0表示第一页
+ * @param pageSize 每页结果数
+ * @param total 结果总数
+ */
+ public Page(int page, int pageSize, int total) {
+ this(page, pageSize);
+
+ this.total = total;
+ this.totalPage = PageUtil.totalPage(total, pageSize);
+ }
+
+ /**
+ * 构造
+ *
+ * @param total 结果总数
+ */
+ public Page(int total) {
+ this(0, total);
+
+ this.total = total;
+ this.totalPage = PageUtil.totalPage(total, pageSize);
+ }
+ //---------------------------------------------------------- Constructor end
+
+ /**
+ * @return 是否第一页
+ */
+ public boolean isFirst() {
+ return this.page == 0;
+ }
+
+ /**
+ * @return 是否最后一页
+ */
+ public boolean isLast() {
+ return this.page >= (this.totalPage - 1);
+ }
+
+ /**
+ * @return 当前页码
+ */
+ public int getCurrentPage() {
+ return this.page + 1;
+ }
+
+ /**
+ * 分页彩虹算法
+ * 来自:https://github.com/iceroot/iceroot/blob/master/src/main/java/com/icexxx/util/IceUtil.java
+ * 通过传入的信息,生成一个分页列表显示
+ * @return 分页条
+ */
+ public int[] rainbow() {
+ return rainbow(5);
+ }
+
+ /**
+ * 分页彩虹算法
+ * 来自:https://github.com/iceroot/iceroot/blob/master/src/main/java/com/icexxx/util/IceUtil.java
+ * 通过传入的信息,生成一个分页列表显示
+ * @param displayCount 每屏展示的页数
+ * @return 分页条
+ */
+ public int[] rainbow(int displayCount) {
+ return PageUtil.rainbow(this.page + 1, this.totalPage, displayCount);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/ShareDO.java b/src/main/java/com/jmal/clouddisk/model/ShareDO.java
new file mode 100644
index 0000000..40288cf
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/ShareDO.java
@@ -0,0 +1,74 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jmal.clouddisk.service.impl.ShareServiceImpl;
+import lombok.Data;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @Description 文件分享模型
+ * @Author jmal
+ * @Date 2020-03-17 16:28
+ */
+@Data
+@Document(collection = ShareServiceImpl.COLLECTION_NAME)
+public class ShareDO {
+ @Id
+ private String id;
+ /**
+ * 父级分享Id
+ */
+ private String fatherShareId;
+ private String shortId;
+ private Boolean shareBase;
+ /**
+ * 链接拥有者
+ */
+ private String userId;
+ /**
+ * 文件Id
+ */
+ private String fileId;
+ /**
+ * 文件名
+ */
+ private String fileName;
+ /**
+ * 文件类型
+ */
+ private String contentType;
+ /**
+ * 是否为文件夹
+ */
+ private Boolean isFolder;
+ /**
+ * 创建时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime createDate;
+ /**
+ * 过期时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
+ private LocalDateTime expireDate;
+ /**
+ * 是否为私密链接
+ */
+ private Boolean isPrivacy;
+ /**
+ * 提取码
+ */
+ private String extractionCode;
+ /**
+ * 操作权限
+ */
+ private List operationPermissionList;
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/ShareVO.java b/src/main/java/com/jmal/clouddisk/model/ShareVO.java
new file mode 100644
index 0000000..ba72801
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/ShareVO.java
@@ -0,0 +1,41 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @Description 文件分享模型
+ * @Author jmal
+ * @Date 2020-03-17 16:28
+ */
+@Data
+public class ShareVO {
+ private String shareId;
+ private String shortId;
+ /***
+ * 提取码
+ */
+ private String extractionCode;
+
+ private Boolean isPrivacy;
+
+ private String userId;
+
+ private String fileName;
+
+ private Boolean shareBase;
+ private Boolean subShare;
+
+ private Boolean isFolder;
+ private String contentType;
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
+ private LocalDateTime expireDate;
+
+ private List operationPermissionList;
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/SharerDTO.java b/src/main/java/com/jmal/clouddisk/model/SharerDTO.java
new file mode 100644
index 0000000..004aeda
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/SharerDTO.java
@@ -0,0 +1,29 @@
+package com.jmal.clouddisk.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+
+/**
+ * @author jmal
+ * @Description 分享者信息
+ * @Date 2021/4/6 4:25 下午
+ */
+@Data
+@Schema
+public class SharerDTO {
+ String shareId;
+ String userId;
+ @Schema(name = "avatar", title = "头像链接")
+ String avatar;
+ @Schema(name = "showName", title = "昵称")
+ String showName;
+ @Schema(name = "username", title = "用户名")
+ String username;
+ @Schema(name = "netdiskLogo", title = "网盘logo文件名", hidden = true)
+ String netdiskLogo;
+ @Schema(name = "netdiskName", title = "网盘名称", hidden = true)
+ String netdiskName;
+ @Schema(name = "iframe", title = "iframe配置", hidden = true)
+ String iframe;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/Tag.java b/src/main/java/com/jmal/clouddisk/model/Tag.java
new file mode 100644
index 0000000..6a3e15e
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/Tag.java
@@ -0,0 +1,12 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import org.springframework.data.annotation.TypeAlias;
+
+@Data
+@TypeAlias("")
+public class Tag {
+ String tagId;
+ String name;
+ String color;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/TagDO.java b/src/main/java/com/jmal/clouddisk/model/TagDO.java
new file mode 100644
index 0000000..722d684
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/TagDO.java
@@ -0,0 +1,72 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.data.annotation.Id;
+
+import java.text.Collator;
+import java.util.Comparator;
+
+/**
+ * @author jmal
+ * @Description 标签
+ * @Date 2020/10/26 4:30 下午
+ */
+@Data
+public class TagDO implements Comparable {
+ @Id
+ private String id;
+
+ private String userId;
+ /**
+ * 标签名称
+ */
+ private String name;
+ /**
+ * 标签缩略名,默认为name
+ */
+ private String slug;
+ /**
+ * 标签颜色,十六进制字符串,例如#00000000
+ */
+ private String color;
+ /**
+ * 标签背景图
+ */
+ String tagBackground;
+ /**
+ * 排序
+ */
+ Integer sort;
+
+ public ArticlesQueryVO toArticlesQuery() {
+ ArticlesQueryVO articlesQueryVO = new ArticlesQueryVO();
+ articlesQueryVO.setBackground(tagBackground);
+ articlesQueryVO.setName("标签 - " + name);
+ return articlesQueryVO;
+ }
+
+ public TagDTO toTagDTO() {
+ TagDTO tagDTO = new TagDTO();
+ tagDTO.setId(id);
+ tagDTO.setUserId(userId);
+ tagDTO.setName(name);
+ tagDTO.setSlug(slug);
+ tagDTO.setTagBackground(tagBackground);
+ tagDTO.setColor(color);
+ return tagDTO;
+ }
+
+ @Override
+ public int compareTo(@org.jetbrains.annotations.NotNull TagDO tagDO) {
+ if (this.sort == null || tagDO.sort == null) {
+ return sort(tagDO);
+ }
+ return this.sort - tagDO.sort;
+ }
+
+ private int sort(@NotNull TagDO tagDO) {
+ Comparator cmp = Collator.getInstance(java.util.Locale.CHINA);
+ return cmp.compare(getName(), tagDO.getName());
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/TagDTO.java b/src/main/java/com/jmal/clouddisk/model/TagDTO.java
new file mode 100644
index 0000000..390700a
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/TagDTO.java
@@ -0,0 +1,59 @@
+package com.jmal.clouddisk.model;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import org.springframework.data.annotation.Id;
+
+import java.text.Collator;
+import java.util.Comparator;
+
+/**
+ * @author jmal
+ * @Description 标签
+ * @Date 2020/10/26 4:30 下午
+ */
+@Data
+@Schema
+@Valid
+public class TagDTO implements Comparable {
+
+ @Id
+ @Schema(hidden = true)
+ private String id;
+
+ @Schema(name = "userId", title = "用户Id")
+ private String userId;
+
+ @NotNull(message = "标签名称不能为空")
+ @Schema(name = "name", title = "标签名称")
+ private String name;
+
+ @Schema(name = "slug", title = "缩略名(默认为name)")
+ private String slug;
+
+ @Schema(name = "tagBackground", title = "标签背景")
+ String tagBackground;
+
+ @Schema(hidden = true, name = "articleNum", title = "文章数")
+ Long articleNum;
+
+ @Schema(hidden = true, name = "color", title = "标签字体颜色")
+ String color;
+
+ @Schema(hidden = true, name = "fontSize", title = "标签字体大小")
+ Long fontSize;
+
+ @Schema(hidden = true, name = "sort", title = "排序")
+ Integer sort;
+
+ @Override
+ public int compareTo(@org.jetbrains.annotations.NotNull TagDTO tagDTO) {
+ if (this.sort == null || tagDTO.sort == null) {
+ Comparator cmp = Collator.getInstance(java.util.Locale.CHINA);
+ return cmp.compare(getName(), tagDTO.getName());
+ }
+ return this.sort - tagDTO.sort;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/Trash.java b/src/main/java/com/jmal/clouddisk/model/Trash.java
new file mode 100644
index 0000000..7fbe513
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/Trash.java
@@ -0,0 +1,91 @@
+package com.jmal.clouddisk.model;
+
+import com.jmal.clouddisk.service.impl.FileServiceImpl;
+import com.jmal.clouddisk.media.VideoInfoDO;
+import lombok.Data;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.util.Objects;
+
+/**
+ * Trash 文件模型
+ *
+ * @author jmal
+ */
+@Data
+@Document(collection = FileServiceImpl.TRASH_COLLECTION_NAME)
+@CompoundIndexes({
+ @CompoundIndex(name = "name_1", def = "{'name': 1}"),
+ @CompoundIndex(name = "size_1", def = "{'size': 1}"),
+ @CompoundIndex(name = "updateDate_1", def = "{'updateDate': 1}"),
+ @CompoundIndex(name = "hidden_1", def = "{'hidden': 1}"),
+})
+public class Trash extends FileBase {
+ private String userId;
+ private String path;
+ /**
+ * 图片的宽度
+ */
+ private String w;
+ /**
+ * 图片的高度
+ */
+ private String h;
+ /**
+ * 文件内容
+ */
+ private byte[] content;
+ /***
+ * 文件后缀名
+ */
+ private String suffix;
+ /**
+ * 音乐
+ */
+ private Music music;
+ /**
+ * 照片exif信息
+ */
+ private ExifInfo exif;
+ /**
+ * 视频信息
+ */
+ private VideoInfoDO video;
+ /**
+ * m3u8文件路径(相对路径)
+ */
+ private String m3u8;
+ /**
+ * vtt文件路径(相对路径)
+ */
+ private String vtt;
+ /**
+ * 是否隐藏显示
+ */
+ private Boolean hidden;
+ /**
+ * 是否移动到回收站, 从原位置移动到jmalcloudTrashDir
+ */
+ private Boolean move;
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ final FileBase other = (FileBase) obj;
+ return Objects.equals(this.id, other.id);
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 7;
+ hash = 89 * hash + (this.id != null ? this.id.hashCode() : 0);
+ return hash;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/UploadApiParamDTO.java b/src/main/java/com/jmal/clouddisk/model/UploadApiParamDTO.java
new file mode 100644
index 0000000..872d27f
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/UploadApiParamDTO.java
@@ -0,0 +1,305 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jmal.clouddisk.util.FileNameUtils;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.InputStream;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @Description UploadApiParam
+ * @Author jmal
+ * @Date 2020-01-27 14:50
+ * @author jmal
+ */
+@Data
+public class UploadApiParamDTO {
+ /***
+ * 当前是第几个分片
+ */
+ Integer chunkNumber;
+ /***
+ * 分片大小
+ */
+ Integer chunkSize;
+ /***
+ * 当前分片大小
+ */
+ Integer currentChunkSize;
+ /***
+ * 文件总大小
+ */
+ Long totalSize;
+ /***
+ * 文件唯一标识MD5
+ */
+ String identifier;
+ /***
+ * 文件或文件夹名
+ */
+ String filename;
+ /**
+ * 文件最后修改时间
+ */
+ Long lastModified;
+ /***
+ * 文件Id
+ */
+ String fileId;
+ /***
+ * 相对路径,上传文件夹是会用到
+ */
+ String relativePath;
+ /***
+ * 总分片大小
+ */
+ Integer totalChunks;
+ MultipartFile file;
+
+ List filenames;
+
+ InputStream inputStream;
+
+ /***
+ * 所有的文件都存在这
+ */
+ String rootPath;
+
+ /***
+ * 用户名
+ */
+ String username;
+ String userId;
+
+ String shareId;
+
+ /***
+ * 当前目录,用户的网盘目录,如果为空则为"/"
+ */
+ String currentDirectory;
+
+ /**
+ * 目录fileId
+ */
+ String folder;
+ Map props;
+ /**
+ * 文件id
+ */
+ String mountFileId;
+
+ /***
+ * 当前目录,用户的实际磁盘目录
+ */
+ String currentAbsoluteDirectory;
+
+ /***
+ * 是否是文件夹
+ */
+ Boolean isFolder;
+ /**
+ * 是否只显示文件夹
+ */
+ Boolean justShowFolder;
+ /**
+ * path后面是否加上文件名
+ */
+ Boolean pathAttachFileName;
+ /***
+ * 当isFolder=true时 生效,是否是根目录
+ */
+ Boolean isRoot;
+ /***
+ * 当isFolder=true时 生效,folderPath为文件夹路径
+ */
+ String folderPath;
+ /***
+ * 文件名后缀
+ */
+ String suffix;
+ /***
+ * 文件类型
+ */
+ String contentType;
+ /***
+ * 要查询的文件类型
+ */
+ String queryFileType;
+ /***
+ * 文件文本
+ */
+ String contentText;
+ /***
+ * 要查询的页数
+ */
+ Integer pageIndex;
+ /***
+ * 要查询的每页条数
+ */
+ Integer pageSize;
+ /***
+ * 要查询的排序参数
+ */
+ String sortableProp;
+ /***
+ * 要查询的排序顺序
+ */
+ String order;
+ /**
+ * 是否显示文件夹大小
+ */
+ Boolean showFolderSize;
+ /***
+ * 是否为草稿
+ */
+ Boolean isDraft;
+ /***
+ * 是否已发布
+ */
+ Boolean isRelease;
+ /**
+ * 是否收藏
+ */
+ Boolean isFavorite;
+ /**
+ * 是否为挂载目录
+ */
+ Boolean isMount;
+ /**
+ * tagId
+ */
+ String tagId;
+ /**
+ * 是否在回收站
+ */
+ Boolean isTrash;
+ /***
+ * 封面
+ */
+ String cover;
+ /***
+ * 缩略名
+ */
+ String slug;
+ /***
+ * 分类Id集合
+ */
+ String[] categoryIds;
+ /***
+ * 标签名称集合
+ */
+ String[] tagNames;
+ /**
+ * 操作权限
+ */
+ List operationPermissionList;
+ /**
+ * 隐藏挂载的文件
+ */
+ Boolean hideMountFile;
+
+ /**
+ * 是否覆盖, 用于移动或复制文件
+ */
+ Boolean isOverride;
+
+ /**
+ * 移动或复制文件的目标路径
+ */
+ String targetPath;
+
+ /**
+ * 修改时间范围开始
+ */
+ Long queryModifyStart;
+
+ /**
+ * 修改时间范围结束
+ */
+ Long queryModifyEnd;
+
+ /**
+ * 文件大小范围最小
+ */
+ Long querySizeMin;
+
+ /**
+ * 文件大小范围最大
+ */
+ Long querySizeMax;
+
+ /**
+ * 是否搜索挂载盘
+ */
+ Boolean searchMount;
+
+ /**
+ * 是否全盘搜索(用户根目录)
+ */
+ Boolean searchOverall;
+
+ /**
+ * 是否精确匹配
+ */
+ Boolean exactSearch;
+
+ /**
+ * 是否包含标签名称
+ */
+ Boolean includeTagName;
+
+ /**
+ * 是否包含文件名
+ */
+ Boolean includeFileName;
+
+ /**
+ * 是否包含文件内容
+ */
+ Boolean includeFileContent;
+
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime uploadDate;
+
+ public String getFilename() {
+ return FileNameUtils.safeDecode(filename);
+ }
+
+ public String getRelativePath() {
+ if (File.separator.equals("\\")) {
+ // Windows 系统
+ relativePath = relativePath.replace("/", "\\");
+ }
+ return FileNameUtils.safeDecode(relativePath);
+ }
+
+ public String getCurrentDirectory() {
+ if (currentDirectory == null || "undefined".equals(currentDirectory)) {
+ return File.separator;
+ }
+ if (File.separator.equals("\\")) {
+ // Windows 系统
+ currentDirectory = currentDirectory.replace("/", "\\");
+ }
+ return FileNameUtils.safeDecode(currentDirectory);
+ }
+
+ public String getFolderPath() {
+ if (folderPath == null || "undefined".equals(folderPath)) {
+ return "";
+ }
+ if (File.separator.equals("\\")) {
+ // Windows 系统
+ folderPath = folderPath.replace("/", "\\");
+ }
+ return FileNameUtils.safeDecode(folderPath);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/UploadImageDTO.java b/src/main/java/com/jmal/clouddisk/model/UploadImageDTO.java
new file mode 100644
index 0000000..6d675d7
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/UploadImageDTO.java
@@ -0,0 +1,24 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * @author jmal
+ * @Description 图片上传传输对象
+ * @Date 2020/11/16 11:12 上午
+ */
+@Data
+public class UploadImageDTO {
+ String username;
+ String userId;
+ /**
+ * markdown文件id
+ */
+ String fileId;
+ /**
+ * 远程url图像
+ */
+ String url;
+ MultipartFile[] files;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/UploadResponse.java b/src/main/java/com/jmal/clouddisk/model/UploadResponse.java
new file mode 100644
index 0000000..7dfe5e2
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/UploadResponse.java
@@ -0,0 +1,44 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @Description UploadResponse
+ * @Author jmal
+ * @Date 2020-01-27 17:04
+ * @author jmal
+ */
+@Data
+public class UploadResponse {
+
+ /***
+ * 服务是否已经存在该文件,通过文件的md5校验
+ */
+ boolean pass;
+ /**
+ * 已存在
+ */
+ boolean exist;
+ /***
+ * 代表这些分片是已经上传过的了
+ */
+ List resume;
+ /***
+ * 是否上传完成
+ */
+ boolean upload;
+ /***
+ * 上传后是合并完成
+ */
+ boolean merge;
+
+ public UploadResponse() {
+ this.pass = false;
+ this.upload = false;
+ this.merge = false;
+ this.resume = new ArrayList<>();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/Urlset.java b/src/main/java/com/jmal/clouddisk/model/Urlset.java
new file mode 100644
index 0000000..fd4dee0
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/Urlset.java
@@ -0,0 +1,47 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
+import lombok.Data;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @author jmal
+ * @Description sitemap xml
+ * @Date 2021/02/25 4:26 下午
+ */
+
+@JacksonXmlRootElement(localName = "urlset")
+@Data
+public class Urlset {
+
+ @JacksonXmlElementWrapper(useWrapping = false)
+ private List url = new ArrayList<>();
+
+ @JacksonXmlRootElement(localName = "url")
+ @Data
+ public static class Url {
+ /***
+ * 必填标签,这是具体某一个链接的定义入口,每一条数据都要用和 包含在里面,这是必须的
+ */
+ @JacksonXmlProperty(localName = "loc")
+ private String loc;
+ /***
+ * 可以不提交该标签,用来指定该链接的最后更新时间
+ */
+ @JacksonXmlProperty(localName = "lastmod")
+ private String lastmod;
+ /***
+ * 可以不提交该标签,用这个标签告诉此链接可能会出现的更新频率
+ */
+ @JacksonXmlProperty(localName = "changefreq")
+ private String changefreq;
+ /***
+ * 可以不提交该标签,用来指定此链接相对于其他链接的优先权比值,此值定于0.0-1.0之间
+ */
+ @JacksonXmlProperty(localName = "priority")
+ private String priority;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/jmal/clouddisk/model/UserAccessTokenDO.java b/src/main/java/com/jmal/clouddisk/model/UserAccessTokenDO.java
new file mode 100644
index 0000000..c0e5362
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/UserAccessTokenDO.java
@@ -0,0 +1,35 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * @Description 用户授权码
+ * @Author jmal
+ * @Date 2020/9/30 10:34 上午
+ */
+@Data
+public class UserAccessTokenDO {
+ String id;
+ /***
+ * 授权码名称
+ */
+ private String name;
+ /***
+ * 用户账号
+ */
+ private String username;
+ /***
+ * 用户授权码
+ */
+ private String accessToken;
+ /***
+ * 创建时间
+ */
+ LocalDateTime createTime;
+ /***
+ * 最近活动时间
+ */
+ LocalDateTime lastActiveTime;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/UserAccessTokenDTO.java b/src/main/java/com/jmal/clouddisk/model/UserAccessTokenDTO.java
new file mode 100644
index 0000000..b1e96e7
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/UserAccessTokenDTO.java
@@ -0,0 +1,38 @@
+package com.jmal.clouddisk.model;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * @Description 用户授权码
+ * @Author jmal
+ * @Date 2020/9/30 10:34 上午
+ */
+@Data
+public class UserAccessTokenDTO {
+ String id;
+ /***
+ * 授权码名称
+ */
+ private String name;
+ /***
+ * 用户账号
+ */
+ private String username;
+ /***
+ * 创建时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @Schema(hidden = true)
+ LocalDateTime createTime;
+ /***
+ * 最近活动时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @Schema(hidden = true)
+ LocalDateTime lastActiveTime;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/UserTokenDO.java b/src/main/java/com/jmal/clouddisk/model/UserTokenDO.java
new file mode 100644
index 0000000..607f1a0
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/UserTokenDO.java
@@ -0,0 +1,20 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * @Description userToken
+ * 用来存储用户登录后的信息
+ * @Author jmal
+ * @Date 2020/9/30 10:34 上午
+ */
+@Data
+@Accessors(chain = true)
+public class UserTokenDO {
+ private String username;
+ /***
+ * 最后一次访问的时间戳
+ */
+ private long timestamp;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/WebsiteSettingDO.java b/src/main/java/com/jmal/clouddisk/model/WebsiteSettingDO.java
new file mode 100644
index 0000000..69da421
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/WebsiteSettingDO.java
@@ -0,0 +1,98 @@
+package com.jmal.clouddisk.model;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author jmal
+ * @Description 网站设置
+ * @Date 2020/11/5 2:45 下午
+ */
+@Data
+public class WebsiteSettingDO {
+
+ /***
+ * 站点背景大图
+ */
+ String backgroundSite;
+ /***
+ * 首页大图内文字 / 心语
+ */
+ String backgroundTextSite;
+ /***
+ * 首页大图内描述
+ */
+ String backgroundDescSite;
+ /***
+ * 网盘logo文件名
+ */
+ String netdiskLogo;
+ /***
+ * 网盘名称
+ */
+ String netdiskName;
+ /***
+ * 站点地址
+ */
+ String siteUrl;
+ /***
+ * 网站 图标
+ */
+ String siteIco;
+ /***
+ * 网站 Logo
+ */
+ String siteLogo;
+ /***
+ * 站点名称
+ */
+ String siteName;
+ /***
+ * 需要显示的独立页面
+ */
+ List alonePages;
+ /***
+ * 操作按钮
+ */
+ String operatingButtons;
+ /***
+ * 分类页面背景
+ */
+ String categoryBackground;
+ /***
+ * 归档页面背景
+ */
+ String archiveBackground;
+ /***
+ * 标签页面背景
+ */
+ String tagBackground;
+ /***
+ * 版权信息
+ */
+ String copyright;
+ /***
+ * 备案许可号
+ */
+ String recordPermissionNum;
+ /**
+ * 公网备案号
+ */
+ String networkRecordNumber;
+
+ /**
+ * 公网备案号 展示文本
+ */
+ String networkRecordNumberStr;
+
+ /**
+ * 页脚html
+ */
+ String footerHtml;
+
+ /**
+ * iframe预览配置
+ */
+ String iframe;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/WebsiteSettingDTO.java b/src/main/java/com/jmal/clouddisk/model/WebsiteSettingDTO.java
new file mode 100644
index 0000000..3fd7e0c
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/WebsiteSettingDTO.java
@@ -0,0 +1,136 @@
+package com.jmal.clouddisk.model;
+
+import cn.hutool.core.util.StrUtil;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author jmal
+ * @Description 网站设置DTO
+ * @Date 2020/11/5 3:58 下午
+ */
+@Data
+public class WebsiteSettingDTO {
+ String userId;
+ /***
+ * 用户头像
+ */
+ String avatar;
+ /***
+ * 站点背景大图
+ */
+ String backgroundSite;
+ /***
+ * 首页大图内文字
+ */
+ String backgroundTextSite;
+ /***
+ * 首页大图内描述
+ */
+ String backgroundDescSite;
+ /***
+ * 网盘logo文件名
+ */
+ String netdiskLogo;
+ /***
+ * 网盘名称
+ */
+ String netdiskName;
+ /***
+ * 站点地址
+ */
+ String siteUrl;
+ /***
+ * 网站 图标
+ */
+ String siteIco;
+ /***
+ * 网站 Logo
+ */
+ String siteLogo;
+ /***
+ * 站点名称
+ */
+ String siteName;
+ /***
+ * 需要显示的独立页面
+ */
+ List alonePages;
+ /***
+ * 操作按钮(原始字符串)
+ */
+ String operatingButtons;
+ /***
+ * 分类页面背景
+ */
+ String categoryBackground;
+ /***
+ * 归档页面背景
+ */
+ String archiveBackground;
+ /***
+ * 标签页面背景
+ */
+ String tagBackground;
+ /***
+ * 操作按钮对象集合
+ */
+ List operatingButtonList;
+
+ /***
+ * 版权信息
+ */
+ String copyright;
+ /***
+ * 备案许可号
+ */
+ String recordPermissionNum;
+
+ /**
+ * 公网备案号
+ */
+ String networkRecordNumber;
+
+ /**
+ * 公网备案号 展示文本
+ */
+ String networkRecordNumberStr;
+
+ /**
+ * 页脚html
+ */
+ String footerHtml;
+
+ /**
+ * iframe预览配置
+ */
+ String iframe;
+
+ Boolean exactSearch;
+
+ @Data
+ public static class OperatingButton {
+ /***
+ * 按钮名称
+ */
+ String title;
+ /***
+ * 按钮图标class
+ */
+ String style;
+ /***
+ * 点击按钮后跳转的地址
+ */
+ String url;
+ }
+
+ public boolean isShowAlonePage(String page){
+ return alonePages.contains(page);
+ }
+
+ public boolean isShowBeian(){
+ return !StrUtil.isBlank(networkRecordNumberStr);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/query/QueryBaseDTO.java b/src/main/java/com/jmal/clouddisk/model/query/QueryBaseDTO.java
new file mode 100644
index 0000000..3deb30b
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/query/QueryBaseDTO.java
@@ -0,0 +1,22 @@
+package com.jmal.clouddisk.model.query;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * @author jmal
+ * @Description 查询条件基类
+ * @Date 2021/1/10 2:03 下午
+ */
+@Data
+@Schema
+public class QueryBaseDTO {
+ @Schema(name = "page", title = "当前页")
+ Integer page;
+ @Schema(name = "pageSize", title = "每页条数")
+ Integer pageSize;
+ @Schema(name = "sortProp", title = "要排序字段")
+ String sortProp;
+ @Schema(name = "sortProp", title = "要排序顺序(descending|ascending)")
+ String sortOrder;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/query/QueryMenuDTO.java b/src/main/java/com/jmal/clouddisk/model/query/QueryMenuDTO.java
new file mode 100644
index 0000000..e6d3056
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/query/QueryMenuDTO.java
@@ -0,0 +1,25 @@
+package com.jmal.clouddisk.model.query;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * @author jmal
+ * @Description 菜单查询条件
+ * @Date 2021/1/10 2:03 下午
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@Schema
+public class QueryMenuDTO extends QueryBaseDTO {
+ @Schema(name = "name", title = "菜单名称")
+ String name;
+ @Schema(name = "code", title = "菜单地址")
+ String path;
+ @Schema(name = "roleId", title = "角色Id")
+ String roleId;
+ @Schema(name = "userId", title = "userId")
+ String userId;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/query/QueryRoleDTO.java b/src/main/java/com/jmal/clouddisk/model/query/QueryRoleDTO.java
new file mode 100644
index 0000000..3e4f5f3
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/query/QueryRoleDTO.java
@@ -0,0 +1,23 @@
+package com.jmal.clouddisk.model.query;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * @author jmal
+ * @Description 角色查询条件
+ * @Date 2021/1/10 2:03 下午
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@Schema
+public class QueryRoleDTO extends QueryBaseDTO {
+ @Schema(name = "name", title = "角色名称")
+ String name;
+ @Schema(name = "code", title = "角色标识")
+ String code;
+ @Schema(name = "remarks", title = "备注")
+ String remarks;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/query/QueryUserDTO.java b/src/main/java/com/jmal/clouddisk/model/query/QueryUserDTO.java
new file mode 100644
index 0000000..e764c9b
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/query/QueryUserDTO.java
@@ -0,0 +1,21 @@
+package com.jmal.clouddisk.model.query;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * @author jmal
+ * @Description 用户查询条件
+ * @Date 2021/1/10 2:03 下午
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@Schema
+public class QueryUserDTO extends QueryBaseDTO {
+ @Schema(name = "username", title = "用户账号")
+ String username;
+ @Schema(name = "showName", title = "用户名")
+ String showName;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/query/SearchDTO.java b/src/main/java/com/jmal/clouddisk/model/query/SearchDTO.java
new file mode 100644
index 0000000..977b126
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/query/SearchDTO.java
@@ -0,0 +1,174 @@
+package com.jmal.clouddisk.model.query;
+
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.util.BooleanUtil;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * @author jmal
+ * @Description Lucene search param
+ * @Date 2021/4/27 5:22 下午
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@Builder
+@Schema
+public class SearchDTO extends QueryBaseDTO {
+
+ String id;
+
+ String userId;
+ /**
+ * 挂载盘用户id
+ */
+ String mountUserId;
+ /**
+ * 要查询的关键字
+ */
+ String keyword;
+ /**
+ * 要查询的文件类型
+ */
+ String type;
+ /**
+ * 当前目录
+ */
+ String currentDirectory;
+ /**
+ * 是否是文件夹
+ */
+ Boolean isFolder;
+ /**
+ * 是否是收藏
+ */
+ Boolean isFavorite;
+ /**
+ * 是否挂载
+ */
+ Boolean isMount;
+ /**
+ * tagId
+ */
+ String tagId;
+ /**
+ * 查询的文件夹
+ */
+ String folder;
+
+ /**
+ * 修改时间范围开始
+ */
+ Long modifyStart;
+
+ /**
+ * 修改时间范围结束
+ */
+ Long modifyEnd;
+
+ /**
+ * 文件大小范围最小
+ */
+ Long sizeMin;
+
+ /**
+ * 文件大小范围最大
+ */
+ Long sizeMax;
+
+ /**
+ * 是否搜索挂载盘
+ */
+ Boolean searchMount;
+
+ /**
+ * 是否全盘搜索(用户根目录)
+ */
+ Boolean searchOverall;
+
+ /**
+ * 是否精确匹配
+ */
+ Boolean exactSearch;
+
+ /**
+ * 是否包含标签名称
+ */
+ Boolean includeTagName;
+
+ /**
+ * 是否包含文件名
+ */
+ Boolean includeFileName;
+
+ /**
+ * 是否包含文件内容
+ */
+ Boolean includeFileContent;
+
+ public String getSearchUserId() {
+ if (CharSequenceUtil.isEmpty(mountUserId)) {
+ return userId;
+ }
+ return isSearchMountSubFolder() ? mountUserId : userId;
+ }
+
+ /**
+ * 是否搜索挂载子目录
+ */
+ public boolean isSearchMountSubFolder() {
+ if (BooleanUtil.isTrue(searchMount)) {
+ return false;
+ }
+ String path = getCurrentDirectory();
+ return CharSequenceUtil.isNotEmpty(folder) && !"/".equals(path);
+ }
+
+ /**
+ * 是否只搜索所有挂载盘
+ */
+ public boolean onlySearchMount() {
+ return BooleanUtil.isTrue(isMount) && !isSearchMountSubFolder();
+ }
+
+ public String getCurrentDirectory() {
+ if (BooleanUtil.isTrue(searchOverall)) {
+ return null;
+ }
+ return currentDirectory;
+ }
+
+ public String getTagId() {
+ String path = getCurrentDirectory();
+ if (CharSequenceUtil.isNotEmpty(path) && !"/".equals(path)) {
+ return null;
+ }
+ return tagId;
+ }
+
+ public SearchOptionHistoryDO toSearchOptionDO() {
+ SearchOptionHistoryDO.SearchOptionHistoryDOBuilder builder = SearchOptionHistoryDO.builder();
+ builder.id(id)
+ .userId(userId)
+ .keyword(keyword)
+ .type(type)
+ .currentDirectory(currentDirectory)
+ .isFolder(isFolder)
+ .isFavorite(isFavorite)
+ .tagId(tagId)
+ .folder(folder)
+ .modifyStart(modifyStart)
+ .modifyEnd(modifyEnd)
+ .sizeMin(sizeMin)
+ .sizeMax(sizeMax)
+ .searchMount(searchMount)
+ .searchOverall(searchOverall)
+ .exactSearch(exactSearch)
+ .includeTagName(includeTagName)
+ .includeFileName(includeFileName)
+ .includeFileContent(includeFileContent);
+ return builder.build();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/query/SearchOptionHistoryDO.java b/src/main/java/com/jmal/clouddisk/model/query/SearchOptionHistoryDO.java
new file mode 100644
index 0000000..fc9bb38
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/query/SearchOptionHistoryDO.java
@@ -0,0 +1,132 @@
+package com.jmal.clouddisk.model.query;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+/**
+ * @author jmal
+ * @Description Lucene search param
+ */
+@EqualsAndHashCode(callSuper = true)
+@Data
+@Builder
+@Document(collection = "searchOptionHistory")
+public class SearchOptionHistoryDO extends QueryBaseDTO {
+
+ String id;
+
+ /**
+ * 搜索时间
+ */
+ Long searchTime;
+ /**
+ * 执行搜索操作的用户id
+ */
+ String searchUserId;
+
+ String userId;
+ /**
+ * 要查询的关键字
+ */
+ String keyword;
+ /**
+ * 要查询的文件类型
+ */
+ String type;
+ /**
+ * 当前目录
+ */
+ String currentDirectory;
+ /**
+ * 是否是文件夹
+ */
+ Boolean isFolder;
+ /**
+ * 是否是收藏
+ */
+ Boolean isFavorite;
+ /**
+ * tagId
+ */
+ String tagId;
+ /**
+ * 查询的文件夹
+ */
+ String folder;
+
+ /**
+ * 修改时间范围开始
+ */
+ Long modifyStart;
+
+ /**
+ * 修改时间范围结束
+ */
+ Long modifyEnd;
+
+ /**
+ * 文件大小范围最小
+ */
+ Long sizeMin;
+
+ /**
+ * 文件大小范围最大
+ */
+ Long sizeMax;
+
+ /**
+ * 是否搜索挂载盘
+ */
+ Boolean searchMount;
+
+ /**
+ * 是否全盘搜索(用户根目录)
+ */
+ Boolean searchOverall;
+
+ /**
+ * 是否精确匹配
+ */
+ Boolean exactSearch;
+
+ /**
+ * 是否包含标签名称
+ */
+ Boolean includeTagName;
+
+ /**
+ * 是否包含文件名
+ */
+ Boolean includeFileName;
+
+ /**
+ * 是否包含文件内容
+ */
+ Boolean includeFileContent;
+
+ public SearchDTO toSearchDTO() {
+ SearchDTO.SearchDTOBuilder builder = SearchDTO.builder();
+ builder.id(id)
+ .userId(userId)
+ .keyword(keyword)
+ .type(type)
+ .currentDirectory(currentDirectory)
+ .isFolder(isFolder)
+ .isFavorite(isFavorite)
+ .tagId(tagId)
+ .folder(folder)
+ .modifyStart(modifyStart)
+ .modifyEnd(modifyEnd)
+ .sizeMin(sizeMin)
+ .sizeMax(sizeMax)
+ .searchMount(searchMount)
+ .searchOverall(searchOverall)
+ .exactSearch(exactSearch)
+ .includeTagName(includeTagName)
+ .includeFileName(includeFileName)
+ .includeFileContent(includeFileContent);
+ return builder.build();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerBase.java b/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerBase.java
new file mode 100644
index 0000000..6c16ca0
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerBase.java
@@ -0,0 +1,15 @@
+package com.jmal.clouddisk.model.rbac;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * @Description ConsumerBase
+ * @blame jmal
+ * @Date 2022/8/20 23:21
+ */
+@Data
+public class ConsumerBase {
+ @Schema(name = "password", title = "密码", example = "123456")
+ String password;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerDO.java b/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerDO.java
new file mode 100644
index 0000000..32b325a
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerDO.java
@@ -0,0 +1,57 @@
+package com.jmal.clouddisk.model.rbac;
+
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.impl.UserServiceImpl;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.data.mongodb.core.index.Indexed;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @Description 用户模型
+ * @author jmal
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema
+@Document(collection = UserServiceImpl.COLLECTION_NAME)
+public class ConsumerDO extends ConsumerBase {
+ String id;
+ @Schema(name = "username", title = "用户名", example = "admin")
+ @Indexed
+ String username;
+ @Indexed
+ @Schema(name = "showName", title = "显示用户名", example = "管理员1")
+ String showName;
+ @Schema(title = "头像", example = "https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif")
+ String avatar;
+ @Schema(name = "slogan", title = "标语")
+ String slogan;
+ @Schema(name = "introduction", title = "简介")
+ String introduction;
+ @Schema(name = "webpDisabled", title = "是否禁用webp")
+ Boolean webpDisabled;
+ @Schema(name = "roles", title = "角色Id集合")
+ List roles;
+ @Schema(name = "quota", title = "默认配额, 10G", example = "10")
+ Integer quota;
+ @Schema(name = "takeUpSpace", title = "已使用的空间")
+ Long takeUpSpace;
+ @Schema(name = Constants.CREATE_TIME, title = "创建时间")
+ LocalDateTime createTime;
+ @Schema(name = "updateTime", title = "修改时间", hidden = true)
+ LocalDateTime updateTime;
+ @Schema(name = "creator", title = "网盘创建者", hidden = true)
+ Boolean creator;
+
+ @Schema(name = "mfaEnabled", title = "mfa_enabled")
+ Boolean mfaEnabled;
+
+ @Schema(name = "mfaSecret", title = "encrypted_mfa_secret")
+ String mfaSecret;
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerDTO.java b/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerDTO.java
new file mode 100644
index 0000000..d733da2
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/rbac/ConsumerDTO.java
@@ -0,0 +1,77 @@
+package com.jmal.clouddisk.model.rbac;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jmal.clouddisk.service.Constants;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springframework.data.annotation.Id;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @Description 用户模型传输对象
+ * @author jmal
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema
+@Valid
+public class ConsumerDTO extends ConsumerBase {
+ @Id
+ String id;
+ @NotNull(message = "用户账号不能为空")
+ @Schema(name = "username", title = "用户账号", example = "admin", requiredMode = Schema.RequiredMode.REQUIRED)
+ String username;
+ @NotNull(message = "用户名不能为空")
+ @Schema(name = "showName", title = "用户名", example = "管理员1", requiredMode = Schema.RequiredMode.REQUIRED)
+ String showName;
+ @Schema(name = "avatar", title = "头像")
+ String avatar;
+ @Schema(name = "slogan", title = "标语")
+ String slogan;
+ @Schema(name = "introduction", title = "简介")
+ String introduction;
+ @Schema(name = "webpDisabled", title = "是否禁用webp")
+ Boolean webpDisabled;
+ @Schema(name = "roles", title = "角色Id集合")
+ List roles;
+ @Schema(name = "roleList", title = "角色集合", hidden = true)
+ List roleList;
+ @Max(value = 1073741824, message = "配额过大")
+ @Schema(name = "quota", title = "默认配额, 10G", example = "10")
+ Integer quota;
+ @Schema(name = "takeUpSpace", title = "已使用的空间")
+ Long takeUpSpace;
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @Schema(name = Constants.CREATE_TIME, title = "创建时间", hidden = true)
+ LocalDateTime createTime;
+ @Schema(name = "netdiskLogo", title = "网盘logo文件名", hidden = true)
+ String netdiskLogo;
+ @Schema(name = "netdiskName", title = "网盘名称", hidden = true)
+ String netdiskName;
+
+ @Schema(name = "rememberMe", title = "记住我", hidden = true)
+ Boolean rememberMe;
+
+ @Schema(name = "newVersion", title = "新版本", hidden = true)
+ String newVersion;
+
+ @Schema(name = "iframe", title = "iframe", hidden = true)
+ String iframe;
+
+ @Schema(name = "exactSearch", title = "exactSearch", hidden = true)
+ Boolean exactSearch;
+
+ @Schema(name = "mfaToken", title = "mfaToken", hidden = true)
+ String mfaToken;
+
+ @Schema(name = "mfaCode", title = "mfaCode", hidden = true)
+ String mfaCode;
+
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/rbac/MenuDO.java b/src/main/java/com/jmal/clouddisk/model/rbac/MenuDO.java
new file mode 100644
index 0000000..c7ae895
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/rbac/MenuDO.java
@@ -0,0 +1,65 @@
+package com.jmal.clouddisk.model.rbac;
+
+import com.jmal.clouddisk.service.impl.MenuService;
+import lombok.Data;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author jmal
+ * @Description 菜单模型
+ * @Date 2021/1/7 8:53 下午
+ */
+@Data
+@Document(collection = MenuService.COLLECTION_NAME)
+public class MenuDO {
+ /***
+ * 主键
+ */
+ String id;
+ /***
+ * 父级菜单Id
+ */
+ String parentId;
+ /***
+ * 菜单名称
+ */
+ String name;
+ /***
+ * 权限标识
+ */
+ String authority;
+ /***
+ * 路由地址
+ */
+ String path;
+ /***
+ * 组件路径
+ */
+ String component;
+ /***
+ * 菜单图标
+ */
+ String icon;
+ /***
+ * 排序号
+ */
+ Integer sortNumber;
+ /***
+ * 菜单类型 0:菜单,1:按钮
+ */
+ Integer menuType;
+ /***
+ * 是否隐藏
+ */
+ Boolean hide;
+ /***
+ * 创建时间
+ */
+ LocalDateTime createTime;
+ /***
+ * 修改时间
+ */
+ LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/rbac/MenuDTO.java b/src/main/java/com/jmal/clouddisk/model/rbac/MenuDTO.java
new file mode 100644
index 0000000..16bcefa
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/rbac/MenuDTO.java
@@ -0,0 +1,75 @@
+package com.jmal.clouddisk.model.rbac;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @Description 菜单传输对象
+ * @blame jmal
+ * @Date 2021/1/7 8:53 下午
+ */
+@Data
+@Valid
+@Schema
+public class MenuDTO implements Comparable{
+ /***
+ * 主键
+ */
+ String id;
+ @Schema(name = "parentId", title = "父级菜单Id")
+ String parentId;
+ @NotNull(message = "菜单名称不能为空")
+ @Schema(name = "name", title = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED)
+ String name;
+ @Schema(name = "authority", title = "权限标识")
+ String authority;
+ @Schema(name = "path", title = "路由地址")
+ String path;
+ @Schema(name = "component", title = "组件路径")
+ String component;
+ @Schema(name = "icon", title = "菜单图标")
+ String icon;
+ @NotNull(message = "排序号不能为空")
+ @Schema(name = "sortNumber", title = "排序号", requiredMode = Schema.RequiredMode.REQUIRED)
+ Integer sortNumber;
+ @NotNull(message = "菜单类型不能为空")
+ @Schema(name = "menuType", title = "菜单类型 0:菜单,1:按钮", requiredMode = Schema.RequiredMode.REQUIRED)
+ Integer menuType;
+ @NotNull(message = "是否隐藏不能为空")
+ @Schema(name = "hide", title = "是否隐藏", requiredMode = Schema.RequiredMode.REQUIRED)
+ Boolean hide;
+ /***
+ * 子菜单
+ */
+ @Schema(hidden = true)
+ private List children;
+ /***
+ * 角色是否拥有该菜单
+ */
+ @Schema(hidden = true)
+ private Boolean checked;
+ /***
+ * 创建时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @Schema(hidden = true)
+ LocalDateTime createTime;
+ /***
+ * 修改时间
+ */
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @Schema(hidden = true)
+ LocalDateTime updateTime;
+
+ @Override
+ public int compareTo(MenuDTO o) {
+ return this.getSortNumber().compareTo(o.getSortNumber());
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/rbac/RoleDO.java b/src/main/java/com/jmal/clouddisk/model/rbac/RoleDO.java
new file mode 100644
index 0000000..6430e62
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/rbac/RoleDO.java
@@ -0,0 +1,46 @@
+package com.jmal.clouddisk.model.rbac;
+
+import com.jmal.clouddisk.service.impl.RoleService;
+import lombok.Data;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @Description 角色模型
+ * @blame jmal
+ * @Date 2021/1/7 7:41 下午
+ */
+@Data
+@Document(collection = RoleService.COLLECTION_NAME)
+public class RoleDO {
+ /***
+ * 主键
+ */
+ String id;
+ /***
+ * 角色名称
+ */
+ String name;
+ /***
+ * 角色标识
+ */
+ String code;
+ /***
+ * 备注
+ */
+ String remarks;
+ /***
+ * 菜单Id列表
+ */
+ List menuIds;
+ /***
+ * 创建时间
+ */
+ LocalDateTime createTime;
+ /***
+ * 修改时间
+ */
+ LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/rbac/RoleDTO.java b/src/main/java/com/jmal/clouddisk/model/rbac/RoleDTO.java
new file mode 100644
index 0000000..b83d9af
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/rbac/RoleDTO.java
@@ -0,0 +1,36 @@
+package com.jmal.clouddisk.model.rbac;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.jmal.clouddisk.service.Constants;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @Description 角色传输对象
+ * @Author jmal
+ * @Date 2021/1/7 7:41 下午
+ */
+@Data
+@Valid
+@Schema
+public class RoleDTO {
+ String id;
+ @NotNull(message = "角色名称不能为空")
+ @Schema(name = "name", title = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED)
+ String name;
+ @NotNull(message = "角色标识不能为空")
+ @Schema(name = "code", title = "角色标识", requiredMode = Schema.RequiredMode.REQUIRED)
+ String code;
+ @Schema(name = "remarks", title = "备注")
+ String remarks;
+ @Schema(name = "menuIds", title = "菜单id列表")
+ List menuIds;
+ @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm:ss")
+ @Schema(name = Constants.CREATE_TIME, title = "创建时间", hidden = true)
+ LocalDateTime createTime;
+}
diff --git a/src/main/java/com/jmal/clouddisk/model/rbac/UserLoginContext.java b/src/main/java/com/jmal/clouddisk/model/rbac/UserLoginContext.java
new file mode 100644
index 0000000..38af501
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/model/rbac/UserLoginContext.java
@@ -0,0 +1,20 @@
+package com.jmal.clouddisk.model.rbac;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @Description 用户登录信息
+ * @blame jmal
+ * @Date 2021/1/9 2:13 下午
+ */
+@Data
+public class UserLoginContext {
+ String userId;
+ String username;
+ /***
+ * 用户权限信息
+ */
+ List authorities;
+}
diff --git a/src/main/java/com/jmal/clouddisk/ocr/CommonOcrService.java b/src/main/java/com/jmal/clouddisk/ocr/CommonOcrService.java
new file mode 100644
index 0000000..b120832
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/ocr/CommonOcrService.java
@@ -0,0 +1,79 @@
+package com.jmal.clouddisk.ocr;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.lang.ObjectId;
+import cn.hutool.core.util.StrUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.media.FFMPEGCommand;
+import com.jmal.clouddisk.service.Constants;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static com.jmal.clouddisk.util.FFMPEGUtils.getWaitingForResults;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class CommonOcrService {
+
+ private final FileProperties fileProperties;
+
+ /**
+ * 生成一个临时的图片路径
+ */
+ public String generateOrcTempImagePath(String username) {
+ Path tempPath;
+ if (StrUtil.isBlank(username)) {
+ tempPath = Paths.get(fileProperties.getRootDir(), fileProperties.getChunkFileDir());
+ } else {
+ tempPath = Paths.get(fileProperties.getRootDir(), fileProperties.getChunkFileDir(), username);
+ }
+ if (!FileUtil.exist(tempPath.toString())) {
+ FileUtil.mkdir(tempPath.toString());
+ }
+ return Paths.get(tempPath.toString(), ObjectId.next(true) + "_temp_ocr.png").toString();
+ }
+
+ /**
+ * 获取OCR识别之前预处理的图片, 使其更易识别
+ * 使用ffmpeg调整灰度和对比度
+ * @param inputPath 原始图片路径
+ * @return 预处理后的图片路径
+ */
+ public static String getPreprocessedOCRImage(String inputPath, String outputPath) {
+ if (FFMPEGCommand.hasNoFFmpeg()) {
+ return outputPath;
+ }
+ if (FileUtil.exist(outputPath)) {
+ return outputPath;
+ }
+ try {
+ ProcessBuilder processBuilder = getPreOCRImageProcessBuilder(inputPath, outputPath);
+ Process process = processBuilder.start();
+ return getWaitingForResults(outputPath, processBuilder, process);
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ return null;
+ } catch (IOException e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ private static ProcessBuilder getPreOCRImageProcessBuilder(String inputPath, String outputPath) {
+ ProcessBuilder processBuilder = new ProcessBuilder(
+ Constants.FFMPEG,
+ "-i", inputPath,
+ "-vf", "format=gray,eq=contrast=1.5:brightness=0.1",
+ outputPath
+ );
+ processBuilder.redirectErrorStream(true);
+ return processBuilder;
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/ocr/IOcrService.java b/src/main/java/com/jmal/clouddisk/ocr/IOcrService.java
new file mode 100644
index 0000000..1313f60
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/ocr/IOcrService.java
@@ -0,0 +1,13 @@
+package com.jmal.clouddisk.ocr;
+
+public interface IOcrService {
+
+ /**
+ * 执行OCR识别
+ * @param imagePath 图片路径
+ * @param tempImagePath 临时图片路径, 用于存放预处理后的图片
+ * @return 识别结果
+ */
+ String doOCR(String imagePath, String tempImagePath);
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/ocr/OcrConfig.java b/src/main/java/com/jmal/clouddisk/ocr/OcrConfig.java
new file mode 100644
index 0000000..998805d
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/ocr/OcrConfig.java
@@ -0,0 +1,55 @@
+package com.jmal.clouddisk.ocr;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import lombok.Data;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+/**
+ * OCR配置
+ */
+@Data
+@Document(collection = "ocrConfig")
+@Valid
+@Schema
+public class OcrConfig {
+
+ @Schema(description = "是否启用orc, 默认开启")
+ private Boolean enable;
+
+ @Max(value = 8, message = "最大任务数不能超过8")
+ @Min(value = 1, message = "最大任务数不能小于1")
+ @Schema(description = "最大任务数, 最多同时处理的ocr任务数, 默认为1")
+ private Integer maxTasks;
+
+ @Schema(description = "ocr引擎, 默认tesseract")
+ private String ocrEngine;
+
+ public Boolean getEnable() {
+ if (enable == null)
+ return true;
+ return enable;
+ }
+
+ public String getOcrEngine() {
+ if (ocrEngine == null)
+ return OcrEngine.TESSERACT.getOcrEngineName();
+ return ocrEngine;
+ }
+
+ public void setOcrEngine(String ocrEngine) {
+ if (ocrEngine == null)
+ return;
+ if (!OcrEngine.TESSERACT.getOcrEngineName().equals(ocrEngine) && !OcrEngine.OCR_LITE_ONNX.getOcrEngineName().equals(ocrEngine))
+ return;
+ this.ocrEngine = ocrEngine;
+ }
+
+ public Integer getMaxTasks() {
+ if (maxTasks == null)
+ return 1;
+ return maxTasks;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/ocr/OcrEngine.java b/src/main/java/com/jmal/clouddisk/ocr/OcrEngine.java
new file mode 100644
index 0000000..a82ed6c
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/ocr/OcrEngine.java
@@ -0,0 +1,15 @@
+package com.jmal.clouddisk.ocr;
+
+import lombok.Getter;
+
+@Getter
+public enum OcrEngine {
+ OCR_LITE_ONNX("ocrLiteOnnx"),
+ TESSERACT("tesseract");
+
+ private final String ocrEngineName;
+
+ OcrEngine(String ocrEngineName) {
+ this.ocrEngineName = ocrEngineName;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/ocr/OcrLiteOnnxService.java b/src/main/java/com/jmal/clouddisk/ocr/OcrLiteOnnxService.java
new file mode 100644
index 0000000..0ac93da
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/ocr/OcrLiteOnnxService.java
@@ -0,0 +1,99 @@
+package com.jmal.clouddisk.ocr;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.media.FFMPEGCommand;
+import com.jmal.clouddisk.service.Constants;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+
+import static com.jmal.clouddisk.util.FFMPEGUtils.getWaitingForResults;
+
+
+@Service("ocrLiteOnnx")
+@RequiredArgsConstructor
+@Slf4j
+public class OcrLiteOnnxService implements IOcrService {
+
+ public final FileProperties fileProperties;
+
+ @Override
+ public String doOCR(String imagePath, String tempImagePath) {
+ if (CharSequenceUtil.isBlank(imagePath)) {
+ return "";
+ }
+ String resultTxtPath = null;
+ try {
+ resultTxtPath = getResultText(imagePath, imagePath + "-result.txt");
+ if (!FileUtil.isFile(resultTxtPath)) {
+ return "";
+ }
+ return FileUtil.readUtf8String(resultTxtPath);
+ } catch (Exception e) {
+ log.warn("Error while performing OCR: {}", e.getMessage(), e);
+ } finally {
+ FileUtil.del(tempImagePath);
+ if (FileUtil.isFile(resultTxtPath)) {
+ FileUtil.del(resultTxtPath);
+ }
+ }
+ return "";
+ }
+
+ /**
+ * 获取OCR识别结果
+ * @param inputPath 输入图片路径
+ * @param outputPath 输出文件路径
+ * @return String
+ */
+ public String getResultText(String inputPath, String outputPath) {
+ if (FFMPEGCommand.hasNoFFmpeg()) {
+ return outputPath;
+ }
+ if (FileUtil.exist(outputPath)) {
+ return outputPath;
+ }
+ try {
+ ProcessBuilder processBuilder = getOcrLiteOnnxProcessBuilder(inputPath);
+ Process process = processBuilder.start();
+ return getWaitingForResults(outputPath, processBuilder, process, 60);
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (IOException e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ /**
+ * 使用ocr_lite_onnx进行OCR识别
+ * @param inputPath 输入图片路径
+ * @return ProcessBuilder
+ */
+ private ProcessBuilder getOcrLiteOnnxProcessBuilder(String inputPath) {
+ ProcessBuilder processBuilder = new ProcessBuilder(
+ Constants.OCR_LITE_ONNX,
+ "--models", fileProperties.getOcrLiteONNXModelPath(),
+ "--det", "dbnet.onnx",
+ "--rec", "crnn_lite_lstm.onnx",
+ "--keys", "keys.txt",
+ "--image", inputPath,
+ "--numThread", "1",
+ "--padding", "40",
+ "--maxSideLen", "2048",
+ "--boxScoreThresh", "0.6",
+ "--boxThresh", "0.3",
+ "--unClipRatio", "2.0",
+ "--doAngle", "0",
+ "--mostAngle", "0",
+ "--outputResultImg", "0"
+ );
+ processBuilder.redirectErrorStream(true);
+ return processBuilder;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/ocr/OcrService.java b/src/main/java/com/jmal/clouddisk/ocr/OcrService.java
new file mode 100644
index 0000000..ded4aa7
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/ocr/OcrService.java
@@ -0,0 +1,162 @@
+package com.jmal.clouddisk.ocr;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.jmal.clouddisk.exception.CommonException;
+import com.jmal.clouddisk.exception.ExceptionType;
+import com.jmal.clouddisk.lucene.TaskProgressService;
+import com.jmal.clouddisk.lucene.TaskType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pdfbox.rendering.ImageType;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.util.Map;
+import java.util.concurrent.Semaphore;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class OcrService {
+
+ private final Map ocrServiceMap;
+
+ private final CommonOcrService commonOcrService;
+
+ private final TaskProgressService taskProgressService;
+
+ private final MongoTemplate mongoTemplate;
+
+ // 初始设置为1个并发请求
+ private final Semaphore semaphore = new Semaphore(1);
+
+ private final Cache ocrConfigCache = Caffeine.newBuilder().build();
+
+ /**
+ * 提取PDF页面并使用OCR识别
+ * @param pdfRenderer PDFRenderer
+ * @param pageIndex 页码
+ * @param username 用户名
+ * @return 识别结果
+ */
+ public String extractPageWithOCR(File file, PDFRenderer pdfRenderer, int pageIndex, int totalPages, String username) {
+ try {
+
+ taskProgressService.addTaskProgress(file, TaskType.OCR, pageIndex + 1 + "/" + totalPages + " - 等待识别");
+
+ // 获取许可,如果没有可用许可则会阻塞
+ semaphore.acquire();
+
+ taskProgressService.addTaskProgress(file, TaskType.OCR, pageIndex + 1 + "/" + totalPages + " - 识别中...");
+
+ BufferedImage pageImage = pdfRenderer.renderImage(pageIndex, 4, ImageType.GRAY);
+ String tempImageFile = generateOrcTempImagePath(username);
+ ImageIO.write(pageImage, "png", new File(tempImageFile));
+ try {
+ // 使用 OCR 识别页面内容
+ return doOCR(tempImageFile, generateOrcTempImagePath(username), null);
+ } finally {
+ FileUtil.del(tempImageFile);
+ taskProgressService.removeTaskProgress(file);
+ // 释放许可
+ semaphore.release();
+ }
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ log.error("Error processing page {}", pageIndex + 1, e);
+ }
+ return "";
+ }
+
+ public String doOCR(String imagePath, String tempImagePath, String ocrEngine) {
+ OcrConfig config = getOcrConfig();
+ if (Boolean.FALSE.equals(config.getEnable())) {
+ return "";
+ }
+ if (CharSequenceUtil.isBlank(ocrEngine)) {
+ ocrEngine = config.getOcrEngine();
+ }
+ IOcrService ocrService = ocrServiceMap.get(ocrEngine);
+ if (ocrService == null) {
+ throw new IllegalArgumentException("Unknown OCR engine: " + ocrEngine);
+ }
+ return ocrService.doOCR(imagePath, tempImagePath);
+ }
+
+ /**
+ * 动态调整并发数量
+ */
+ public void setMaxConcurrentRequests(int maxConcurrentRequests) {
+ int currentPermits = semaphore.availablePermits();
+ if (maxConcurrentRequests > currentPermits) {
+ semaphore.release(maxConcurrentRequests - currentPermits);
+ } else if (maxConcurrentRequests < currentPermits) {
+ // 清空所有许可并重新设置
+ semaphore.drainPermits();
+ semaphore.release(maxConcurrentRequests);
+ }
+ }
+
+ public OcrConfig getOcrConfig() {
+ return ocrConfigCache.get("ocrConfig", key -> {
+ OcrConfig config = mongoTemplate.findOne(new Query(), OcrConfig.class);
+ if (config != null) {
+ setMaxConcurrentRequests(config.getMaxTasks());
+ return config;
+ }
+ return new OcrConfig();
+ });
+ }
+
+ private static Update getOcrConfigUpdate(OcrConfig config) {
+ Update update = new Update();
+ update.set("enable", config.getEnable());
+ update.set("maxTasks", config.getMaxTasks());
+ update.set("ocrEngine", config.getOcrEngine());
+ return update;
+ }
+
+ /**
+ * 设置Ocr配置
+ *
+ * @param config OcrConfig
+ */
+ public long setOcrConfig(OcrConfig config) {
+ if (config == null) {
+ throw new CommonException(ExceptionType.PARAMETERS_VALUE);
+ }
+ Query query = new Query();
+ OcrConfig ocrConfig = mongoTemplate.findOne(query, OcrConfig.class);
+ if (ocrConfig == null) {
+ mongoTemplate.save(config);
+ } else {
+ Update update = getOcrConfigUpdate(config);
+ mongoTemplate.updateFirst(query, update, OcrConfig.class);
+ if (!semaphore.hasQueuedThreads() && semaphore.availablePermits() == ocrConfig.getMaxTasks()) {
+ semaphore.drainPermits();
+ semaphore.release(config.getMaxTasks());
+ }
+ }
+ ocrConfigCache.put("ocrConfig", config);
+ return 0;
+ }
+
+ /**
+ * 生成一个临时的图片路径
+ */
+ public String generateOrcTempImagePath(String username) {
+ return commonOcrService.generateOrcTempImagePath(username);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/ocr/TesseractOcrConfig.java b/src/main/java/com/jmal/clouddisk/ocr/TesseractOcrConfig.java
new file mode 100644
index 0000000..ec1e9e2
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/ocr/TesseractOcrConfig.java
@@ -0,0 +1,36 @@
+package com.jmal.clouddisk.ocr;
+
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import net.sourceforge.tess4j.ITessAPI;
+import net.sourceforge.tess4j.Tesseract;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@Configuration
+@ConfigurationProperties(prefix = "tess4j")
+@Slf4j
+public class TesseractOcrConfig {
+
+ private String dataPath;
+
+ @Bean
+ public ThreadLocal tesseractThreadLocal() {
+ // 设置 Tess4J 的日志级别
+ return ThreadLocal.withInitial(() -> {
+ Tesseract tesseract = new Tesseract();
+ // 设置数据文件夹路径
+ tesseract.setDatapath(dataPath);
+ // 设置为中文简体
+ tesseract.setLanguage("chi_sim");
+ tesseract.setOcrEngineMode(ITessAPI.TessOcrEngineMode.OEM_LSTM_ONLY);
+ tesseract.setPageSegMode(ITessAPI.TessPageSegMode.PSM_AUTO);
+ return tesseract;
+ });
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/ocr/TesseractService.java b/src/main/java/com/jmal/clouddisk/ocr/TesseractService.java
new file mode 100644
index 0000000..7c30d2c
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/ocr/TesseractService.java
@@ -0,0 +1,55 @@
+package com.jmal.clouddisk.ocr;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import net.sourceforge.tess4j.ITesseract;
+import net.sourceforge.tess4j.Tesseract;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+
+
+@Service("tesseract")
+@RequiredArgsConstructor
+@Slf4j
+@Primary
+public class TesseractService implements IOcrService {
+
+ private final ThreadLocal tesseractThreadLocal;
+
+ private final CommonOcrService commonOcrService;
+
+ @Override
+ public String doOCR(String imagePath, String tempImagePath) {
+ if (CharSequenceUtil.isBlank(imagePath)) {
+ return "";
+ }
+ try {
+ if (CharSequenceUtil.isBlank(tempImagePath)) {
+ tempImagePath = commonOcrService.generateOrcTempImagePath(null);
+ }
+ // 预处理后的图片
+ String preprocessedOCRImage = CommonOcrService.getPreprocessedOCRImage(imagePath, tempImagePath);
+ if (CharSequenceUtil.isBlank(preprocessedOCRImage)) {
+ return "";
+ }
+ File imageFile = new File(preprocessedOCRImage);
+ ITesseract tesseract = tesseractThreadLocal.get();
+ return tesseract.doOCR(imageFile);
+ } catch (Exception e) {
+ log.warn("Error while performing OCR: {}", e.getMessage(), e);
+ } finally {
+ FileUtil.del(tempImagePath);
+ }
+ return "";
+ }
+
+ @PreDestroy
+ public void unload() {
+ tesseractThreadLocal.remove();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/OfficeConfigService.java b/src/main/java/com/jmal/clouddisk/office/OfficeConfigService.java
new file mode 100644
index 0000000..cbe09bc
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/OfficeConfigService.java
@@ -0,0 +1,106 @@
+package com.jmal.clouddisk.office;
+
+import cn.hutool.core.codec.Base62;
+import cn.hutool.core.util.BooleanUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.KeyUtil;
+import cn.hutool.crypto.symmetric.SymmetricAlgorithm;
+import cn.hutool.jwt.JWT;
+import cn.hutool.jwt.signers.JWTSigner;
+import cn.hutool.jwt.signers.JWTSignerUtil;
+import com.jmal.clouddisk.office.model.OfficeConfigDO;
+import com.jmal.clouddisk.office.model.OfficeConfigDTO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class OfficeConfigService {
+
+ public final static String VO_KEY = "****************";
+
+ private final MongoTemplate mongoTemplate;
+
+ private volatile OfficeConfigDTO officeConfigDTO;
+
+ private OfficeConfigDTO getOfficeConfigCache() {
+ if (officeConfigDTO == null) {
+ synchronized (this) {
+ if (officeConfigDTO == null) {
+ OfficeConfigDO officeConfigDO = mongoTemplate.findOne(new Query(), OfficeConfigDO.class);
+ if (officeConfigDO == null) {
+ officeConfigDTO = new OfficeConfigDTO();
+ } else {
+ officeConfigDTO = officeConfigDO.toOfficeConfigCache();
+ }
+ }
+ }
+ }
+ return officeConfigDTO;
+ }
+
+ public String createOfficeToken(final Map payloadClaims) {
+ String secret = officeConfigDTO.getSecret();
+ if (StrUtil.isBlank(secret)) {
+ return "";
+ }
+ final JWTSigner signer = JWTSignerUtil.hs256(secret.getBytes());
+ JWT jwt = JWT.create().addPayloads(payloadClaims);
+ return jwt.sign(signer);
+ }
+
+ public OfficeConfigDTO getOfficeConfig() {
+ OfficeConfigDTO officeConfigDTO = getOfficeConfigCache();
+ OfficeConfigDTO officeConfigVO = new OfficeConfigDTO();
+ officeConfigVO.setDocumentServer(officeConfigDTO.getDocumentServer());
+ officeConfigVO.setCallbackServer(officeConfigDTO.getCallbackServer());
+ officeConfigVO.setTokenEnabled(officeConfigDTO.isTokenEnabled());
+ officeConfigVO.setFormat(officeConfigDTO.getFormat());
+ if (StrUtil.isNotBlank(officeConfigDTO.getSecret())) {
+ officeConfigVO.setSecret(VO_KEY);
+ }
+ return officeConfigVO;
+ }
+
+ public void setOfficeConfig(OfficeConfigDTO officeConfigDTO) {
+ OfficeConfigDO officeConfigDO = officeConfigDTO.toOfficeConfigDO();
+ Query query = new Query();
+ Update update = new Update()
+ .set("documentServer", officeConfigDO.getDocumentServer())
+ .set("callbackServer", officeConfigDO.getCallbackServer())
+ .set("tokenEnabled", officeConfigDO.getTokenEnabled())
+ .set("format", officeConfigDO.getFormat());
+ if (!VO_KEY.equals(officeConfigDTO.getSecret())) {
+ update.set("encrypted", officeConfigDO.getEncrypted());
+ update.set("key", officeConfigDO.getKey());
+ }
+ mongoTemplate.upsert(query, update, OfficeConfigDO.class);
+
+ // 重置缓存的密钥,使其在下次访问时重新加载
+ synchronized (this) {
+ this.officeConfigDTO.setDocumentServer(officeConfigDTO.getDocumentServer());
+ this.officeConfigDTO.setCallbackServer(officeConfigDTO.getCallbackServer());
+ this.officeConfigDTO.setFormat(officeConfigDTO.getFormat());
+ this.officeConfigDTO.setTokenEnabled(StrUtil.isNotBlank(officeConfigDTO.getSecret()));
+ if (VO_KEY.equals(officeConfigDTO.getSecret())) {
+ return;
+ }
+ if (BooleanUtil.isTrue(this.officeConfigDTO.isTokenEnabled())) {
+ this.officeConfigDTO.setSecret(officeConfigDTO.getSecret());
+ } else {
+ this.officeConfigDTO.setSecret(null);
+ }
+ }
+ }
+
+ public static String generateKey() {
+ byte[] keyBytes = KeyUtil.generateKey(SymmetricAlgorithm.AES.getValue(), 256).getEncoded();
+ String base62Key = Base62.encode(keyBytes);
+ return base62Key.length() > 32 ? base62Key.substring(0, 32) : base62Key;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/OfficeController.java b/src/main/java/com/jmal/clouddisk/office/OfficeController.java
new file mode 100644
index 0000000..a497024
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/OfficeController.java
@@ -0,0 +1,81 @@
+package com.jmal.clouddisk.office;
+
+import com.jmal.clouddisk.annotation.Permission;
+import com.jmal.clouddisk.office.callbacks.CallbackHandler;
+import com.jmal.clouddisk.office.model.OfficeConfigDTO;
+import com.jmal.clouddisk.office.model.Track;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.impl.ShareServiceImpl;
+import com.jmal.clouddisk.util.ResponseResult;
+import com.jmal.clouddisk.util.ResultUtil;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * @author jmal
+ * @Description office文件管理
+ * @date 2022/8/11 16:29
+ */
+@Tag(name = "office文件管理")
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class OfficeController {
+
+ private final CallbackHandler callbackHandler;
+
+ private final ShareServiceImpl shareService;
+
+ private final OfficeConfigService officeConfigService;
+
+ @Operation(summary = "office 回调")
+ @PostMapping("/office/track")
+ @Permission("cloud:file:list")
+ public String track(@RequestParam(Constants.FILE_ID) String fileId, @RequestBody Track body) {
+ body.setFileId(fileId);
+ int error = callbackHandler.handle(body);
+ return "{\"error\":" + error + "}";
+ }
+
+ @Operation(summary = "signature")
+ @PostMapping("/office/signature")
+ public ResponseResult signature(@RequestBody Map config) {
+ return ResultUtil.success(officeConfigService.createOfficeToken(config));
+ }
+
+ @Operation(summary = "获取office配置")
+ @GetMapping("/office/config")
+ @Permission(value = "cloud:file:list")
+ public ResponseResult getOfficeConfig() {
+ return ResultUtil.success(officeConfigService.getOfficeConfig());
+ }
+
+ @Operation(summary = "更新office配置")
+ @PutMapping("/office/config")
+ @Permission(value = "cloud:set:sync")
+ public ResponseResult putOfficeConfig(@RequestBody @Validated OfficeConfigDTO officeConfigDTO) {
+ officeConfigService.setOfficeConfig(officeConfigDTO);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "public signature")
+ @PostMapping("/public/office/signature")
+ public ResponseResult signaturePublic(@RequestBody Map config, @RequestParam String shareId, String shareToken) {
+ shareService.validShare(shareToken, shareId);
+ return ResultUtil.success(officeConfigService.createOfficeToken(config));
+ }
+
+ @Operation(summary = "获取public office配置")
+ @GetMapping("/public/office/config")
+ public ResponseResult getPublicOfficeConfig(@RequestParam String shareId, String shareToken) {
+ shareService.validShare(shareToken, shareId);
+ return ResultUtil.success(officeConfigService.getOfficeConfig());
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/OfficeHistory.java b/src/main/java/com/jmal/clouddisk/office/OfficeHistory.java
new file mode 100644
index 0000000..91a87df
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/OfficeHistory.java
@@ -0,0 +1,17 @@
+package com.jmal.clouddisk.office;
+
+import lombok.Data;
+
+@Data
+public class OfficeHistory {
+ private String created;
+ private String key;
+ private User user;
+ private String version;
+
+ @Data
+ public static class User {
+ private String id;
+ private String name;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/callbacks/Callback.java b/src/main/java/com/jmal/clouddisk/office/callbacks/Callback.java
new file mode 100644
index 0000000..5007abb
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/callbacks/Callback.java
@@ -0,0 +1,33 @@
+package com.jmal.clouddisk.office.callbacks;
+
+import com.jmal.clouddisk.office.model.Track;
+import org.springframework.beans.factory.annotation.Autowired;
+
+/**
+ * @author jmal
+ * @Description OnlyOfficeService Callback
+ * @date 2022/8/11 16:29
+ */
+public interface Callback {
+ /**
+ * 处理回调
+ * @param body body
+ * @return 返回码
+ */
+ int handle(Track body);
+
+ /**
+ * 获取文档状态
+ * @return Status
+ */
+ int getStatus();
+
+ /**
+ * 注册一个回调处理程序
+ * @param callbackHandler CallbackHandler
+ */
+ @Autowired
+ default void selfRegistration(CallbackHandler callbackHandler){
+ callbackHandler.register(getStatus(), this);
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/callbacks/CallbackHandler.java b/src/main/java/com/jmal/clouddisk/office/callbacks/CallbackHandler.java
new file mode 100644
index 0000000..3402c5a
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/callbacks/CallbackHandler.java
@@ -0,0 +1,33 @@
+package com.jmal.clouddisk.office.callbacks;
+
+import com.jmal.clouddisk.office.model.Track;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author jmal
+ * @Description CallbackHandler
+ * @date 2022/8/11 16:29
+ */
+@Service
+@Slf4j
+public class CallbackHandler {
+
+ private final Map callbackHandlers = new HashMap<>();
+
+ public void register(int code, Callback callback){
+ // 注册一个回调处理程序
+ callbackHandlers.put(code, callback);
+ }
+
+ public int handle(Track body){
+ Callback callback = callbackHandlers.get(body.getStatus());
+ if (callback == null){
+ log.warn("Callback status "+body.getStatus()+" is not supported yet");
+ return 0;
+ }
+ return callback.handle(body);
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/callbacks/Status.java b/src/main/java/com/jmal/clouddisk/office/callbacks/Status.java
new file mode 100644
index 0000000..fbb472e
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/callbacks/Status.java
@@ -0,0 +1,40 @@
+package com.jmal.clouddisk.office.callbacks;
+
+/**
+ * @author jmal
+ * @Description 文件状态
+ * @date 2022/8/11 16:29
+ */
+public enum Status {
+ /**
+ * 正在编辑文件
+ */
+ EDITING(1),
+ /**
+ * 文件已准备好保存
+ */
+ SAVE(2),
+ /**
+ * 发生文件保存错误
+ */
+ CORRUPTED(3),
+ /**
+ * 退出编辑但是什么都没做
+ */
+ EDIT_NOTHING(4),
+ /**
+ * 正在编辑文档,但保存了当前文档状态
+ */
+ MUST_FORCE_SAVE(6),
+ /**
+ * 强制保存文档时发生错误
+ */
+ CORRUPTED_FORCE_SAVE(7);
+ private final int code;
+ Status(int code){
+ this.code = code;
+ }
+ public int getCode(){
+ return this.code;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/EditCallback.java b/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/EditCallback.java
new file mode 100644
index 0000000..bc35ae1
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/EditCallback.java
@@ -0,0 +1,26 @@
+package com.jmal.clouddisk.office.callbacks.implementations;
+
+import com.jmal.clouddisk.office.callbacks.Callback;
+import com.jmal.clouddisk.office.callbacks.Status;
+import com.jmal.clouddisk.office.model.Track;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author jmal
+ * @Description 在编辑文档时处理回调
+ * @date 2022/8/11 16:29
+ */
+@Component
+public class EditCallback implements Callback {
+
+ @Override
+ public int handle(Track body) {
+ return 0;
+ }
+
+ @Override
+ public int getStatus() {
+ // 1 -文件正在编辑中
+ return Status.EDITING.getCode();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/EditNothingCallback.java b/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/EditNothingCallback.java
new file mode 100644
index 0000000..368f830
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/EditNothingCallback.java
@@ -0,0 +1,26 @@
+package com.jmal.clouddisk.office.callbacks.implementations;
+
+import com.jmal.clouddisk.office.callbacks.Callback;
+import com.jmal.clouddisk.office.callbacks.Status;
+import com.jmal.clouddisk.office.model.Track;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author jmal
+ * @Description 在编辑文档时处理回调
+ * @date 2022/8/11 16:29
+ */
+@Component
+public class EditNothingCallback implements Callback {
+
+ @Override
+ public int handle(Track body) {
+ return 0;
+ }
+
+ @Override
+ public int getStatus() {
+ // 4 - 退出正在编辑的文件
+ return Status.EDIT_NOTHING.getCode();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/ForceSaveCallback.java b/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/ForceSaveCallback.java
new file mode 100644
index 0000000..0590308
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/ForceSaveCallback.java
@@ -0,0 +1,26 @@
+package com.jmal.clouddisk.office.callbacks.implementations;
+
+import com.jmal.clouddisk.office.callbacks.Callback;
+import com.jmal.clouddisk.office.callbacks.Status;
+import com.jmal.clouddisk.office.model.Track;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author jmal
+ * @Description 执行强制保存请求时处理回调
+ * @date 2022/8/11 16:29
+ */
+@Component
+public class ForceSaveCallback implements Callback {
+
+ @Override
+ public int handle(Track body) {
+ return 0;
+ }
+
+ @Override
+ public int getStatus() {
+ // 6 -正在编辑文档,但保存了当前文档状态
+ return Status.MUST_FORCE_SAVE.getCode();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/SaveCallback.java b/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/SaveCallback.java
new file mode 100644
index 0000000..37ae36b
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/callbacks/implementations/SaveCallback.java
@@ -0,0 +1,97 @@
+package com.jmal.clouddisk.office.callbacks.implementations;
+
+import cn.hutool.http.HttpUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.model.OperationPermission;
+import com.jmal.clouddisk.office.callbacks.Callback;
+import com.jmal.clouddisk.office.callbacks.Status;
+import com.jmal.clouddisk.office.model.Track;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IFileVersionService;
+import com.jmal.clouddisk.service.IUserService;
+import com.jmal.clouddisk.service.impl.CommonFileService;
+import com.jmal.clouddisk.service.impl.LogService;
+import com.jmal.clouddisk.service.impl.UserLoginHolder;
+import com.jmal.clouddisk.util.TimeUntils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @author jmal
+ * @Description 在执行保存请求时处理回调
+ * @date 2022/8/11 16:29
+ */
+@Component
+@Slf4j
+@RequiredArgsConstructor
+public class SaveCallback implements Callback {
+
+ private final FileProperties fileProperties;
+
+ private final UserLoginHolder userLoginHolder;
+
+ private final MongoTemplate mongoTemplate;
+
+ private final CommonFileService commonFileService;
+
+ private final LogService logService;
+
+ private final IUserService userService;
+
+ private final IFileVersionService fileVersionService;
+
+ @Override
+ public int handle(Track body) {
+ int result = 0;
+ try {
+
+ FileDocument fileDocument = mongoTemplate.findById(body.getFileId(), FileDocument.class, CommonFileService.COLLECTION_NAME);
+ if (fileDocument == null) {
+ return 1;
+ }
+
+ // 检查权限
+ String userId = fileDocument.getUserId();
+ String fileUsername = userService.getUserNameById(userId);
+ List operationPermissionList = fileDocument.getOperationPermissionList();
+ commonFileService.checkPermissionUserId(userId, operationPermissionList, OperationPermission.PUT);
+
+ Path path = Paths.get(fileProperties.getRootDir(), fileUsername, fileDocument.getPath(), fileDocument.getName());
+
+ // 下载最新的文件
+ long size = HttpUtil.downloadFile(body.getUrl(), path.toString());
+ String md5 = size + "/" + fileDocument.getName();
+ LocalDateTime updateDate = LocalDateTime.now(TimeUntils.ZONE_ID);
+ // 推送修改文件的通知
+ fileDocument.setSize(size);
+ fileDocument.setUpdateDate(updateDate);
+ fileDocument.setMd5(md5);
+ // 修改文件日志
+ logService.asyncAddLogFileOperation(fileUsername, Paths.get(fileDocument.getPath(), fileDocument.getName()).toString(), "修改文件");
+ commonFileService.pushMessage(userLoginHolder.getUsername(), fileDocument, Constants.UPDATE_FILE);
+
+ // 修改文件之后保存历史版本
+ String relativePath = Paths.get(fileDocument.getPath(), fileDocument.getName()).toString();
+ fileVersionService.saveFileVersion(fileUsername, relativePath, userId);
+
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ return 1;
+ }
+ return result;
+ }
+
+ @Override
+ public int getStatus() {
+ // 2 - 文件已准备好保存
+ return Status.SAVE.getCode();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/model/Action.java b/src/main/java/com/jmal/clouddisk/office/model/Action.java
new file mode 100644
index 0000000..fa91a32
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/model/Action.java
@@ -0,0 +1,15 @@
+package com.jmal.clouddisk.office.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+public class Action {
+ private String userid;
+ private com.jmal.clouddisk.office.model.enums.Action type;
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/model/OfficeConfigDO.java b/src/main/java/com/jmal/clouddisk/office/model/OfficeConfigDO.java
new file mode 100644
index 0000000..4516ce0
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/model/OfficeConfigDO.java
@@ -0,0 +1,39 @@
+package com.jmal.clouddisk.office.model;
+
+import cn.hutool.crypto.symmetric.SymmetricAlgorithm;
+import cn.hutool.crypto.symmetric.SymmetricCrypto;
+import lombok.Data;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.util.List;
+
+@Data
+@Document(collection = "officeConfig")
+public class OfficeConfigDO {
+
+ private String documentServer;
+
+ private String callbackServer;
+
+ private String encrypted;
+
+ private String key;
+
+ private Boolean tokenEnabled;
+
+ private List format;
+
+ public OfficeConfigDTO toOfficeConfigCache() {
+ OfficeConfigDTO officeConfigDTO = new OfficeConfigDTO();
+ officeConfigDTO.setDocumentServer(this.documentServer);
+ officeConfigDTO.setCallbackServer(this.callbackServer);
+ officeConfigDTO.setFormat(this.format);
+ officeConfigDTO.setTokenEnabled(this.tokenEnabled);
+ if (this.tokenEnabled && this.encrypted != null && this.key != null) {
+ SymmetricCrypto symmetricCrypto = new SymmetricCrypto(SymmetricAlgorithm.AES, this.key.getBytes());
+ String secret = symmetricCrypto.decryptStr(this.encrypted);
+ officeConfigDTO.setSecret(secret);
+ }
+ return officeConfigDTO;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/model/OfficeConfigDTO.java b/src/main/java/com/jmal/clouddisk/office/model/OfficeConfigDTO.java
new file mode 100644
index 0000000..9408b52
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/model/OfficeConfigDTO.java
@@ -0,0 +1,63 @@
+package com.jmal.clouddisk.office.model;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.symmetric.SymmetricAlgorithm;
+import cn.hutool.crypto.symmetric.SymmetricCrypto;
+import com.jmal.clouddisk.office.OfficeConfigService;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@Valid
+public class OfficeConfigDTO {
+
+ @Pattern(
+ regexp = "^(https?://)([\\w.-]+)(:[0-9]+)?(/.*)?$|^$",
+ message = "文档服务器地址格式不正确"
+ )
+ @Schema(name = "documentServer", title = "文档服务器地址", example = "http://localhost:8082/office/word")
+ private String documentServer;
+
+ @Schema(name = "secret", title = "密钥")
+ private String secret;
+
+ @Pattern(
+ regexp = "^(https?://)([\\w.-]+)(:[0-9]+)?(/.*)?$|^$",
+ message = "回调服务地址格式不正确"
+ )
+ @Schema(name = "callbackServer", title = "回调服务地址")
+ private String callbackServer;
+
+ @Schema(name = "tokenEnabled", title = "是否启用token", hidden = true)
+ public boolean tokenEnabled;
+
+ @Schema(name = "format", title = "默认关联的文件格式")
+ private List format;
+
+ public OfficeConfigDO toOfficeConfigDO() {
+ OfficeConfigDO officeConfigDO = new OfficeConfigDO();
+ officeConfigDO.setDocumentServer(this.documentServer);
+ officeConfigDO.setCallbackServer(this.callbackServer);
+ officeConfigDO.setFormat(this.format);
+ boolean tokenEnabled = StrUtil.isNotBlank(this.secret);
+ officeConfigDO.setTokenEnabled(tokenEnabled);
+ if (OfficeConfigService.VO_KEY.equals(this.secret)) {
+ return officeConfigDO;
+ }
+ if (tokenEnabled) {
+ String key = OfficeConfigService.generateKey();
+ String encrypted = new SymmetricCrypto(SymmetricAlgorithm.AES, key.getBytes()).encryptHex(this.secret);
+ officeConfigDO.setKey(key);
+ officeConfigDO.setEncrypted(encrypted);
+ } else {
+ officeConfigDO.setKey(null);
+ officeConfigDO.setEncrypted(null);
+ }
+ return officeConfigDO;
+ }
+}
+
diff --git a/src/main/java/com/jmal/clouddisk/office/model/Track.java b/src/main/java/com/jmal/clouddisk/office/model/Track.java
new file mode 100644
index 0000000..86d64f9
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/model/Track.java
@@ -0,0 +1,113 @@
+package com.jmal.clouddisk.office.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.List;
+
+/**
+ * @author jmal
+ */
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class Track {
+
+ /**
+ * 定义用户对文档进行操作时接收的对象数组。
+ *
+ * type 字段的值可以是以下值:
+ *
+ * 0 - 用户断开文档协同编辑;
+ * 1 - 新用户连接到文档协同编辑;
+ * 2 - 用户点击强制保存按钮。
+ *
+ * userid 字段的值是用户标识符。
+ * 类型:对象数组
+ */
+ private List actions;
+
+ /**
+ * 定义了包含文档编辑数据的文件链接,用于跟踪和显示文档的更改历史。
+ * 仅当状态值等于 2、3、6 或 7 时,此链接才存在。
+ * 该文件必须已保存,并且其地址必须通过 {@code setHistoryData} 方法作为 {@code changesUrl} 参数发送,
+ * 以便显示与特定文档版本对应的更改。
+ */
+ private String changesurl;
+
+ /**
+ * 定义从{@code url}参数指定链接下载的文档的文件类型。
+ * 默认情况下,文件类型为OOXML。但如果服务器设置{@code assemblyFormatAsOrigin}被启用,文件将以其原始格式保存。
+ */
+ private String filetype;
+
+ /**
+ * 强制保存类型。
+ * 定义强制保存请求执行时的发起方类型或触发方式。
+ *
+ * 可选值如下:
+ *
+ * 0: 强制保存请求发送至命令服务。
+ * 1: 每次执行保存操作时触发(例如点击保存按钮)。此选项仅在 {@code forcesave} 选项为 {@code true} 时可用。
+ * 2: 通过定时器触发,设置来源于服务器配置。
+ * 3: 每次表单提交时触发(例如点击“完成并提交”按钮)。
+ *
+ *
+ * 注意: 此字段仅在 {@code status} 值为 6 或 7 时有效。
+ */
+ private Integer forcesavetype;
+
+ /**
+ * 定义编辑文档标识符。
+ */
+ private String key;
+
+ /**
+ * 文档的当前状态。
+ * 可选值:
+ *
+ * 1 - 文档正在编辑中;
+ * 2 - 文档已准备好保存;
+ * 3 - 文档保存时发生错误;
+ * 4 - 文档已关闭且未做任何更改;
+ * 6 - 文档正在编辑中,但当前状态已保存;
+ * 7 - 强制保存文档时发生错误。
+ *
+ */
+ private Integer status;
+
+ /**
+ * 定义了指向已编辑文档的链接,该链接将随文档一同保存到文档存储服务。
+ * 仅当状态值等于2、3、6或7时,此链接才存在。
+ * 类型:字符串。
+ */
+ private String url;
+
+ /**
+ * 表示发送给命令服务的自定义信息,用于 forcesave 和 info 命令,当请求中存在时。
+ */
+ private String userdata;
+
+ /**
+ * 定义了打开文档进行编辑的用户标识符列表。
+ * 当文档被修改时,此字段将返回最后编辑文档的用户标识符(适用于状态2和状态6的回复)。
+ *
+ * 类型:字符串数组。
+ * 必需参数。
+ *
+ * 服务器存储所有回调URL,并根据执行操作的用户选择使用哪个回调URL。
+ * 自版本5.5起,callbackUrl根据请求状态选择。
+ * 从版本4.4到版本5.5,callbackUrl使用最后加入协同编辑的用户。
+ * 在版本4.4之前,协同编辑时,callbackUrl使用第一个打开文件进行编辑的用户。
+ *
+ * 自版本7.0起,callbackUrl使用同一用户的最后一个标签页。
+ * 在版本7.0之前,使用第一个用户标签页的callbackUrl。
+ */
+ private List users;
+
+ private String token;
+
+ private String lastsave;
+ private Boolean notmodified;
+ private String fileId;
+}
diff --git a/src/main/java/com/jmal/clouddisk/office/model/enums/Action.java b/src/main/java/com/jmal/clouddisk/office/model/enums/Action.java
new file mode 100644
index 0000000..bdd29cd
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/office/model/enums/Action.java
@@ -0,0 +1,22 @@
+package com.jmal.clouddisk.office.model.enums;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public enum Action {
+ @JsonProperty("edit")
+ edit,
+ @JsonProperty("review")
+ review,
+ @JsonProperty("view")
+ view,
+ @JsonProperty("embedded")
+ embedded,
+ @JsonProperty("filter")
+ filter,
+ @JsonProperty("comment")
+ comment,
+ @JsonProperty("fillForms")
+ fillForms,
+ @JsonProperty("blockcontent")
+ blockcontent;
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/AbstractOssObject.java b/src/main/java/com/jmal/clouddisk/oss/AbstractOssObject.java
new file mode 100644
index 0000000..3391fef
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/AbstractOssObject.java
@@ -0,0 +1,26 @@
+package com.jmal.clouddisk.oss;
+
+import java.io.Closeable;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @author jmal
+ * @Description AbstractOssObject
+ * @date 2023/3/29 12:03
+ */
+public abstract class AbstractOssObject implements Closeable {
+
+ public abstract InputStream getInputStream() throws FileNotFoundException;
+
+ public abstract void closeObject() throws IOException;
+
+ public abstract String getKey();
+
+ public abstract IOssService getOssService();
+
+ public abstract FileInfo getFileInfo();
+
+ public abstract long getContentLength();
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/BaseOssService.java b/src/main/java/com/jmal/clouddisk/oss/BaseOssService.java
new file mode 100644
index 0000000..d69d82d
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/BaseOssService.java
@@ -0,0 +1,566 @@
+package com.jmal.clouddisk.oss;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.file.PathUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.exception.CommonException;
+import com.jmal.clouddisk.exception.ExceptionType;
+import com.jmal.clouddisk.oss.web.model.OssConfigDTO;
+import com.jmal.clouddisk.util.FileContentTypeUtils;
+import com.jmal.clouddisk.webdav.MyWebdavServlet;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * @author jmal
+ * @Description OssStorageBase
+ * @date 2023/4/3 16:33
+ */
+@Slf4j
+public class BaseOssService {
+
+ private final FileProperties fileProperties;
+ /**
+ * 目录下文件名列表缓存
+ * key: objectName
+ * value: object下的文件名列表
+ */
+ private final Cache> fileInfoListCache;
+ /**
+ * object缓存
+ * key: objectName
+ * value: FileInfo
+ */
+ private final Cache fileInfoCache;
+ /**
+ * 临时文件缓存
+ * key: objectName
+ * value: 临时文件绝对路径
+ */
+ private final Cache tempFileCache;
+ /**
+ * 临时文件列表缓存
+ * key: objectName
+ * value: object下的文件名列表
+ */
+ private final Cache> tempFileListCache;
+
+ /**
+ * 等待上传文件的缓存
+ * key: objectName
+ * value: 临时文件绝对路径
+ */
+ private final Cache waitingUploadCache;
+
+ private final Map updateIdCache = new ConcurrentHashMap<>();
+
+ private final Set objectNameLock = new CopyOnWriteArraySet<>();
+
+ private final String bucketName;
+
+ private final IOssService ossService;
+
+ public BaseOssService(IOssService ossService, String bucketName, FileProperties fileProperties, ScheduledThreadPoolExecutor scheduledThreadPoolExecutor, OssConfigDTO ossConfigDTO) {
+ this.ossService = ossService;
+ this.bucketName = bucketName;
+ this.fileProperties = fileProperties;
+ scheduledThreadPoolExecutor.scheduleWithFixedDelay(this::checkUpload, 1, 1, TimeUnit.SECONDS);
+ this.fileInfoListCache = Caffeine.newBuilder().initialCapacity(128).maximumSize(1024).expireAfterWrite(5, TimeUnit.SECONDS).build();
+ this.fileInfoCache = Caffeine.newBuilder().initialCapacity(128).maximumSize(1024).expireAfterWrite(5, TimeUnit.SECONDS).build();
+ this.tempFileCache = Caffeine.newBuilder().build();
+ this.tempFileListCache = Caffeine.newBuilder().build();
+ this.waitingUploadCache = Caffeine.newBuilder().build();
+ log.info("{}配置加载成功, bucket: {}, username: {}, {}", ossService.getPlatform().getValue(), bucketName, ossConfigDTO.getUsername(), ossService.hashCode());
+ }
+
+ public String getUploadId(String objectName) {
+ String uploadId;
+ if (updateIdCache.containsKey(objectName)) {
+ uploadId = updateIdCache.get(objectName);
+ } else {
+ uploadId = ossService.initiateMultipartUpload(objectName);
+ updateIdCache.put(objectName, uploadId);
+ }
+ return uploadId;
+ }
+
+ public void setUpdateIdCache(String objectName, String uploadId) {
+ updateIdCache.put(objectName, uploadId);
+ }
+
+ /**
+ * 根据objectName获取FileInfo
+ * @param objectName objectName
+ * @return FileInfo
+ */
+ public FileInfo getFileInfo(String objectName) {
+ FileInfo fileInfo = getFileInfoCache(objectName);
+ if (fileInfo == null) {
+ String path = getObjectParentName(objectName);
+ refresh(path);
+ }
+ fileInfo = getFileInfoCache(objectName);
+ if (fileInfo == null) {
+ refresh(objectName);
+ }
+ fileInfo = getFileInfoCache(objectName);
+ return fileInfo;
+ }
+
+ public boolean delete(String objectName) {
+ if (isLock(objectName)) {
+ throw new CommonException(ExceptionType.LOCKED_RESOURCES);
+ }
+ boolean deleted;
+ deleteTempFileCache(objectName);
+ if (objectName.endsWith("/")) {
+ // 删除目录
+ deleted = ossService.deleteDir(objectName);
+ } else {
+ // 删除文件
+ deleted = ossService.deleteObject(objectName);
+ }
+ if (deleted) {
+ onDeleteSuccess(objectName);
+ }
+ return deleted;
+ }
+
+ public boolean mkdir(String objectName) {
+ if (isLock(objectName)) {
+ throw new CommonException(ExceptionType.LOCKED_RESOURCES);
+ }
+ FileInfo fileInfo = ossService.newFolder(objectName);
+ if (fileInfo != null) {
+ onMkdirSuccess(objectName, fileInfo);
+ }
+ return fileInfo != null;
+ }
+
+ /**
+ * 上传对象到临时文件
+ * @param inputStream 网络输入流
+ * @param ossPath oss路径前缀, 例如:/username/aliyunoss
+ * @param objectName objectName
+ * @return 写入临时文件是否成功
+ */
+ public boolean writeTempFile(InputStream inputStream, String ossPath, String objectName) {
+ if (isLock(objectName)) {
+ throw new CommonException(ExceptionType.LOCKED_RESOURCES);
+ }
+ // 临时文件绝对路径
+ Path tempFileAbsolutePath = Paths.get(fileProperties.getRootDir(), ossPath, objectName);
+ try {
+ if (tempFileAbsolutePath.getNameCount() > 1) {
+ Path parentPath = tempFileAbsolutePath.getParent();
+ if (!Files.exists(parentPath)) {
+ PathUtil.mkdir(parentPath);
+ }
+ }
+ Files.copy(inputStream, tempFileAbsolutePath, StandardCopyOption.REPLACE_EXISTING);
+ setTempFileCache(objectName, tempFileAbsolutePath);
+ setWaitingUploadCache(objectName, tempFileAbsolutePath);
+ // 稍后执行真正的上传, 在 checkUpload 方法里
+ return true;
+ } catch (IOException e) {
+ log.warn(e.getMessage(), e);
+ return false;
+ }
+ }
+
+ /**
+ * 获取文件名列表
+ * @param objectName objectName
+ * @return object下的文件名列表
+ */
+ public List getFileNameList(String objectName) {
+ List fileInfoList = getFileInfoListCache(objectName);
+ List fileNameList = fileInfoList.stream().map(FileInfo::getName).collect(Collectors.toList());
+ fileNameList = getTmepFileNameList(objectName, fileNameList);
+ return fileNameList;
+ }
+
+ public List getFileInfoListCache(String objectName) {
+ return fileInfoListCache.get(objectName, key -> {
+ List fileInfos = ossService.getFileInfoList(objectName);
+ if (fileInfos != null && !fileInfos.isEmpty()) {
+ for (FileInfo fileInfo : fileInfos) {
+ setFileInfoCache(fileInfo.getKey(), fileInfo);
+ }
+ }
+ return fileInfos;
+ });
+ }
+
+ public AbstractOssObject getObject(String objectName) {
+ Path path = getTempFileCache(objectName);
+ if (path != null) {
+ return new TempFileObject(path.toFile(), objectName, bucketName, ossService);
+ }
+ printOperation(ossService.getPlatform().getKey(), "getObject", objectName);
+ return ossService.getAbstractOssObject(objectName);
+ }
+
+ /**
+ * 删除临时文件缓存
+ * @param objectName objectName
+ */
+ private void deleteTempFileCache(String objectName) {
+ Path path = getTempFileCache(objectName);
+ if (path != null) {
+ clearTempFileCache(objectName);
+ }
+ }
+
+ /**
+ * 删除oss文件成功后,要干的事
+ * @param objectName objectName
+ */
+ private void onDeleteSuccess(String objectName) {
+ log.info("delete success: {}", objectName);
+ FileInfo fileInfo = getFileInfoCache(objectName);
+ if (fileInfo != null) {
+ clearFileCache(objectName);
+ clearFileListCache(objectName);
+ }
+ }
+
+ private void clearFileListCache(String objectName) {
+ Path objectNamePath = Paths.get(objectName);
+ if (objectNamePath.getNameCount() > 1) {
+ String pathObjectName = objectNamePath.getParent().toString() + MyWebdavServlet.PATH_DELIMITER;
+ fileInfoListCache.invalidate(pathObjectName);
+ for (String key : fileInfoListCache.asMap().keySet()) {
+ if (key.startsWith(objectName)) {
+ fileInfoListCache.invalidate(key);
+ }
+ }
+ } else {
+ fileInfoListCache.invalidate(objectName);
+ fileInfoListCache.invalidate("");
+ }
+ }
+
+ private void clearFileCache(String objectName) {
+ if (objectName.endsWith("/")) {
+ fileInfoCache.invalidate(objectName.substring(0,objectName.length() - 1));
+ for (String key : fileInfoCache.asMap().keySet()) {
+ if (key.startsWith(objectName)) {
+ fileInfoCache.invalidate(key);
+ }
+ }
+ } else {
+ fileInfoCache.invalidate(objectName);
+ }
+ }
+
+ /**
+ * 上传至oos成功后,要干的事
+ * @param objectName objectName
+ * @param tempFileAbsolutePath 临时文件绝对路径
+ */
+ public void onUploadSuccess(String objectName, Path tempFileAbsolutePath) {
+ clearTempFileCache(objectName);
+ setFileInfoCache(objectName, newFileInfo(objectName, tempFileAbsolutePath.toFile()));
+ clearFileListCache(objectName);
+ removeWaitingUploadCache(objectName);
+ printSuccess(objectName);
+ }
+
+ /**
+ * 上传至oos成功后,要干的事
+ * @param objectName objectName
+ * @param fileSize 文件大小
+ */
+ public void onUploadSuccess(String objectName, Long fileSize) {
+ clearTempFileCache(objectName);
+ setFileInfoCache(objectName, newFileInfo(objectName, fileSize));
+ clearFileListCache(objectName);
+ removeWaitingUploadCache(objectName);
+ printSuccess(objectName);
+ }
+
+ private static void printSuccess(String objectName) {
+ log.info("upload success: {}", objectName);
+ }
+
+ /**
+ * 在oss创建目录成功后,要干的事
+ * @param objectName objectName
+ * @param fileInfo FileInfo
+ */
+ private void onMkdirSuccess(String objectName, FileInfo fileInfo) {
+ log.info("mkdir success: {}", objectName);
+ setFileInfoCache(objectName, fileInfo);
+ clearFileListCache(objectName);
+ }
+
+ /**
+ * 刷新缓存
+ * @param objectName objectName
+ */
+ private void refresh(String objectName) {
+ if (objectName.equals("/")) {
+ getFileNameList(objectName);
+ } else {
+ if (objectName.length() > 1) {
+ getFileNameList(objectName + "/");
+ } else {
+ getFileNameList(objectName);
+ }
+ }
+ }
+
+ /**
+ * 获取临时文件名列表
+ * @param objectName objectName
+ * @param fileNameList object下的文件名列表
+ * @return object下的文件名列表 + object下的临时文件名列表
+ */
+ private List getTmepFileNameList(String objectName, List fileNameList) {
+ if (fileNameList == null) {
+ fileNameList = new ArrayList<>();
+ }
+ String objectParentName = objectName;
+ if (objectParentName.endsWith("/")) {
+ objectParentName = objectParentName.substring(0, objectParentName.length() - 1);
+ }
+ Set nameList = getTempFileListCache(objectParentName);
+ if (nameList != null) {
+ fileNameList.addAll(nameList);
+ }
+ return fileNameList;
+ }
+
+ /**
+ * 检查是否有需要上传的文件
+ * 有等待上传的文件且文件的最后修改时间大于5秒就上传
+ * 该方法每秒执行一次
+ */
+ private void checkUpload() {
+ getWaitingUploadCacheMap().forEach((objectName, tempFileAbsolutePath) -> {
+ long lastModified = tempFileAbsolutePath.toFile().lastModified();
+ // 临时文件的最后修改时间大于5秒就上传
+ if ((System.currentTimeMillis() - lastModified) > 5000) {
+ removeWaitingUploadCache(objectName);
+ ThreadUtil.execute(() -> ossService.uploadFile(tempFileAbsolutePath, objectName));
+ }
+ });
+ }
+
+ public void addFileInfoList(String objectName, List fileInfoList, S3ObjectSummary s3ObjectSummary) {
+ if (!s3ObjectSummary.getKey().equals(objectName)) {
+ FileInfo fileInfo = getFileInfo(s3ObjectSummary);
+ fileInfoList.add(fileInfo);
+ } else {
+ FileInfo fileInfo = getFileInfoCache(objectName);
+ if (fileInfo == null) {
+ setFileInfoCache(objectName, newFileInfo(objectName));
+ }
+ }
+ }
+
+ public FileInfo getFileInfo(S3ObjectSummary objectSummary) {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setSize(objectSummary.getSize());
+ fileInfo.setKey(objectSummary.getKey());
+ fileInfo.setETag(objectSummary.getETag());
+ fileInfo.setLastModified(objectSummary.getLastModified());
+ fileInfo.setBucketName(objectSummary.getBucketName());
+ return fileInfo;
+ }
+
+ public FileInfo newFileInfo(String objectName) {
+ return newFileInfo(objectName, bucketName);
+ }
+
+ public static FileInfo newFileInfo(String objectName, String bucketName) {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setSize(0);
+ fileInfo.setKey(objectName);
+ fileInfo.setLastModified(new Date());
+ fileInfo.setBucketName(bucketName);
+ return fileInfo;
+ }
+
+ public FileInfo newFileInfo(String objectName, File file) {
+ return newFileInfo(objectName, bucketName, file);
+ }
+
+ public static FileInfo newFileInfo(String objectName, String bucketName, File file) {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setSize(file.length());
+ fileInfo.setKey(objectName);
+ fileInfo.setLastModified(new Date(file.lastModified()));
+ fileInfo.setBucketName(bucketName);
+ return fileInfo;
+ }
+
+ private FileInfo newFileInfo(String objectName, Long fileSize) {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setSize(fileSize);
+ fileInfo.setKey(objectName);
+ fileInfo.setLastModified(new Date());
+ fileInfo.setBucketName(bucketName);
+ return fileInfo;
+ }
+
+ /**
+ * 获取object的父级object
+ * @param objectName objectName
+ * @return 父级objectName, 结尾不带"/"
+ */
+ private String getObjectParentName(String objectName) {
+ Path path = Paths.get(objectName);
+ String objectParentName = "";
+ if (path.getNameCount() > 1) {
+ objectParentName = path.getParent().toString();
+ }
+ return objectParentName;
+ }
+
+ private void setFileInfoCache(String key, FileInfo fileInfo) {
+ if (key.length() > 1 && key.endsWith("/")) {
+ key = key.substring(0, key.length() - 1);
+ }
+ fileInfoCache.put(key, fileInfo);
+ }
+
+ private FileInfo getFileInfoCache(String key) {
+ if (key.length() > 1 && key.endsWith("/")) {
+ key = key.substring(0, key.length() - 1);
+ }
+ FileInfo fileInfo = fileInfoCache.getIfPresent(key);
+ if (fileInfo == null) {
+ Path path = getTempFileCache(key);
+ if (path != null) {
+ fileInfo = newFileInfo(key, path.toFile());
+ }
+ }
+ return fileInfo;
+ }
+
+ public void printOperation(String platform, String operation, String objectName) {
+ log.info("{}, {}, {}", platform, operation, objectName);
+ }
+
+ /**
+ * 设置临时文件缓存
+ * @param objectName objectName
+ * @param tempFileAbsolutePath 临时文件绝对路径
+ */
+ private void setTempFileCache(String objectName, Path tempFileAbsolutePath) {
+ tempFileCache.put(objectName, tempFileAbsolutePath);
+ // setTempFileListCache
+ String objectParentName = getObjectParentName(objectName);
+ Set fileNameList = getTempFileListCache(objectParentName);
+ if (fileNameList == null) {
+ fileNameList = new HashSet<>();
+ }
+ fileNameList.add(tempFileAbsolutePath.getFileName().toString());
+ setTempFileListCache(objectParentName, fileNameList);
+ }
+
+
+ /**
+ * 清理临时文件缓存
+ * @param objectName objectName
+ */
+ private void clearTempFileCache(String objectName) {
+ Path path = getTempFileCache(objectName);
+ if (path != null) {
+ tempFileCache.invalidate(objectName);
+ try {
+ // 删除临时文件
+ Files.delete(path);
+ } catch (IOException e) {
+ log.warn(e.getMessage(), e);
+ }
+ }
+ String objectParentName = getObjectParentName(objectName);
+ Set fileNameList = getTempFileListCache(objectParentName);
+ if (fileNameList != null) {
+ String objectFileName = Paths.get(objectName).getFileName().toString();
+ if (fileNameList.contains(objectFileName)) {
+ fileNameList.remove(objectFileName);
+ setTempFileListCache(objectParentName, fileNameList);
+ }
+ if (fileNameList.isEmpty()) {
+ tempFileListCache.invalidate(objectParentName);
+ }
+ }
+ }
+
+ private Set getTempFileListCache(String objectParentName) {
+ return tempFileListCache.getIfPresent(objectParentName);
+ }
+
+ private void setTempFileListCache(String objectParentName, Set fileNameList) {
+ tempFileListCache.put(objectParentName, fileNameList);
+ }
+
+ private Path getTempFileCache(String objectName) {
+ return tempFileCache.getIfPresent(objectName);
+ }
+
+ private void setWaitingUploadCache(String objectName, Path tempFileAbsolutePath) {
+ waitingUploadCache.put(objectName, tempFileAbsolutePath);
+ }
+
+ private Map getWaitingUploadCacheMap() {
+ return waitingUploadCache.asMap();
+ }
+
+ private void removeWaitingUploadCache(String objectName) {
+ waitingUploadCache.invalidate(objectName);
+ }
+
+ public void setObjectNameLock(String objectName) {
+ objectNameLock.add(objectName);
+ }
+
+ public void removeObjectNameLock(String objectName) {
+ objectNameLock.remove(objectName);
+ }
+
+ public boolean isLock(String objectName) {
+ if (objectNameLock.contains(objectName)) {
+ return true;
+ }
+ return objectNameLock.stream().anyMatch(objectName::startsWith);
+ }
+
+ public String getContentType(String objectName) {
+ String contentType = FileContentTypeUtils.getContentType(FileUtil.getSuffix(objectName));
+ if (CharSequenceUtil.isBlank(contentType)) {
+ contentType = "application/octet-stream";
+ }
+ return contentType;
+ }
+
+ public void clearCache(String objectName) {
+ clearFileCache(objectName);
+ clearFileListCache(objectName);
+ }
+
+ public void closePrint() {
+ log.info("platform: {}, bucketName: {} shutdown... {}", this.ossService.getPlatform().getValue(), bucketName, this.ossService.hashCode());
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/BucketInfo.java b/src/main/java/com/jmal/clouddisk/oss/BucketInfo.java
new file mode 100644
index 0000000..0a33428
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/BucketInfo.java
@@ -0,0 +1,22 @@
+package com.jmal.clouddisk.oss;
+
+import com.jmal.clouddisk.webdav.MyWebdavServlet;
+import lombok.Data;
+
+/**
+ * @author jmal
+ * @Description OSS BucketInfo
+ * @date 2023/3/29 13:49
+ */
+@Data
+public class BucketInfo {
+ String bucketName;
+ String folderName;
+ String username;
+ PlatformOSS platform;
+
+ public String getWebPathPrefix() {
+ return MyWebdavServlet.getPathDelimiter(username, folderName);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/FileInfo.java b/src/main/java/com/jmal/clouddisk/oss/FileInfo.java
new file mode 100644
index 0000000..15bad54
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/FileInfo.java
@@ -0,0 +1,90 @@
+package com.jmal.clouddisk.oss;
+
+import cn.hutool.core.date.LocalDateTimeUtil;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.model.FileIntroVO;
+import com.jmal.clouddisk.oss.web.WebOssCommonService;
+import com.jmal.clouddisk.util.CaffeineUtil;
+import com.jmal.clouddisk.util.FileContentTypeUtils;
+import com.jmal.clouddisk.util.MyFileUtils;
+import lombok.Data;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.util.Date;
+
+@Data
+public class FileInfo {
+ private String bucketName;
+ private String key;
+ private String eTag;
+ private long size;
+ private Date lastModified;
+
+ public FileInfo() {
+ }
+
+ public FileInfo(String key, String eTag, long size, Date lastModified) {
+ this.key = key;
+ this.eTag = eTag;
+ this.size = size;
+ this.lastModified = lastModified;
+ }
+
+ public String getName() {
+ Path path = Paths.get(key);
+ return path.getFileName().toString();
+ }
+
+ public boolean isFolder() {
+ return key.endsWith("/");
+ }
+
+ public FileIntroVO toFileIntroVO(String ossPath, String userId) {
+ BucketInfo bucketInfo = CaffeineUtil.getOssDiameterPrefixCache(ossPath);
+ String username = Paths.get(ossPath).subpath(0, 1).toString();
+ String rootName = bucketInfo.getFolderName();
+ FileIntroVO fileIntroVO = new FileIntroVO();
+ String fileName = getName();
+ fileIntroVO.setId(WebOssCommonService.getFileId(rootName, key, username));
+ fileIntroVO.setUsername(username);
+ fileIntroVO.setIsFavorite(false);
+ fileIntroVO.setIsFolder(isFolder());
+ fileIntroVO.setName(fileName);
+ fileIntroVO.setPath(WebOssCommonService.getPath(key, rootName));
+ fileIntroVO.setSize(size);
+ String suffix = MyFileUtils.extName(fileName);
+ fileIntroVO.setSuffix(suffix);
+ fileIntroVO.setMd5(eTag);
+ fileIntroVO.setContentType(FileContentTypeUtils.getContentType(suffix));
+ if (lastModified != null) {
+ LocalDateTime updateTime = LocalDateTimeUtil.of(lastModified);
+ fileIntroVO.setAgoTime(System.currentTimeMillis() - lastModified.getTime());
+ fileIntroVO.setUploadDate(updateTime);
+ fileIntroVO.setUpdateDate(updateTime);
+ }
+ fileIntroVO.setUserId(userId);
+ return fileIntroVO;
+ }
+
+ public FileDocument toFileDocument(String ossPath, String userId) {
+ FileDocument fileDocument = new FileDocument();
+ FileIntroVO fileIntroVO = toFileIntroVO(ossPath, userId);
+ fileDocument.setAgoTime(fileIntroVO.getAgoTime());
+ fileDocument.setId(fileIntroVO.getId());
+ fileDocument.setUsername(fileDocument.getUsername());
+ fileDocument.setIsFavorite(fileIntroVO.getIsFavorite());
+ fileDocument.setIsFolder(fileIntroVO.getIsFolder());
+ fileDocument.setName(fileIntroVO.getName());
+ fileDocument.setPath(fileIntroVO.getPath());
+ fileDocument.setSize(fileIntroVO.getSize());
+ fileDocument.setSuffix(fileIntroVO.getSuffix());
+ fileDocument.setMd5(fileIntroVO.getMd5());
+ fileDocument.setContentType(fileIntroVO.getContentType());
+ fileDocument.setUploadDate(fileIntroVO.getUploadDate());
+ fileDocument.setUpdateDate(fileIntroVO.getUpdateDate());
+ fileDocument.setUserId(userId);
+ return fileDocument;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/IOssService.java b/src/main/java/com/jmal/clouddisk/oss/IOssService.java
new file mode 100644
index 0000000..cfaf21a
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/IOssService.java
@@ -0,0 +1,252 @@
+package com.jmal.clouddisk.oss;
+
+import java.io.File;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * @author jmal
+ * @Description oss 存储接口
+ * @date 2023/3/29 11:48
+ */
+public interface IOssService {
+
+ PlatformOSS getPlatform();
+
+ /**
+ * Webdav 获取FileInfo
+ * @param objectName object key
+ * @return FileInfo
+ */
+ FileInfo getFileInfo(String objectName);
+
+ /**
+ * Webdav 删除文件
+ * @param objectName object key
+ * @return 是否删除成功
+ */
+ boolean delete(String objectName);
+
+ /**
+ * Webdav 创建文件夹
+ * @param objectName object key
+ * @return 是否创建成功
+ */
+ boolean mkdir(String objectName);
+
+ /**
+ * Webdav 上传文件
+ * @param inputStream 文件输入流
+ * @param ossPath ossPath
+ * @param objectName object key
+ * @return 是否上传成功
+ */
+ boolean write(InputStream inputStream, String ossPath, String objectName);
+
+ /**
+ * Webdav 列出当前文件夹下的所有文件和文件夹
+ * @param objectName object key
+ * @return 文件名称列表(包含文件夹)
+ */
+ String[] list(String objectName);
+
+ /**
+ * Webdav 获取文件对象(有时是下载)
+ * @param objectName object key
+ * @return AbstractOssObject
+ */
+ AbstractOssObject getObjectCache(String objectName);
+
+
+ /**
+ * 获取 AbstractOssObject
+ * @param objectName object key
+ * @return AbstractOssObject
+ */
+ AbstractOssObject getAbstractOssObject(String objectName);
+
+ /**
+ * 获取 AbstractOssObject
+ * @param objectName object key
+ * @param rangeStart rangeStart 分段
+ * @param rangeEnd rangeEnd 分段
+ * @return AbstractOssObject
+ */
+ AbstractOssObject getAbstractOssObject(String objectName, Long rangeStart, Long rangeEnd);
+
+ /**
+ * 删除文件
+ * @param objectName object key
+ */
+ boolean deleteObject(String objectName);
+
+ /**
+ * 删除文件夹
+ * @param objectName object key
+ */
+ boolean deleteDir(String objectName);
+
+ /**
+ * 从缓存中获取objectName下的所有文件夹和文件
+ * @param objectName object key
+ * @return List
+ */
+ List getFileInfoListCache(String objectName);
+
+ /**
+ * 获取objectName下的所有文件夹和文件
+ * @param objectName object key
+ * @return List
+ */
+ List getFileInfoList(String objectName);
+
+ /**
+ * 列举所有包含指定前缀的所有文件
+ * @param objectName objectName
+ * @return objectNameList
+ */
+ List getAllObjectsWithPrefix(String objectName);
+
+ /**
+ * 创建文件夹
+ * @param objectName object key
+ */
+ FileInfo newFolder(String objectName);
+
+ /**
+ * 上传文件到 OSS
+ * @param tempFileAbsolutePath 临时文件的绝对路径
+ * @param objectName object key
+ */
+ void uploadFile(Path tempFileAbsolutePath, String objectName);
+
+ /**
+ * 上传文件到 OSS
+ * @param inputStream inputStream
+ * @param objectName object key
+ */
+ void uploadFile(InputStream inputStream, String objectName, long inputStreamLength);
+
+ /**
+ * 检查Bucket是否存在,并且验证配置是否可用,用于创建OSS配置时使用
+ */
+ boolean doesBucketExist();
+
+ /**
+ * 判断对象是否存在
+ */
+ boolean doesObjectExist(String objectName);
+
+ /**
+ * 获取分片上传事件的唯一标识
+ * @param objectName objectName
+ * @return uploadId
+ */
+ String getUploadId(String objectName);
+
+ /**
+ * 初始化分片上传事件
+ * @param objectName objectName
+ * @return uploadId
+ */
+ String initiateMultipartUpload(String objectName);
+
+ /**
+ * 获取分片号列表
+ * @param objectName objectName
+ * @param uploadId uploadId
+ * @return 分片号列表
+ */
+ CopyOnWriteArrayList getListParts(String objectName, String uploadId);
+
+ /**
+ * 上传分片
+ * @param inputStream 输入流
+ * @param objectName objectName
+ * @param partSize 分片大小
+ * @param partNumber 分片编号
+ * @param uploadId uploadId
+ * @return 分片是否上传成功
+ */
+ boolean uploadPart(InputStream inputStream, String objectName, int partSize, int partNumber, String uploadId);
+
+ /**
+ * 取消分片上传
+ * @param objectName objectName
+ * @param uploadId uploadId
+ */
+ void abortMultipartUpload(String objectName, String uploadId);
+
+ /**
+ * 完成分片上传(合并分片)
+ *
+ * @param objectName objectName
+ * @param uploadId uploadId
+ * @param fileTotalSize 文件总大小
+ */
+ void completeMultipartUpload(String objectName, String uploadId, Long fileTotalSize);
+
+ /**
+ * 获取缩略图, 指定目标图片宽度为 Width,高度等比缩放
+ * @param objectName objectName
+ * @param file 临时文件
+ * @param width 图片宽度
+ */
+ FileInfo getThumbnail(String objectName, File file, int width);
+
+ /**
+ * 生成预签名URL
+ *
+ * @param objectName objectName
+ * @param expiryTime 过期时间(秒)
+ * @return 预签名URL
+ */
+ URL getPresignedObjectUrl(String objectName, int expiryTime);
+
+ /**
+ * 拷贝对象(相同Bucket之间拷贝)
+ *
+ * @param sourceKey 源objectName
+ * @param destinationKey 目标objectName
+ * @return 复制成功的objectName列表
+ */
+ List copyObject(String sourceKey, String destinationKey);
+
+ /**
+ * 拷贝对象(不同Bucket之间拷贝)
+ *
+ * @param sourceBucketName 源Bucket
+ * @param sourceKey 源objectName
+ * @param destinationBucketName 目标Bucket
+ * @param destinationKey 目标objectName
+ * @return 复制成功的objectName列表
+ */
+ List copyObject(String sourceBucketName, String sourceKey, String destinationBucketName, String destinationKey);
+
+ /**
+ * 锁对象
+ * @param objectName objectName
+ */
+ void lock(String objectName);
+
+ /**
+ * 解锁对象
+ * @param objectName objectName
+ */
+ void unlock(String objectName);
+
+ /**
+ * 清除缓存 objectName 及以下的所有缓存
+ * @param objectName objectName
+ */
+ void clearCache(String objectName);
+
+ /**
+ * 关闭需要关闭的一切
+ */
+ void close();
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/OssConfigService.java b/src/main/java/com/jmal/clouddisk/oss/OssConfigService.java
new file mode 100644
index 0000000..3825026
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/OssConfigService.java
@@ -0,0 +1,305 @@
+package com.jmal.clouddisk.oss;
+
+import cn.hutool.core.io.file.PathUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.core.util.ReUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.exception.CommonException;
+import com.jmal.clouddisk.exception.ExceptionType;
+import com.jmal.clouddisk.listener.FileMonitor;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.model.rbac.ConsumerDO;
+import com.jmal.clouddisk.oss.aliyun.AliyunOssService;
+import com.jmal.clouddisk.oss.minio.MinIOService;
+import com.jmal.clouddisk.oss.tencent.TencentOssService;
+import com.jmal.clouddisk.oss.web.model.OssConfigDO;
+import com.jmal.clouddisk.oss.web.model.OssConfigDTO;
+import com.jmal.clouddisk.service.impl.UserServiceImpl;
+import com.jmal.clouddisk.util.CaffeineUtil;
+import com.jmal.clouddisk.util.ResponseResult;
+import com.jmal.clouddisk.util.ResultUtil;
+import com.jmal.clouddisk.webdav.MyWebdavServlet;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.security.crypto.encrypt.TextEncryptor;
+import org.springframework.stereotype.Service;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * @author jmal
+ * @Description OssConfig
+ * @date 2023/4/4 15:13
+ */
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class OssConfigService {
+
+ public static final String COLLECTION_NAME = "OssConfig";
+ private static final Map OSS_SERVICE_MAP = new ConcurrentHashMap<>();
+ private final UserServiceImpl userService;
+
+ private final FileProperties fileProperties;
+
+ private final FileMonitor fileMonitor;
+
+ private final MongoTemplate mongoTemplate;
+
+ private final TextEncryptor textEncryptor;
+
+
+ @PostConstruct
+ public void init() {
+ // load config
+ ThreadUtil.execute(() -> {
+ List ossConfigDOList = mongoTemplate.findAll(OssConfigDO.class);
+ for (OssConfigDO ossConfigDO : ossConfigDOList) {
+ String userId = ossConfigDO.getUserId();
+ ConsumerDO consumerDO = userService.userInfoById(userId);
+ if (consumerDO == null) {
+ continue;
+ }
+ OssConfigDTO ossConfigDTO = ossConfigDO.toOssConfigDTO(textEncryptor);
+ ossConfigDTO.setUsername(consumerDO.getUsername());
+ IOssService ossService = null;
+ try {
+ ossService = newOssService(fileProperties, ossConfigDO.getPlatform(), ossConfigDTO);
+ if (ossService != null) {
+ setBucketInfoCache(ossConfigDO.getPlatform(), ossConfigDTO, ossService);
+ }
+ } catch (Exception e) {
+ log.error("{} 配置加载失败!", ossConfigDO.getPlatform().getValue());
+ log.error(e.getMessage(), e);
+ if (ossService != null) {
+ ossService.close();
+ }
+ }
+ }
+ });
+ }
+
+ public void setOssServiceMap(String key, IOssService ossService) {
+ OSS_SERVICE_MAP.put(key, ossService);
+ }
+
+ public static IOssService getOssStorageService(String ossPath) {
+ BucketInfo bucketInfo = CaffeineUtil.getOssDiameterPrefixCache(ossPath);
+ return OssConfigService.getOssServiceValue(bucketInfo.getWebPathPrefix());
+ }
+
+ public static String getObjectName(String path, String ossPath) {
+ Path prePath = Paths.get(path);
+ String name = "";
+ if (prePath.getNameCount() > 2) {
+ name = path.substring(ossPath.length() + 1);
+ if (!name.endsWith("/")) {
+ name = name + "/";
+ }
+ }
+ return name;
+ }
+
+ private static IOssService getOssServiceValue(String key) {
+ return OSS_SERVICE_MAP.get(key);
+ }
+
+ private void setBucketInfoCache(PlatformOSS platformOSS, OssConfigDTO ossConfigDTO, IOssService ossService) {
+ BucketInfo bucketInfo = new BucketInfo();
+ bucketInfo.setPlatform(platformOSS);
+ bucketInfo.setBucketName(ossConfigDTO.getBucket());
+ bucketInfo.setUsername(ossConfigDTO.getUsername());
+ bucketInfo.setFolderName(ossConfigDTO.getFolderName());
+ String webPathPrefix = bucketInfo.getWebPathPrefix();
+ // 销毁掉之前的IOssService
+ destroyOssService(webPathPrefix);
+ // setOssDiameterPrefixCache
+ CaffeineUtil.setOssDiameterPrefixCache(webPathPrefix, bucketInfo);
+ // setOssService
+ setOssServiceMap(webPathPrefix, ossService);
+ fileMonitor.addDirFilter(webPathPrefix);
+ }
+
+ private void destroyOssService(String key) {
+ CaffeineUtil.removeOssDiameterPrefixCache(key);
+ if (OSS_SERVICE_MAP.containsKey(key)) {
+ // 销毁掉之前的IOssService
+ getOssServiceValue(key).close();
+ }
+ OSS_SERVICE_MAP.remove(key);
+ // 移除webPathPrefix的文件夹的 过滤器
+ fileMonitor.removeDirFilter(key);
+ }
+
+ /**
+ * 创建IOssService对象
+ * @param fileProperties FileProperties
+ * @param platformOSS PlatformOSS
+ * @param ossConfigDTO OssConfigDTO
+ * @return IOssService 对象
+ */
+ private static IOssService newOssService(FileProperties fileProperties, PlatformOSS platformOSS, OssConfigDTO ossConfigDTO) {
+ IOssService ossService = null;
+ switch (platformOSS) {
+ case ALIYUN -> ossService = new AliyunOssService(fileProperties, ossConfigDTO);
+ case TENCENT -> ossService = new TencentOssService(fileProperties, ossConfigDTO);
+ case MINIO -> ossService = new MinIOService(fileProperties, ossConfigDTO);
+ }
+ return ossService;
+ }
+
+ @PreDestroy
+ public void destroy() {
+ OSS_SERVICE_MAP.values().forEach(IOssService::close);
+ }
+
+ public void putOssConfig(OssConfigDTO ossConfigDTO) {
+ IOssService ossService = null;
+ String userId = ossConfigDTO.getUserId();
+ ConsumerDO consumerDO = userService.getUserInfoById(userId);
+ if (consumerDO == null) {
+ throw new CommonException(ExceptionType.PARAMETERS_VALUE.getCode(), "无效参数 userId");
+ }
+ ossConfigDTO.setUsername(consumerDO.getUsername());
+ Query query = new Query();
+ // 销毁旧配置
+ destroyOldConfig(ossConfigDTO, userId, consumerDO, query);
+
+ // 配置转换 DTO -> DO
+ OssConfigDO ossConfigDO = ossConfigDTO.toOssConfigDO(textEncryptor);
+
+ String configErr = "配置有误 或者 Access Key 没有权限";
+ try {
+ // 检查配置可用性
+ ossService = newOssService(fileProperties, ossConfigDO.getPlatform(), ossConfigDTO);
+ if (ossService == null) {
+ throw new CommonException(ExceptionType.WARNING.getCode(), configErr);
+ }
+ boolean doesBucketExist = ossService.doesBucketExist();
+ if (!doesBucketExist) {
+ ossService.close();
+ throw new CommonException(ExceptionType.WARNING.getCode(), "Bucket 不存在");
+ } else {
+ // 更新配置
+ updateOssConfig(ossConfigDTO, ossService, query, ossConfigDO);
+ }
+ } catch (Exception e) {
+ log.warn(e.getMessage());
+ if (ossService != null) {
+ ossService.close();
+ }
+ throw new CommonException(ExceptionType.WARNING.getCode(), configErr);
+ }
+ }
+
+ /**
+ * 销毁旧配置
+ */
+ private void destroyOldConfig(OssConfigDTO ossConfigDTO, String userId, ConsumerDO consumerDO, Query query) {
+ query.addCriteria(Criteria.where("userId").is(userId));
+ query.addCriteria(Criteria.where("endpoint").is(ossConfigDTO.getEndpoint()));
+ query.addCriteria(Criteria.where("bucket").is(ossConfigDTO.getBucket()));
+ query.addCriteria(Criteria.where("platform").is(PlatformOSS.getPlatform(ossConfigDTO.getPlatform())));
+ // 检查目录是否存在
+ boolean existFolder = existFolderName(consumerDO.getUsername(), ossConfigDTO.getFolderName());
+ // 旧配置
+ OssConfigDO oldOssConfigDO = mongoTemplate.findOne(query, OssConfigDO.class);
+ if (oldOssConfigDO != null) {
+ String webPathPrefix = MyWebdavServlet.getPathDelimiter(ossConfigDTO.getUsername(), oldOssConfigDO.getFolderName());
+ if (!oldOssConfigDO.getFolderName().equals(ossConfigDTO.getFolderName())) {
+ // 修改了目录名
+ // 修改了目录名
+ Path oldPath = Paths.get(fileProperties.getRootDir(), ossConfigDTO.getUsername(), oldOssConfigDO.getFolderName());
+ PathUtil.del(oldPath);
+ fileMonitor.removeDirFilter(webPathPrefix);
+ if (existFolder) {
+ throw new CommonException(ExceptionType.WARNING.getCode(), "目录已存在: " + ossConfigDTO.getFolderName());
+ }
+ }
+ // 销毁 old OssService
+ destroyOssService(webPathPrefix);
+ if (ossConfigDTO.getAccessKey().contains("*")) {
+ ossConfigDTO.setAccessKey(textEncryptor.decrypt(oldOssConfigDO.getAccessKey()));
+ }
+ if (ossConfigDTO.getSecretKey().contains("*")) {
+ ossConfigDTO.setSecretKey(textEncryptor.decrypt(oldOssConfigDO.getSecretKey()));
+ }
+ }
+ if (existFolder && oldOssConfigDO == null) {
+ throw new CommonException(ExceptionType.WARNING.getCode(), "目录已存在: " + ossConfigDTO.getFolderName());
+ }
+
+ }
+
+ /**
+ * 更新配置
+ */
+ private void updateOssConfig(OssConfigDTO ossConfigDTO, IOssService ossService, Query query, OssConfigDO ossConfigDO) {
+ // mkdir
+ Path path = Paths.get(fileProperties.getRootDir(), ossConfigDTO.getUsername(), ossConfigDTO.getFolderName());
+ PathUtil.mkdir(path);
+ setBucketInfoCache(ossConfigDO.getPlatform(), ossConfigDTO, ossService);
+ Update update = new Update();
+ update.set("platform", ossConfigDO.getPlatform());
+ update.set("folderName", ossConfigDO.getFolderName());
+ update.set("accessKey", ossConfigDO.getAccessKey());
+ update.set("secretKey", ossConfigDO.getSecretKey());
+ update.set("endpoint", ossConfigDO.getEndpoint());
+ update.set("region", ossConfigDO.getRegion());
+ update.set("bucket", ossConfigDO.getBucket());
+ update.set("userId", ossConfigDO.getUserId());
+ mongoTemplate.upsert(query, update, COLLECTION_NAME);
+ }
+
+ /**
+ * 判断目录是否存在
+ *
+ * @param username username
+ * @param folderName 目录名
+ */
+ private boolean existFolderName(String username, String folderName) {
+ Path path = Paths.get(fileProperties.getRootDir(), username, folderName);
+ return PathUtil.exists(path, false);
+ }
+
+ /**
+ * OSS配置列表
+ */
+ public ResponseResult ossConfigList() {
+ List ossConfigDOList = mongoTemplate.findAll(OssConfigDO.class);
+ List ossConfigDTOList = ossConfigDOList.stream().map(OssConfigDO::toOssConfigDTO).toList();
+ return ResultUtil.success(ossConfigDTOList);
+ }
+
+ /**
+ * 删除OSS配置
+ * @param id ossConfigId
+ */
+ public ResponseResult deleteOssConfig(String id) {
+ Query query = new Query();
+ query.addCriteria(Criteria.where("_id").is(id));
+ OssConfigDO ossConfigDO = mongoTemplate.findAndRemove(query, OssConfigDO.class);
+ if (ossConfigDO != null) {
+ // 销毁IOssService
+ String username = userService.getUserNameById(ossConfigDO.getUserId());
+ String key = MyWebdavServlet.getPathDelimiter(username, ossConfigDO.getFolderName());
+ destroyOssService(key);
+ // 删除相关缓存
+ Query removeQuery = new Query();
+ removeQuery.addCriteria(Criteria.where("_id").regex("^" + ReUtil.escape(Paths.get(username, ossConfigDO.getFolderName()) + "/")));
+ mongoTemplate.remove(removeQuery, FileDocument.class);
+ PathUtil.del(Paths.get(fileProperties.getRootDir(), key));
+ }
+ return ResultUtil.success();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/OssInputStream.java b/src/main/java/com/jmal/clouddisk/oss/OssInputStream.java
new file mode 100644
index 0000000..a17eed8
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/OssInputStream.java
@@ -0,0 +1,24 @@
+package com.jmal.clouddisk.oss;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.InputStream;
+import java.util.zip.CheckedInputStream;
+import java.util.zip.Checksum;
+
+/**
+ * @author jmal
+ * @Description OssInputStream
+ * @date 2023/3/28 16:32
+ */
+public class OssInputStream extends CheckedInputStream {
+
+ @Getter
+ @Setter
+ private AbstractOssObject abstractOssObject;
+
+ public OssInputStream(InputStream in, Checksum checksum) {
+ super(in, checksum);
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/PlatformOSS.java b/src/main/java/com/jmal/clouddisk/oss/PlatformOSS.java
new file mode 100644
index 0000000..fbb6b6b
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/PlatformOSS.java
@@ -0,0 +1,34 @@
+package com.jmal.clouddisk.oss;
+
+import lombok.Getter;
+
+/**
+ * @author jmal
+ * @Description platform
+ * @date 2023/3/29 14:00
+ */
+public enum PlatformOSS {
+ ALIYUN("aliyun", "阿里云OSS"),
+ TENCENT("tencent", "腾讯云OSS"),
+ MINIO("minio", "S3兼容");
+
+ @Getter
+ private final String key;
+ @Getter
+ private final String value;
+
+ PlatformOSS(String key, String value) {
+ this.key = key;
+ this.value = value;
+ }
+
+ public static PlatformOSS getPlatform(String key) {
+ for (PlatformOSS platformOSS : values()) {
+ if (platformOSS.getKey().equals(key)) {
+ return platformOSS;
+ }
+ }
+ return ALIYUN;
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/S3ObjectSummary.java b/src/main/java/com/jmal/clouddisk/oss/S3ObjectSummary.java
new file mode 100644
index 0000000..456be03
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/S3ObjectSummary.java
@@ -0,0 +1,27 @@
+package com.jmal.clouddisk.oss;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * @author jmal
+ * @Description S3ObjectSummary
+ * @date 2023/4/6 11:08
+ */
+@Data
+public class S3ObjectSummary {
+ private String bucketName;
+ private String key;
+ private String eTag;
+ private long size;
+ private Date lastModified;
+
+ public S3ObjectSummary(long size, String key, String eTag, Date lastModified, String bucketName) {
+ this.size = size;
+ this.key = key;
+ this.eTag = eTag;
+ this.lastModified = lastModified;
+ this.bucketName = bucketName;
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/TempFileObject.java b/src/main/java/com/jmal/clouddisk/oss/TempFileObject.java
new file mode 100644
index 0000000..010088b
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/TempFileObject.java
@@ -0,0 +1,68 @@
+package com.jmal.clouddisk.oss;
+
+import java.io.*;
+import java.util.Date;
+
+/**
+ * @author jmal
+ * @Description TempFileObject
+ * @date 2023/4/3 14:03
+ */
+public class TempFileObject extends AbstractOssObject {
+
+ private final File tempFile;
+
+ private final String objectName;
+
+ private final String bucketName;
+
+ private final IOssService ossService;
+
+ public TempFileObject(File file, String objectName, String bucketName, IOssService ossService) {
+ this.tempFile = file;
+ this.objectName = objectName;
+ this.bucketName = bucketName;
+ this.ossService = ossService;
+ }
+
+ @Override
+ public InputStream getInputStream() throws FileNotFoundException {
+ return new FileInputStream(this.tempFile);
+ }
+
+ @Override
+ public void closeObject() {
+ // none
+ }
+
+ @Override
+ public String getKey() {
+ return this.objectName;
+ }
+
+ @Override
+ public IOssService getOssService() {
+ return this.ossService;
+ }
+
+ @Override
+ public FileInfo getFileInfo() {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setSize(this.tempFile.length());
+ fileInfo.setKey(this.objectName);
+ fileInfo.setLastModified(new Date(tempFile.lastModified()));
+ fileInfo.setETag(String.valueOf(tempFile.hashCode()));
+ fileInfo.setBucketName(this.bucketName);
+ return fileInfo;
+ }
+
+ @Override
+ public long getContentLength() {
+ return tempFile.length();
+ }
+
+ @Override
+ public void close() throws IOException {
+ closeObject();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/aliyun/AliyunOssObject.java b/src/main/java/com/jmal/clouddisk/oss/aliyun/AliyunOssObject.java
new file mode 100644
index 0000000..db32797
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/aliyun/AliyunOssObject.java
@@ -0,0 +1,67 @@
+package com.jmal.clouddisk.oss.aliyun;
+
+import com.aliyun.oss.model.OSSObject;
+import com.jmal.clouddisk.oss.AbstractOssObject;
+import com.jmal.clouddisk.oss.FileInfo;
+import com.jmal.clouddisk.oss.IOssService;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @author jmal
+ * @Description AliyunOssObject
+ * @date 2023/3/29 12:06
+ */
+public class AliyunOssObject extends AbstractOssObject {
+
+ private final OSSObject ossObject;
+
+ private final IOssService ossService;
+
+ public AliyunOssObject(OSSObject ossObject, IOssService ossService) {
+ this.ossObject = ossObject;
+ this.ossService = ossService;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return this.ossObject.getObjectContent();
+ }
+
+ @Override
+ public void closeObject() throws IOException {
+ this.ossObject.forcedClose();
+ }
+
+ @Override
+ public String getKey() {
+ return this.ossObject.getKey();
+ }
+
+ @Override
+ public IOssService getOssService() {
+ return this.ossService;
+ }
+
+ @Override
+ public FileInfo getFileInfo() {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setSize(this.ossObject.getObjectMetadata().getContentLength());
+ fileInfo.setKey(this.ossObject.getKey());
+ fileInfo.setLastModified(this.ossObject.getObjectMetadata().getLastModified());
+ fileInfo.setETag(this.ossObject.getObjectMetadata().getETag());
+ fileInfo.setBucketName(this.ossObject.getBucketName());
+ return fileInfo;
+ }
+
+ @Override
+ public long getContentLength() {
+ return this.ossObject.getObjectMetadata().getContentLength();
+ }
+
+ @Override
+ public void close() throws IOException {
+ closeObject();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/aliyun/AliyunOssService.java b/src/main/java/com/jmal/clouddisk/oss/aliyun/AliyunOssService.java
new file mode 100644
index 0000000..573a589
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/aliyun/AliyunOssService.java
@@ -0,0 +1,557 @@
+package com.jmal.clouddisk.oss.aliyun;
+
+import cn.hutool.core.io.file.PathUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import com.aliyun.oss.ClientException;
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClientBuilder;
+import com.aliyun.oss.OSSException;
+import com.aliyun.oss.model.*;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.oss.*;
+import com.jmal.clouddisk.oss.web.model.OssConfigDTO;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+@Slf4j
+public class AliyunOssService implements IOssService {
+
+ private final String bucketName;
+
+ private final OSS ossClient;
+
+ private final BaseOssService baseOssService;
+
+ private final ScheduledThreadPoolExecutor scheduledThreadPoolExecutor;
+
+ public AliyunOssService(FileProperties fileProperties, OssConfigDTO ossConfigDTO) {
+ String endpoint = ossConfigDTO.getEndpoint();
+ String accessKeyId = ossConfigDTO.getAccessKey();
+ String accessKeySecret = ossConfigDTO.getSecretKey();
+ this.bucketName = ossConfigDTO.getBucket();
+ // 创建OSSClient实例。
+ this.ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
+ scheduledThreadPoolExecutor = ThreadUtil.createScheduledExecutor(1);
+ this.baseOssService = new BaseOssService(this, bucketName, fileProperties, scheduledThreadPoolExecutor, ossConfigDTO);
+ ThreadUtil.execute(this::getMultipartUploads);
+ }
+
+ @Override
+ public PlatformOSS getPlatform() {
+ return PlatformOSS.ALIYUN;
+ }
+
+ @Override
+ public FileInfo getFileInfo(String objectName) {
+ return baseOssService.getFileInfo(objectName);
+ }
+
+ @Override
+ public boolean delete(String objectName) {
+ return baseOssService.delete(objectName);
+ }
+
+ @Override
+ public boolean mkdir(String objectName) {
+ return baseOssService.mkdir(objectName);
+ }
+
+ @Override
+ public boolean write(InputStream inputStream, String ossPath, String objectName) {
+ return baseOssService.writeTempFile(inputStream, ossPath, objectName);
+ }
+
+ @Override
+ public String[] list(String objectName) {
+ return baseOssService.getFileNameList(objectName).toArray(new String[0]);
+ }
+
+ @Override
+ public AbstractOssObject getObjectCache(String objectName) {
+ return baseOssService.getObject(objectName);
+ }
+
+ @Override
+ public AbstractOssObject getAbstractOssObject(String objectName) {
+ return getAbstractOssObject(objectName, null, null);
+ }
+
+ @Override
+ public AbstractOssObject getAbstractOssObject(String objectName, Long rangeStart, Long rangeEnd) {
+ OSSObject ossObject = null;
+ try {
+ GetObjectRequest getObjectRequest = new GetObjectRequest(bucketName, objectName);
+ if (rangeStart != null && rangeEnd != null) {
+ getObjectRequest.setRange(rangeStart, rangeEnd);
+ }
+ ossObject = this.ossClient.getObject(getObjectRequest);
+ } catch (Exception e) {
+ log.warn(e.getMessage());
+ }
+ if (ossObject == null) {
+ return null;
+ }
+ return new AliyunOssObject(ossObject, this);
+ }
+
+
+ @Override
+ public boolean deleteObject(String objectName) {
+ try {
+ baseOssService.printOperation(getPlatform().getKey(), "deleteObject", objectName);
+ ossClient.deleteObject(bucketName, objectName);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean deleteDir(String objectName) {
+ try {
+ baseOssService.printOperation(getPlatform().getKey(), "deleteDir", objectName);
+ // 删除目录及目录下的所有文件。
+ String nextMarker = null;
+ ObjectListing objectListing;
+ do {
+ ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName)
+ .withPrefix(objectName)
+ .withMarker(nextMarker);
+
+ objectListing = ossClient.listObjects(listObjectsRequest);
+ if (!objectListing.getObjectSummaries().isEmpty()) {
+ List keys = new ArrayList<>();
+ for (OSSObjectSummary s : objectListing.getObjectSummaries()) {
+ keys.add(s.getKey());
+ }
+ DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(bucketName).withKeys(keys).withEncodingType("url");
+ ossClient.deleteObjects(deleteObjectsRequest);
+ }
+ nextMarker = objectListing.getNextMarker();
+ } while (objectListing.isTruncated());
+ return true;
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return false;
+ }
+
+ @Override
+ public List getFileInfoList(String objectName) {
+ List fileInfoList = new ArrayList<>();
+ try {
+ // 构造ListObjectsRequest请求。
+ ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName);
+
+ // 设置正斜线(/)为文件夹的分隔符。
+ listObjectsRequest.setDelimiter("/");
+
+ // 列出fun目录下的所有文件和文件夹。
+ listObjectsRequest.setPrefix(objectName);
+ baseOssService.printOperation(getPlatform().getKey(), "getFileInfoList", objectName);
+ ObjectListing listing = ossClient.listObjects(listObjectsRequest);
+
+ // 对象列表
+ for (OSSObjectSummary objectSummary : listing.getObjectSummaries()) {
+ S3ObjectSummary s3ObjectSummary = new S3ObjectSummary(objectSummary.getSize(), objectSummary.getKey(), objectSummary.getETag(), objectSummary.getLastModified(), objectSummary.getBucketName());
+ baseOssService.addFileInfoList(objectName, fileInfoList, s3ObjectSummary);
+ }
+ // 子目录
+ for (String commonPrefix : listing.getCommonPrefixes()) {
+ fileInfoList.add(baseOssService.newFileInfo(commonPrefix));
+ }
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return fileInfoList;
+ }
+
+ @Override
+ public List getAllObjectsWithPrefix(String objectName) {
+ // 列举所有包含指定前缀的所有文件
+ String nextMarker = null;
+ ObjectListing objectListing;
+ List fileInfoList = new ArrayList<>();
+ try {
+ do {
+ ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName).withPrefix(objectName).withMarker(nextMarker);
+ objectListing = ossClient.listObjects(listObjectsRequest);
+ if (!objectListing.getObjectSummaries().isEmpty()) {
+ objectListing.getObjectSummaries().forEach(ossObjectSummary -> fileInfoList.add(new FileInfo(ossObjectSummary.getKey(), ossObjectSummary.getETag(), ossObjectSummary.getSize(), ossObjectSummary.getLastModified())));
+ }
+ nextMarker = objectListing.getNextMarker();
+ } while (objectListing.isTruncated());
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return fileInfoList;
+ }
+
+ @Override
+ public List getFileInfoListCache(String objectName) {
+ return baseOssService.getFileInfoListCache(objectName);
+ }
+
+ @Override
+ public boolean doesBucketExist() {
+ boolean exist;
+ exist = this.ossClient.doesBucketExist(bucketName);
+ if (exist) {
+ this.ossClient.getBucketStat(bucketName);
+ }
+ return exist;
+ }
+
+ public String getUploadId(String objectName) {
+ return baseOssService.getUploadId(objectName);
+ }
+
+ @Override
+ public String initiateMultipartUpload(String objectName) {
+ // 创建InitiateMultipartUploadRequest对象。
+ InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, objectName);
+ // 初始化分片。
+ InitiateMultipartUploadResult uploadResult = ossClient.initiateMultipartUpload(request);
+ // 返回uploadId,它是分片上传事件的唯一标识。您可以根据该uploadId发起相关的操作,例如取消分片上传、查询分片上传等。
+ return uploadResult.getUploadId();
+ }
+
+ private void getMultipartUploads() {
+ try {
+ // 列举分片上传事件。
+ MultipartUploadListing multipartUploadListing;
+ ListMultipartUploadsRequest listMultipartUploadsRequest = new ListMultipartUploadsRequest(bucketName);
+ // 设置每页列举的分片上传事件个数。
+ listMultipartUploadsRequest.setMaxUploads(50);
+ do {
+ multipartUploadListing = ossClient.listMultipartUploads(listMultipartUploadsRequest);
+ for (MultipartUpload multipartUpload : multipartUploadListing.getMultipartUploads()) {
+ log.info("{}, 碎片文件: objectName: {}, uploadId: {}", getPlatform().getValue(), multipartUpload.getKey(), multipartUpload.getUploadId());
+ baseOssService.setUpdateIdCache(multipartUpload.getKey(), multipartUpload.getUploadId());
+ }
+ listMultipartUploadsRequest.setKeyMarker(multipartUploadListing.getNextKeyMarker());
+ listMultipartUploadsRequest.setUploadIdMarker(multipartUploadListing.getNextUploadIdMarker());
+ } while (multipartUploadListing.isTruncated());
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ }
+
+ @Override
+ public boolean doesObjectExist(String objectName) {
+ return this.ossClient.doesObjectExist(bucketName, objectName);
+ }
+
+ @Override
+ public CopyOnWriteArrayList getListParts(String objectName, String uploadId) {
+ return new CopyOnWriteArrayList<>(getPartETagList(objectName, uploadId).stream().map(PartETag::getPartNumber).toList());
+ }
+
+ private List getPartETagList(String objectName, String uploadId) {
+ List listParts = new ArrayList<>();
+ try {
+ // 列举所有已上传的分片。
+ PartListing partListing;
+ ListPartsRequest listPartsRequest = new ListPartsRequest(bucketName, objectName, uploadId);
+ do {
+ partListing = ossClient.listParts(listPartsRequest);
+ for (PartSummary part : partListing.getParts()) {
+ listParts.add(new PartETag(part.getPartNumber(), part.getETag()));
+ }
+ // 指定List的起始位置,只有分片号大于此参数值的分片会被列出。
+ listPartsRequest.setPartNumberMarker(partListing.getNextPartNumberMarker());
+ } while (partListing.isTruncated());
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return listParts;
+ }
+
+ @Override
+ public boolean uploadPart(InputStream inputStream, String objectName, int partSize, int partNumber, String uploadId) {
+ UploadPartRequest uploadPartRequest = new UploadPartRequest();
+ uploadPartRequest.setBucketName(bucketName);
+ uploadPartRequest.setKey(objectName);
+ uploadPartRequest.setUploadId(uploadId);
+ // 设置上传的分片流。
+ uploadPartRequest.setInputStream(inputStream);
+ // 设置分片大小。除了最后一个分片没有大小限制,其他的分片最小为100 KB。
+ uploadPartRequest.setPartSize(partSize);
+ // 设置分片号。每一个上传的分片都有一个分片号,取值范围是1~10000,如果超出此范围,OSS将返回InvalidArgument错误码。
+ uploadPartRequest.setPartNumber(partNumber);
+ // 每个分片不需要按顺序上传,甚至可以在不同客户端上传,OSS会按照分片号排序组成完整的文件。
+ try {
+ this.ossClient.uploadPart(uploadPartRequest);
+ return true;
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return false;
+ }
+
+ @Override
+ public void abortMultipartUpload(String objectName, String uploadId) {
+ try {
+ // 取消分片上传。
+ AbortMultipartUploadRequest abortMultipartUploadRequest = new AbortMultipartUploadRequest(bucketName, objectName, uploadId);
+ ossClient.abortMultipartUpload(abortMultipartUploadRequest);
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ }
+
+ @Override
+ public void completeMultipartUpload(String objectName, String uploadId, Long totalSize) {
+ baseOssService.printOperation(getPlatform().getKey(), "completeMultipartUpload", objectName);
+ List partETags = getPartETagList(objectName, uploadId);
+ // 创建CompleteMultipartUploadRequest对象。
+ // 在执行完成分片上传操作时,需要提供所有有效的partETags。OSS收到提交的partETags后,会逐一验证每个分片的有效性。当所有的数据分片验证通过后,OSS将把这些分片组合成一个完整的文件。
+ CompleteMultipartUploadRequest completeMultipartUploadRequest =
+ new CompleteMultipartUploadRequest(bucketName, objectName, uploadId, partETags);
+ // 完成分片上传。
+ ossClient.completeMultipartUpload(completeMultipartUploadRequest);
+ baseOssService.onUploadSuccess(objectName, totalSize);
+ }
+
+ @Override
+ public FileInfo getThumbnail(String objectName, File file, int width) {
+ try {
+ // 将图片缩放为固定宽高100 px。
+ String style = "image/resize,m_mfit,w_" + width;
+ GetObjectRequest request = new GetObjectRequest(bucketName, objectName);
+ request.setProcess(style);
+ // 将处理后的图片命名为example-resize.jpg并保存到本地。
+ // 如果未指定本地路径只填写了本地文件名称(例如example-resize.jpg),则文件默认保存到示例程序所属项目对应本地路径中。
+ ossClient.getObject(request, file);
+ return baseOssService.getFileInfo(objectName);
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return null;
+ }
+
+ @Override
+ public FileInfo newFolder(String objectName) {
+ baseOssService.printOperation(getPlatform().getKey(), "mkdir", objectName);
+ if (!objectName.endsWith("/")) {
+ objectName = objectName + "/";
+ }
+ try {
+ String content = "";
+ // 创建PutObjectRequest对象。
+ PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, new ByteArrayInputStream(content.getBytes()));
+ // 上传字符串
+ ossClient.putObject(putObjectRequest);
+ return baseOssService.newFileInfo(objectName);
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return null;
+ }
+
+ @Override
+ public void uploadFile(Path tempFileAbsolutePath, String objectName) {
+ executeUpload(tempFileAbsolutePath, objectName);
+ }
+
+ private void executeUpload(Path tempFileAbsolutePath, String objectName) {
+ try {
+ if (!PathUtil.exists(tempFileAbsolutePath, false)) {
+ return;
+ }
+ baseOssService.printOperation(getPlatform().getKey(), "upload", objectName);
+ // 创建PutObjectRequest对象。
+ PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, tempFileAbsolutePath.toFile());
+ // 设置该属性可以返回response。如果不设置,则返回的response为空。
+ putObjectRequest.setProcess("true");
+ // 创建PutObject请求。
+ PutObjectResult putObjectResult = this.ossClient.putObject(putObjectRequest);
+ if (putObjectResult.getResponse().getStatusCode() == 200) {
+ // 上传成功
+ baseOssService.onUploadSuccess(objectName, tempFileAbsolutePath);
+ }
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ }
+
+ @Override
+ public void uploadFile(InputStream inputStream, String objectName, long inputStreamLength) {
+ try {
+ baseOssService.printOperation(getPlatform().getKey(), "uploadFile inputStream", objectName);
+ ObjectMetadata objectMetadata = new ObjectMetadata();
+ objectMetadata.setContentLength(inputStreamLength);
+ // 创建PutObjectRequest对象。
+ PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, inputStream, objectMetadata );
+ // 创建PutObject请求。
+ ossClient.putObject(putObjectRequest);
+ baseOssService.onUploadSuccess(objectName, inputStreamLength);
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ }
+
+ public List copyObject(String sourceKey, String destinationKey) {
+ return copyObject(bucketName, sourceKey, bucketName, destinationKey);
+ }
+
+ public List copyObject(String sourceBucketName, String sourceKey, String destinationBucketName, String destinationKey) {
+ baseOssService.setObjectNameLock(sourceBucketName);
+ baseOssService.setObjectNameLock(destinationBucketName);
+ List copiedList = new ArrayList<>();
+ try {
+ if (sourceKey.endsWith("/")) {
+ // 复制文件夹
+ // 列举所有包含指定前缀的文件并copy
+ String nextMarker = null;
+ ObjectListing objectListing;
+ do {
+ ListObjectsRequest listObjectsRequest = new ListObjectsRequest(sourceBucketName).withPrefix(sourceKey).withMarker(nextMarker);
+ objectListing = ossClient.listObjects(listObjectsRequest);
+ if (!objectListing.getObjectSummaries().isEmpty()) {
+ objectListing.getObjectSummaries().parallelStream().forEach(ossObjectSummary -> {
+ String destKey = destinationKey + ossObjectSummary.getKey().substring(sourceKey.length());
+ copyObjectFile(ossObjectSummary.getBucketName(), ossObjectSummary.getKey(), destinationBucketName, destKey);
+ copiedList.add(destKey);
+ });
+ }
+ nextMarker = objectListing.getNextMarker();
+ } while (objectListing.isTruncated());
+ } else {
+ // 复制文件
+ copyObjectFile(sourceBucketName, sourceKey, destinationBucketName, destinationKey);
+ copiedList.add(destinationKey);
+ }
+ return copiedList;
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ } finally {
+ baseOssService.removeObjectNameLock(sourceBucketName);
+ baseOssService.removeObjectNameLock(destinationBucketName);
+ }
+ return copiedList;
+ }
+
+ private void copyObjectFile(String sourceBucketName, String sourceKey, String destinationBucketName, String destinationKey) {
+ baseOssService.printOperation(getPlatform().getKey(), "copyObject start" + "destinationKey: " + destinationKey, "sourceKey:" + sourceKey);
+ ObjectMetadata objectMetadata = ossClient.getObjectMetadata(sourceBucketName, sourceKey);
+ // 获取被拷贝文件的大小。
+ long contentLength = objectMetadata.getContentLength();
+
+ if (contentLength < 1024 * 1024 * 10L) {
+ // 小文件执行普通拷贝
+ ossClient.copyObject(sourceBucketName, sourceKey, destinationBucketName, destinationKey);
+ }
+
+ // 设置分片大小为10 MB。单位为字节。
+ long partSize = 1024 * 1024 * 10L;
+
+ // 计算分片总数。
+ int partCount = (int) (contentLength / partSize);
+ if (contentLength % partSize != 0) {
+ partCount++;
+ }
+
+ // 初始化拷贝任务。可以通过InitiateMultipartUploadRequest指定目标文件元信息。
+ InitiateMultipartUploadRequest initiateMultipartUploadRequest = new InitiateMultipartUploadRequest(destinationBucketName, destinationKey);
+ InitiateMultipartUploadResult initiateMultipartUploadResult = ossClient.initiateMultipartUpload(initiateMultipartUploadRequest);
+ String uploadId = initiateMultipartUploadResult.getUploadId();
+
+ // 分片拷贝。
+ List partETags = new ArrayList<>();
+ for (int i = 0; i < partCount; i++) {
+ // 计算每个分片的大小。
+ long skipBytes = partSize * i;
+ long size = Math.min(partSize, contentLength - skipBytes);
+ // 创建UploadPartCopyRequest。可以通过UploadPartCopyRequest指定限定条件。
+ UploadPartCopyRequest uploadPartCopyRequest = new UploadPartCopyRequest(sourceBucketName, sourceKey, destinationBucketName, destinationKey);
+ uploadPartCopyRequest.setUploadId(uploadId);
+ uploadPartCopyRequest.setPartSize(size);
+ uploadPartCopyRequest.setBeginIndex(skipBytes);
+ uploadPartCopyRequest.setPartNumber(i + 1);
+ UploadPartCopyResult uploadPartCopyResult = ossClient.uploadPartCopy(uploadPartCopyRequest);
+ // 将返回的分片ETag保存到partETags中。
+ partETags.add(uploadPartCopyResult.getPartETag());
+ }
+ // 提交分片拷贝任务。
+ CompleteMultipartUploadRequest completeMultipartUploadRequest = new CompleteMultipartUploadRequest(
+ destinationBucketName, destinationKey, uploadId, partETags);
+ ossClient.completeMultipartUpload(completeMultipartUploadRequest);
+ baseOssService.printOperation(getPlatform().getKey(), "copyObject complete" + "destinationKey: " + destinationKey, "sourceKey:" + sourceKey);
+ }
+
+ @Override
+ public URL getPresignedObjectUrl(String objectName, int expiryTime) {
+ try {
+ Date expirationDate = new Date(System.currentTimeMillis() + expiryTime * 1000L);
+ // 生成以GET方法访问的签名URL,访客可以直接通过浏览器访问相关内容。
+ return ossClient.generatePresignedUrl(bucketName, objectName, expirationDate);
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return null;
+ }
+
+ @Override
+ public void clearCache(String objectName) {
+ baseOssService.clearCache(objectName);
+ }
+
+ @Override
+ public void lock(String objectName) {
+ baseOssService.setObjectNameLock(objectName);
+ }
+
+ @Override
+ public void unlock(String objectName) {
+ baseOssService.removeObjectNameLock(objectName);
+ }
+
+ @Override
+ public void close() {
+ baseOssService.closePrint();
+ if (this.ossClient != null) {
+ this.ossClient.shutdown();
+ }
+ if (scheduledThreadPoolExecutor != null) {
+ scheduledThreadPoolExecutor.shutdown();
+ }
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/minio/MinIOObject.java b/src/main/java/com/jmal/clouddisk/oss/minio/MinIOObject.java
new file mode 100644
index 0000000..b6c610c
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/minio/MinIOObject.java
@@ -0,0 +1,72 @@
+package com.jmal.clouddisk.oss.minio;
+
+import com.jmal.clouddisk.oss.AbstractOssObject;
+import com.jmal.clouddisk.oss.FileInfo;
+import com.jmal.clouddisk.oss.IOssService;
+import io.minio.GetObjectResponse;
+import io.minio.StatObjectResponse;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Date;
+
+/**
+ * @author jmal
+ * @Description MinIOOssObject
+ * @date 2023/4/23 16:33
+ */
+public class MinIOObject extends AbstractOssObject {
+
+ private final StatObjectResponse statObjectResponse;
+ private final GetObjectResponse getObjectResponse;
+
+ private final IOssService ossService;
+
+ public MinIOObject(StatObjectResponse statObjectResponse, GetObjectResponse getObjectResponse, IOssService ossService) {
+ this.statObjectResponse = statObjectResponse;
+ this.getObjectResponse = getObjectResponse;
+ this.ossService = ossService;
+ }
+
+ @Override
+ public InputStream getInputStream() throws FileNotFoundException {
+ return getObjectResponse;
+ }
+
+ @Override
+ public void closeObject() throws IOException {
+ this.getObjectResponse.close();
+ }
+
+ @Override
+ public IOssService getOssService() {
+ return this.ossService;
+ }
+
+ @Override
+ public String getKey() {
+ return this.statObjectResponse.object();
+ }
+
+ @Override
+ public FileInfo getFileInfo() {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setSize(this.statObjectResponse.size());
+ fileInfo.setKey(this.statObjectResponse.object());
+ fileInfo.setLastModified(Date.from(this.statObjectResponse.lastModified().toInstant()));
+ fileInfo.setETag(this.statObjectResponse.etag());
+ fileInfo.setBucketName(this.statObjectResponse.bucket());
+ return fileInfo;
+ }
+
+ @Override
+ public long getContentLength() {
+ return this.statObjectResponse.size();
+ }
+
+ @Override
+ public void close() throws IOException {
+ closeObject();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/minio/MinIOService.java b/src/main/java/com/jmal/clouddisk/oss/minio/MinIOService.java
new file mode 100644
index 0000000..52613d5
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/minio/MinIOService.java
@@ -0,0 +1,537 @@
+package com.jmal.clouddisk.oss.minio;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.file.PathUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.core.util.BooleanUtil;
+import com.google.common.collect.HashMultimap;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.interceptor.FileInterceptor;
+import com.jmal.clouddisk.oss.*;
+import com.jmal.clouddisk.oss.web.model.OssConfigDTO;
+import io.minio.*;
+import io.minio.errors.*;
+import io.minio.http.Method;
+import io.minio.messages.DeleteError;
+import io.minio.messages.DeleteObject;
+import io.minio.messages.Item;
+import io.minio.messages.Part;
+import lombok.extern.slf4j.Slf4j;
+import org.jetbrains.annotations.NotNull;
+
+import java.io.*;
+import java.net.URL;
+import java.nio.file.Path;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.stream.Collectors;
+
+/**
+ * @author jmal
+ * @Description MinIOService
+ * @date 2023/4/23 16:36
+ */
+@Slf4j
+public class MinIOService implements IOssService {
+
+ private final String bucketName;
+
+ private final MinIoClient minIoClient;
+
+ private final BaseOssService baseOssService;
+
+ private final ScheduledThreadPoolExecutor scheduledThreadPoolExecutor;
+
+ public MinIOService(FileProperties fileProperties, OssConfigDTO ossConfigDTO) {
+ String endpoint = ossConfigDTO.getEndpoint();
+ String region = ossConfigDTO.getRegion();
+ String accessKeyId = ossConfigDTO.getAccessKey();
+ String accessKeySecret = ossConfigDTO.getSecretKey();
+ this.bucketName = ossConfigDTO.getBucket();
+ // 创建ossClient实例。
+ this.minIoClient = new MinIoClient(MinioAsyncClient.builder()
+ .endpoint(endpoint)
+ .region(region)
+ .credentials(accessKeyId, accessKeySecret)
+ .build());
+ scheduledThreadPoolExecutor = ThreadUtil.createScheduledExecutor(1);
+ this.baseOssService = new BaseOssService(this, bucketName, fileProperties, scheduledThreadPoolExecutor, ossConfigDTO);
+ ThreadUtil.execute(this::getMultipartUploads);
+ }
+
+ @Override
+ public PlatformOSS getPlatform() {
+ return PlatformOSS.MINIO;
+ }
+
+ @Override
+ public FileInfo getFileInfo(String objectName) {
+ return baseOssService.getFileInfo(objectName);
+ }
+
+ @Override
+ public boolean delete(String objectName) {
+ return baseOssService.delete(objectName);
+ }
+
+ @Override
+ public boolean mkdir(String objectName) {
+ return baseOssService.mkdir(objectName);
+ }
+
+ @Override
+ public boolean write(InputStream inputStream, String ossPath, String objectName) {
+ return baseOssService.writeTempFile(inputStream, ossPath, objectName);
+ }
+
+ @Override
+ public String[] list(String objectName) {
+ return baseOssService.getFileNameList(objectName).toArray(new String[0]);
+ }
+
+ @Override
+ public AbstractOssObject getObjectCache(String objectName) {
+ return baseOssService.getObject(objectName);
+ }
+
+ @Override
+ public AbstractOssObject getAbstractOssObject(String objectName) {
+ return getAbstractOssObject(objectName, null, null);
+ }
+
+ @Override
+ public AbstractOssObject getAbstractOssObject(String objectName, Long rangeStart, Long rangeEnd) {
+ MinIOObject ossObject = null;
+ try {
+ StatObjectResponse statObjectResponse = this.minIoClient.statObject(bucketName, objectName);
+ GetObjectResponse getObjectResponse = this.minIoClient.getObject(bucketName, objectName, rangeStart, rangeEnd);
+ ossObject = new MinIOObject(statObjectResponse, getObjectResponse, this);
+ } catch (Exception e) {
+ log.error(e.getMessage());
+ }
+ return ossObject;
+ }
+
+ @Override
+ public boolean deleteObject(String objectName) {
+ try {
+ baseOssService.printOperation(getPlatform().getKey(), "deleteObject", objectName);
+ doDeleteObject(objectName);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ return false;
+ }
+ return true;
+ }
+
+ private void doDeleteObject(String objectName) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, XmlParserException, InternalException, ServerException, ErrorResponseException, InvalidResponseException {
+ this.minIoClient.removeObject(bucketName, objectName);
+ }
+
+ @Override
+ public boolean deleteDir(String objectName) {
+ boolean deleted = true;
+ try {
+ baseOssService.printOperation(getPlatform().getKey(), "deleteDir", objectName);
+ ListObjectsArgs listObjectsArgs = ListObjectsArgs.builder().bucket(bucketName).prefix(objectName).recursive(true).includeVersions(true).build();
+ Iterable> results1 = this.minIoClient.listObjects(listObjectsArgs);
+ List objects = new LinkedList<>();
+ for (Result- result : results1) {
+ Item item = result.get();
+ objects.add(new DeleteObject(item.objectName(), item.versionId()));
+ }
+ RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket(bucketName).objects(objects).build();
+ Iterable
> results = this.minIoClient.removeObjects(removeObjectsArgs);
+ for (Result result : results) {
+ DeleteError error = result.get();
+ deleted = false;
+ log.error("Error in deleting object {}; {}", error.objectName(), error.message());
+ }
+ } catch (Exception e) {
+ deleted = false;
+ log.error(e.getMessage(), e);
+ }
+ return deleted;
+ }
+
+ @Override
+ public List getFileInfoListCache(String objectName) {
+ return baseOssService.getFileInfoListCache(objectName);
+ }
+
+ @Override
+ public List getFileInfoList(String objectName) {
+ List fileInfoList = new ArrayList<>();
+ try {
+ ListObjectsArgs listObjectsArgs = ListObjectsArgs.builder().bucket(bucketName).prefix(objectName).delimiter("/").recursive(false).includeVersions(false).build();
+ Iterable> results = this.minIoClient.listObjects(listObjectsArgs);
+ for (Result- result : results) {
+ Item item = result.get();
+ if (item == null) {
+ continue;
+ }
+ Date lastModified = null;
+ if (BooleanUtil.isFalse(item.isDir())) {
+ lastModified = Date.from(item.lastModified().toInstant());
+ }
+ S3ObjectSummary s3ObjectSummary = new S3ObjectSummary(item.size(), item.objectName(), item.etag(), lastModified, bucketName);
+ baseOssService.addFileInfoList(objectName, fileInfoList, s3ObjectSummary);
+ if (item.isDir()) {
+ String key = item.objectName();
+ if (fileInfoList.stream().noneMatch(fileInfo -> key.equals(fileInfo.getKey()))) {
+ fileInfoList.add(baseOssService.newFileInfo(key));
+ }
+ }
+ fileInfoList = setLastModified(fileInfoList);
+ }
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return fileInfoList;
+ }
+
+ @NotNull
+ private List
setLastModified(List fileInfoList) {
+ fileInfoList = fileInfoList.parallelStream().peek(fileInfo -> {
+ if (fileInfo.getLastModified() == null) {
+ Date date = new Date(0);
+ try {
+ date = getLastModified(fileInfo.getKey());
+ } catch (Exception e) {
+ log.warn("getLastModified Failed: {}, key: {}", e.getMessage(), fileInfo.getKey());
+ }
+ fileInfo.setLastModified(date);
+ }
+ }).collect(Collectors.toList());
+ return fileInfoList;
+ }
+
+ @Override
+ public List getAllObjectsWithPrefix(String objectName) {
+ List fileInfoList = new ArrayList<>();
+ try {
+ ListObjectsArgs listObjectsArgs = ListObjectsArgs.builder().bucket(bucketName).prefix(objectName).recursive(true).includeVersions(false).build();
+ Iterable> results = this.minIoClient.listObjects(listObjectsArgs);
+ for (Result- result : results) {
+ Item item = result.get();
+ if (item == null) {
+ continue;
+ }
+ Date lastModified = Date.from(item.lastModified().toInstant());
+ S3ObjectSummary s3ObjectSummary = new S3ObjectSummary(item.size(), item.objectName(), item.etag(), lastModified, bucketName);
+ fileInfoList.add(baseOssService.getFileInfo(s3ObjectSummary));
+ }
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return fileInfoList;
+ }
+
+ private Date getLastModified(String objectName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
+ StatObjectResponse statObjectResponse = this.minIoClient.statObject(bucketName, objectName);
+ return Date.from(statObjectResponse.lastModified().toInstant());
+ }
+
+ @Override
+ public FileInfo newFolder(String objectName) {
+ baseOssService.printOperation(getPlatform().getKey(), "mkdir", objectName);
+ if (!objectName.endsWith("/")) {
+ objectName = objectName + "/";
+ }
+ try {
+ // 上传字符串
+ this.minIoClient.putObject2(
+ PutObjectArgs.builder().bucket(bucketName).object(objectName).stream(
+ new ByteArrayInputStream(new byte[]{}), 0, -1)
+ .build());
+ return baseOssService.newFileInfo(objectName);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ @Override
+ public void uploadFile(Path tempFileAbsolutePath, String objectName) {
+ try {
+ if (!PathUtil.exists(tempFileAbsolutePath, false)) {
+ return;
+ }
+ File file = tempFileAbsolutePath.toFile();
+ baseOssService.printOperation(getPlatform().getKey(), "upload", objectName);
+ ObjectWriteResponse objectWriteResponse;
+ try (InputStream inputStream = new FileInputStream(file)) {
+ PutObjectArgs putObjectArgs = getPutObjectArgs(inputStream, objectName, tempFileAbsolutePath.toFile().length());
+ objectWriteResponse = this.minIoClient.putObject2(putObjectArgs);
+ }
+ if (objectWriteResponse != null && objectWriteResponse.etag() != null) {
+ baseOssService.onUploadSuccess(objectName, tempFileAbsolutePath);
+ }
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void uploadFile(InputStream inputStream, String objectName, long inputStreamLength) {
+ try {
+ baseOssService.printOperation(getPlatform().getKey(), "uploadFile inputStream", objectName);
+ ObjectWriteResponse objectWriteResponse;
+ PutObjectArgs putObjectArgs = getPutObjectArgs(inputStream, objectName, inputStreamLength);
+ objectWriteResponse = this.minIoClient.putObject2(putObjectArgs);
+ if (objectWriteResponse != null && objectWriteResponse.etag() != null) {
+ // 上传成功
+ baseOssService.onUploadSuccess(objectName, inputStreamLength);
+ }
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ private PutObjectArgs getPutObjectArgs(InputStream inputStream, String objectName, long inputStreamLength) {
+ String contentType = baseOssService.getContentType(objectName);
+ PutObjectArgs putObjectArgs;
+ if (inputStreamLength >= 5 * 1024 * 1024) {
+ putObjectArgs = PutObjectArgs.builder().bucket(bucketName).object(objectName).stream(
+ inputStream, -1, inputStreamLength)
+ .contentType(contentType)
+ .build();
+ } else {
+ putObjectArgs = PutObjectArgs.builder().bucket(bucketName).object(objectName).stream(
+ inputStream, inputStreamLength, -1)
+ .contentType(contentType)
+ .build();
+ }
+ return putObjectArgs;
+ }
+
+ @Override
+ public boolean doesBucketExist() {
+ boolean exist = false;
+ try {
+ exist = this.minIoClient.bucketExists(bucketName);
+ }catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return exist;
+ }
+
+ @Override
+ public boolean doesObjectExist(String objectName) {
+ try {
+ StatObjectResponse statObjectResponse = this.minIoClient.statObject(bucketName, objectName);
+ if (statObjectResponse == null) {
+ return false;
+ }
+ if (!statObjectResponse.deleteMarker()) {
+ return true;
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return false;
+ }
+
+ @Override
+ public String getUploadId(String objectName) {
+ return baseOssService.getUploadId(objectName);
+ }
+
+ @Override
+ public String initiateMultipartUpload(String objectName) {
+ String uploadId = null;
+ try {
+ String contentType = baseOssService.getContentType(objectName);
+ HashMultimap
headers = HashMultimap.create();
+ headers.put("Content-Type", contentType);
+ uploadId = this.minIoClient.initMultiPartUpload(bucketName, null, objectName, headers);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return uploadId;
+ }
+
+ @Override
+ public CopyOnWriteArrayList getListParts(String objectName, String uploadId) {
+ return new CopyOnWriteArrayList<>(getPartsList(objectName, uploadId).stream().map(Part::partNumber).toList());
+ }
+
+ private List getPartsList(String objectName, String uploadId) {
+ List listParts = new ArrayList<>();
+ try {
+ int maxParts = 1000;
+ int partNumberMarker = 0;
+ boolean isTruncated;
+
+ do {
+ ListPartsResponse partResult = this.minIoClient.listMultipart(bucketName, null, objectName, maxParts, partNumberMarker, uploadId);
+ List currentParts = partResult.result().partList();
+
+ if (currentParts.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ listParts.addAll(currentParts);
+ isTruncated = partResult.result().isTruncated();
+
+ if (isTruncated) {
+ partNumberMarker = currentParts.get(currentParts.size() - 1).partNumber();
+ }
+ } while (isTruncated);
+
+ } catch (Exception e) {
+ log.error("Error listing parts for object: {}, uploadId: {}. Exception: {}", objectName, uploadId, e.getMessage(), e);
+ return Collections.emptyList();
+ }
+ return listParts;
+ }
+
+
+
+
+ private void getMultipartUploads() {
+ }
+
+ @Override
+ public boolean uploadPart(InputStream inputStream, String objectName, int partSize, int partNumber, String uploadId) {
+ try {
+ UploadPartResponse uploadPartResponse = this.minIoClient.uploadPart(bucketName, null, objectName, inputStream, partSize, uploadId, partNumber);
+ if (uploadPartResponse != null && uploadId.equals(uploadPartResponse.uploadId())) {
+ return true;
+ }
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return false;
+ }
+
+ @Override
+ public void abortMultipartUpload(String objectName, String uploadId) {
+ try {
+ this.minIoClient.abortMultipartUpload(bucketName, null, objectName, uploadId);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void completeMultipartUpload(String objectName, String uploadId, Long fileTotalSize) {
+ try {
+ List partList = getPartsList(objectName, uploadId);
+ Part[] parts = partList.toArray(new Part[0]);
+ this.minIoClient.mergeMultipartUpload(bucketName, null, objectName, uploadId, parts);
+ baseOssService.onUploadSuccess(objectName, fileTotalSize);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public FileInfo getThumbnail(String objectName, File file, int width) {
+ try {
+ this.minIoClient.downloadObject(bucketName, objectName, file);
+ byte[] bytes = FileInterceptor.imageCrop(file, "80", String.valueOf(width), null);
+ FileUtil.writeBytes(bytes, file);
+ return baseOssService.getFileInfo(objectName);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ @Override
+ public List copyObject(String sourceKey, String destinationKey) {
+ return copyObject(bucketName, sourceKey, bucketName, destinationKey);
+ }
+
+ @Override
+ public List copyObject(String sourceBucketName, String sourceKey, String destinationBucketName, String destinationKey) {
+ baseOssService.setObjectNameLock(sourceBucketName);
+ baseOssService.setObjectNameLock(destinationBucketName);
+ List copiedList = new ArrayList<>();
+ try {
+ if (sourceKey.endsWith("/")) {
+ // 复制文件夹
+ ListObjectsArgs listObjectsArgs = ListObjectsArgs.builder().bucket(sourceBucketName).prefix(sourceKey).recursive(true).build();
+ Iterable> results1 = this.minIoClient.listObjects(listObjectsArgs);
+ for (Result- result : results1) {
+ Item item = result.get();
+ String destKey = destinationKey + item.objectName().substring(sourceKey.length());
+ copyObjectFile(sourceBucketName, item.objectName(), destinationBucketName, destKey);
+ copiedList.add(destKey);
+ }
+ } else {
+ // 复制文件
+ copyObjectFile(sourceBucketName, sourceKey, destinationBucketName, destinationKey);
+ copiedList.add(destinationKey);
+ }
+ return copiedList;
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ } finally {
+ baseOssService.removeObjectNameLock(sourceBucketName);
+ baseOssService.removeObjectNameLock(destinationBucketName);
+ }
+ return copiedList;
+ }
+
+ private void copyObjectFile(String sourceBucketName, String sourceKey, String destinationBucketName, String destinationKey) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, XmlParserException, InternalException, ExecutionException, InterruptedException {
+ baseOssService.printOperation(getPlatform().getKey(), "copyObject start" + "destinationKey: " + destinationKey, "sourceKey:" + sourceKey);
+ this.minIoClient.copyObject(
+ CopyObjectArgs.builder()
+ .bucket(destinationBucketName)
+ .object(destinationKey)
+ .source(CopySource.builder()
+ .bucket(sourceBucketName)
+ .object(sourceKey)
+ .build()).build()).get();
+ baseOssService.printOperation(getPlatform().getKey(), "copyObject complete" + "destinationKey: " + destinationKey, "sourceKey:" + sourceKey);
+ }
+
+ @Override
+ public URL getPresignedObjectUrl(String objectName, int expiryTime) {
+ try {
+ String url = this.minIoClient.getPresignedObjectUrl(
+ GetPresignedObjectUrlArgs.builder()
+ .method(Method.GET)
+ .bucket(bucketName)
+ .object(objectName)
+ .expiry(expiryTime)
+ .build());
+ return new URL(url);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ @Override
+ public void lock(String objectName) {
+ baseOssService.setObjectNameLock(objectName);
+ }
+
+ @Override
+ public void unlock(String objectName) {
+ baseOssService.removeObjectNameLock(objectName);
+ }
+
+ @Override
+ public void clearCache(String objectName) {
+ baseOssService.clearCache(objectName);
+ }
+
+ @Override
+ public void close() {
+ baseOssService.closePrint();
+ if (scheduledThreadPoolExecutor != null) {
+ scheduledThreadPoolExecutor.shutdown();
+ }
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/minio/MinIoClient.java b/src/main/java/com/jmal/clouddisk/oss/minio/MinIoClient.java
new file mode 100644
index 0000000..c7ba129
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/minio/MinIoClient.java
@@ -0,0 +1,206 @@
+package com.jmal.clouddisk.oss.minio;
+
+import com.google.common.collect.Multimap;
+import io.minio.*;
+import io.minio.errors.*;
+import io.minio.messages.Part;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.File;
+import java.io.IOException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * @author jmal
+ * @Description MinIoClient
+ * @date 2023/4/24 14:24
+ */
+@Slf4j
+public class MinIoClient extends MinioAsyncClient {
+ protected MinIoClient(MinioAsyncClient client) {
+ super(client);
+ }
+
+ public boolean bucketExists(String bucketName)
+ throws ErrorResponseException, InsufficientDataException, InternalException,
+ InvalidKeyException, InvalidResponseException, IOException, NoSuchAlgorithmException,
+ ServerException, XmlParserException {
+ try {
+ BucketExistsArgs bucketExistsArgs = BucketExistsArgs.builder().bucket(bucketName).build();
+ return super.bucketExists(bucketExistsArgs).get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ return false;
+ }
+
+ public StatObjectResponse statObject(String bucketName, String objectName)
+ throws ErrorResponseException, InsufficientDataException, InternalException,
+ InvalidKeyException, InvalidResponseException, IOException, NoSuchAlgorithmException,
+ ServerException, XmlParserException {
+ try {
+ StatObjectArgs args = StatObjectArgs.builder().bucket(bucketName).object(objectName).build();
+ return super.statObject(args).get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ return null;
+ }
+
+ public GetObjectResponse getObject(String bucketName, String objectName, Long rangeStart, Long rangeEnd)
+ throws ErrorResponseException, InsufficientDataException, InternalException,
+ InvalidKeyException, InvalidResponseException, IOException, NoSuchAlgorithmException,
+ ServerException, XmlParserException {
+ try {
+ GetObjectArgs objectArgs;
+ if (rangeStart != null && rangeEnd != null) {
+ long contentLength = rangeEnd - rangeStart + 1;
+ objectArgs = GetObjectArgs
+ .builder()
+ .bucket(bucketName)
+ .object(objectName)
+ .offset(rangeStart)
+ .length(contentLength)
+ .build();
+ } else {
+ objectArgs = GetObjectArgs
+ .builder()
+ .bucket(bucketName)
+ .object(objectName)
+ .build();
+ }
+ return super.getObject(objectArgs).get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ return null;
+ }
+
+ public void downloadObject(String bucketName, String objectName, File file)
+ throws ErrorResponseException, InsufficientDataException, InternalException,
+ InvalidKeyException, InvalidResponseException, IOException, NoSuchAlgorithmException,
+ ServerException, XmlParserException {
+ try {
+ DownloadObjectArgs downloadObjectArgs = DownloadObjectArgs.builder().bucket(bucketName).object(objectName).filename(file.getAbsolutePath()).build();
+ super.downloadObject(downloadObjectArgs).get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ }
+
+ public void removeObject(String bucketName, String objectName)
+ throws ErrorResponseException, InsufficientDataException, InternalException,
+ InvalidKeyException, InvalidResponseException, IOException, NoSuchAlgorithmException,
+ ServerException, XmlParserException {
+ try {
+ RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder().bucket(bucketName).object(objectName).build();
+ super.removeObject(removeObjectArgs).get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ }
+
+ public ObjectWriteResponse putObject2(PutObjectArgs args)
+ throws ErrorResponseException, InsufficientDataException, InternalException,
+ InvalidKeyException, InvalidResponseException, IOException, NoSuchAlgorithmException,
+ ServerException, XmlParserException {
+ try {
+ return super.putObject(args).get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ return null;
+ }
+
+ public String initMultiPartUpload(String bucket, String region, String object, Multimap
headers) throws InsufficientDataException, NoSuchAlgorithmException, IOException, InvalidKeyException, XmlParserException, InternalException, ServerException, ErrorResponseException, InvalidResponseException {
+ try {
+ CompletableFuture response = super.createMultipartUploadAsync(bucket, region, object, headers, null);
+ return response.get().result().uploadId();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ return null;
+ }
+
+ public void mergeMultipartUpload(String bucketName, String region, String objectName, String uploadId, Part[] parts) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, XmlParserException, InternalException, ServerException, ErrorResponseException, InvalidResponseException {
+ try {
+ this.completeMultipartUploadAsync(
+ bucketName,
+ region,
+ objectName,
+ uploadId,
+ parts,
+ null,
+ null).get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ }
+
+ public ListPartsResponse listMultipart(String bucketName, String region, String objectName, Integer maxParts, Integer partNumberMarker, String uploadId) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
+ try {
+ CompletableFuture listPartsResponse = listPartsAsync(bucketName, region, objectName, maxParts, partNumberMarker, uploadId, null, null);
+ return listPartsResponse.get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ return null;
+ }
+
+ public void abortMultipartUpload(String bucketName, String region, String objectName, String uploadId) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
+ try {
+ CompletableFuture abortMultipartUploadAsync = abortMultipartUploadAsync(bucketName, region, objectName, uploadId, null, null);
+ abortMultipartUploadAsync.get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ }
+
+ public UploadPartResponse uploadPart(String bucketName, String region, String objectName, Object data, long length, String uploadId, int partNumber) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
+ try {
+ CompletableFuture uploadPartResponse = uploadPartAsync(bucketName, region, objectName, data, length, uploadId, partNumber, null, null);
+ return uploadPartResponse.get();
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ throwEncapsulatedException(e);
+ }
+ return null;
+ }
+
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/tencent/TencentOssObject.java b/src/main/java/com/jmal/clouddisk/oss/tencent/TencentOssObject.java
new file mode 100644
index 0000000..aeb528d
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/tencent/TencentOssObject.java
@@ -0,0 +1,67 @@
+package com.jmal.clouddisk.oss.tencent;
+
+import com.jmal.clouddisk.oss.AbstractOssObject;
+import com.jmal.clouddisk.oss.FileInfo;
+import com.jmal.clouddisk.oss.IOssService;
+import com.qcloud.cos.model.COSObject;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @author jmal
+ * @Description TencentOssObject
+ * @date 2023/3/29 12:06
+ */
+public class TencentOssObject extends AbstractOssObject {
+
+ private final COSObject cosObject;
+
+ private final IOssService ossService;
+
+ public TencentOssObject(COSObject cosObject, IOssService ossService) {
+ this.cosObject = cosObject;
+ this.ossService = ossService;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return this.cosObject.getObjectContent();
+ }
+
+ @Override
+ public void closeObject() throws IOException {
+ this.cosObject.close();
+ }
+
+ @Override
+ public String getKey() {
+ return this.cosObject.getKey();
+ }
+
+ @Override
+ public IOssService getOssService() {
+ return this.ossService;
+ }
+
+ @Override
+ public FileInfo getFileInfo() {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setSize(this.cosObject.getObjectMetadata().getContentLength());
+ fileInfo.setKey(this.cosObject.getKey());
+ fileInfo.setLastModified(this.cosObject.getObjectMetadata().getLastModified());
+ fileInfo.setETag(this.cosObject.getObjectMetadata().getETag());
+ fileInfo.setBucketName(this.cosObject.getBucketName());
+ return fileInfo;
+ }
+
+ @Override
+ public long getContentLength() {
+ return this.cosObject.getObjectMetadata().getContentLength();
+ }
+
+ @Override
+ public void close() throws IOException {
+ closeObject();
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/tencent/TencentOssService.java b/src/main/java/com/jmal/clouddisk/oss/tencent/TencentOssService.java
new file mode 100644
index 0000000..d8bfbf5
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/tencent/TencentOssService.java
@@ -0,0 +1,563 @@
+package com.jmal.clouddisk.oss.tencent;
+
+import cn.hutool.core.io.file.PathUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import com.aliyun.oss.ClientException;
+import com.aliyun.oss.OSSException;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.oss.*;
+import com.jmal.clouddisk.oss.web.model.OssConfigDTO;
+import com.qcloud.cos.COSClient;
+import com.qcloud.cos.ClientConfig;
+import com.qcloud.cos.auth.BasicCOSCredentials;
+import com.qcloud.cos.auth.COSCredentials;
+import com.qcloud.cos.exception.CosClientException;
+import com.qcloud.cos.http.HttpMethodName;
+import com.qcloud.cos.http.HttpProtocol;
+import com.qcloud.cos.model.*;
+import com.qcloud.cos.region.Region;
+import com.qcloud.cos.transfer.Copy;
+import com.qcloud.cos.transfer.TransferManager;
+import com.qcloud.cos.transfer.TransferManagerConfiguration;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+@Slf4j
+public class TencentOssService implements IOssService {
+
+ private final String bucketName;
+
+ private final COSClient cosClient;
+
+ private final BaseOssService baseOssService;
+
+ private final Region region;
+
+ private final TransferManager transferManager;
+
+ private final ScheduledThreadPoolExecutor scheduledThreadPoolExecutor;
+
+ public TencentOssService(FileProperties fileProperties, OssConfigDTO ossConfigDTO) {
+ // 创建COSClient实例。
+ String accessKeyId = ossConfigDTO.getAccessKey();
+ String accessKeySecret = ossConfigDTO.getSecretKey();
+ this.region = new Region(ossConfigDTO.getRegion());
+ this.bucketName = ossConfigDTO.getBucket();
+ COSCredentials cred = new BasicCOSCredentials(accessKeyId, accessKeySecret);
+ ClientConfig clientConfig = new ClientConfig(region);
+ clientConfig.setHttpProtocol(HttpProtocol.https);
+ this.cosClient = new COSClient(cred, clientConfig);
+ scheduledThreadPoolExecutor = ThreadUtil.createScheduledExecutor(1);
+ this.baseOssService = new BaseOssService(this, bucketName, fileProperties, scheduledThreadPoolExecutor, ossConfigDTO);
+ ThreadUtil.execute(this::getMultipartUploads);
+ this.transferManager = new TransferManager(cosClient);
+ createTransferManager();
+ }
+
+ // 创建 TransferManager 实例,这个实例用来后续调用高级接口
+ private void createTransferManager() {
+ // 创建一个 COSClient 实例,这是访问 COS 服务的基础实例。
+ // 这里创建的 cosClient 是以复制的目的端信息为基础的
+ // 自定义线程池大小,建议在客户端与 COS 网络充足(例如使用腾讯云的 CVM,同地域上传 COS)的情况下,设置成16或32即可,可较充分的利用网络资源
+ // 对于使用公网传输且网络带宽质量不高的情况,建议减小该值,避免因网速过慢,造成请求超时。
+ // 传入一个 threadpool, 若不传入线程池,默认 TransferManager 中会生成一个单线程的线程池。
+ // 设置高级接口的配置项
+ // 分块复制阈值和分块大小分别为 5MB 和 1MB
+ TransferManagerConfiguration transferManagerConfiguration = new TransferManagerConfiguration();
+ transferManagerConfiguration.setMultipartCopyThreshold(5L * 1024L * 1024L);
+ transferManagerConfiguration.setMultipartCopyPartSize(1024L * 1024L);
+ this.transferManager.setConfiguration(transferManagerConfiguration);
+ }
+
+ @Override
+ public PlatformOSS getPlatform() {
+ return PlatformOSS.TENCENT;
+ }
+
+ @Override
+ public FileInfo getFileInfo(String objectName) {
+ return baseOssService.getFileInfo(objectName);
+ }
+
+ @Override
+ public boolean delete(String objectName) {
+ return baseOssService.delete(objectName);
+ }
+
+ @Override
+ public boolean mkdir(String objectName) {
+ return baseOssService.mkdir(objectName);
+ }
+
+ @Override
+ public boolean write(InputStream inputStream, String ossPath, String objectName) {
+ return baseOssService.writeTempFile(inputStream, ossPath, objectName);
+ }
+
+ @Override
+ public String[] list(String objectName) {
+ return baseOssService.getFileNameList(objectName).toArray(new String[0]);
+ }
+
+ @Override
+ public AbstractOssObject getObjectCache(String objectName) {
+ return baseOssService.getObject(objectName);
+ }
+
+ @Override
+ public AbstractOssObject getAbstractOssObject(String objectName) {
+ return getAbstractOssObject(objectName, null, null);
+ }
+
+ @Override
+ public AbstractOssObject getAbstractOssObject(String objectName, Long rangeStart, Long rangeEnd) {
+ COSObject ossObject = null;
+ try {
+ GetObjectRequest getObjectRequest = new GetObjectRequest(bucketName, objectName);
+ if (rangeStart != null && rangeEnd != null) {
+ getObjectRequest.setRange(rangeStart, rangeEnd);
+ }
+ ossObject = this.cosClient.getObject(getObjectRequest);
+ } catch (Exception e) {
+ log.warn(e.getMessage());
+ }
+ if (ossObject == null) {
+ return null;
+ }
+ return new TencentOssObject(ossObject, this);
+ }
+
+ @Override
+ public boolean deleteObject(String objectName) {
+ try {
+ baseOssService.printOperation(getPlatform().getKey(), "deleteObject", objectName);
+ cosClient.deleteObject(bucketName, objectName);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean deleteDir(String objectName) {
+ try {
+ ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
+ // 设置 bucket 名称
+ listObjectsRequest.setBucketName(bucketName);
+ // prefix 表示列出的对象名以 prefix 为前缀
+ // 这里填要列出的目录的相对 bucket 的路径
+ listObjectsRequest.setPrefix(objectName);
+ // 设置最大遍历出多少个对象, 一次 listobject 最大支持1000
+ listObjectsRequest.setMaxKeys(1000);
+ // 保存每次列出的结果
+ ObjectListing objectListing;
+ do {
+ objectListing = cosClient.listObjects(listObjectsRequest);
+ // 这里保存列出的对象列表
+ List cosObjectSummaries = objectListing.getObjectSummaries();
+ ArrayList delObjects = new ArrayList<>();
+ for (COSObjectSummary cosObjectSummary : cosObjectSummaries) {
+ delObjects.add(new DeleteObjectsRequest.KeyVersion(cosObjectSummary.getKey()));
+ }
+ DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(bucketName);
+ deleteObjectsRequest.setKeys(delObjects);
+ cosClient.deleteObjects(deleteObjectsRequest);
+ // 标记下一次开始的位置
+ String nextMarker = objectListing.getNextMarker();
+ listObjectsRequest.setMarker(nextMarker);
+ } while (objectListing.isTruncated());
+ return true;
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ return false;
+ }
+
+ @Override
+ public List getFileInfoList(String objectName) {
+ List fileInfoList = new ArrayList<>();
+ try {
+ baseOssService.printOperation(getPlatform().getKey(), "getFileInfoList", objectName);
+ ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
+ // 设置 bucket 名称
+ listObjectsRequest.setBucketName(bucketName);
+ // prefix 表示列出的对象名以 prefix 为前缀
+ // 这里填要列出的目录的相对 bucket 的路径
+ listObjectsRequest.setPrefix(objectName);
+ // delimiter 表示目录的截断符, 例如:设置为 / 则表示对象名遇到 / 就当做一级目录)
+ listObjectsRequest.setDelimiter("/");
+ // 设置最大遍历出多少个对象, 一次 listobject 最大支持1000
+ listObjectsRequest.setMaxKeys(1000);
+ // 保存每次列出的结果
+ ObjectListing objectListing;
+ do {
+ objectListing = cosClient.listObjects(listObjectsRequest);
+ // 对象列表
+ List cosObjectSummaries = objectListing.getObjectSummaries();
+ for (COSObjectSummary objectSummary : cosObjectSummaries) {
+ S3ObjectSummary s3ObjectSummary = new S3ObjectSummary(objectSummary.getSize(), objectSummary.getKey(), objectSummary.getETag(), objectSummary.getLastModified(), objectSummary.getBucketName());
+ baseOssService.addFileInfoList(objectName, fileInfoList, s3ObjectSummary);
+ }
+ // 子目录
+ for (String commonPrefix : objectListing.getCommonPrefixes()) {
+ fileInfoList.add(baseOssService.newFileInfo(commonPrefix));
+ }
+ // 标记下一次开始的位置
+ String nextMarker = objectListing.getNextMarker();
+ listObjectsRequest.setMarker(nextMarker);
+ } while (objectListing.isTruncated());
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ return fileInfoList;
+ }
+
+ @Override
+ public List getAllObjectsWithPrefix(String objectName) {
+ // 列举所有包含指定前缀的所有文件
+ List fileInfoList = new ArrayList<>();
+ try {
+ ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
+ // 设置 bucket 名称
+ listObjectsRequest.setBucketName(bucketName);
+ // prefix 表示列出的对象名以 prefix 为前缀
+ // 这里填要列出的目录的相对 bucket 的路径
+ listObjectsRequest.setPrefix(objectName);
+ // 设置最大遍历出多少个对象, 一次 listObject 最大支持1000
+ listObjectsRequest.setMaxKeys(1000);
+ // 保存每次列出的结果
+ ObjectListing objectListing;
+ do {
+ objectListing = cosClient.listObjects(listObjectsRequest);
+ // 这里保存列出的对象列表
+ List cosObjectSummaries = objectListing.getObjectSummaries();
+ cosObjectSummaries.parallelStream().forEach(cosObjectSummary -> fileInfoList.add(new FileInfo(cosObjectSummary.getKey(), cosObjectSummary.getETag(), cosObjectSummary.getSize(), cosObjectSummary.getLastModified())));
+ // 标记下一次开始的位置
+ String nextMarker = objectListing.getNextMarker();
+ listObjectsRequest.setMarker(nextMarker);
+ } while (objectListing.isTruncated());
+ } catch (OSSException oe) {
+ log.error(oe.getMessage(), oe);
+ } catch (ClientException ce) {
+ log.error(ce.getMessage(), ce);
+ }
+ return fileInfoList;
+ }
+
+ @Override
+ public List getFileInfoListCache(String objectName) {
+ return baseOssService.getFileInfoListCache(objectName);
+ }
+
+ @Override
+ public boolean doesBucketExist() {
+ boolean exist;
+ exist = this.cosClient.doesBucketExist(bucketName);
+ if (exist) {
+ this.cosClient.getBucketAcl(bucketName);
+ }
+ return exist;
+ }
+
+ @Override
+ public boolean doesObjectExist(String objectName) {
+ return this.cosClient.doesObjectExist(bucketName, objectName);
+ }
+
+ @Override
+ public String getUploadId(String objectName) {
+ return baseOssService.getUploadId(objectName);
+ }
+
+ @Override
+ public String initiateMultipartUpload(String objectName) {
+ InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, objectName);
+ // 分块上传的过程中,仅能通过初始化分块指定文件上传之后的 metadata
+ // 需要的头部可以在这里指定
+ ObjectMetadata objectMetadata = new ObjectMetadata();
+ request.setObjectMetadata(objectMetadata);
+ InitiateMultipartUploadResult initResult = cosClient.initiateMultipartUpload(request);
+ // 获取 uploadId
+ return initResult.getUploadId();
+ }
+
+ private void getMultipartUploads() {
+ try {
+ ListMultipartUploadsRequest listMultipartUploadsRequest = new ListMultipartUploadsRequest(bucketName);
+ // 每次请求最多列出多少个
+ listMultipartUploadsRequest.setMaxUploads(100);
+ MultipartUploadListing multipartUploadListing;
+ do {
+ multipartUploadListing = cosClient.listMultipartUploads(listMultipartUploadsRequest);
+ List multipartUploads = multipartUploadListing.getMultipartUploads();
+ for (MultipartUpload mUpload : multipartUploads) {
+ log.info("{}, 碎片文件: objectName: {}, uploadId: {}", getPlatform().getValue(), mUpload.getKey(), mUpload.getUploadId());
+ baseOssService.setUpdateIdCache(mUpload.getKey(), mUpload.getUploadId());
+ }
+ listMultipartUploadsRequest.setKeyMarker(multipartUploadListing.getNextKeyMarker());
+ listMultipartUploadsRequest.setUploadIdMarker(multipartUploadListing.getNextUploadIdMarker());
+ } while (multipartUploadListing.isTruncated());
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public CopyOnWriteArrayList getListParts(String objectName, String uploadId) {
+ return new CopyOnWriteArrayList<>(getPartETagList(objectName, uploadId).stream().map(PartETag::getPartNumber).toList());
+ }
+
+ private List getPartETagList(String objectName, String uploadId) {
+ List partETagList = new ArrayList<>();
+ try {
+ PartListing partListing;
+ ListPartsRequest listPartsRequest = new ListPartsRequest(bucketName, objectName, uploadId);
+ do {
+ partListing = cosClient.listParts(listPartsRequest);
+ for (PartSummary partSummary : partListing.getParts()) {
+ partETagList.add(new PartETag(partSummary.getPartNumber(), partSummary.getETag()));
+ }
+ listPartsRequest.setPartNumberMarker(partListing.getNextPartNumberMarker());
+ } while (partListing.isTruncated());
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ return partETagList;
+ }
+
+ @Override
+ public boolean uploadPart(InputStream inputStream, String objectName, int partSize, int partNumber, String uploadId) {
+ UploadPartRequest uploadPartRequest = new UploadPartRequest();
+ uploadPartRequest.setBucketName(bucketName);
+ uploadPartRequest.setKey(objectName);
+ uploadPartRequest.setUploadId(uploadId);
+ uploadPartRequest.setInputStream(inputStream);
+ // 设置分块的长度
+ uploadPartRequest.setPartSize(partSize);
+ // 设置要上传的分块编号
+ uploadPartRequest.setPartNumber(partNumber);
+ try {
+ this.cosClient.uploadPart(uploadPartRequest);
+ return true;
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ return false;
+ }
+
+ @Override
+ public void abortMultipartUpload(String objectName, String uploadId) {
+ AbortMultipartUploadRequest abortMultipartUploadRequest = new AbortMultipartUploadRequest(bucketName, objectName, uploadId);
+ try {
+ cosClient.abortMultipartUpload(abortMultipartUploadRequest);
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ public void completeMultipartUpload(String objectName, String uploadId, Long totalSize) {
+ baseOssService.printOperation(getPlatform().getKey(), "completeMultipartUpload", objectName);
+ // 查询已上传的分片
+ List partETags = getPartETagList(objectName, uploadId);
+ // 完成分片上传
+ CompleteMultipartUploadRequest completeMultipartUploadRequest = new CompleteMultipartUploadRequest(bucketName, objectName, uploadId, partETags);
+ cosClient.completeMultipartUpload(completeMultipartUploadRequest);
+ baseOssService.onUploadSuccess(objectName, totalSize);
+ }
+
+ @Override
+ public FileInfo getThumbnail(String objectName, File file, int width) {
+ try {
+ GetObjectRequest request = new GetObjectRequest(bucketName, objectName);
+ // 指定目标图片宽度为 Width,高度等比缩放
+ String rule = "imageMogr2/thumbnail/" + width + "x";
+ request.putCustomQueryParameter(rule, null);
+ cosClient.getObject(request, file);
+ return baseOssService.getFileInfo(objectName);
+ } catch (Exception e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ @Override
+ public List copyObject(String sourceKey, String destinationKey) {
+ return copyObject(bucketName, sourceKey, bucketName, destinationKey);
+ }
+
+ @Override
+ public List copyObject(String sourceBucketName, String sourceKey, String destinationBucketName, String destinationKey) {
+ baseOssService.setObjectNameLock(sourceBucketName);
+ baseOssService.setObjectNameLock(destinationBucketName);
+ List copiedList = new ArrayList<>();
+ try {
+ if (sourceKey.endsWith("/")) {
+ // 复制文件夹
+ ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
+ // 设置 bucket 名称
+ listObjectsRequest.setBucketName(bucketName);
+ // prefix 表示列出的对象名以 prefix 为前缀
+ // 这里填要列出的目录的相对 bucket 的路径
+ listObjectsRequest.setPrefix(sourceKey);
+ // 设置最大遍历出多少个对象, 一次 listobject 最大支持1000
+ listObjectsRequest.setMaxKeys(1000);
+ // 保存每次列出的结果
+ ObjectListing objectListing;
+ do {
+ objectListing = cosClient.listObjects(listObjectsRequest);
+ // 这里保存列出的对象列表
+ List cosObjectSummaries = objectListing.getObjectSummaries();
+ cosObjectSummaries.parallelStream().forEach(cosObjectSummary -> {
+ String destKey = destinationKey + cosObjectSummary.getKey().substring(sourceKey.length());
+ try {
+ copyObjectFile(cosObjectSummary.getBucketName(), cosObjectSummary.getKey(), destinationBucketName, destKey);
+ copiedList.add(destKey);
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ }
+ });
+ // 标记下一次开始的位置
+ String nextMarker = objectListing.getNextMarker();
+ listObjectsRequest.setMarker(nextMarker);
+ } while (objectListing.isTruncated());
+ } else {
+ // 复制文件
+ copyObjectFile(sourceBucketName, sourceKey, destinationBucketName, destinationKey);
+ copiedList.add(destinationKey);
+ }
+ return copiedList;
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ } catch (InterruptedException e) {
+ log.error(e.getMessage(), e);
+ Thread.currentThread().interrupt();
+ } finally {
+ baseOssService.removeObjectNameLock(sourceBucketName);
+ baseOssService.removeObjectNameLock(destinationBucketName);
+ }
+ return copiedList;
+ }
+
+ public void copyObjectFile(String sourceBucketName, String sourceKey, String destinationBucketName, String destinationKey) throws InterruptedException {
+ CopyObjectRequest copyObjectRequest = new CopyObjectRequest(region, sourceBucketName, sourceKey, destinationBucketName, destinationKey);
+ baseOssService.printOperation(getPlatform().getKey(), "copyObject start" + "destinationKey: " + destinationKey, "sourceKey:" + sourceKey);
+ Copy copy = transferManager.copy(copyObjectRequest);
+ // 高级接口会返回一个异步结果 Copy
+ // 可同步的调用 waitForCopyResult 等待复制结束, 成功返回 CopyResult, 失败抛出异常
+ copy.waitForCopyResult();
+ }
+
+ @Override
+ public FileInfo newFolder(String objectName) {
+ baseOssService.printOperation(getPlatform().getKey(), "mkdir", objectName);
+ if (!objectName.endsWith("/")) {
+ objectName = objectName + "/";
+ }
+ try {
+ // 这里创建一个空的 ByteArrayInputStream 来作为示例
+ InputStream inputStream = new ByteArrayInputStream(new byte[0]);
+ ObjectMetadata objectMetadata = new ObjectMetadata();
+ objectMetadata.setContentLength(0);
+ PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, inputStream, objectMetadata);
+ cosClient.putObject(putObjectRequest);
+ return baseOssService.newFileInfo(objectName);
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ @Override
+ public void uploadFile(Path tempFileAbsolutePath, String objectName) {
+ executeUpload(tempFileAbsolutePath, objectName);
+ }
+
+ private void executeUpload(Path tempFileAbsolutePath, String objectName) {
+ try {
+ if (!PathUtil.exists(tempFileAbsolutePath, false)) {
+ return;
+ }
+ baseOssService.printOperation(getPlatform().getKey(), "upload", objectName);
+ // 创建PutObjectRequest对象。
+ PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, tempFileAbsolutePath.toFile());
+ // 创建PutObject请求。
+ PutObjectResult putObjectResult = this.cosClient.putObject(putObjectRequest);
+ if (putObjectResult.getRequestId() != null) {
+ // 上传成功
+ baseOssService.onUploadSuccess(objectName, tempFileAbsolutePath);
+ }
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void uploadFile(InputStream inputStream, String objectName, long inputStreamLength) {
+ baseOssService.printOperation(getPlatform().getKey(), "uploadFile inputStream", objectName);
+ ObjectMetadata objectMetadata = new ObjectMetadata();
+ // 上传的流如果能够获取准确的流长度,则推荐一定填写 content-length
+ // 如果确实没办法获取到,则下面这行可以省略,但同时高级接口也没办法使用分块上传了
+ objectMetadata.setContentLength(inputStreamLength);
+ PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, inputStream, objectMetadata);
+ putObjectRequest.setStorageClass(StorageClass.Standard);
+ try {
+ cosClient.putObject(putObjectRequest);
+ baseOssService.onUploadSuccess(objectName, inputStreamLength);
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public URL getPresignedObjectUrl(String objectName, int expiryTime) {
+ try {
+ // 设置签名过期时间(可选), 若未进行设置则默认使用 ClientConfig 中的签名过期时间(1小时)
+ // 这里设置签名在半个小时后过期
+ Date expirationDate = new Date(System.currentTimeMillis() + expiryTime * 1000L);
+ // 请求的 HTTP 方法,上传请求用 PUT,下载请求用 GET,删除请求用 DELETE
+ HttpMethodName method = HttpMethodName.GET;
+ return cosClient.generatePresignedUrl(bucketName, objectName, expirationDate, method);
+ } catch (CosClientException e) {
+ log.error(e.getMessage(), e);
+ }
+ return null;
+ }
+
+ @Override
+ public void clearCache(String objectName) {
+ baseOssService.clearCache(objectName);
+ }
+
+ @Override
+ public void lock(String objectName) {
+ baseOssService.setObjectNameLock(objectName);
+ }
+
+ @Override
+ public void unlock(String objectName) {
+ baseOssService.removeObjectNameLock(objectName);
+ }
+
+ @Override
+ public void close() {
+ baseOssService.closePrint();
+ if (this.cosClient != null) {
+ this.cosClient.shutdown();
+ }
+ if (scheduledThreadPoolExecutor != null) {
+ scheduledThreadPoolExecutor.shutdown();
+ }
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/web/OssController.java b/src/main/java/com/jmal/clouddisk/oss/web/OssController.java
new file mode 100644
index 0000000..c41d6db
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/web/OssController.java
@@ -0,0 +1,61 @@
+package com.jmal.clouddisk.oss.web;
+
+import com.jmal.clouddisk.annotation.LogOperatingFun;
+import com.jmal.clouddisk.annotation.Permission;
+import com.jmal.clouddisk.model.LogOperation;
+import com.jmal.clouddisk.oss.OssConfigService;
+import com.jmal.clouddisk.oss.web.model.OssConfigDTO;
+import com.jmal.clouddisk.util.ResponseResult;
+import com.jmal.clouddisk.util.ResultUtil;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import jakarta.validation.Valid;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("oss")
+@Tag(name = "OSS管理")
+@RequiredArgsConstructor
+public class OssController {
+
+ private final OssConfigService ossConfigService;
+
+ public final WebOssService webOssService;
+
+ @Operation(summary = "获取支持的平台的列表")
+ @LogOperatingFun(logType = LogOperation.Type.BROWSE)
+ @GetMapping("getPlatformList")
+ public ResponseResult>> getPlatformList() {
+ return ResultUtil.success(webOssService.getPlatformList());
+ }
+
+ @Operation(summary = "OSS配置列表")
+ @LogOperatingFun(logType = LogOperation.Type.OPERATION)
+ @GetMapping("ossConfigList")
+ @Permission(value = "cloud:oss:get")
+ public ResponseResult ossConfigList() {
+ return ossConfigService.ossConfigList();
+ }
+
+ @Operation(summary = "新增/修改OSS配置")
+ @LogOperatingFun(logType = LogOperation.Type.OPERATION)
+ @PutMapping("putOssConfig")
+ @Permission(value = "cloud:oss:put")
+ public ResponseResult putOssConfig(@Valid @RequestBody OssConfigDTO ossConfigDTO) {
+ ossConfigService.putOssConfig(ossConfigDTO);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除OSS配置")
+ @LogOperatingFun(logType = LogOperation.Type.OPERATION)
+ @DeleteMapping("deleteOssConfig")
+ @Permission(value = "cloud:oss:delete")
+ public ResponseResult deleteOssConfig(@RequestParam String id) {
+ return ossConfigService.deleteOssConfig(id);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/web/STSObjectVO.java b/src/main/java/com/jmal/clouddisk/oss/web/STSObjectVO.java
new file mode 100644
index 0000000..057d901
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/web/STSObjectVO.java
@@ -0,0 +1,16 @@
+package com.jmal.clouddisk.oss.web;
+
+import lombok.Data;
+
+/**
+ * @author jmal
+ * @Description STS
+ * @date 2023/4/4 16:00
+ */
+@Data
+public class STSObjectVO {
+ private String accessKeyId;
+ private String accessKeySecret;
+ private String securityToken;
+ private String expiration;
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/web/WebOssCommonService.java b/src/main/java/com/jmal/clouddisk/oss/web/WebOssCommonService.java
new file mode 100644
index 0000000..2d53b2a
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/web/WebOssCommonService.java
@@ -0,0 +1,143 @@
+package com.jmal.clouddisk.oss.web;
+
+import cn.hutool.core.convert.Convert;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.model.FileIntroVO;
+import com.jmal.clouddisk.model.OperationPermission;
+import com.jmal.clouddisk.oss.BaseOssService;
+import com.jmal.clouddisk.oss.BucketInfo;
+import com.jmal.clouddisk.oss.FileInfo;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.jmal.clouddisk.service.impl.CommonFileService;
+import com.jmal.clouddisk.util.CaffeineUtil;
+import com.jmal.clouddisk.webdav.MyWebdavServlet;
+import org.bson.Document;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.stereotype.Service;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+
+/**
+ * @author jmal
+ * @Description WebOssCommonService
+ * @date 2023/4/14 10:22
+ */
+@Service
+public class WebOssCommonService {
+
+ @Autowired
+ MongoTemplate mongoTemplate;
+
+ @Autowired
+ FileProperties fileProperties;
+
+ @Autowired
+ IUserService userService;
+
+ @Autowired
+ CommonFileService commonFileService;
+
+ public void notifyCreateFile(String username, String objectName, String ossRootFolderName) {
+ FileIntroVO fileIntroVO = new FileIntroVO();
+ fileIntroVO.setPath(getPathByObjectName(ossRootFolderName, objectName));
+ fileIntroVO.setName(Paths.get(objectName).getFileName().toString());
+ commonFileService.pushMessage(username, fileIntroVO, Constants.CREATE_FILE);
+ }
+
+ public void notifyUpdateFile(String ossPath, String objectName, long size) {
+ FileIntroVO fileIntroVO = new FileIntroVO();
+ String username = getUsernameByOssPath(ossPath);
+ String id = getFileId(getOssRootFolderName(ossPath), objectName, username);
+ fileIntroVO.setId(id);
+ fileIntroVO.setSize(size);
+ fileIntroVO.setUpdateDate(LocalDateTime.now());
+ commonFileService.pushMessage(username, fileIntroVO, Constants.UPDATE_FILE);
+ }
+
+ public void notifyDeleteFile(String ossPath, String objectName) {
+ String username = getUsernameByOssPath(ossPath);
+ String id = getFileId(getOssRootFolderName(ossPath), objectName, username);
+ String filename = Paths.get(objectName).getFileName().toString();
+ String path = id.substring(username.length(), id.length() - filename.length());
+ commonFileService.pushMessage(username, path, Constants.DELETE_FILE);
+ }
+
+ public static String getFileId(String rootName, String objectName, String username) {
+ boolean isFolder = objectName.endsWith("/");
+ return Paths.get(username, rootName, objectName) + (isFolder ? MyWebdavServlet.PATH_DELIMITER : "");
+ }
+
+ public static String getPathByObjectName(String ossRootFolderName, String objectName) {
+ String path = MyWebdavServlet.PATH_DELIMITER;
+ Path filePath = Paths.get(ossRootFolderName, objectName);
+ if (filePath.getNameCount() > 1) {
+ path += filePath.getParent().toString() + MyWebdavServlet.PATH_DELIMITER;
+ }
+ return path;
+ }
+
+ public static String getUsernameByOssPath(String ossPath) {
+ return Paths.get(ossPath).subpath(0, 1).toString();
+ }
+
+ public static String getOssRootFolderName(String ossPath) {
+ return CaffeineUtil.getOssDiameterPrefixCache(ossPath).getFolderName();
+ }
+
+ public static String getPath(String objectName, String rootName) {
+ String path;
+ Path keyPath = Paths.get(objectName);
+ if (keyPath.getNameCount() > 1) {
+ path = MyWebdavServlet.PATH_DELIMITER + Paths.get(rootName, objectName).getParent().toString() + MyWebdavServlet.PATH_DELIMITER;
+ } else {
+ path = MyWebdavServlet.PATH_DELIMITER + rootName + MyWebdavServlet.PATH_DELIMITER;
+ }
+ return path;
+ }
+
+ private FileDocument getFileDocument(String ossPathTo, String objectName) {
+ BucketInfo bucketInfo = CaffeineUtil.getOssDiameterPrefixCache(ossPathTo);
+ FileInfo fileInfo = BaseOssService.newFileInfo(objectName, bucketInfo.getBucketName());
+ String username = getUsernameByOssPath(ossPathTo);
+ return fileInfo.toFileDocument(ossPathTo, userService.getUserIdByUserName(username));
+ }
+
+ public void afterUploadComplete(String objectName, String ossPath, FileDocument fileDocument) {
+ if (fileDocument == null) {
+ fileDocument = getFileDocument(ossPath, objectName);
+ }
+ String rootName = getOssRootFolderName(ossPath);
+ Update update = new Update();
+ commonFileService.checkShareBase(update, getPath(objectName, rootName));
+ Document updateObject = update.getUpdateObject();
+ if (updateObject.get("$set") != null) {
+ Document document = updateObject.get("$set", Document.class);
+ if (document.get(Constants.IS_SHARE) != null) {
+ fileDocument.setIsShare(document.getBoolean(Constants.IS_SHARE));
+ }
+ if (document.get(Constants.SHARE_ID) != null) {
+ fileDocument.setShareId(document.getString(Constants.SHARE_ID));
+ }
+ if (document.get(Constants.EXPIRES_AT) != null) {
+ fileDocument.setExpiresAt(document.getLong(Constants.EXPIRES_AT));
+ }
+ if (document.get(Constants.IS_PRIVACY) != null) {
+ fileDocument.setIsPrivacy(document.getBoolean(Constants.IS_PRIVACY));
+ }
+ if (document.get(Constants.EXTRACTION_CODE) != null) {
+ fileDocument.setExtractionCode(document.getString(Constants.EXTRACTION_CODE));
+ }
+ if (document.get(Constants.OPERATION_PERMISSION_LIST) != null) {
+ fileDocument.setOperationPermissionList(Convert.toList(OperationPermission.class, document.get(Constants.OPERATION_PERMISSION_LIST)));
+ }
+ }
+ mongoTemplate.save(fileDocument);
+ }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/web/WebOssCopyFileService.java b/src/main/java/com/jmal/clouddisk/oss/web/WebOssCopyFileService.java
new file mode 100644
index 0000000..0a95fc4
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/web/WebOssCopyFileService.java
@@ -0,0 +1,408 @@
+package com.jmal.clouddisk.oss.web;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.file.PathUtil;
+import com.jmal.clouddisk.exception.CommonException;
+import com.jmal.clouddisk.exception.ExceptionType;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.oss.*;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.impl.CommonFileService;
+import com.jmal.clouddisk.util.CaffeineUtil;
+import com.jmal.clouddisk.util.ResponseResult;
+import com.jmal.clouddisk.util.ResultUtil;
+import com.jmal.clouddisk.webdav.MyWebdavServlet;
+import io.reactivex.rxjava3.core.Completable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.file.SimplePathVisitor;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Service
+@Slf4j
+public class WebOssCopyFileService extends WebOssCommonService {
+
+ /**
+ * 从 oss 复制 到 oss
+ *
+ * @param ossPathFrom 源ossPath
+ * @param sourceObjectNamePath 源objectPath
+ * @param ossPathTo 目标ossPath
+ * @param destinationObjectNamePath 目标objectPath
+ * @param isMove 是否为移动操作
+ */
+ public ResponseResult copyOssToOss(String ossPathFrom, String sourceObjectNamePath, String ossPathTo, String destinationObjectNamePath, boolean isMove) {
+
+ IOssService ossServiceFrom = OssConfigService.getOssStorageService(ossPathFrom);
+ IOssService ossServiceTo = OssConfigService.getOssStorageService(ossPathTo);
+
+ BucketInfo bucketInfoFrom = CaffeineUtil.getOssDiameterPrefixCache(ossPathFrom);
+ BucketInfo bucketInfoTo = CaffeineUtil.getOssDiameterPrefixCache(ossPathTo);
+
+ String objectNameFrom = sourceObjectNamePath.substring(ossPathFrom.length());
+
+ boolean isFolder = objectNameFrom.endsWith("/");
+ String objectNameTo;
+ Path objectNameFromPath = Paths.get(objectNameFrom);
+ objectNameTo = destinationObjectNamePath.substring(ossPathTo.length()) + objectNameFromPath.getFileName();
+ if (objectNameFrom.isEmpty()) {
+ isFolder = true;
+ objectNameTo = destinationObjectNamePath.substring(ossPathTo.length()) + Paths.get(ossPathFrom).getFileName().toString();
+ }
+ if (isFolder) {
+ objectNameTo += MyWebdavServlet.PATH_DELIMITER;
+ }
+
+ // 判断目标文件/夹是否存在
+ if (ossServiceTo.doesObjectExist(objectNameTo)) {
+ return ResultUtil.warning(Constants.COPY_EXISTS_FILE);
+ }
+
+ if (ossServiceFrom.getPlatform() == ossServiceTo.getPlatform()) {
+ // 同平台间复制
+ List copiedList = ossServiceFrom.copyObject(bucketInfoFrom.getBucketName(), objectNameFrom, bucketInfoTo.getBucketName(), objectNameTo);
+ for (String objectName : copiedList) {
+ afterUploadComplete(objectName, ossPathTo, null);
+ }
+ if (copiedList.isEmpty()) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), "复制失败");
+ }
+ } else {
+ if (isFolder) {
+ // 复制文件夹
+ copyDir(ossServiceFrom, ossServiceTo, objectNameFrom, objectNameTo, ossPathTo);
+ } else {
+ // 复制文件
+ copyFile(ossServiceFrom, ossServiceTo, objectNameFrom, objectNameTo, ossPathTo);
+ }
+ }
+ String finalObjectNameTo = objectNameTo;
+ // 复制成功
+ ossServiceTo.clearCache(finalObjectNameTo);
+ notifyCreateFile(getUsernameByOssPath(ossPathTo), finalObjectNameTo, getOssRootFolderName(ossPathTo));
+ String operation = isMove ? "移动" : "复制";
+ Path fromPath = Paths.get(getOssRootFolderName(ossPathFrom), objectNameFrom);
+ Path toPath = Paths.get(getOssRootFolderName(ossPathTo), finalObjectNameTo);
+ commonFileService.pushMessageOperationFileSuccess(fromPath.toString(), toPath.toString(), getUsernameByOssPath(ossPathFrom), operation);
+ return ResultUtil.success();
+ }
+
+ /**
+ * 不同oss平台间的文件夹复制
+ * 复制完成需要解锁 源objectName
+ *
+ * @param ossServiceFrom 源ossService
+ * @param ossServiceTo 目标ossService
+ * @param objectNameFrom 源objectName
+ * @param objectNameTo 目标objectName
+ * @param ossPathTo 目标ossPath
+ */
+ private void copyDir(IOssService ossServiceFrom, IOssService ossServiceTo, String objectNameFrom, String objectNameTo, String ossPathTo) {
+ // 锁对象
+ ossServiceFrom.lock(objectNameFrom);
+ try {
+ // 首先在目标oss创建文件夹
+ if (ossServiceTo.mkdir(objectNameTo)) {
+ // 列出源objectName下的所有文件/文件夹
+ List fileInfoList = ossServiceFrom.getAllObjectsWithPrefix(objectNameFrom);
+ // 先创建文件夹
+ fileInfoList.stream().filter(FileInfo::isFolder).parallel().forEach(fileInfo -> {
+ String relativePath = fileInfo.getKey().substring(objectNameFrom.length());
+ // 目标objectName
+ String destObjectName = objectNameTo + relativePath;
+ ossServiceTo.mkdir(destObjectName);
+ afterUploadComplete(destObjectName, ossPathTo, null);
+ });
+ // 再复制文件
+ fileInfoList.stream().filter(fileInfo -> !fileInfo.isFolder()).parallel().forEach(fileInfo -> {
+ String relativePath = fileInfo.getKey().substring(objectNameFrom.length());
+ // 目标objectName
+ String destObjectName = objectNameTo + relativePath;
+ try (AbstractOssObject abstractOssObject = ossServiceFrom.getAbstractOssObject(fileInfo.getKey());
+ InputStream inputStream = abstractOssObject.getInputStream()) {
+ // 上传文件
+ ossServiceTo.uploadFile(inputStream, destObjectName, abstractOssObject.getContentLength());
+ afterUploadComplete(destObjectName, ossPathTo, null);
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ }
+ });
+ }
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ } finally {
+ // 解锁对象
+ ossServiceFrom.unlock(objectNameFrom);
+ }
+ }
+
+ /**
+ * 不同oss平台间的文件复制
+ * 复制完成需要解锁 源objectName
+ *
+ * @param ossServiceFrom 源ossService
+ * @param ossServiceTo 目标ossService
+ * @param objectNameFrom 源objectName
+ * @param objectNameTo 目标objectName
+ * @param ossPathTo 目标ossPath
+ */
+ private void copyFile(IOssService ossServiceFrom, IOssService ossServiceTo, String objectNameFrom, String objectNameTo, String ossPathTo) {
+ // 锁对象
+ ossServiceFrom.lock(objectNameFrom);
+ try (AbstractOssObject abstractOssObject = ossServiceFrom.getAbstractOssObject(objectNameFrom);
+ InputStream inputStream = abstractOssObject.getInputStream()) {
+ // 上传文件
+ ossServiceTo.uploadFile(inputStream, objectNameTo, abstractOssObject.getContentLength());
+ afterUploadComplete(objectNameTo, ossPathTo, null);
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ } finally {
+ // 解锁对象
+ ossServiceFrom.unlock(objectNameFrom);
+ }
+ }
+
+ /**
+ * 从oss复制文件/夹到本地存储
+ *
+ * @param ossPathFrom 源ossPath
+ * @param sourceObjectNamePath 源objectPath
+ * @param fileId 目标文件fileId
+ * @param isMove 是否为移动操作
+ */
+ public ResponseResult copyOssToLocal(String ossPathFrom, String sourceObjectNamePath, String fileId, boolean isMove) {
+ IOssService ossServiceFrom = OssConfigService.getOssStorageService(ossPathFrom);
+ String objectNameFrom = sourceObjectNamePath.substring(ossPathFrom.length());
+ boolean isFolder = objectNameFrom.endsWith("/");
+
+ FileDocument destFileDocument;
+ if (Constants.REGION_DEFAULT.equals(fileId)) {
+ // 根目录
+ destFileDocument = new FileDocument();
+ destFileDocument.setName("");
+ destFileDocument.setPath("/");
+ } else {
+ destFileDocument = mongoTemplate.findById(fileId, FileDocument.class);
+ if (destFileDocument == null) {
+ throw new CommonException(ExceptionType.DIR_NOT_FIND);
+ }
+ }
+ destFileDocument.setUsername(getUsernameByOssPath(ossPathFrom));
+ if (isFolder) {
+ // 复制文件夹
+ copyDir(ossServiceFrom, objectNameFrom, destFileDocument);
+ } else {
+ // 复制文件
+ copyFile(ossServiceFrom, objectNameFrom, destFileDocument);
+ }
+ String operation = isMove ? "移动" : "复制";
+ Path fromPath = Paths.get(getOssRootFolderName(ossPathFrom), objectNameFrom);
+ Path toPath = Paths.get(destFileDocument.getPath(), destFileDocument.getName(), Paths.get(objectNameFrom).getFileName().toString());
+ commonFileService.pushMessageOperationFileSuccess(fromPath.toString(), toPath.toString(), destFileDocument.getUsername(), operation);
+ return ResultUtil.success();
+ }
+
+ /**
+ * 从oss复制文件夹到本地
+ *
+ * @param ossServiceFrom 源ossService
+ * @param objectNameFrom 源objectName
+ * @param destFileDocument 目标fileDocument
+ */
+ private void copyDir(IOssService ossServiceFrom, String objectNameFrom, FileDocument destFileDocument) {
+ // 锁对象
+ ossServiceFrom.lock(objectNameFrom);
+ try {
+ String destDir = Paths.get(fileProperties.getRootDir(), destFileDocument.getUsername(), destFileDocument.getPath(), destFileDocument.getName()).toString();
+ String destDirPath = Paths.get(destDir, Paths.get(objectNameFrom).getFileName().toString()).toString();
+ // 首先创建文件夹
+ FileUtil.mkdir(destDirPath);
+ // 列出源objectName下的所有文件/文件夹
+ List fileInfoList = ossServiceFrom.getAllObjectsWithPrefix(objectNameFrom);
+ // 先创建文件夹
+ fileInfoList.stream().filter(FileInfo::isFolder).parallel().forEach(fileInfo -> {
+ String relativePath = fileInfo.getKey().substring(objectNameFrom.length());
+ // 目标目录
+ Path destPath = Paths.get(destDirPath, relativePath);
+ PathUtil.mkdir(destPath);
+ commonFileService.createFile(destFileDocument.getUsername(), destPath.toFile(), null, null);
+ });
+ // 再复制文件
+ fileInfoList.stream().filter(fileInfo -> !fileInfo.isFolder()).parallel().forEach(fileInfo -> {
+ String relativePath = fileInfo.getKey().substring(objectNameFrom.length());
+ // 目标objectName
+ try (AbstractOssObject abstractOssObject = ossServiceFrom.getAbstractOssObject(fileInfo.getKey());
+ InputStream inputStream = abstractOssObject.getInputStream()) {
+ // 目标文件
+ File destFile = Paths.get(destDirPath, relativePath).toFile();
+ FileUtil.writeFromStream(inputStream, destFile);
+ commonFileService.createFile(destFileDocument.getUsername(), destFile, null, null);
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ }
+ });
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ } finally {
+ // 解锁对象
+ ossServiceFrom.unlock(objectNameFrom);
+ }
+ }
+
+ /**
+ * 从oss复制文件到本地
+ *
+ * @param ossServiceFrom 源ossService
+ * @param objectNameFrom 源objectName
+ * @param destFileDocument 目标fileDocument
+ */
+ private void copyFile(IOssService ossServiceFrom, String objectNameFrom, FileDocument destFileDocument) {
+ // 锁对象
+ ossServiceFrom.lock(objectNameFrom);
+ try (AbstractOssObject abstractOssObject = ossServiceFrom.getAbstractOssObject(objectNameFrom);
+ InputStream inputStream = abstractOssObject.getInputStream()) {
+ // 上传文件
+ Path destPath = Paths.get(fileProperties.getRootDir(), destFileDocument.getUsername(), destFileDocument.getPath(), destFileDocument.getName());
+ PathUtil.mkdir(destPath);
+ // 目标文件
+ File destFile = Paths.get(destPath.toString(), Paths.get(objectNameFrom).getFileName().toString()).toFile();
+ FileUtil.writeFromStream(inputStream, destFile);
+ commonFileService.createFile(destFileDocument.getUsername(), destFile, null, null);
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ } finally {
+ // 解锁对象
+ ossServiceFrom.unlock(objectNameFrom);
+ }
+ }
+
+ /**
+ * 从本地存储复制文件/夹到oss
+ *
+ * @param ossPathTo 目标ossPath
+ * @param fileId 源fileId
+ * @param destObjectNamePath 目标objectNamePath
+ * @param isMove 是否为移动操作
+ */
+ public ResponseResult copyLocalToOss(String ossPathTo, String fileId, String destObjectNamePath, boolean isMove) {
+ IOssService ossServiceTo = OssConfigService.getOssStorageService(ossPathTo);
+ FileDocument fromFileDocument = mongoTemplate.findById(fileId, FileDocument.class);
+ if (fromFileDocument == null) {
+ throw new CommonException(ExceptionType.FILE_NOT_FIND);
+ }
+ boolean isFolder = fromFileDocument.getIsFolder();
+ fromFileDocument.setUsername(getUsernameByOssPath(ossPathTo));
+ // 目标objectName
+ String objectNameTo = destObjectNamePath.substring(ossPathTo.length()) + fromFileDocument.getName();
+ // 判断目标文件/夹是否存在
+ if (ossServiceTo.doesObjectExist(objectNameTo)) {
+ return ResultUtil.warning(Constants.COPY_EXISTS_FILE);
+ }
+ if (isFolder) {
+ // 复制文件夹
+ copyDir(fromFileDocument, ossServiceTo, objectNameTo, ossPathTo);
+ } else {
+ // 复制文件
+ copyFile(fromFileDocument, ossServiceTo, objectNameTo, ossPathTo);
+ }
+ if (isMove) {
+ // 删除源文件
+ Path fromPathFile = Paths.get(fileProperties.getRootDir(), fromFileDocument.getUsername(), fromFileDocument.getPath(), fromFileDocument.getName());
+ FileUtil.del(fromPathFile);
+ }
+ Completable.fromAction(() -> {
+ TimeUnit.SECONDS.sleep(1);
+ notifyCreateFile(fromFileDocument.getUsername(), objectNameTo, getOssRootFolderName(ossPathTo));
+ Path fromPath = Paths.get(fromFileDocument.getPath(), fromFileDocument.getName());
+ Path toPath = Paths.get(getOssRootFolderName(ossPathTo), objectNameTo);
+ commonFileService.pushMessageOperationFileSuccess(fromPath.toString(), toPath.toString(), fromFileDocument.getUsername(), isMove ? "移动" : "复制");
+ })
+ .subscribeOn(Schedulers.io())
+ .subscribe();
+ return ResultUtil.success();
+ }
+
+ /**
+ * 从本地复制文件夹到oss
+ *
+ * @param fromFileDocument 源FileDocument
+ * @param ossServiceTo 目标ossService
+ * @param objectNameTo 目标objectName
+ * @param ossPathTo 目标ossPath
+ */
+ private void copyDir(FileDocument fromFileDocument, IOssService ossServiceTo, String objectNameTo, String ossPathTo) {
+ // 锁文件
+ CommonFileService.lockFile(fromFileDocument);
+ Path fromPath = Paths.get(fileProperties.getRootDir(), fromFileDocument.getUsername(), fromFileDocument.getPath(), fromFileDocument.getName());
+ try {
+ // 首先在目标oss创建文件夹
+ if (ossServiceTo.mkdir(objectNameTo)) {
+ // 遍历fromPath下的所有目录和文件上传至目标oss
+ PathUtil.walkFiles(fromPath, new SimplePathVisitor() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
+ if (!dir.equals(fromPath)) {
+ String objectName = objectNameTo + dir.toString().substring(fromPath.toString().length());
+ ossServiceTo.mkdir(objectName);
+ afterUploadComplete(objectName, ossPathTo, null);
+ }
+ return super.preVisitDirectory(dir, attrs);
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+ String objectName = objectNameTo + file.toString().substring(fromPath.toString().length());
+ File fromFile = file.toFile();
+ try (InputStream inputStream = new FileInputStream(fromFile)) {
+ ossServiceTo.uploadFile(inputStream, objectName, fromFile.length());
+ afterUploadComplete(objectName, ossPathTo, null);
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ }
+ return super.visitFile(file, attrs);
+ }
+ });
+ }
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ } finally {
+ // 解锁文件
+ CommonFileService.unLockFile(fromFileDocument);
+ }
+ }
+
+ /**
+ * 从本地复制文件到oss
+ *
+ * @param fromFileDocument 源FileDocument
+ * @param ossServiceTo 目标ossService
+ * @param objectNameTo 目标objectName
+ * @param ossPathTo 目标ossPath
+ */
+ private void copyFile(FileDocument fromFileDocument, IOssService ossServiceTo, String objectNameTo, String ossPathTo) {
+ // 锁文件
+ CommonFileService.lockFile(fromFileDocument);
+ // 上传文件
+ File fromFile = Paths.get(fileProperties.getRootDir(), fromFileDocument.getUsername(), fromFileDocument.getPath(), fromFileDocument.getName()).toFile();
+ try (InputStream intStream = new FileInputStream(fromFile)) {
+ ossServiceTo.uploadFile(intStream, objectNameTo, fromFile.length());
+ afterUploadComplete(objectNameTo, ossPathTo, null);
+ } catch (Exception e) {
+ throw new CommonException(ExceptionType.SYSTEM_ERROR.getCode(), e.getMessage());
+ } finally {
+ // 解锁文件
+ CommonFileService.unLockFile(fromFileDocument);
+ }
+ }
+}
diff --git a/src/main/java/com/jmal/clouddisk/oss/web/WebOssService.java b/src/main/java/com/jmal/clouddisk/oss/web/WebOssService.java
new file mode 100644
index 0000000..96fc574
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/oss/web/WebOssService.java
@@ -0,0 +1,840 @@
+package com.jmal.clouddisk.oss.web;
+
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.IORuntimeException;
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.io.file.PathUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.util.BooleanUtil;
+import cn.hutool.core.util.ReUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.jmal.clouddisk.exception.CommonException;
+import com.jmal.clouddisk.exception.ExceptionType;
+import com.jmal.clouddisk.model.*;
+import com.jmal.clouddisk.oss.*;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IFileVersionService;
+import com.jmal.clouddisk.service.IUserService;
+import com.jmal.clouddisk.service.impl.UserLoginHolder;
+import com.jmal.clouddisk.util.*;
+import com.jmal.clouddisk.webdav.MyWebdavServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.catalina.connector.ClientAbortException;
+import org.bson.Document;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.data.mongodb.core.query.Criteria;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.core.query.Update;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+
+import java.io.*;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class WebOssService extends WebOssCommonService {
+
+
+ private final IFileVersionService fileVersionService;
+
+ private final UserLoginHolder userLoginHolder;
+
+ /***
+ * 断点恢复上传缓存(已上传的分片缓存)
+ * key: uploadId
+ * value: 已上传的分片号列表
+ */
+ private static final Cache> LIST_PARTS_CACHE = Caffeine.newBuilder().build();
+
+ public List> getPlatformList() {
+ List> maps = new ArrayList<>(PlatformOSS.values().length);
+ for (PlatformOSS platformOSS : PlatformOSS.values()) {
+ Map map = new HashMap<>(2);
+ map.put("value", platformOSS.getKey());
+ map.put("label", platformOSS.getValue());
+ maps.add(map);
+ }
+ return maps;
+ }
+
+ public static String getObjectName(Path prePath, String ossPath, boolean isFolder) {
+ String name = "";
+ int ossPathCount = Paths.get(ossPath).getNameCount();
+ if (prePath.getNameCount() > ossPathCount) {
+ name = prePath.subpath(ossPathCount, prePath.getNameCount()).toString();
+ if (!name.endsWith("/") && isFolder) {
+ name = name + "/";
+ }
+ }
+ return FileNameUtils.safeDecode(name);
+ }
+
+ public ResponseResult searchFileAndOpenOssFolder(Path prePth, UploadApiParamDTO upload) {
+ String ossPath = CaffeineUtil.getOssPath(prePth);
+ commonFileService.pushMessage(upload.getUsername(), Constants.OSS_CHUNK_SIZE, Constants.UPLOADER_CHUNK_SIZE);
+ if (ossPath == null) {
+ return ResultUtil.success().setData(new ArrayList<>(0)).setCode(0);
+ }
+ return getOssFileList(ossPath, prePth, upload);
+ }
+
+ public ResponseResult getOssFileList(String ossPath, Path prePth, UploadApiParamDTO upload) {
+ ResponseResult result = ResultUtil.genResult();
+ result.setData(Collections.emptyList());
+ result.setCount(0);
+ List fileIntroVOList;
+ IOssService ossService = OssConfigService.getOssStorageService(ossPath);
+ String objectName = getObjectName(prePth, ossPath, true);
+ List list = ossService.getFileInfoListCache(objectName);
+ if (!list.isEmpty()) {
+ String userId = null;
+ if (upload != null) {
+ userId = upload.getUserId();
+ }
+ if (CharSequenceUtil.isNotBlank(userId)) {
+ userId = userService.getUserIdByUserName(getUsernameByOssPath(ossPath));
+ }
+
+ // 过滤条件
+ list = filterOther(upload, list);
+ result.setCount(list.size());
+
+ // 设置附加属性
+ fileIntroVOList = setAdditionalAttributes(ossPath, upload, list, objectName, userId);
+
+ // 排序
+ fileIntroVOList = getSortFileList(upload, fileIntroVOList);
+ // 分页
+ if (upload != null && upload.getPageIndex() != null && upload.getPageSize() != null) {
+ fileIntroVOList = getPageFileList(fileIntroVOList, upload.getPageSize(), upload.getPageIndex());
+ }
+ result.setData(fileIntroVOList);
+ }
+ return result;
+ }
+
+ /**
+ * 设置附加属性
+ * @param ossPath ossPath
+ * @param upload upload
+ * @param list list
+ * @param objectName objectName
+ * @param finalUserId finalUserId
+ */
+ @NotNull
+ private List setAdditionalAttributes(String ossPath, UploadApiParamDTO upload, List