Repository: jamebal/jmal-cloud-server
Pull request #253: feat: 添加 ETag服务并更新相关功能
Author: login: jamebal
Labels: name: feature
Created: 2025-05-22T09:06:12Z | Merged: 2025-05-23T02:47:47Z

- 新增 EtagService 类，实现文件和文件夹的 ETag 更新逻辑
- 在 FileDocument 模型中添加与 ETag 相关的字段
- 更新 FileServiceImpl 和 LuceneService，集成 ETag功能
- 在 Constants 中添加 ETag 相关的常量


Diff:
diff --git a/src/main/java/com/jmal/clouddisk/lucene/EtagService.java b/src/main/java/com/jmal/clouddisk/lucene/EtagService.java
new file mode 100644
index 00000000..6454f2d5
--- /dev/null
+++ b/src/main/java/com/jmal/clouddisk/lucene/EtagService.java
@@ -0,0 +1,520 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    private final AtomicBoolean processingScheduled = new AtomicBoolean(false);
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
+        }
+        ThreadUtil.execute(() -> {
+            Query queryNoEtagQuery = new Query();
+            queryNoEtagQuery.addCriteria(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(true));
+            long countOfFoldersWithoutEtag = mongoTemplate.count(queryNoEtagQuery, FileDocument.class);
+            if (countOfFoldersWithoutEtag > 0) {
+                log.info("{} folders found without ETag. Marking them for ETag calculation.", countOfFoldersWithoutEtag);
+                Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+                mongoTemplate.updateMulti(queryNoEtagQuery, update, FileDocument.class);
+            }
+            processRootFolderFiles();
+            log.info("Initial ETag setup finished. Ensuring ETag processing worker is active if needed.");
+            ensureProcessingMarkedFolders();
+        });
+    }
+
+    /**
+     * 当文件内容发生变化（上传/修改/重命名/删除等）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    public void updateFileEtagAsync(String username, File file) {
+        if (!FileUtil.exist(file)) {
+            return;
+        }
+        if (FileUtil.isFile(file)) {
+            updateFileEtagAndMarkParentAsync(username, file);
+        }
+    }
+
+    /**
+     * 确保 ETag 处理任务已调度或正在运行。
+     * 如果当前没有任务在执行或计划执行，则提交一个新任务到执行器。
+     * 这个方法是幂等的，多次调用（如果任务已调度）不会产生副作用。
+     */
+    private void ensureProcessingMarkedFolders() {
+        // 使用 compareAndSet 来原子性地检查并设置标志
+        // 如果 processingScheduled 为 false，则将其设置为 true 并执行提交逻辑
+        if (processingScheduled.compareAndSet(false, true)) {
+            log.info("ETag processing task for marked folders is being scheduled.");
+            executorMarkedFoldersService.execute(() -> {
+                String workerId = "markedFoldersWorker-" + Thread.currentThread().getName();
+                try {
+                    // 调用实际的循环处理逻辑
+                    processMarkedFoldersLoop(workerId);
+                } finally {
+                    // 当任务完成（无论是正常结束还是异常结束）后，
+                    // 将标志重置为 false，允许后续的 ensureProcessingMarkedFolders 调用再次调度任务。
+                    processingScheduled.set(false);
+                    log.debug("[Worker {}] ETag processing task finished. Resetting schedule flag.", workerId);
+
+                    // 关键：检查在本次处理运行期间是否有新的文件夹被标记
+                    // 如果有，则再次尝试调度，确保它们得到处理
+                    if (hasMoreMarkedFoldersInDb()) {
+                        log.info("[Worker {}] New folders were marked during or after the last processing run. Re-triggering ETag processing.", workerId);
+                        ensureProcessingMarkedFolders();
+                    } else {
+                        log.info("[Worker {}] No more marked folders found after processing run. ETag worker will rest until new marks.", workerId);
+                    }
+                }
+            });
+        } else {
+            // 如果 processingScheduled 已经是 true，说明任务已在队列中或正在运行
+            log.debug("ETag processing task is already scheduled or running. This trigger call is a no-op.");
+        }
+    }
+
+    /**
+     * 检查数据库中是否还有需要更新ETag的文件夹。
+     */
+    private boolean hasMoreMarkedFoldersInDb() {
+        Query query = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true));
+        query.limit(1); // 只需要知道是否存在，不需要完整计数
+        return mongoTemplate.exists(query, FileDocument.class);
+    }
+
+    /**
+     * 当文件内容发生变化（上传新版本/修改）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    private String updateFileEtagAndMarkParentAsync(String username, File file) {
+        try {
+            if (!FileUtil.exist(file) || !FileUtil.isFile(file)) {
+                return null;
+            }
+            String newEtag = SecureUtil.sha256(file);
+
+            String fileName = file.getName();
+            String relativePath = getDbPath(username, file);
+            if (relativePath == null) {
+                return null;
+            }
+            String userId = userService.getUserIdByUserName(username);
+            Query query = getQueryByPath(userId, relativePath, fileName);
+            query.fields().include(Constants.ETAG);
+            FileDocument currentFileDoc = mongoTemplate.findOne(query, FileDocument.class);
+            String oldEtag = (currentFileDoc != null) ? currentFileDoc.getEtag() : null;
+
+            if (newEtag != null && !newEtag.equals(oldEtag)) {
+                Update update = new Update().set(Constants.ETAG, newEtag);
+                // 如果有版本控制字段，这里也应该更新
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("File ETag updated for {}: {} -> {}", relativePath, oldEtag, newEtag);
+
+                // 标记父文件夹需要更新ETag
+                markFolderForEtagUpdate(userId, relativePath);
+            } else {
+                log.debug("File ETag for {} did not change or calculation failed.", file.getAbsoluteFile());
+            }
+            return newEtag;
+        } catch (Exception e) {
+            log.error("Error updating file ETag for {}: {}", file.getAbsoluteFile(), e.getMessage(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 获取改文件在数据库中的路径(PATH_FIELD)字段的值
+     *
+     * @param username     用户名
+     * @param physicalFile 文件
+     */
+    private String getDbPath(String username, File physicalFile) {
+        Path physicalPath = physicalFile.toPath();
+        Path userRootPath = Paths.get(fileProperties.getRootDir(), username);
+
+        if (!physicalPath.startsWith(userRootPath)) {
+            return null;
+        }
+
+        Path relativeNioPath = userRootPath.relativize(physicalPath);
+
+        if (relativeNioPath.toString().isEmpty()) {
+            return null;
+        }
+
+        if (relativeNioPath.getNameCount() <= 1 || relativeNioPath.getParent() == null) {
+            return "/";
+        }
+        return "/" + relativeNioPath.getParent().toString().replace(File.separatorChar, '/') + "/";
+    }
+
+    /**
+     * 当新文件夹创建或重命名时调用。
+     * 立即设置新文件夹的初始ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param username          用户名
+     * @param physicalNewFolder 新创建的文件夹的完整路径
+     */
+    public void handleNewFolderCreationAsync(String username, File physicalNewFolder) {
+        if (!FileUtil.exist(physicalNewFolder) || !FileUtil.isDirectory(physicalNewFolder)) {
+            return;
+        }
+        String fileName = physicalNewFolder.getName();
+        String relativePath = getDbPath(username, physicalNewFolder);
+        if (relativePath == null) {
+            return;
+        }
+        try {
+            String userId = userService.getUserIdByUserName(username);
+
+            // 查询当前文件夹下是否有内容, 由于是异步处理，刚创建的文件夹可能还没来得及设置etag, 其下就有新文件了, 所有需要查询一次
+            String currentFolderNormalizedPath = relativePath + fileName + "/";
+            Query childrenQuery = Query.query(Criteria.where(Constants.PATH_FIELD).is(currentFolderNormalizedPath));
+            childrenQuery.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+            boolean hasChildren = mongoTemplate.exists(childrenQuery, FileDocument.class);
+            if (!hasChildren) {
+                // 如果没有内容，则设置初始ETag
+                String initialEtag = SecureUtil.sha256(EMPTY_FOLDER_ETAG_BASE_STRING);
+                Query query = getQueryByPath(userId, relativePath, fileName);
+                Update update = new Update().set(Constants.ETAG, initialEtag).set(Constants.NEEDS_ETAG_UPDATE_FIELD, false);
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("Initial ETag set for new folder {}: {}", relativePath, initialEtag);
+            }
+            // 标记父文件夹需要更新ETag
+            markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+        } catch (DataAccessException e) {
+            log.error("Database error setting initial ETag for folder {}: {}", relativePath, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 通过 userId 和 dbPath 还有filename 可获取到唯一的文件/文件夹
+     *
+     * @param userId   用户id
+     * @param dbPath   dbPath
+     * @param filename 文件名
+     */
+    private static Query getQueryByPath(String userId, String dbPath, String filename) {
+        Query query = Query.query(Criteria.where(IUserService.USER_ID).is(userId));
+        query.addCriteria(Criteria.where(Constants.PATH_FIELD).is(dbPath));
+        query.addCriteria(Criteria.where(Constants.FILENAME_FIELD).is(filename));
+        return query;
+    }
+
+    /**
+     * 当文件或文件夹被删除时调用。
+     * 异步标记其原父文件夹需要更新ETag。
+     *
+     * @param username 用户名
+     * @param file     被删除的文件
+     */
+    public void handleItemDeletionAsync(String username, File file) {
+        log.info("Handling ETag update for parent of deleted item: {}", file.getAbsoluteFile());
+        String relativePath = getDbPath(username, file);
+        if (relativePath == null) {
+            return;
+        }
+
+        // 标记父文件夹需要更新ETag
+        markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+    }
+
+    /**
+     * 处理根目录下文件的ETag
+     */
+    private void processRootFolderFiles() {
+        boolean run = true;
+        while (run) {
+            Query findQuery = Query.query(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(false).and(Constants.PATH_FIELD).is("/")).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG);
+            long count = mongoTemplate.count(findQuery, FileDocument.class);
+            if (count == 0) {
+                run = false;
+            }
+
+            // 获取根据下所有的文件
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                continue;
+            }
+            log.debug("Found {} files marked for ETag update. Processing...", tasks.size());
+            for (FileDocument fileDoc : tasks) {
+                String username = userService.getUserNameById(fileDoc.getUserId());
+                Path path = Paths.get(fileProperties.getRootDir(), username, fileDoc.getPath(), fileDoc.getName());
+
+                if (Files.exists(path)) {
+                    updateFileEtagAndMarkParentAsync(username, path.toFile());
+                }
+            }
+        }
+    }
+
+    /**
+     * 实际处理标记为需要更新ETag的文件夹的循环。
+     * 此方法由 ensureProcessingMarkedFolders 提交的任务在 EtagWorker 线程中执行。
+     *
+     * @param workerId 当前worker的唯一标识
+     */
+    private void processMarkedFoldersLoop(String workerId) {
+        boolean run = true;
+        while (run && !Thread.currentThread().isInterrupted()) {
+            Query findQuery = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true)).with(Sort.by(Sort.Direction.ASC, Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD)).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG, "_id");
+
+
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                run = false;
+                continue;
+            }
+            log.info("[Worker {}] Found {} folders marked for ETag update. Processing...", workerId, tasks.size());
+
+            for (FileDocument folderDoc : tasks) {
+                if (Thread.currentThread().isInterrupted()) {
+                    log.warn("[Worker {}] ETag processing loop was interrupted.", workerId);
+                    run = false; // 尊重中断信号
+                    break;
+                }
+
+                String folderPath = folderDoc.getPath();
+                String docId = folderDoc.getId();
+                String userId = folderDoc.getUserId();
+                log.debug("[Worker {}] Processing ETag for folder: {} (ID: {})", workerId, folderPath, docId);
+
+                try {
+                    boolean etagActuallyChanged = calculateAndUpdateSingleFolderEtagInternal(folderDoc, workerId);
+
+                    Query clearMarkQuery = Query.query(Criteria.where("_id").is(docId));
+                    Update clearMarkUpdate = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, false).unset(Constants.ETAG_UPDATE_FAILED_ATTEMPTS_FIELD).unset(Constants.LAST_ETAG_UPDATE_ERROR_FIELD);
+                    mongoTemplate.updateFirst(clearMarkQuery, clearMarkUpdate, FileDocument.class);
+                    log.debug("[Worker {}] Cleared ETag update mark for folder: {}", workerId, folderPath);
+
+                    if (etagActuallyChanged) {
+                        // 如果ETag变化了，标记其父文件夹
+                        markFolderForEtagUpdate(userId, folderPath);
+                    }
+                } catch (Exception e) {
+                    log.error("[Worker {}] Critical error processing ETag for folder {}: {}", workerId, folderPath, e.getMessage(), e);
+                    handleProcessingError(docId, folderPath, e, workerId);
+                }
+            }
+            log.debug("[Worker {}] Finished processing batch of {} folders.", workerId, tasks.size());
+        }
+        log.info("[Worker {}] ETag processing loop finished or paused.", workerId);
+    }
+
+    /**
+     * 标记指定文件夹需要ETag更新
+     *
+     * @param userId            用户ID
+     * @param currentFolderPath 当前文件夹路径
+     */
+    private void markFolderForEtagUpdate(String userId, String currentFolderPath) {
+        String parentFolderPath = getParentDbPath(currentFolderPath);
+        if (parentFolderPath == null) {
+            return;
+        }
+        String folderName = Paths.get(currentFolderPath).getFileName().toString();
+        Query query = getQueryByPath(userId, parentFolderPath, folderName);
+        Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+
+        try {
+            UpdateResult result = mongoTemplate.updateFirst(query, update, FileDocument.class);
+            if (result.getModifiedCount() > 0 || result.getMatchedCount() > 0) { // 即使已经是true也更新时间戳
+                log.info("Marked folder for ETag update: {}", currentFolderPath);
+                ensureProcessingMarkedFolders();
+            } else {
+                log.warn("Folder not found to mark for ETag update: {}", currentFolderPath);
+            }
+        } catch (DataAccessException e) {
+            log.error("DB error marking folder for ETag update {}: {}", currentFolderPath, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 内部方法：计算并更新单个文件夹的ETag。
+     * 被 processMarkedFolders 调用。
+     *
+     * @return true 如果ETag实际发生了变化并被更新
+     */
+    private boolean calculateAndUpdateSingleFolderEtagInternal(FileDocument folderDoc, String workerId) {
+        String folderPath = folderDoc.getPath();
+        String oldEtag = folderDoc.getEtag();
+        String userId = folderDoc.getUserId();
+        String newCalculatedEtag;
+
+        String currentFolderNormalizedPath = folderDoc.getPath() + folderDoc.getName() + "/";
+        Query childrenQuery = Query.query(Criteria.where(Constants.PATH_FIELD).is(currentFolderNormalizedPath));
+        childrenQuery.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+        childrenQuery.fields().include(Constants.FILENAME_FIELD).include(Constants.ETAG).include(Constants.IS_FOLDER).include(IUserService.USER_ID).include(Constants.PATH_FIELD);
+        List<FileDocument> children = mongoTemplate.find(childrenQuery, FileDocument.class);
+
+        if (children.isEmpty()) {
+            newCalculatedEtag = SecureUtil.sha256(EMPTY_FOLDER_ETAG_BASE_STRING);
+        } else {
+            List<String> childRepresentations = children.stream().sorted(Comparator.comparing(FileDocument::getName)) // 按名称排序
+                    .map(child -> formatChildRepresentation(workerId, child)).toList();
+            StringBuilder combinedRepresentation = new StringBuilder();
+            for (String rep : childRepresentations) {
+                if (rep == null) {
+                    return false;
+                }
+                if (!rep.isBlank()) {
+                    combinedRepresentation.append(rep).append(";");
+                }
+            }
+            newCalculatedEtag = SecureUtil.sha256(combinedRepresentation.toString());
+        }
+
+        if (!newCalculatedEtag.equals(oldEtag)) {
+            Query updateQuery = Query.query(Criteria.where("_id").is(folderDoc.getId()));
+            // 如果使用乐观锁，这里也需要检查版本号
+            // query.addCriteria(Criteria.where("_version").is(folderDoc.getVersion()));
+            Update update = new Update().set(Constants.ETAG, newCalculatedEtag);
+            // update.inc("_version", 1);
+            UpdateResult result = mongoTemplate.updateFirst(updateQuery, update, FileDocument.class);
+
+            if (result.getModifiedCount() > 0) {
+                log.info("[Worker {}] Folder ETag updated for {}: {} -> {}", workerId, folderPath, oldEtag, newCalculatedEtag);
+                return true;
+            } else {
+                // 可能在计算和写入之间，该文档被其他worker处理了（如果ETag已更新为相同值）或版本冲突
+                log.warn("[Worker {}] Folder ETag for {} was calculated as {} (old: {}), but DB update reported no modification. Possible race condition or already up-to-date.", workerId, folderPath, newCalculatedEtag, oldEtag);
+                return false; // 没有实际修改数据库
+            }
+        }
+        log.debug("[Worker {}] Folder ETag for {} did not change (calculated: {}, current: {}).", workerId, folderPath, newCalculatedEtag, oldEtag);
+        return false;
+    }
+
+    private void handleProcessingError(String docId, String folderPath, Exception e, String workerId) {
+        Query query = Query.query(Criteria.where("_id").is(docId));
+        FileDocument failedDoc = mongoTemplate.findOne(query, FileDocument.class);
+        int attempts = (failedDoc != null && failedDoc.getEtagUpdateFailedAttempts() != null) ? failedDoc.getEtagUpdateFailedAttempts() + 1 : 1;
+
+        Update update = new Update().set(Constants.LAST_ETAG_UPDATE_ERROR_FIELD, e.getMessage().substring(0, Math.min(e.getMessage().length(), 1000))) // 限制错误信息长度
+                .set(Constants.ETAG_UPDATE_FAILED_ATTEMPTS_FIELD, attempts);
+
+        if (attempts >= MAX_ETAG_UPDATE_ATTEMPTS) {
+            update.set(Constants.NEEDS_ETAG_UPDATE_FIELD, false); // 停止重试
+            log.error("[Worker {}] Folder {} ETag update failed after {} attempts. Giving up.", workerId, folderPath, attempts);
+        } else {
+            // 可以选择让它下次被重新拾取 (needsEtagUpdate 保持 true)
+            log.warn("[Worker {}] Folder {} ETag update failed (attempt {}). Will retry.", workerId, folderPath, attempts);
+        }
+        mongoTemplate.updateFirst(query, update, FileDocument.class);
+    }
+
+    private static String getParentDbPath(String currentFolderPath) {
+        if (CharSequenceUtil.isBlank(currentFolderPath) || currentFolderPath.equals("/")) {
+            return null; // 根目录没有父目录，或者已经是根
+        }
+        // 确保路径以 "/" 开头和结尾
+        if (!currentFolderPath.endsWith("/") || !currentFolderPath.startsWith("/")) {
+            return null;
+        }
+
+        // 移除尾部的斜杠以便找到倒数第二个斜杠
+        String pathWithoutTrailingSlash = currentFolderPath.substring(0, currentFolderPath.length() - 1);
+        int lastSlashIndex = pathWithoutTrailingSlash.lastIndexOf('/');
+
+        if (lastSlashIndex < 0) { // 例如 "folder/" 这种情况，父路径是 "/"
+            return "/";
+        }
+        if (lastSlashIndex == 0) { // 例如 "/folder/", 父路径是 "/"
+            return "/";
+        }
+        // 例如 "/A/B/", pathWithoutTrailingSlash = "/A/B", lastSlashIndex 在 A 后面
+        // substring(0, lastSlashIndex + 1) 会得到 "/A/"
+        return pathWithoutTrailingSlash.substring(0, lastSlashIndex + 1);
+    }
+
+    private String formatChildRepresentation(String workerId, FileDocument child) {
+        if (child.getEtag() == null) {
+            String username = userService.getUserNameById(child.getUserId());
+            Path path = Paths.get(fileProperties.getRootDir(), username, child.getPath(), child.getName());
+            if (Files.exists(path)) {
+                // 只对文件执行此操作
+                if (!child.getIsFolder()) {
+                    return updateFileEtagAndMarkParentAsync(username, path.toFile());
+                } else {
+                    // 子文件夹ETag为null，父文件夹本次计算中止
+                    return null;
+                }
+            } else {
+                log.warn("[Worker {}] File {} does not exist. Skipping.", workerId, path);
+                return "";
+            }
+        }
+        return child.getName() + ":" + child.getEtag();
+    }
+
+    @PreDestroy
+    public void cleanup() {
+        if (executorMarkedFoldersService != null) {
+            executorMarkedFoldersService.shutdown();
+        }
+    }
+
+}
diff --git a/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java b/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java
index a3d50ebd..aa9b8082 100644
--- a/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java
+++ b/src/main/java/com/jmal/clouddisk/lucene/LuceneService.java
@@ -62,6 +62,7 @@ public class LuceneService {
     private final ReadContentService readContentService;
     private final RebuildIndexTaskService rebuildIndexTaskService;
     private final SearchFileService searchFileService;
+    private final EtagService esTagService;
 
     public static final String MONGO_INDEX_FIELD = "index";
 
@@ -226,8 +227,7 @@ private void createIndexFiles(List<String> fileIdList) {
 
     private void updateIndex(boolean readContent, FileIntroVO fileIntroVO) {
         try {
-            String username = userService.getUserNameById(fileIntroVO.getUserId());
-            File file = Paths.get(fileProperties.getRootDir(), username, fileIntroVO.getPath(), fileIntroVO.getName()).toFile();
+            File file = getFileByFileIntroVO(fileIntroVO);
             boolean isContent = checkFileContent(file);
             if (!readContent && isContent) {
                 pushCreateIndexContentQueue(fileIntroVO.getId());
@@ -252,7 +252,7 @@ private void updateIndex(boolean readContent, FileIntroVO fileIntroVO) {
                 startProcessFilesToBeIndexed();
             }
         } catch (Exception e) {
-            log.warn("updateIndexError: {}", e.getMessage());
+            log.warn("updateIndexError: {}", e.getMessage(), e);
         } finally {
             updateIndexStatus(fileIntroVO, IndexStatus.INDEXED);
             if (readContent) {
@@ -273,11 +273,20 @@ private void updateIndexStatus(FileIntroVO fileIntroVO, IndexStatus indexStatus)
         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 fileDocument) {
-        if (fileDocument != null && fileDocument.getTags() != null && !fileDocument.getTags().isEmpty()) {
-            return fileDocument.getTags().stream().map(Tag::getName).reduce((a, b) -> a + " " + b).orElse("");
+    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;
     }
@@ -466,6 +475,7 @@ public void updateIndexDocument(IndexWriter indexWriter, FileIndex fileIndex, St
     public FileIntroVO getFileIntroVO(String fileId) {
         org.springframework.data.mongodb.core.query.Query query = new org.springframework.data.mongodb.core.query.Query();
         query.addCriteria(Criteria.where("_id").is(fileId));
+        query.fields().include("id", "name", "userId", "path", "isFolder", "isFavorite", "remark", "tags", "etag");
         return mongoTemplate.findOne(query, FileIntroVO.class, COLLECTION_NAME);
     }
 
diff --git a/src/main/java/com/jmal/clouddisk/model/FileDocument.java b/src/main/java/com/jmal/clouddisk/model/FileDocument.java
index 37b28816..6792b107 100644
--- a/src/main/java/com/jmal/clouddisk/model/FileDocument.java
+++ b/src/main/java/com/jmal/clouddisk/model/FileDocument.java
@@ -1,12 +1,13 @@
 package com.jmal.clouddisk.model;
 
-import com.jmal.clouddisk.service.impl.FileServiceImpl;
 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;
 
@@ -31,6 +32,7 @@
         @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;
@@ -212,6 +214,31 @@ public class FileDocument extends FileBase {
      */
     private List<OperationPermission> 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) {
diff --git a/src/main/java/com/jmal/clouddisk/model/FileIntroVO.java b/src/main/java/com/jmal/clouddisk/model/FileIntroVO.java
index 4efc9c3c..8fda395c 100644
--- a/src/main/java/com/jmal/clouddisk/model/FileIntroVO.java
+++ b/src/main/java/com/jmal/clouddisk/model/FileIntroVO.java
@@ -110,4 +110,8 @@ public class FileIntroVO extends FileBase {
      * 操作权限
      */
     private List<OperationPermission> operationPermissionList;
+    /**
+     * 文件的etag
+     */
+    private String etag;
 }
diff --git a/src/main/java/com/jmal/clouddisk/service/Constants.java b/src/main/java/com/jmal/clouddisk/service/Constants.java
index 332049ea..ba146eda 100644
--- a/src/main/java/com/jmal/clouddisk/service/Constants.java
+++ b/src/main/java/com/jmal/clouddisk/service/Constants.java
@@ -1,6 +1,14 @@
 package com.jmal.clouddisk.service;
 
 public class Constants {
+
+    public static final String NEEDS_ETAG_UPDATE_FIELD = "needsEtagUpdate";
+    public static final String LAST_ETAG_UPDATE_REQUEST_AT_FIELD = "lastEtagUpdateRequestAt";
+    public static final String ETAG_UPDATE_FAILED_ATTEMPTS_FIELD = "etagUpdateFailedAttempts";
+    public static final String LAST_ETAG_UPDATE_ERROR_FIELD = "lastEtagUpdateError";
+
+    private Constants() { }
+
     public static final String CONTENT_TYPE = "contentType";
     public static final String SUFFIX = "suffix";
     public static final String RELEASE = "release";
@@ -10,8 +18,10 @@ public class Constants {
     public static final String IS_FAVORITE = "isFavorite";
     public static final String TAG_IDS = "tagIds";
     public static final String TOTAL_SIZE = "totalSize"; //totalSize
+    public static final String ETAG = "etag";
+    public static final String PATH_FIELD = "path";
+    public static final String FILENAME_FIELD = "name";
 
-    private Constants() { }
 
     public static final String REGION_DEFAULT = "0";
     public static final String EXTRACTION_CODE = "extractionCode";
diff --git a/src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java b/src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java
index 6d90e9fe..0f1386ad 100644
--- a/src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java
+++ b/src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java
@@ -17,6 +17,7 @@
 import com.jmal.clouddisk.controller.sse.SseController;
 import com.jmal.clouddisk.exception.CommonException;
 import com.jmal.clouddisk.exception.ExceptionType;
+import com.jmal.clouddisk.lucene.EtagService;
 import com.jmal.clouddisk.lucene.LuceneService;
 import com.jmal.clouddisk.lucene.RebuildIndexTaskService;
 import com.jmal.clouddisk.media.HeifUtils;
@@ -128,6 +129,9 @@ public class CommonFileService {
     @Autowired
     public LuceneService luceneService;
 
+    @Autowired
+    EtagService etagService;
+
     private final Cache<String, Map<String, ThrottleExecutor>> throttleExecutorCache = Caffeine.newBuilder().build();
 
     private final Cache<String, Long> userSpaceCache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS).build();
@@ -395,6 +399,9 @@ public String createFile(String username, File file, String userId, Boolean isPu
             pushMessage(username, update.getUpdateObject(), Constants.CREATE_FILE);
             // 添加文件索引
             luceneService.pushCreateIndexQueue(fileId);
+            if (file.isDirectory()) {
+                etagService.handleNewFolderCreationAsync(username, file);
+            }
         } catch (Exception e) {
             log.error("{} file: {}", e.getMessage(), file.getAbsoluteFile(), e);
         } finally {
diff --git a/src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java b/src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java
index 8585ab14..03cfd5eb 100644
--- a/src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java
+++ b/src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java
@@ -1196,6 +1196,10 @@ public void deleteFile(String username, File file) {
                 luceneService.deleteIndexDocuments(Collections.singletonList(fileDocument.getId()));
             }
         }
+
+        // update parent folder etag
+        etagService.handleItemDeletionAsync(username, file);
+
         pushMessage(username, relativePath, Constants.DELETE_FILE);
     }
 


Reviews:
user: login: gemini-code-assist[bot]
body: Hello @jamebal, I'm Gemini Code Assist[^1]! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

## Summary of Changes
Hello team, gemini-code-assist here to provide a summary of this pull request. This PR introduces ETag functionality for files and folders within the cloud disk application. The primary goal is to provide a mechanism for detecting changes in file and folder content, which can be used for caching, synchronization, or other purposes. This involves adding a new service responsible for calculating and updating ETags, modifying the data model to store ETag values and related metadata, and integrating this logic into existing file management and indexing workflows.

### Highlights
* **New ETag Service**: A new service, `EtagService`, is added to handle the calculation and management of ETags for both files and folders. It includes logic for asynchronously updating file ETags upon changes and marking parent folders for subsequent ETag recalculation.
* **Folder ETag Calculation**: The `EtagService` implements a background worker to process folders marked for ETag updates. Folder ETags are calculated based on the names and ETags of their direct children, ensuring that any change within a folder (addition, deletion, modification of a child) results in a new ETag for the folder itself.
* **Data Model Updates**: The `FileDocument` model is updated to include fields for storing the ETag (`etag`), tracking failed update attempts (`etagUpdateFailedAttempts`), marking folders that need ETag recalculation (`needsEtagUpdate`), and storing the timestamp of the last update request (`lastEtagUpdateRequestAt`) and any associated error (`lastEtagUpdateError`). A new compound index is added to efficiently query folders needing ETag updates.
* **Integration with File Operations**: The ETag update logic is integrated into key file operations. When a file's index status is updated in `LuceneService`, its ETag is asynchronously recalculated. When a new folder is created (`CommonFileService`), its initial ETag is set, and its parent is marked for update. When an item is deleted (`FileServiceImpl`), its parent folder is marked for ETag update.
* **New Constants**: Several new constants are added to the `Constants` class to represent the new ETag-related fields in the database model.

### Changelog
<details>
<summary>Click here to see the changelog</summary>

* **src/main/java/com/jmal/clouddisk/lucene/EtagService.java**
  * Added new service class `EtagService`.
  * Implements logic for calculating file SHA-256 ETags.
  * Implements logic for calculating folder ETags based on child names and ETags.
  * Includes methods to asynchronously update file ETags and mark parent folders (`updateFileEtagAsync`, `updateFileEtagAndMarkParentAsync`).
  * Includes methods to handle ETag updates for new folder creation (`handleNewFolderCreationAsync`).
  * Includes methods to handle ETag updates for item deletion (`handleItemDeletionAsync`).
  * Includes a background worker (`processMarkedFolders`) to process folders needing ETag updates.
  * Adds retry logic for failed ETag updates on folders.
  * Includes helper methods for path manipulation (`getDbPath`, `getParentDbPath`) and querying (`getQueryByPath`).
  * Initializes the worker thread on application startup (`@PostConstruct`).
  * Shuts down the worker thread on application shutdown (`@PreDestroy`).
* **src/main/java/com/jmal/clouddisk/lucene/LuceneService.java**
  * Injected `EtagService` dependency.
  * Modified `updateIndexStatus` to call `etagService.updateFileEtagAsync` after updating the index status, ensuring file ETags are updated when files are indexed.
  * Added private helper method `getFileByFileIntroVO` to get the physical `File` object from a `FileIntroVO`.
  * Updated `getFileIntroVO` query to include the `etag` field when fetching data.
  * Improved logging in `updateIndex` to include the exception stack trace.
* **src/main/java/com/jmal/clouddisk/model/FileDocument.java**
  * Added new fields: `etag` (String), `etagUpdateFailedAttempts` (Integer), `needsEtagUpdate` (Boolean), `lastEtagUpdateRequestAt` (LocalDateTime), `lastEtagUpdateError` (String).
  * Added a compound index `process_marked_folders` on `needsEtagUpdate`, `isFolder`, and `lastEtagUpdateRequestAt` to support efficient querying for the ETag worker.
* **src/main/java/com/jmal/clouddisk/model/FileIntroVO.java**
  * Added `etag` (String) field to the view object.
* **src/main/java/com/jmal/clouddisk/service/Constants.java**
  * Added new public static final String constants for ETag-related database fields: `NEEDS_ETAG_UPDATE_FIELD`, `LAST_ETAG_UPDATE_REQUEST_AT_FIELD`, `ETAG_UPDATE_FAILED_ATTEMPTS_FIELD`, `LAST_ETAG_UPDATE_ERROR_FIELD`, `ETAG`, `PATH_FIELD`, `FILENAME_FIELD`.
* **src/main/java/com/jmal/clouddisk/service/impl/CommonFileService.java**
  * Injected `EtagService` dependency.
  * Added a call to `etagService.handleNewFolderCreationAsync` within the `createFile` method when a new directory is successfully created.
* **src/main/java/com/jmal/clouddisk/service/impl/FileServiceImpl.java**
  * Added a call to `etagService.handleItemDeletionAsync` within the `deleteFile` method to mark the parent folder for ETag update after an item is deleted.

</details>


<details>

<summary><b>Using Gemini Code Assist</b></summary>
<br>

The full guide for Gemini Code Assist can be found on our [documentation page](https://developers.google.com/gemini-code-assist/docs/review-github-code), here are some quick tips.

<b>Invoking Gemini</b>

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either `/gemini <command>` or `@gemini-code-assist <command>`. Below is a summary of the supported commands.

Feature | Command | Description
--- | --- | ---
Code Review | `/gemini review` | Performs a code review for the current pull request in its current state.
Pull Request Summary | `/gemini summary` | Provides a summary of the current pull request in its current state.
Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments.
Help | `/gemini help` | Displays a list of available commands.

<b>Customization</b>

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a `.gemini/` folder in the base of the repository. Detailed instructions can be found [here](https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github).

<b>Limitations & Feedback</b>

Gemini Code Assist is currently in preview and may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with :thumbsup: and :thumbsdown: on @gemini-code-assist comments to provide feedback.

</details>

<b>You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the [Gemini Code Assist IDE Extension](https://cloud.google.com/products/gemini/code-assist).</b>


[^1]: Review the [Privacy Notices](https://policies.google.com/privacy), [Generative AI Prohibited Use Policy](https://policies.google.com/terms/generative-ai/use-policy), [Terms of Service](https://policies.google.com/terms), and learn how to configure Gemini Code Assist in GitHub [here](https://developers.google.com/gemini-code-assist/docs/review-github-code). Gemini can make mistakes, so double check it and [use code with caution](https://support.google.com/legal/answer/13505487).

---

> Files change, bytes shift,
> A hash is born, a digital gift.
> Folder contents rearrange,
> ETag updates, a subtle change.


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:06:36Z
user: login: gemini-code-assist[bot]
body: ## Code Review
This pull request introduces a comprehensive ETag service, which is a valuable addition for tracking file and folder changes. The implementation includes asynchronous processing, database indexing for ETags, and integration into existing file operation workflows. The code is generally well-structured and includes error handling and retry mechanisms. I have a few suggestions and points for discussion, primarily around the ETag worker's executor service configuration and task submission strategy.

### Summary of Findings
* **Executor Service Configuration for ETag Worker**: The `EtagService` uses a single-threaded executor with a queue size of 1 for processing marked folders. Rapid, successive submissions of tasks (e.g., from cascading ETag updates in nested directories) could lead to task rejections. This was commented on with 'medium' severity.
* **Redundant Query Criteria**: In `EtagService.init()`, line 75, `queryNoEtagQuery.addCriteria(Criteria.where(Constants.IS_FOLDER).is(true));` appears to be redundant as this condition was already part of the query used for the preceding count. This is a minor issue and was not commented on due to review settings (low severity).
* **Null Check for Hashing Result**: In `EtagService.updateFileEtagAndMarkParentAsync()`, line 133, there's a `newEtag != null` check. `SecureUtil.sha256(file)` usually throws an exception on failure rather than returning null. This check might be overly cautious or imply a specific non-exception null-return scenario. This is a minor issue and was not commented on due to review settings (low severity).

### Merge Readiness
The pull request introduces a significant and well-designed ETag feature. However, there are some considerations regarding the ETag worker's executor service configuration and its behavior under potentially high task submission rates, which I've flagged with 'medium' severity. I recommend addressing or clarifying these points before merging to ensure robustness. As an AI, I am not authorized to approve pull requests; please ensure further review and approval by authorized team members.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:08:45Z
user: login: gemini-code-assist[bot]
body: ## Code Review
This pull request introduces a new `EtagService` for calculating and managing ETags for files and folders. It includes asynchronous processing for ETag updates, new ETag-related fields in `FileDocument`, and integration of ETag updates into file creation, modification, and deletion workflows. The overall approach is robust, with considerations for initialization, error handling, and propagation of ETag changes.

The code is generally well-structured and the new ETag functionality seems to be thoughtfully integrated. I have one suggestion for improving the robustness of a loop in the `EtagService`.

### Summary of Findings
* **Potential Infinite Loop in `EtagService.processRootFolderFiles`**: The loop in `processRootFolderFiles` might not terminate correctly if `mongoTemplate.count` returns a positive number but the subsequent `mongoTemplate.find` returns an empty list. This could lead to an infinite loop. A revised loop structure that breaks when no tasks are found would be more robust.
* **Logging Improvement in `LuceneService`**: The change to include the full exception stack trace in the `log.warn` call within `LuceneService.updateIndex` is a good improvement for error diagnostics.
* **ETag Integration**: The ETag service is well-integrated into file creation, deletion, and Lucene indexing processes, ensuring ETags are updated upon relevant file operations.
* **Database Schema and Indexing**: New fields for ETag management have been added to `FileDocument`, along with a new compound index `process_marked_folders`, which is beneficial for query performance related to ETag updates.
* **Constants Management**: New constants related to ETag fields have been added to `Constants.java`, which improves code maintainability by avoiding magic strings.

### Merge Readiness
The pull request is in good shape and introduces a valuable ETag management feature. There is one medium-severity issue identified in `EtagService.java` concerning a potential infinite loop that should be addressed. Once that's resolved, the changes look good to merge. As an AI, I am not authorized to approve pull requests; please ensure further review and approval from authorized team members.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:44:13Z
user: login: gemini-code-assist[bot]
body: ## Code Review
This pull request introduces a comprehensive ETag service, which is a valuable addition for managing file and folder states. The implementation is well-structured, handling asynchronous updates, database interactions, and error retries effectively. The changes in related services like `LuceneService` and `FileServiceImpl` integrate this new ETag functionality logically.

I've identified a couple of areas for potential improvement related to performance and exception handling clarity. Overall, great work on this feature!

### Summary of Findings
* **Performance of File Hashing**: Synchronous SHA256 calculation for files in `EtagService.updateFileEtagAndMarkParentAsync` might be I/O intensive for large files, potentially impacting performance in sensitive code paths.
* **Exception Handling Clarity**: The `throws NoSuchAlgorithmException` clause in `EtagService.hashString` and its callers might not accurately reflect the exceptions thrown by `SecureUtil.sha256`, which typically uses `CryptoException`.
* **Magic Numbers**: The batch processing limit (e.g., `limit(16)`) in `EtagService` is used in a few places. Consider defining it as a named constant for better maintainability. (Severity: low, not commented due to review settings)
* **Variable Reuse**: In `EtagService`, `userService.getUserIdByUserName(username)` is called multiple times in some methods where the `userId` could be fetched once and reused (e.g., in `handleNewFolderCreationAsync`, `handleItemDeletionAsync`). (Severity: low, not commented due to review settings)

### Merge Readiness
The pull request introduces a significant and well-implemented ETag feature. However, there are a couple of medium-severity concerns regarding potential performance impacts of synchronous file hashing and clarity in exception handling that should be addressed. I recommend making these changes before merging. As an AI, I am not authorized to approve pull requests; please ensure further review and approval from team members.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:54:12Z
user: login: gemini-code-assist[bot]
body: ## Code Review
This pull request introduces a new ETag service to calculate and manage ETags for files and folders, which is a significant feature for caching and data integrity. The integration points in `FileServiceImpl` and `LuceneService` seem appropriate, and the necessary model and constant changes are included.

The `EtagService` itself is quite comprehensive, handling asynchronous updates, retries, and initial ETag generation. However, there are a few areas that need attention, particularly regarding path construction consistency, potential performance bottlenecks in the ETag worker, and the ETag calculation dependency on child folder ETags.

Overall, a good effort in implementing a complex feature. Addressing the identified issues will improve its robustness and correctness.

### Summary of Findings
* **Path Consistency for Root Folders**: In `EtagService.handleNewFolderCreationAsync`, the construction of `currentFolderNormalizedPath` can lead to a double slash (e.g., `//folderName/`) when the new folder is at the root. This is inconsistent with how child paths are typically stored (e.g., `/folderName/`) and could cause queries for children to fail. This is a high-severity issue affecting correctness.
* **ETag Worker Performance**: The `EtagService` uses a single-threaded executor for processing marked folders. This could become a performance bottleneck if many folders require ETag updates or if individual ETag calculations are time-consuming.
* **ETag Calculation Dependency**: The ETag calculation for a folder in `EtagService` aborts if any of its child folders do not yet have an ETag. This could lead to stalled or repeated ETag update attempts for parent folders.
* **Logging Improvement**: In `LuceneService.updateIndex`, the logging for `updateIndexError` was improved by adding the full exception stack trace. This was not commented on directly due to review settings but is a good enhancement.

### Merge Readiness
The pull request introduces a valuable ETag service. However, there's a high-severity issue in `EtagService.java` related to path construction for root-level folders that needs to be addressed to ensure correctness. Additionally, there are a couple of medium-severity concerns regarding potential performance bottlenecks and ETag calculation dependencies that should be considered.

I recommend addressing at least the high-severity issue before merging. As a reviewer, I am not authorized to approve pull requests; please ensure further review and approval from authorized maintainers after addressing the feedback.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T10:02:03Z

Review comments:
diff_hunk: @@ -0,0 +1,492 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.NoSuchAlgorithmException;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    private final ReentrantLock processMarkedFoldersLock = new ReentrantLock();
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
+        }
path: src/main/java/com/jmal/clouddisk/lucene/EtagService.java
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The `executorMarkedFoldersService` is initialized as `ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false)`, meaning it's a single-threaded executor with a task queue of size 1. While the `processMarkedFoldersLock` ensures serial execution of the main processing logic, `startProcessMarkedFolders()` (which submits tasks to this executor) can be called frequently, for example, when ETags propagate up a nested directory structure.

If `startProcessMarkedFolders()` is invoked multiple times while the single worker thread is busy and the queue (size 1) is also full, subsequent task submissions could be rejected (typically with an `AbortPolicy` leading to `RejectedExecutionException`).

Could you clarify if this scenario of rapid re-submission and potential task rejection has been considered? Is the current setup expected to handle this gracefully, or might a larger queue size or a different rejection policy for the executor be more robust under high load or with deep directory structures?
created_at: 2025-05-22T09:08:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,492 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.NoSuchAlgorithmException;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    private final ReentrantLock processMarkedFoldersLock = new ReentrantLock();
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
+        }
+        ThreadUtil.execute(() -> {
+            Query queryNoEtagQuery = new Query();
+            queryNoEtagQuery.addCriteria(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(true));
+            long countOfFoldersWithoutEtag = mongoTemplate.count(queryNoEtagQuery, FileDocument.class);
+            if (countOfFoldersWithoutEtag > 0) {
+                log.info("{} folders found without ETag. Marking them for ETag calculation.", countOfFoldersWithoutEtag);
+                Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true)
+                        .currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+                queryNoEtagQuery.addCriteria(Criteria.where(Constants.IS_FOLDER).is(true));
+                mongoTemplate.updateMulti(queryNoEtagQuery, update, FileDocument.class);
+            }
+            processRootFolderFiles();
+            startProcessMarkedFolders();
+        });
+    }
+
+    /**
+     * 当文件内容发生变化（上传/修改/重命名/删除等）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    public void updateFileEtagAsync(String username, File file) {
+        if (!FileUtil.exist(file)) {
+            return;
+        }
+        if (FileUtil.isFile(file)) {
+            updateFileEtagAndMarkParentAsync(username, file);
+        }
+    }
+
+    private void startProcessMarkedFolders() {
+        executorMarkedFoldersService.execute(() -> {
+            processMarkedFoldersLock.lock();
+            try {
+                processMarkedFolders("markedFoldersWorker-" + Thread.currentThread().getName());
+            } finally {
+                processMarkedFoldersLock.unlock();
+            }
+        });
+    }
+
+    /**
+     * 当文件内容发生变化（上传新版本/修改）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    private String updateFileEtagAndMarkParentAsync(String username, File file) {
+        try {
+            if (!FileUtil.exist(file) || !FileUtil.isFile(file)) {
+                return null;
+            }
+            String newEtag = SecureUtil.sha256(file);
+
+            String fileName = file.getName();
+            String relativePath = getDbPath(username, file);
+            if (relativePath == null) {
+                return null;
+            }
+            String userId = userService.getUserIdByUserName(username);
+            Query query = getQueryByPath(userId, relativePath, fileName);
+            query.fields().include(Constants.ETAG);
+            FileDocument currentFileDoc = mongoTemplate.findOne(query, FileDocument.class);
+            String oldEtag = (currentFileDoc != null) ? currentFileDoc.getEtag() : null;
+
+            if (newEtag != null && !newEtag.equals(oldEtag)) {
+                Update update = new Update().set(Constants.ETAG, newEtag);
+                // 如果有版本控制字段，这里也应该更新
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("File ETag updated for {}: {} -> {}", relativePath, oldEtag, newEtag);
+
+                // 标记父文件夹需要更新ETag
+                markFolderForEtagUpdate(userId, relativePath);
+            } else {
+                log.debug("File ETag for {} did not change or calculation failed.", file.getAbsoluteFile());
+            }
+            return newEtag;
+        } catch (Exception e) {
+            log.error("Error updating file ETag for {}: {}", file.getAbsoluteFile(), e.getMessage(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 获取改文件在数据库中的路径(PATH_FIELD)字段的值
+     *
+     * @param username     用户名
+     * @param physicalFile 文件
+     */
+    private String getDbPath(String username, File physicalFile) {
+        Path physicalPath = physicalFile.toPath();
+        Path userRootPath = Paths.get(fileProperties.getRootDir(), username);
+
+        if (!physicalPath.startsWith(userRootPath)) {
+            return null;
+        }
+
+        Path relativeNioPath = userRootPath.relativize(physicalPath);
+
+        if (relativeNioPath.toString().isEmpty()) {
+            return null;
+        }
+
+        if (relativeNioPath.getNameCount() <= 1 || relativeNioPath.getParent() == null) {
+            return "/";
+        }
+        return "/" + relativeNioPath.getParent().toString().replace(File.separatorChar, '/') + "/";
+    }
+
+    /**
+     * 当新文件夹创建或重命名时调用。
+     * 立即设置新文件夹的初始ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param username          用户名
+     * @param physicalNewFolder 新创建的文件夹的完整路径
+     */
+    public void handleNewFolderCreationAsync(String username, File physicalNewFolder) {
+        if (!FileUtil.exist(physicalNewFolder) || !FileUtil.isDirectory(physicalNewFolder)) {
+            return;
+        }
+        String fileName = physicalNewFolder.getName();
+        String relativePath = getDbPath(username, physicalNewFolder);
+        if (relativePath == null) {
+            return;
+        }
+        try {
+            String userId = userService.getUserIdByUserName(username);
+
+            // 查询当前文件夹下是否有内容, 由于是异步处理，刚创建的文件夹可能还没来得及设置etag, 其下就有新文件了, 所有需要查询一次
+            String currentFolderNormalizedPath = relativePath + fileName + "/";
+            Query childrenQuery = Query.query(Criteria.where(Constants.PATH_FIELD).is(currentFolderNormalizedPath));
+            childrenQuery.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+            boolean hasChildren = mongoTemplate.exists(childrenQuery, FileDocument.class);
+            if (!hasChildren) {
+                // 如果没有内容，则设置初始ETag
+                String initialEtag = hashString(EMPTY_FOLDER_ETAG_BASE_STRING);
+                Query query = getQueryByPath(userId, relativePath, fileName);
+                Update update = new Update().set(Constants.ETAG, initialEtag).set(Constants.NEEDS_ETAG_UPDATE_FIELD, false);
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("Initial ETag set for new folder {}: {}", relativePath, initialEtag);
+            }
+            // 标记父文件夹需要更新ETag
+            markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+        } catch (NoSuchAlgorithmException e) {
+            log.error("Algorithm error setting initial ETag for folder {}: {}", relativePath, e.getMessage(), e);
+        } catch (DataAccessException e) {
+            log.error("Database error setting initial ETag for folder {}: {}", relativePath, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 通过 userId 和 dbPath 还有filename 可获取到唯一的文件/文件夹
+     *
+     * @param userId   用户id
+     * @param dbPath   dbPath
+     * @param filename 文件名
+     */
+    private static Query getQueryByPath(String userId, String dbPath, String filename) {
+        Query query = Query.query(Criteria.where(IUserService.USER_ID).is(userId));
+        query.addCriteria(Criteria.where(Constants.PATH_FIELD).is(dbPath));
+        query.addCriteria(Criteria.where(Constants.FILENAME_FIELD).is(filename));
+        return query;
+    }
+
+    /**
+     * 当文件或文件夹被删除时调用。
+     * 异步标记其原父文件夹需要更新ETag。
+     *
+     * @param username 用户名
+     * @param file     被删除的文件
+     */
+    public void handleItemDeletionAsync(String username, File file) {
+        log.info("Handling ETag update for parent of deleted item: {}", file.getAbsoluteFile());
+        String relativePath = getDbPath(username, file);
+        if (relativePath == null) {
+            return;
+        }
+
+        // 标记父文件夹需要更新ETag
+        markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+    }
+
+    /**
+     * 处理根目录下文件的ETag
+     */
+    private void processRootFolderFiles() {
+        boolean run = true;
+        while (run) {
+            Query findQuery = Query.query(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(false).and(Constants.PATH_FIELD).is("/")).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG);
+            long count = mongoTemplate.count(findQuery, FileDocument.class);
+            if (count == 0) {
+                run = false;
+            }
+
+            // 获取根据下所有的文件
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                continue;
+            }
+            log.debug("Found {} files marked for ETag update. Processing...", tasks.size());
+            for (FileDocument fileDoc : tasks) {
+                String username = userService.getUserNameById(fileDoc.getUserId());
+                Path path = Paths.get(fileProperties.getRootDir(), username, fileDoc.getPath(), fileDoc.getName());
+
+                if (Files.exists(path)) {
+                    updateFileEtagAndMarkParentAsync(username, path.toFile());
+                }
+            }
+        }
+    }
+
+    /**
+     * 由后台Worker调用，处理标记为需要更新ETag的文件夹。
+     * 每次处理一定数量的文件夹。
+     *
+     * @param workerId 当前worker的唯一标识，用于任务锁定（如果实现）
+     */
+    private void processMarkedFolders(String workerId) {
+        boolean run = true;
+        while (run) {
+            Query findQuery = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true)).with(Sort.by(Sort.Direction.ASC, Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD)).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG);
+            long count = mongoTemplate.count(findQuery, FileDocument.class);
+            if (count == 0) {
+                log.info("[Worker {}] No folders marked for ETag update. Exiting.", workerId);
+                run = false;
+            }
+
+            // 简单的任务获取，没有复杂的锁定机制，适合单worker实例或能接受少量重复计算的场景
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                continue;
+            }
+            log.info("[Worker {}] Found {} folders marked for ETag update. Processing...", workerId, tasks.size());
+
+            for (FileDocument folderDoc : tasks) {
+                String folderPath = folderDoc.getPath();
+                String docId = folderDoc.getId();
+                String userId = folderDoc.getUserId();
+                log.debug("[Worker {}] Processing ETag for folder: {} (ID: {})", workerId, folderPath, docId);
+
+                try {
+                    boolean etagActuallyChanged = calculateAndUpdateSingleFolderEtagInternal(folderDoc, workerId);
+
+                    // 清除标记 (无论ETag是否变化，只要处理过程没有抛出不可恢复的异常)
+                    Query clearMarkQuery = Query.query(Criteria.where("_id").is(docId));
+                    Update clearMarkUpdate = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, false).unset(Constants.ETAG_UPDATE_FAILED_ATTEMPTS_FIELD) // 成功后清除失败尝试
+                            .unset(Constants.LAST_ETAG_UPDATE_ERROR_FIELD);
+                    mongoTemplate.updateFirst(clearMarkQuery, clearMarkUpdate, FileDocument.class);
+                    log.debug("[Worker {}] Cleared ETag update mark for folder: {}", workerId, folderPath);
+
+                    if (etagActuallyChanged) {
+                        // 如果ETag变化了，标记其父文件夹
+                        markFolderForEtagUpdate(userId, folderPath);
+                    }
+                } catch (Exception e) { // 捕获所有可能的异常，防止worker线程中断
+                    log.error("[Worker {}] Critical error processing ETag for folder {}: {}", workerId, folderPath, e.getMessage(), e);
+                    handleProcessingError(docId, folderPath, e, workerId);
+                }
+            }
+            log.debug("[Worker {}] Finished processing batch of {} folders.", workerId, tasks.size());
+        }
+    }
+
+    /**
+     * 标记指定文件夹需要ETag更新
+     *
+     * @param userId          用户ID
+     * @param currentFolderPath 当前文件夹路径
+     */
+    private void markFolderForEtagUpdate(String userId, String currentFolderPath) {
+        String parentFolderPath = getParentDbPath(currentFolderPath);
+        if (parentFolderPath == null) {
+            return;
+        }
+        String folderName = Paths.get(currentFolderPath).getFileName().toString();
+        Query query = getQueryByPath(userId, parentFolderPath, folderName);
+        Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+
+        try {
+            UpdateResult result = mongoTemplate.updateFirst(query, update, FileDocument.class);
+            if (result.getModifiedCount() > 0 || result.getMatchedCount() > 0) { // 即使已经是true也更新时间戳
+                log.info("Marked folder for ETag update: {}", currentFolderPath);
+                startProcessMarkedFolders();
+            } else {
+                log.warn("Folder not found to mark for ETag update: {}", currentFolderPath);
+            }
path: src/main/java/com/jmal/clouddisk/lucene/EtagService.java
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The method `startProcessMarkedFolders()` is invoked here within `markFolderForEtagUpdate()`. This means each time a folder (typically a parent) is marked for an ETag update, a new task is submitted to `executorMarkedFoldersService`.

Considering this executor is single-threaded with a queue of size 1 (as configured in `init()`), and `processMarkedFolders()` is a potentially long-running loop, how is the system designed to handle multiple, rapid calls to `markFolderForEtagUpdate()`?

For instance, if changes in a deep directory structure trigger a cascade of parent folder updates, `startProcessMarkedFolders()` might be called many times in quick succession. If the worker thread is busy and its queue is full, new task submissions could be rejected.

Is the expectation that the `processMarkedFoldersLock` and the single-threaded nature of the executor will sufficiently throttle these submissions, or would it be beneficial to add a check within `startProcessMarkedFolders()` to see if a task is already active/queued to prevent redundant submissions or to configure the executor with a larger queue/different rejection policy?
created_at: 2025-05-22T09:08:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,526 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.NoSuchAlgorithmException;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    private final AtomicBoolean processingScheduled = new AtomicBoolean(false);
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
+        }
+        ThreadUtil.execute(() -> {
+            Query queryNoEtagQuery = new Query();
+            queryNoEtagQuery.addCriteria(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(true));
+            long countOfFoldersWithoutEtag = mongoTemplate.count(queryNoEtagQuery, FileDocument.class);
+            if (countOfFoldersWithoutEtag > 0) {
+                log.info("{} folders found without ETag. Marking them for ETag calculation.", countOfFoldersWithoutEtag);
+                Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+                mongoTemplate.updateMulti(queryNoEtagQuery, update, FileDocument.class);
+            }
+            processRootFolderFiles();
+            log.info("Initial ETag setup finished. Ensuring ETag processing worker is active if needed.");
+            ensureProcessingMarkedFolders();
+        });
+    }
+
+    /**
+     * 当文件内容发生变化（上传/修改/重命名/删除等）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    public void updateFileEtagAsync(String username, File file) {
+        if (!FileUtil.exist(file)) {
+            return;
+        }
+        if (FileUtil.isFile(file)) {
+            updateFileEtagAndMarkParentAsync(username, file);
+        }
+    }
+
+    /**
+     * 确保 ETag 处理任务已调度或正在运行。
+     * 如果当前没有任务在执行或计划执行，则提交一个新任务到执行器。
+     * 这个方法是幂等的，多次调用（如果任务已调度）不会产生副作用。
+     */
+    private void ensureProcessingMarkedFolders() {
+        // 使用 compareAndSet 来原子性地检查并设置标志
+        // 如果 processingScheduled 为 false，则将其设置为 true 并执行提交逻辑
+        if (processingScheduled.compareAndSet(false, true)) {
+            log.info("ETag processing task for marked folders is being scheduled.");
+            executorMarkedFoldersService.execute(() -> {
+                String workerId = "markedFoldersWorker-" + Thread.currentThread().getName();
+                try {
+                    // 调用实际的循环处理逻辑
+                    processMarkedFoldersLoop(workerId);
+                } finally {
+                    // 当任务完成（无论是正常结束还是异常结束）后，
+                    // 将标志重置为 false，允许后续的 ensureProcessingMarkedFolders 调用再次调度任务。
+                    processingScheduled.set(false);
+                    log.debug("[Worker {}] ETag processing task finished. Resetting schedule flag.", workerId);
+
+                    // 关键：检查在本次处理运行期间是否有新的文件夹被标记
+                    // 如果有，则再次尝试调度，确保它们得到处理
+                    if (hasMoreMarkedFoldersInDb()) {
+                        log.info("[Worker {}] New folders were marked during or after the last processing run. Re-triggering ETag processing.", workerId);
+                        ensureProcessingMarkedFolders();
+                    } else {
+                        log.info("[Worker {}] No more marked folders found after processing run. ETag worker will rest until new marks.", workerId);
+                    }
+                }
+            });
+        } else {
+            // 如果 processingScheduled 已经是 true，说明任务已在队列中或正在运行
+            log.debug("ETag processing task is already scheduled or running. This trigger call is a no-op.");
+        }
+    }
+
+    /**
+     * 检查数据库中是否还有需要更新ETag的文件夹。
+     */
+    private boolean hasMoreMarkedFoldersInDb() {
+        Query query = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true));
+        query.limit(1); // 只需要知道是否存在，不需要完整计数
+        return mongoTemplate.exists(query, FileDocument.class);
+    }
+
+    /**
+     * 当文件内容发生变化（上传新版本/修改）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    private String updateFileEtagAndMarkParentAsync(String username, File file) {
+        try {
+            if (!FileUtil.exist(file) || !FileUtil.isFile(file)) {
+                return null;
+            }
+            String newEtag = SecureUtil.sha256(file);
+
+            String fileName = file.getName();
+            String relativePath = getDbPath(username, file);
+            if (relativePath == null) {
+                return null;
+            }
+            String userId = userService.getUserIdByUserName(username);
+            Query query = getQueryByPath(userId, relativePath, fileName);
+            query.fields().include(Constants.ETAG);
+            FileDocument currentFileDoc = mongoTemplate.findOne(query, FileDocument.class);
+            String oldEtag = (currentFileDoc != null) ? currentFileDoc.getEtag() : null;
+
+            if (newEtag != null && !newEtag.equals(oldEtag)) {
+                Update update = new Update().set(Constants.ETAG, newEtag);
+                // 如果有版本控制字段，这里也应该更新
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("File ETag updated for {}: {} -> {}", relativePath, oldEtag, newEtag);
+
+                // 标记父文件夹需要更新ETag
+                markFolderForEtagUpdate(userId, relativePath);
+            } else {
+                log.debug("File ETag for {} did not change or calculation failed.", file.getAbsoluteFile());
+            }
+            return newEtag;
+        } catch (Exception e) {
+            log.error("Error updating file ETag for {}: {}", file.getAbsoluteFile(), e.getMessage(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 获取改文件在数据库中的路径(PATH_FIELD)字段的值
+     *
+     * @param username     用户名
+     * @param physicalFile 文件
+     */
+    private String getDbPath(String username, File physicalFile) {
+        Path physicalPath = physicalFile.toPath();
+        Path userRootPath = Paths.get(fileProperties.getRootDir(), username);
+
+        if (!physicalPath.startsWith(userRootPath)) {
+            return null;
+        }
+
+        Path relativeNioPath = userRootPath.relativize(physicalPath);
+
+        if (relativeNioPath.toString().isEmpty()) {
+            return null;
+        }
+
+        if (relativeNioPath.getNameCount() <= 1 || relativeNioPath.getParent() == null) {
+            return "/";
+        }
+        return "/" + relativeNioPath.getParent().toString().replace(File.separatorChar, '/') + "/";
+    }
+
+    /**
+     * 当新文件夹创建或重命名时调用。
+     * 立即设置新文件夹的初始ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param username          用户名
+     * @param physicalNewFolder 新创建的文件夹的完整路径
+     */
+    public void handleNewFolderCreationAsync(String username, File physicalNewFolder) {
+        if (!FileUtil.exist(physicalNewFolder) || !FileUtil.isDirectory(physicalNewFolder)) {
+            return;
+        }
+        String fileName = physicalNewFolder.getName();
+        String relativePath = getDbPath(username, physicalNewFolder);
+        if (relativePath == null) {
+            return;
+        }
+        try {
+            String userId = userService.getUserIdByUserName(username);
+
+            // 查询当前文件夹下是否有内容, 由于是异步处理，刚创建的文件夹可能还没来得及设置etag, 其下就有新文件了, 所有需要查询一次
+            String currentFolderNormalizedPath = relativePath + fileName + "/";
+            Query childrenQuery = Query.query(Criteria.where(Constants.PATH_FIELD).is(currentFolderNormalizedPath));
+            childrenQuery.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+            boolean hasChildren = mongoTemplate.exists(childrenQuery, FileDocument.class);
+            if (!hasChildren) {
+                // 如果没有内容，则设置初始ETag
+                String initialEtag = hashString(EMPTY_FOLDER_ETAG_BASE_STRING);
+                Query query = getQueryByPath(userId, relativePath, fileName);
+                Update update = new Update().set(Constants.ETAG, initialEtag).set(Constants.NEEDS_ETAG_UPDATE_FIELD, false);
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("Initial ETag set for new folder {}: {}", relativePath, initialEtag);
+            }
+            // 标记父文件夹需要更新ETag
+            markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+        } catch (NoSuchAlgorithmException e) {
+            log.error("Algorithm error setting initial ETag for folder {}: {}", relativePath, e.getMessage(), e);
+        } catch (DataAccessException e) {
+            log.error("Database error setting initial ETag for folder {}: {}", relativePath, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 通过 userId 和 dbPath 还有filename 可获取到唯一的文件/文件夹
+     *
+     * @param userId   用户id
+     * @param dbPath   dbPath
+     * @param filename 文件名
+     */
+    private static Query getQueryByPath(String userId, String dbPath, String filename) {
+        Query query = Query.query(Criteria.where(IUserService.USER_ID).is(userId));
+        query.addCriteria(Criteria.where(Constants.PATH_FIELD).is(dbPath));
+        query.addCriteria(Criteria.where(Constants.FILENAME_FIELD).is(filename));
+        return query;
+    }
+
+    /**
+     * 当文件或文件夹被删除时调用。
+     * 异步标记其原父文件夹需要更新ETag。
+     *
+     * @param username 用户名
+     * @param file     被删除的文件
+     */
+    public void handleItemDeletionAsync(String username, File file) {
+        log.info("Handling ETag update for parent of deleted item: {}", file.getAbsoluteFile());
+        String relativePath = getDbPath(username, file);
+        if (relativePath == null) {
+            return;
+        }
+
+        // 标记父文件夹需要更新ETag
+        markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+    }
+
+    /**
+     * 处理根目录下文件的ETag
+     */
+    private void processRootFolderFiles() {
+        boolean run = true;
+        while (run) {
+            Query findQuery = Query.query(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(false).and(Constants.PATH_FIELD).is("/")).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG);
+            long count = mongoTemplate.count(findQuery, FileDocument.class);
+            if (count == 0) {
+                run = false;
+            }
+
+            // 获取根据下所有的文件
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                continue;
+            }
path: src/main/java/com/jmal/clouddisk/lucene/EtagService.java
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

There's a potential issue in this `while(run)` loop. If `mongoTemplate.count(findQuery, FileDocument.class)` (line 292) returns a `count > 0`, but `mongoTemplate.find(findQuery, FileDocument.class)` (line 298) subsequently returns an empty `tasks` list (e.g., due to documents being deleted between the two calls or some other inconsistency), the `if (tasks.isEmpty()) { continue; }` (lines 300-301) will be executed. Since `run` is only set to `false` when `count == 0` (line 294), and `count` is not re-evaluated inside the loop after the `find` operation, the loop could continue indefinitely if `count` was initially positive but `tasks` becomes persistently empty.

Consider restructuring the loop to break if no tasks are found in an iteration. For example:

```java
private void processRootFolderFiles() {
    while (true) { // Loop indefinitely until explicitly broken
        Query findQuery = Query.query(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(false).and(Constants.PATH_FIELD).is("/")).limit(16);
        findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG);

        List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);

        if (tasks.isEmpty()) {
            break; // No tasks found, exit loop
        }
        log.debug("Found {} files marked for ETag update. Processing...", tasks.size());
        for (FileDocument fileDoc : tasks) {
            String username = userService.getUserNameById(fileDoc.getUserId());
            Path path = Paths.get(fileProperties.getRootDir(), username, fileDoc.getPath(), fileDoc.getName());

            if (Files.exists(path)) {
                updateFileEtagAndMarkParentAsync(username, path.toFile());
            }
        }
        // Optional: if tasks.size() < 16, could also break if you assume that means all done.
        // However, relying on tasks.isEmpty() is generally safer.
    }
}
```
What are your thoughts on this potential infinite loop and the suggested change?

```java
        while (true) { // Loop indefinitely until explicitly broken
            Query findQuery = Query.query(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(false).and(Constants.PATH_FIELD).is("/")).limit(16);
            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG);

            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);

            if (tasks.isEmpty()) {
                break; // No tasks found, exit loop
            }
            log.debug("Found {} files marked for ETag update. Processing...", tasks.size());
            for (FileDocument fileDoc : tasks) {
```
created_at: 2025-05-22T09:44:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,527 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.NoSuchAlgorithmException;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    private final AtomicBoolean processingScheduled = new AtomicBoolean(false);
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
+        }
+        ThreadUtil.execute(() -> {
+            Query queryNoEtagQuery = new Query();
+            queryNoEtagQuery.addCriteria(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(true));
+            long countOfFoldersWithoutEtag = mongoTemplate.count(queryNoEtagQuery, FileDocument.class);
+            if (countOfFoldersWithoutEtag > 0) {
+                log.info("{} folders found without ETag. Marking them for ETag calculation.", countOfFoldersWithoutEtag);
+                Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+                mongoTemplate.updateMulti(queryNoEtagQuery, update, FileDocument.class);
+            }
+            processRootFolderFiles();
+            log.info("Initial ETag setup finished. Ensuring ETag processing worker is active if needed.");
+            ensureProcessingMarkedFolders();
+        });
+    }
+
+    /**
+     * 当文件内容发生变化（上传/修改/重命名/删除等）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    public void updateFileEtagAsync(String username, File file) {
+        if (!FileUtil.exist(file)) {
+            return;
+        }
+        if (FileUtil.isFile(file)) {
+            updateFileEtagAndMarkParentAsync(username, file);
+        }
+    }
+
+    /**
+     * 确保 ETag 处理任务已调度或正在运行。
+     * 如果当前没有任务在执行或计划执行，则提交一个新任务到执行器。
+     * 这个方法是幂等的，多次调用（如果任务已调度）不会产生副作用。
+     */
+    private void ensureProcessingMarkedFolders() {
+        // 使用 compareAndSet 来原子性地检查并设置标志
+        // 如果 processingScheduled 为 false，则将其设置为 true 并执行提交逻辑
+        if (processingScheduled.compareAndSet(false, true)) {
+            log.info("ETag processing task for marked folders is being scheduled.");
+            executorMarkedFoldersService.execute(() -> {
+                String workerId = "markedFoldersWorker-" + Thread.currentThread().getName();
+                try {
+                    // 调用实际的循环处理逻辑
+                    processMarkedFoldersLoop(workerId);
+                } finally {
+                    // 当任务完成（无论是正常结束还是异常结束）后，
+                    // 将标志重置为 false，允许后续的 ensureProcessingMarkedFolders 调用再次调度任务。
+                    processingScheduled.set(false);
+                    log.debug("[Worker {}] ETag processing task finished. Resetting schedule flag.", workerId);
+
+                    // 关键：检查在本次处理运行期间是否有新的文件夹被标记
+                    // 如果有，则再次尝试调度，确保它们得到处理
+                    if (hasMoreMarkedFoldersInDb()) {
+                        log.info("[Worker {}] New folders were marked during or after the last processing run. Re-triggering ETag processing.", workerId);
+                        ensureProcessingMarkedFolders();
+                    } else {
+                        log.info("[Worker {}] No more marked folders found after processing run. ETag worker will rest until new marks.", workerId);
+                    }
+                }
+            });
+        } else {
+            // 如果 processingScheduled 已经是 true，说明任务已在队列中或正在运行
+            log.debug("ETag processing task is already scheduled or running. This trigger call is a no-op.");
+        }
+    }
+
+    /**
+     * 检查数据库中是否还有需要更新ETag的文件夹。
+     */
+    private boolean hasMoreMarkedFoldersInDb() {
+        Query query = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true));
+        query.limit(1); // 只需要知道是否存在，不需要完整计数
+        return mongoTemplate.exists(query, FileDocument.class);
+    }
+
+    /**
+     * 当文件内容发生变化（上传新版本/修改）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    private String updateFileEtagAndMarkParentAsync(String username, File file) {
+        try {
+            if (!FileUtil.exist(file) || !FileUtil.isFile(file)) {
+                return null;
+            }
+            String newEtag = SecureUtil.sha256(file);
path: src/main/java/com/jmal/clouddisk/lucene/EtagService.java
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The call `SecureUtil.sha256(file)` reads the entire file to compute its SHA256 hash. For large files, this operation can be I/O intensive and potentially slow down the calling thread. While the subsequent marking of the parent folder is asynchronous (`markFolderForEtagUpdate`), the ETag calculation for the file itself within this method is synchronous.

If `updateFileEtagAndMarkParentAsync` is called from a performance-sensitive path (e.g., an API request handler that blocks user interaction until completion), this could lead to noticeable latency for large files. 

Could you consider if there are scenarios where this synchronous file hashing might be a bottleneck? Perhaps for very large files, this specific part could also be offloaded, or a warning logged if it exceeds a certain duration?
created_at: 2025-05-22T09:54:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,527 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.NoSuchAlgorithmException;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    private final AtomicBoolean processingScheduled = new AtomicBoolean(false);
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
+        }
+        ThreadUtil.execute(() -> {
+            Query queryNoEtagQuery = new Query();
+            queryNoEtagQuery.addCriteria(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(true));
+            long countOfFoldersWithoutEtag = mongoTemplate.count(queryNoEtagQuery, FileDocument.class);
+            if (countOfFoldersWithoutEtag > 0) {
+                log.info("{} folders found without ETag. Marking them for ETag calculation.", countOfFoldersWithoutEtag);
+                Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+                mongoTemplate.updateMulti(queryNoEtagQuery, update, FileDocument.class);
+            }
+            processRootFolderFiles();
+            log.info("Initial ETag setup finished. Ensuring ETag processing worker is active if needed.");
+            ensureProcessingMarkedFolders();
+        });
+    }
+
+    /**
+     * 当文件内容发生变化（上传/修改/重命名/删除等）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    public void updateFileEtagAsync(String username, File file) {
+        if (!FileUtil.exist(file)) {
+            return;
+        }
+        if (FileUtil.isFile(file)) {
+            updateFileEtagAndMarkParentAsync(username, file);
+        }
+    }
+
+    /**
+     * 确保 ETag 处理任务已调度或正在运行。
+     * 如果当前没有任务在执行或计划执行，则提交一个新任务到执行器。
+     * 这个方法是幂等的，多次调用（如果任务已调度）不会产生副作用。
+     */
+    private void ensureProcessingMarkedFolders() {
+        // 使用 compareAndSet 来原子性地检查并设置标志
+        // 如果 processingScheduled 为 false，则将其设置为 true 并执行提交逻辑
+        if (processingScheduled.compareAndSet(false, true)) {
+            log.info("ETag processing task for marked folders is being scheduled.");
+            executorMarkedFoldersService.execute(() -> {
+                String workerId = "markedFoldersWorker-" + Thread.currentThread().getName();
+                try {
+                    // 调用实际的循环处理逻辑
+                    processMarkedFoldersLoop(workerId);
+                } finally {
+                    // 当任务完成（无论是正常结束还是异常结束）后，
+                    // 将标志重置为 false，允许后续的 ensureProcessingMarkedFolders 调用再次调度任务。
+                    processingScheduled.set(false);
+                    log.debug("[Worker {}] ETag processing task finished. Resetting schedule flag.", workerId);
+
+                    // 关键：检查在本次处理运行期间是否有新的文件夹被标记
+                    // 如果有，则再次尝试调度，确保它们得到处理
+                    if (hasMoreMarkedFoldersInDb()) {
+                        log.info("[Worker {}] New folders were marked during or after the last processing run. Re-triggering ETag processing.", workerId);
+                        ensureProcessingMarkedFolders();
+                    } else {
+                        log.info("[Worker {}] No more marked folders found after processing run. ETag worker will rest until new marks.", workerId);
+                    }
+                }
+            });
+        } else {
+            // 如果 processingScheduled 已经是 true，说明任务已在队列中或正在运行
+            log.debug("ETag processing task is already scheduled or running. This trigger call is a no-op.");
+        }
+    }
+
+    /**
+     * 检查数据库中是否还有需要更新ETag的文件夹。
+     */
+    private boolean hasMoreMarkedFoldersInDb() {
+        Query query = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true));
+        query.limit(1); // 只需要知道是否存在，不需要完整计数
+        return mongoTemplate.exists(query, FileDocument.class);
+    }
+
+    /**
+     * 当文件内容发生变化（上传新版本/修改）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    private String updateFileEtagAndMarkParentAsync(String username, File file) {
+        try {
+            if (!FileUtil.exist(file) || !FileUtil.isFile(file)) {
+                return null;
+            }
+            String newEtag = SecureUtil.sha256(file);
+
+            String fileName = file.getName();
+            String relativePath = getDbPath(username, file);
+            if (relativePath == null) {
+                return null;
+            }
+            String userId = userService.getUserIdByUserName(username);
+            Query query = getQueryByPath(userId, relativePath, fileName);
+            query.fields().include(Constants.ETAG);
+            FileDocument currentFileDoc = mongoTemplate.findOne(query, FileDocument.class);
+            String oldEtag = (currentFileDoc != null) ? currentFileDoc.getEtag() : null;
+
+            if (newEtag != null && !newEtag.equals(oldEtag)) {
+                Update update = new Update().set(Constants.ETAG, newEtag);
+                // 如果有版本控制字段，这里也应该更新
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("File ETag updated for {}: {} -> {}", relativePath, oldEtag, newEtag);
+
+                // 标记父文件夹需要更新ETag
+                markFolderForEtagUpdate(userId, relativePath);
+            } else {
+                log.debug("File ETag for {} did not change or calculation failed.", file.getAbsoluteFile());
+            }
+            return newEtag;
+        } catch (Exception e) {
+            log.error("Error updating file ETag for {}: {}", file.getAbsoluteFile(), e.getMessage(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 获取改文件在数据库中的路径(PATH_FIELD)字段的值
+     *
+     * @param username     用户名
+     * @param physicalFile 文件
+     */
+    private String getDbPath(String username, File physicalFile) {
+        Path physicalPath = physicalFile.toPath();
+        Path userRootPath = Paths.get(fileProperties.getRootDir(), username);
+
+        if (!physicalPath.startsWith(userRootPath)) {
+            return null;
+        }
+
+        Path relativeNioPath = userRootPath.relativize(physicalPath);
+
+        if (relativeNioPath.toString().isEmpty()) {
+            return null;
+        }
+
+        if (relativeNioPath.getNameCount() <= 1 || relativeNioPath.getParent() == null) {
+            return "/";
+        }
+        return "/" + relativeNioPath.getParent().toString().replace(File.separatorChar, '/') + "/";
+    }
+
+    /**
+     * 当新文件夹创建或重命名时调用。
+     * 立即设置新文件夹的初始ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param username          用户名
+     * @param physicalNewFolder 新创建的文件夹的完整路径
+     */
+    public void handleNewFolderCreationAsync(String username, File physicalNewFolder) {
+        if (!FileUtil.exist(physicalNewFolder) || !FileUtil.isDirectory(physicalNewFolder)) {
+            return;
+        }
+        String fileName = physicalNewFolder.getName();
+        String relativePath = getDbPath(username, physicalNewFolder);
+        if (relativePath == null) {
+            return;
+        }
+        try {
+            String userId = userService.getUserIdByUserName(username);
+
+            // 查询当前文件夹下是否有内容, 由于是异步处理，刚创建的文件夹可能还没来得及设置etag, 其下就有新文件了, 所有需要查询一次
+            String currentFolderNormalizedPath = relativePath + fileName + "/";
+            Query childrenQuery = Query.query(Criteria.where(Constants.PATH_FIELD).is(currentFolderNormalizedPath));
+            childrenQuery.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+            boolean hasChildren = mongoTemplate.exists(childrenQuery, FileDocument.class);
+            if (!hasChildren) {
+                // 如果没有内容，则设置初始ETag
+                String initialEtag = hashString(EMPTY_FOLDER_ETAG_BASE_STRING);
+                Query query = getQueryByPath(userId, relativePath, fileName);
+                Update update = new Update().set(Constants.ETAG, initialEtag).set(Constants.NEEDS_ETAG_UPDATE_FIELD, false);
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("Initial ETag set for new folder {}: {}", relativePath, initialEtag);
+            }
+            // 标记父文件夹需要更新ETag
+            markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+        } catch (NoSuchAlgorithmException e) {
+            log.error("Algorithm error setting initial ETag for folder {}: {}", relativePath, e.getMessage(), e);
+        } catch (DataAccessException e) {
+            log.error("Database error setting initial ETag for folder {}: {}", relativePath, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 通过 userId 和 dbPath 还有filename 可获取到唯一的文件/文件夹
+     *
+     * @param userId   用户id
+     * @param dbPath   dbPath
+     * @param filename 文件名
+     */
+    private static Query getQueryByPath(String userId, String dbPath, String filename) {
+        Query query = Query.query(Criteria.where(IUserService.USER_ID).is(userId));
+        query.addCriteria(Criteria.where(Constants.PATH_FIELD).is(dbPath));
+        query.addCriteria(Criteria.where(Constants.FILENAME_FIELD).is(filename));
+        return query;
+    }
+
+    /**
+     * 当文件或文件夹被删除时调用。
+     * 异步标记其原父文件夹需要更新ETag。
+     *
+     * @param username 用户名
+     * @param file     被删除的文件
+     */
+    public void handleItemDeletionAsync(String username, File file) {
+        log.info("Handling ETag update for parent of deleted item: {}", file.getAbsoluteFile());
+        String relativePath = getDbPath(username, file);
+        if (relativePath == null) {
+            return;
+        }
+
+        // 标记父文件夹需要更新ETag
+        markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+    }
+
+    /**
+     * 处理根目录下文件的ETag
+     */
+    private void processRootFolderFiles() {
+        boolean run = true;
+        while (run) {
+            Query findQuery = Query.query(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(false).and(Constants.PATH_FIELD).is("/")).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG);
+            long count = mongoTemplate.count(findQuery, FileDocument.class);
+            if (count == 0) {
+                run = false;
+            }
+
+            // 获取根据下所有的文件
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                continue;
+            }
+            log.debug("Found {} files marked for ETag update. Processing...", tasks.size());
+            for (FileDocument fileDoc : tasks) {
+                String username = userService.getUserNameById(fileDoc.getUserId());
+                Path path = Paths.get(fileProperties.getRootDir(), username, fileDoc.getPath(), fileDoc.getName());
+
+                if (Files.exists(path)) {
+                    updateFileEtagAndMarkParentAsync(username, path.toFile());
+                }
+            }
+        }
+    }
+
+    /**
+     * 实际处理标记为需要更新ETag的文件夹的循环。
+     * 此方法由 ensureProcessingMarkedFolders 提交的任务在 EtagWorker 线程中执行。
+     *
+     * @param workerId 当前worker的唯一标识
+     */
+    private void processMarkedFoldersLoop(String workerId) {
+        boolean run = true;
+        while (run && !Thread.currentThread().isInterrupted()) {
+            Query findQuery = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true)).with(Sort.by(Sort.Direction.ASC, Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD)).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG, "_id");
+
+
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                run = false;
+                continue;
+            }
+            log.info("[Worker {}] Found {} folders marked for ETag update. Processing...", workerId, tasks.size());
+
+            for (FileDocument folderDoc : tasks) {
+                if (Thread.currentThread().isInterrupted()) {
+                    log.warn("[Worker {}] ETag processing loop was interrupted.", workerId);
+                    run = false; // 尊重中断信号
+                    break;
+                }
+
+                String folderPath = folderDoc.getPath();
+                String docId = folderDoc.getId();
+                String userId = folderDoc.getUserId();
+                log.debug("[Worker {}] Processing ETag for folder: {} (ID: {})", workerId, folderPath, docId);
+
+                try {
+                    boolean etagActuallyChanged = calculateAndUpdateSingleFolderEtagInternal(folderDoc, workerId);
+
+                    Query clearMarkQuery = Query.query(Criteria.where("_id").is(docId));
+                    Update clearMarkUpdate = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, false).unset(Constants.ETAG_UPDATE_FAILED_ATTEMPTS_FIELD).unset(Constants.LAST_ETAG_UPDATE_ERROR_FIELD);
+                    mongoTemplate.updateFirst(clearMarkQuery, clearMarkUpdate, FileDocument.class);
+                    log.debug("[Worker {}] Cleared ETag update mark for folder: {}", workerId, folderPath);
+
+                    if (etagActuallyChanged) {
+                        // 如果ETag变化了，标记其父文件夹
+                        markFolderForEtagUpdate(userId, folderPath);
+                    }
+                } catch (Exception e) {
+                    log.error("[Worker {}] Critical error processing ETag for folder {}: {}", workerId, folderPath, e.getMessage(), e);
+                    handleProcessingError(docId, folderPath, e, workerId);
+                }
+            }
+            log.debug("[Worker {}] Finished processing batch of {} folders.", workerId, tasks.size());
+        }
+        log.info("[Worker {}] ETag processing loop finished or paused.", workerId);
+    }
+
+    /**
+     * 标记指定文件夹需要ETag更新
+     *
+     * @param userId            用户ID
+     * @param currentFolderPath 当前文件夹路径
+     */
+    private void markFolderForEtagUpdate(String userId, String currentFolderPath) {
+        String parentFolderPath = getParentDbPath(currentFolderPath);
+        if (parentFolderPath == null) {
+            return;
+        }
+        String folderName = Paths.get(currentFolderPath).getFileName().toString();
+        Query query = getQueryByPath(userId, parentFolderPath, folderName);
+        Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+
+        try {
+            UpdateResult result = mongoTemplate.updateFirst(query, update, FileDocument.class);
+            if (result.getModifiedCount() > 0 || result.getMatchedCount() > 0) { // 即使已经是true也更新时间戳
+                log.info("Marked folder for ETag update: {}", currentFolderPath);
+                ensureProcessingMarkedFolders();
+            } else {
+                log.warn("Folder not found to mark for ETag update: {}", currentFolderPath);
+            }
+        } catch (DataAccessException e) {
+            log.error("DB error marking folder for ETag update {}: {}", currentFolderPath, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 内部方法：计算并更新单个文件夹的ETag。
+     * 被 processMarkedFolders 调用。
+     *
+     * @return true 如果ETag实际发生了变化并被更新
+     */
+    private boolean calculateAndUpdateSingleFolderEtagInternal(FileDocument folderDoc, String workerId) throws NoSuchAlgorithmException {
+        String folderPath = folderDoc.getPath();
+        String oldEtag = folderDoc.getEtag();
+        String userId = folderDoc.getUserId();
+        String newCalculatedEtag;
+
+        String currentFolderNormalizedPath = folderDoc.getPath() + folderDoc.getName() + "/";
+        Query childrenQuery = Query.query(Criteria.where(Constants.PATH_FIELD).is(currentFolderNormalizedPath));
+        childrenQuery.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+        childrenQuery.fields().include(Constants.FILENAME_FIELD).include(Constants.ETAG).include(Constants.IS_FOLDER).include(IUserService.USER_ID).include(Constants.PATH_FIELD);
+        List<FileDocument> children = mongoTemplate.find(childrenQuery, FileDocument.class);
+
+        if (children.isEmpty()) {
+            newCalculatedEtag = hashString(EMPTY_FOLDER_ETAG_BASE_STRING);
+        } else {
+            List<String> childRepresentations = children.stream().sorted(Comparator.comparing(FileDocument::getName)) // 按名称排序
+                    .map(child -> formatChildRepresentation(workerId, child)).toList();
+            StringBuilder combinedRepresentation = new StringBuilder();
+            for (String rep : childRepresentations) {
+                if (rep == null) {
+                    return false;
+                }
+                if (!rep.isBlank()) {
+                    combinedRepresentation.append(rep).append(";");
+                }
+            }
+            newCalculatedEtag = hashString(combinedRepresentation.toString());
+        }
+
+        if (!newCalculatedEtag.equals(oldEtag)) {
+            Query updateQuery = Query.query(Criteria.where("_id").is(folderDoc.getId()));
+            // 如果使用乐观锁，这里也需要检查版本号
+            // query.addCriteria(Criteria.where("_version").is(folderDoc.getVersion()));
+            Update update = new Update().set(Constants.ETAG, newCalculatedEtag);
+            // update.inc("_version", 1);
+            UpdateResult result = mongoTemplate.updateFirst(updateQuery, update, FileDocument.class);
+
+            if (result.getModifiedCount() > 0) {
+                log.info("[Worker {}] Folder ETag updated for {}: {} -> {}", workerId, folderPath, oldEtag, newCalculatedEtag);
+                return true;
+            } else {
+                // 可能在计算和写入之间，该文档被其他worker处理了（如果ETag已更新为相同值）或版本冲突
+                log.warn("[Worker {}] Folder ETag for {} was calculated as {} (old: {}), but DB update reported no modification. Possible race condition or already up-to-date.", workerId, folderPath, newCalculatedEtag, oldEtag);
+                return false; // 没有实际修改数据库
+            }
+        }
+        log.debug("[Worker {}] Folder ETag for {} did not change (calculated: {}, current: {}).", workerId, folderPath, newCalculatedEtag, oldEtag);
+        return false;
+    }
+
+    private void handleProcessingError(String docId, String folderPath, Exception e, String workerId) {
+        Query query = Query.query(Criteria.where("_id").is(docId));
+        FileDocument failedDoc = mongoTemplate.findOne(query, FileDocument.class);
+        int attempts = (failedDoc != null && failedDoc.getEtagUpdateFailedAttempts() != null) ? failedDoc.getEtagUpdateFailedAttempts() + 1 : 1;
+
+        Update update = new Update().set(Constants.LAST_ETAG_UPDATE_ERROR_FIELD, e.getMessage().substring(0, Math.min(e.getMessage().length(), 1000))) // 限制错误信息长度
+                .set(Constants.ETAG_UPDATE_FAILED_ATTEMPTS_FIELD, attempts);
+
+        if (attempts >= MAX_ETAG_UPDATE_ATTEMPTS) {
+            update.set(Constants.NEEDS_ETAG_UPDATE_FIELD, false); // 停止重试
+            log.error("[Worker {}] Folder {} ETag update failed after {} attempts. Giving up.", workerId, folderPath, attempts);
+        } else {
+            // 可以选择让它下次被重新拾取 (needsEtagUpdate 保持 true)
+            log.warn("[Worker {}] Folder {} ETag update failed (attempt {}). Will retry.", workerId, folderPath, attempts);
+        }
+        mongoTemplate.updateFirst(query, update, FileDocument.class);
+    }
+
+    private static String getParentDbPath(String currentFolderPath) {
+        if (CharSequenceUtil.isBlank(currentFolderPath) || currentFolderPath.equals("/")) {
+            return null; // 根目录没有父目录，或者已经是根
+        }
+        // 确保路径以 "/" 开头和结尾
+        if (!currentFolderPath.endsWith("/") || !currentFolderPath.startsWith("/")) {
+            return null;
+        }
+
+        // 移除尾部的斜杠以便找到倒数第二个斜杠
+        String pathWithoutTrailingSlash = currentFolderPath.substring(0, currentFolderPath.length() - 1);
+        int lastSlashIndex = pathWithoutTrailingSlash.lastIndexOf('/');
+
+        if (lastSlashIndex < 0) { // 例如 "folder/" 这种情况，父路径是 "/"
+            return "/";
+        }
+        if (lastSlashIndex == 0) { // 例如 "/folder/", 父路径是 "/"
+            return "/";
+        }
+        // 例如 "/A/B/", pathWithoutTrailingSlash = "/A/B", lastSlashIndex 在 A 后面
+        // substring(0, lastSlashIndex + 1) 会得到 "/A/"
+        return pathWithoutTrailingSlash.substring(0, lastSlashIndex + 1);
+    }
+
+    private String formatChildRepresentation(String workerId, FileDocument child) {
+        if (child.getEtag() == null) {
+            String username = userService.getUserNameById(child.getUserId());
+            Path path = Paths.get(fileProperties.getRootDir(), username, child.getPath(), child.getName());
+            if (Files.exists(path)) {
+                // 只对文件执行此操作
+                if (!child.getIsFolder()) {
+                    return updateFileEtagAndMarkParentAsync(username, path.toFile());
+                } else {
+                    // 子文件夹ETag为null，父文件夹本次计算中止
+                    return null;
+                }
+            } else {
+                log.warn("[Worker {}] File {} does not exist. Skipping.", workerId, path);
+                return "";
+            }
+        }
+        return child.getName() + ":" + child.getEtag();
+    }
+
+    private String hashString(String input) throws NoSuchAlgorithmException {
path: src/main/java/com/jmal/clouddisk/lucene/EtagService.java
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The `hashString` method is declared with `throws NoSuchAlgorithmException`. However, Hutool's `SecureUtil.sha256(String)` typically wraps `NoSuchAlgorithmException` into a `cn.hutool.crypto.CryptoException` (which is a runtime exception) if the algorithm is not found. Standard algorithms like "SHA-256" are generally guaranteed to be present in the JVM.

If `SecureUtil.sha256` does not throw a checked `NoSuchAlgorithmException` directly:
1. The `throws NoSuchAlgorithmException` clause on `hashString` might be misleading.
2. Callers like `calculateAndUpdateSingleFolderEtagInternal` (which also declares `throws NoSuchAlgorithmException` due to this) and `handleNewFolderCreationAsync` (which catches `NoSuchAlgorithmException`) might be handling an exception type that isn't directly thrown by `SecureUtil.sha256`.

Could you clarify the expected exception behavior from `SecureUtil.sha256`? If it throws `CryptoException`, it might be better to either:
- Let `CryptoException` (as a runtime exception) propagate.
- Catch `CryptoException` in `hashString` and, if its cause is `NoSuchAlgorithmException`, then re-throw `NoSuchAlgorithmException` to match the current method signature.
This would make the exception handling chain clearer and more accurate.
created_at: 2025-05-22T09:54:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,520 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    private final AtomicBoolean processingScheduled = new AtomicBoolean(false);
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
+        }
+        ThreadUtil.execute(() -> {
+            Query queryNoEtagQuery = new Query();
+            queryNoEtagQuery.addCriteria(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(true));
+            long countOfFoldersWithoutEtag = mongoTemplate.count(queryNoEtagQuery, FileDocument.class);
+            if (countOfFoldersWithoutEtag > 0) {
+                log.info("{} folders found without ETag. Marking them for ETag calculation.", countOfFoldersWithoutEtag);
+                Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+                mongoTemplate.updateMulti(queryNoEtagQuery, update, FileDocument.class);
+            }
+            processRootFolderFiles();
+            log.info("Initial ETag setup finished. Ensuring ETag processing worker is active if needed.");
+            ensureProcessingMarkedFolders();
+        });
+    }
+
+    /**
+     * 当文件内容发生变化（上传/修改/重命名/删除等）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    public void updateFileEtagAsync(String username, File file) {
+        if (!FileUtil.exist(file)) {
+            return;
+        }
+        if (FileUtil.isFile(file)) {
+            updateFileEtagAndMarkParentAsync(username, file);
+        }
+    }
+
+    /**
+     * 确保 ETag 处理任务已调度或正在运行。
+     * 如果当前没有任务在执行或计划执行，则提交一个新任务到执行器。
+     * 这个方法是幂等的，多次调用（如果任务已调度）不会产生副作用。
+     */
+    private void ensureProcessingMarkedFolders() {
+        // 使用 compareAndSet 来原子性地检查并设置标志
+        // 如果 processingScheduled 为 false，则将其设置为 true 并执行提交逻辑
+        if (processingScheduled.compareAndSet(false, true)) {
+            log.info("ETag processing task for marked folders is being scheduled.");
+            executorMarkedFoldersService.execute(() -> {
+                String workerId = "markedFoldersWorker-" + Thread.currentThread().getName();
+                try {
+                    // 调用实际的循环处理逻辑
+                    processMarkedFoldersLoop(workerId);
+                } finally {
+                    // 当任务完成（无论是正常结束还是异常结束）后，
+                    // 将标志重置为 false，允许后续的 ensureProcessingMarkedFolders 调用再次调度任务。
+                    processingScheduled.set(false);
+                    log.debug("[Worker {}] ETag processing task finished. Resetting schedule flag.", workerId);
+
+                    // 关键：检查在本次处理运行期间是否有新的文件夹被标记
+                    // 如果有，则再次尝试调度，确保它们得到处理
+                    if (hasMoreMarkedFoldersInDb()) {
+                        log.info("[Worker {}] New folders were marked during or after the last processing run. Re-triggering ETag processing.", workerId);
+                        ensureProcessingMarkedFolders();
+                    } else {
+                        log.info("[Worker {}] No more marked folders found after processing run. ETag worker will rest until new marks.", workerId);
+                    }
+                }
+            });
+        } else {
+            // 如果 processingScheduled 已经是 true，说明任务已在队列中或正在运行
+            log.debug("ETag processing task is already scheduled or running. This trigger call is a no-op.");
+        }
+    }
+
+    /**
+     * 检查数据库中是否还有需要更新ETag的文件夹。
+     */
+    private boolean hasMoreMarkedFoldersInDb() {
+        Query query = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true));
+        query.limit(1); // 只需要知道是否存在，不需要完整计数
+        return mongoTemplate.exists(query, FileDocument.class);
+    }
+
+    /**
+     * 当文件内容发生变化（上传新版本/修改）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    private String updateFileEtagAndMarkParentAsync(String username, File file) {
+        try {
+            if (!FileUtil.exist(file) || !FileUtil.isFile(file)) {
+                return null;
+            }
+            String newEtag = SecureUtil.sha256(file);
+
+            String fileName = file.getName();
+            String relativePath = getDbPath(username, file);
+            if (relativePath == null) {
+                return null;
+            }
+            String userId = userService.getUserIdByUserName(username);
+            Query query = getQueryByPath(userId, relativePath, fileName);
+            query.fields().include(Constants.ETAG);
+            FileDocument currentFileDoc = mongoTemplate.findOne(query, FileDocument.class);
+            String oldEtag = (currentFileDoc != null) ? currentFileDoc.getEtag() : null;
+
+            if (newEtag != null && !newEtag.equals(oldEtag)) {
+                Update update = new Update().set(Constants.ETAG, newEtag);
+                // 如果有版本控制字段，这里也应该更新
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("File ETag updated for {}: {} -> {}", relativePath, oldEtag, newEtag);
+
+                // 标记父文件夹需要更新ETag
+                markFolderForEtagUpdate(userId, relativePath);
+            } else {
+                log.debug("File ETag for {} did not change or calculation failed.", file.getAbsoluteFile());
+            }
+            return newEtag;
+        } catch (Exception e) {
+            log.error("Error updating file ETag for {}: {}", file.getAbsoluteFile(), e.getMessage(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 获取改文件在数据库中的路径(PATH_FIELD)字段的值
+     *
+     * @param username     用户名
+     * @param physicalFile 文件
+     */
+    private String getDbPath(String username, File physicalFile) {
+        Path physicalPath = physicalFile.toPath();
+        Path userRootPath = Paths.get(fileProperties.getRootDir(), username);
+
+        if (!physicalPath.startsWith(userRootPath)) {
+            return null;
+        }
+
+        Path relativeNioPath = userRootPath.relativize(physicalPath);
+
+        if (relativeNioPath.toString().isEmpty()) {
+            return null;
+        }
+
+        if (relativeNioPath.getNameCount() <= 1 || relativeNioPath.getParent() == null) {
+            return "/";
+        }
+        return "/" + relativeNioPath.getParent().toString().replace(File.separatorChar, '/') + "/";
+    }
+
+    /**
+     * 当新文件夹创建或重命名时调用。
+     * 立即设置新文件夹的初始ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param username          用户名
+     * @param physicalNewFolder 新创建的文件夹的完整路径
+     */
+    public void handleNewFolderCreationAsync(String username, File physicalNewFolder) {
+        if (!FileUtil.exist(physicalNewFolder) || !FileUtil.isDirectory(physicalNewFolder)) {
+            return;
+        }
+        String fileName = physicalNewFolder.getName();
+        String relativePath = getDbPath(username, physicalNewFolder);
+        if (relativePath == null) {
+            return;
+        }
+        try {
+            String userId = userService.getUserIdByUserName(username);
+
+            // 查询当前文件夹下是否有内容, 由于是异步处理，刚创建的文件夹可能还没来得及设置etag, 其下就有新文件了, 所有需要查询一次
+            String currentFolderNormalizedPath = relativePath + fileName + "/";
path: src/main/java/com/jmal/clouddisk/lucene/EtagService.java
user: login: gemini-code-assist[bot]
body: ![high](https://img.shields.io/badge/priority-high-orange)

In `handleNewFolderCreationAsync`, when constructing `currentFolderNormalizedPath`, if `relativePath` is `/` (meaning the new folder is at the user's root), `currentFolderNormalizedPath` becomes `//${fileName}/` due to `relativePath + fileName + "/"`.

However, children of a root-level folder (e.g., `/newFolder`) are typically stored with `path: "/newFolder/"` in `FileDocument`. A query using `Criteria.where(Constants.PATH_FIELD).is("//newFolder/")` would likely not match children stored with `path: "/newFolder/"`.

This could lead to `hasChildren` being incorrectly `false`, causing an initial ETag for an empty folder to be set even if children exist (or are added before this async task runs), and subsequently, the parent folder's ETag might not be updated correctly.

Consider adjusting the construction of `currentFolderNormalizedPath` to avoid the double slash for root paths. For example:

```java
String currentFolderNormalizedPath;
if ("/".equals(relativePath)) {
    currentFolderNormalizedPath = "/" + fileName + "/";
} else {
    currentFolderNormalizedPath = relativePath + fileName + "/";
}
```
This logic is used correctly in `calculateAndUpdateSingleFolderEtagInternal` (line 407) where `folderDoc.getPath()` would be `/` for a root folder.

```java
            String currentFolderNormalizedPath;
            if ("/".equals(relativePath)) {
                currentFolderNormalizedPath = "/" + fileName + "/";
            } else {
                currentFolderNormalizedPath = relativePath + fileName + "/";
            }
```
created_at: 2025-05-22T10:02:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,520 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    private final AtomicBoolean processingScheduled = new AtomicBoolean(false);
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
path: src/main/java/com/jmal/clouddisk/lucene/EtagService.java
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The `executorMarkedFoldersService` is initialized as a single-threaded executor with a queue size of 1 (`ThreadUtil.newFixedExecutor(1, 1, ...)`).

Could this become a performance bottleneck if a large number of folders are marked for ETag update simultaneously, or if the `processMarkedFoldersLoop` (which processes batches of 16 folders) takes a significant amount of time for each batch? While the `ensureProcessingMarkedFolders` logic correctly re-triggers processing if more items are marked, the actual ETag calculation for folders is serialized through this single worker thread.
created_at: 2025-05-22T10:02:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,520 @@
+package com.jmal.clouddisk.lucene;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.text.CharSequenceUtil;
+import cn.hutool.core.thread.ThreadUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.jmal.clouddisk.config.FileProperties;
+import com.jmal.clouddisk.model.FileDocument;
+import com.jmal.clouddisk.service.Constants;
+import com.jmal.clouddisk.service.IUserService;
+import com.mongodb.client.result.UpdateResult;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.domain.Sort;
+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.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * EtagService
+ * 文件和文件夹的 ETag 更新逻辑
+ * 异步处理文件变化和删除的 ETag 更新
+ * 后台 Worker 处理标记为需要更新 ETag 的文件夹
+ * ETag 计算和更新的内部方法
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class EtagService {
+
+    private final MongoTemplate mongoTemplate;
+
+    private final FileProperties fileProperties;
+
+    private final IUserService userService;
+
+    private static final String EMPTY_FOLDER_ETAG_BASE_STRING = "EMPTY_FOLDER_REPRESENTATION_MONGO_V2";
+    private static final int MAX_ETAG_UPDATE_ATTEMPTS = 5; // 最大失败重试次数
+
+    private final AtomicBoolean processingScheduled = new AtomicBoolean(false);
+
+    /**
+     * 处理文件夹Etag的线程池
+     */
+    private ExecutorService executorMarkedFoldersService;
+
+    @PostConstruct
+    public void init() {
+        if (executorMarkedFoldersService == null) {
+            executorMarkedFoldersService = ThreadUtil.newFixedExecutor(1, 1, "EtagWorker-", false);
+        }
+        ThreadUtil.execute(() -> {
+            Query queryNoEtagQuery = new Query();
+            queryNoEtagQuery.addCriteria(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(true));
+            long countOfFoldersWithoutEtag = mongoTemplate.count(queryNoEtagQuery, FileDocument.class);
+            if (countOfFoldersWithoutEtag > 0) {
+                log.info("{} folders found without ETag. Marking them for ETag calculation.", countOfFoldersWithoutEtag);
+                Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+                mongoTemplate.updateMulti(queryNoEtagQuery, update, FileDocument.class);
+            }
+            processRootFolderFiles();
+            log.info("Initial ETag setup finished. Ensuring ETag processing worker is active if needed.");
+            ensureProcessingMarkedFolders();
+        });
+    }
+
+    /**
+     * 当文件内容发生变化（上传/修改/重命名/删除等）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    public void updateFileEtagAsync(String username, File file) {
+        if (!FileUtil.exist(file)) {
+            return;
+        }
+        if (FileUtil.isFile(file)) {
+            updateFileEtagAndMarkParentAsync(username, file);
+        }
+    }
+
+    /**
+     * 确保 ETag 处理任务已调度或正在运行。
+     * 如果当前没有任务在执行或计划执行，则提交一个新任务到执行器。
+     * 这个方法是幂等的，多次调用（如果任务已调度）不会产生副作用。
+     */
+    private void ensureProcessingMarkedFolders() {
+        // 使用 compareAndSet 来原子性地检查并设置标志
+        // 如果 processingScheduled 为 false，则将其设置为 true 并执行提交逻辑
+        if (processingScheduled.compareAndSet(false, true)) {
+            log.info("ETag processing task for marked folders is being scheduled.");
+            executorMarkedFoldersService.execute(() -> {
+                String workerId = "markedFoldersWorker-" + Thread.currentThread().getName();
+                try {
+                    // 调用实际的循环处理逻辑
+                    processMarkedFoldersLoop(workerId);
+                } finally {
+                    // 当任务完成（无论是正常结束还是异常结束）后，
+                    // 将标志重置为 false，允许后续的 ensureProcessingMarkedFolders 调用再次调度任务。
+                    processingScheduled.set(false);
+                    log.debug("[Worker {}] ETag processing task finished. Resetting schedule flag.", workerId);
+
+                    // 关键：检查在本次处理运行期间是否有新的文件夹被标记
+                    // 如果有，则再次尝试调度，确保它们得到处理
+                    if (hasMoreMarkedFoldersInDb()) {
+                        log.info("[Worker {}] New folders were marked during or after the last processing run. Re-triggering ETag processing.", workerId);
+                        ensureProcessingMarkedFolders();
+                    } else {
+                        log.info("[Worker {}] No more marked folders found after processing run. ETag worker will rest until new marks.", workerId);
+                    }
+                }
+            });
+        } else {
+            // 如果 processingScheduled 已经是 true，说明任务已在队列中或正在运行
+            log.debug("ETag processing task is already scheduled or running. This trigger call is a no-op.");
+        }
+    }
+
+    /**
+     * 检查数据库中是否还有需要更新ETag的文件夹。
+     */
+    private boolean hasMoreMarkedFoldersInDb() {
+        Query query = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true));
+        query.limit(1); // 只需要知道是否存在，不需要完整计数
+        return mongoTemplate.exists(query, FileDocument.class);
+    }
+
+    /**
+     * 当文件内容发生变化（上传新版本/修改）时调用。
+     * 立即更新文件的ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param file 发生变化的文件
+     */
+    private String updateFileEtagAndMarkParentAsync(String username, File file) {
+        try {
+            if (!FileUtil.exist(file) || !FileUtil.isFile(file)) {
+                return null;
+            }
+            String newEtag = SecureUtil.sha256(file);
+
+            String fileName = file.getName();
+            String relativePath = getDbPath(username, file);
+            if (relativePath == null) {
+                return null;
+            }
+            String userId = userService.getUserIdByUserName(username);
+            Query query = getQueryByPath(userId, relativePath, fileName);
+            query.fields().include(Constants.ETAG);
+            FileDocument currentFileDoc = mongoTemplate.findOne(query, FileDocument.class);
+            String oldEtag = (currentFileDoc != null) ? currentFileDoc.getEtag() : null;
+
+            if (newEtag != null && !newEtag.equals(oldEtag)) {
+                Update update = new Update().set(Constants.ETAG, newEtag);
+                // 如果有版本控制字段，这里也应该更新
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("File ETag updated for {}: {} -> {}", relativePath, oldEtag, newEtag);
+
+                // 标记父文件夹需要更新ETag
+                markFolderForEtagUpdate(userId, relativePath);
+            } else {
+                log.debug("File ETag for {} did not change or calculation failed.", file.getAbsoluteFile());
+            }
+            return newEtag;
+        } catch (Exception e) {
+            log.error("Error updating file ETag for {}: {}", file.getAbsoluteFile(), e.getMessage(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 获取改文件在数据库中的路径(PATH_FIELD)字段的值
+     *
+     * @param username     用户名
+     * @param physicalFile 文件
+     */
+    private String getDbPath(String username, File physicalFile) {
+        Path physicalPath = physicalFile.toPath();
+        Path userRootPath = Paths.get(fileProperties.getRootDir(), username);
+
+        if (!physicalPath.startsWith(userRootPath)) {
+            return null;
+        }
+
+        Path relativeNioPath = userRootPath.relativize(physicalPath);
+
+        if (relativeNioPath.toString().isEmpty()) {
+            return null;
+        }
+
+        if (relativeNioPath.getNameCount() <= 1 || relativeNioPath.getParent() == null) {
+            return "/";
+        }
+        return "/" + relativeNioPath.getParent().toString().replace(File.separatorChar, '/') + "/";
+    }
+
+    /**
+     * 当新文件夹创建或重命名时调用。
+     * 立即设置新文件夹的初始ETag，并异步标记其父文件夹需要更新ETag。
+     *
+     * @param username          用户名
+     * @param physicalNewFolder 新创建的文件夹的完整路径
+     */
+    public void handleNewFolderCreationAsync(String username, File physicalNewFolder) {
+        if (!FileUtil.exist(physicalNewFolder) || !FileUtil.isDirectory(physicalNewFolder)) {
+            return;
+        }
+        String fileName = physicalNewFolder.getName();
+        String relativePath = getDbPath(username, physicalNewFolder);
+        if (relativePath == null) {
+            return;
+        }
+        try {
+            String userId = userService.getUserIdByUserName(username);
+
+            // 查询当前文件夹下是否有内容, 由于是异步处理，刚创建的文件夹可能还没来得及设置etag, 其下就有新文件了, 所有需要查询一次
+            String currentFolderNormalizedPath = relativePath + fileName + "/";
+            Query childrenQuery = Query.query(Criteria.where(Constants.PATH_FIELD).is(currentFolderNormalizedPath));
+            childrenQuery.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+            boolean hasChildren = mongoTemplate.exists(childrenQuery, FileDocument.class);
+            if (!hasChildren) {
+                // 如果没有内容，则设置初始ETag
+                String initialEtag = SecureUtil.sha256(EMPTY_FOLDER_ETAG_BASE_STRING);
+                Query query = getQueryByPath(userId, relativePath, fileName);
+                Update update = new Update().set(Constants.ETAG, initialEtag).set(Constants.NEEDS_ETAG_UPDATE_FIELD, false);
+                mongoTemplate.updateFirst(query, update, FileDocument.class);
+                log.info("Initial ETag set for new folder {}: {}", relativePath, initialEtag);
+            }
+            // 标记父文件夹需要更新ETag
+            markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+        } catch (DataAccessException e) {
+            log.error("Database error setting initial ETag for folder {}: {}", relativePath, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 通过 userId 和 dbPath 还有filename 可获取到唯一的文件/文件夹
+     *
+     * @param userId   用户id
+     * @param dbPath   dbPath
+     * @param filename 文件名
+     */
+    private static Query getQueryByPath(String userId, String dbPath, String filename) {
+        Query query = Query.query(Criteria.where(IUserService.USER_ID).is(userId));
+        query.addCriteria(Criteria.where(Constants.PATH_FIELD).is(dbPath));
+        query.addCriteria(Criteria.where(Constants.FILENAME_FIELD).is(filename));
+        return query;
+    }
+
+    /**
+     * 当文件或文件夹被删除时调用。
+     * 异步标记其原父文件夹需要更新ETag。
+     *
+     * @param username 用户名
+     * @param file     被删除的文件
+     */
+    public void handleItemDeletionAsync(String username, File file) {
+        log.info("Handling ETag update for parent of deleted item: {}", file.getAbsoluteFile());
+        String relativePath = getDbPath(username, file);
+        if (relativePath == null) {
+            return;
+        }
+
+        // 标记父文件夹需要更新ETag
+        markFolderForEtagUpdate(userService.getUserIdByUserName(username), relativePath);
+    }
+
+    /**
+     * 处理根目录下文件的ETag
+     */
+    private void processRootFolderFiles() {
+        boolean run = true;
+        while (run) {
+            Query findQuery = Query.query(Criteria.where(Constants.ETAG).exists(false).and(Constants.IS_FOLDER).is(false).and(Constants.PATH_FIELD).is("/")).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG);
+            long count = mongoTemplate.count(findQuery, FileDocument.class);
+            if (count == 0) {
+                run = false;
+            }
+
+            // 获取根据下所有的文件
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                continue;
+            }
+            log.debug("Found {} files marked for ETag update. Processing...", tasks.size());
+            for (FileDocument fileDoc : tasks) {
+                String username = userService.getUserNameById(fileDoc.getUserId());
+                Path path = Paths.get(fileProperties.getRootDir(), username, fileDoc.getPath(), fileDoc.getName());
+
+                if (Files.exists(path)) {
+                    updateFileEtagAndMarkParentAsync(username, path.toFile());
+                }
+            }
+        }
+    }
+
+    /**
+     * 实际处理标记为需要更新ETag的文件夹的循环。
+     * 此方法由 ensureProcessingMarkedFolders 提交的任务在 EtagWorker 线程中执行。
+     *
+     * @param workerId 当前worker的唯一标识
+     */
+    private void processMarkedFoldersLoop(String workerId) {
+        boolean run = true;
+        while (run && !Thread.currentThread().isInterrupted()) {
+            Query findQuery = Query.query(Criteria.where(Constants.NEEDS_ETAG_UPDATE_FIELD).is(true).and(Constants.IS_FOLDER).is(true)).with(Sort.by(Sort.Direction.ASC, Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD)).limit(16);
+            findQuery.fields().include(Constants.PATH_FIELD, Constants.FILENAME_FIELD, IUserService.USER_ID, Constants.ETAG, "_id");
+
+
+            List<FileDocument> tasks = mongoTemplate.find(findQuery, FileDocument.class);
+
+            if (tasks.isEmpty()) {
+                run = false;
+                continue;
+            }
+            log.info("[Worker {}] Found {} folders marked for ETag update. Processing...", workerId, tasks.size());
+
+            for (FileDocument folderDoc : tasks) {
+                if (Thread.currentThread().isInterrupted()) {
+                    log.warn("[Worker {}] ETag processing loop was interrupted.", workerId);
+                    run = false; // 尊重中断信号
+                    break;
+                }
+
+                String folderPath = folderDoc.getPath();
+                String docId = folderDoc.getId();
+                String userId = folderDoc.getUserId();
+                log.debug("[Worker {}] Processing ETag for folder: {} (ID: {})", workerId, folderPath, docId);
+
+                try {
+                    boolean etagActuallyChanged = calculateAndUpdateSingleFolderEtagInternal(folderDoc, workerId);
+
+                    Query clearMarkQuery = Query.query(Criteria.where("_id").is(docId));
+                    Update clearMarkUpdate = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, false).unset(Constants.ETAG_UPDATE_FAILED_ATTEMPTS_FIELD).unset(Constants.LAST_ETAG_UPDATE_ERROR_FIELD);
+                    mongoTemplate.updateFirst(clearMarkQuery, clearMarkUpdate, FileDocument.class);
+                    log.debug("[Worker {}] Cleared ETag update mark for folder: {}", workerId, folderPath);
+
+                    if (etagActuallyChanged) {
+                        // 如果ETag变化了，标记其父文件夹
+                        markFolderForEtagUpdate(userId, folderPath);
+                    }
+                } catch (Exception e) {
+                    log.error("[Worker {}] Critical error processing ETag for folder {}: {}", workerId, folderPath, e.getMessage(), e);
+                    handleProcessingError(docId, folderPath, e, workerId);
+                }
+            }
+            log.debug("[Worker {}] Finished processing batch of {} folders.", workerId, tasks.size());
+        }
+        log.info("[Worker {}] ETag processing loop finished or paused.", workerId);
+    }
+
+    /**
+     * 标记指定文件夹需要ETag更新
+     *
+     * @param userId            用户ID
+     * @param currentFolderPath 当前文件夹路径
+     */
+    private void markFolderForEtagUpdate(String userId, String currentFolderPath) {
+        String parentFolderPath = getParentDbPath(currentFolderPath);
+        if (parentFolderPath == null) {
+            return;
+        }
+        String folderName = Paths.get(currentFolderPath).getFileName().toString();
+        Query query = getQueryByPath(userId, parentFolderPath, folderName);
+        Update update = new Update().set(Constants.NEEDS_ETAG_UPDATE_FIELD, true).currentDate(Constants.LAST_ETAG_UPDATE_REQUEST_AT_FIELD);
+
+        try {
+            UpdateResult result = mongoTemplate.updateFirst(query, update, FileDocument.class);
+            if (result.getModifiedCount() > 0 || result.getMatchedCount() > 0) { // 即使已经是true也更新时间戳
+                log.info("Marked folder for ETag update: {}", currentFolderPath);
+                ensureProcessingMarkedFolders();
+            } else {
+                log.warn("Folder not found to mark for ETag update: {}", currentFolderPath);
+            }
+        } catch (DataAccessException e) {
+            log.error("DB error marking folder for ETag update {}: {}", currentFolderPath, e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 内部方法：计算并更新单个文件夹的ETag。
+     * 被 processMarkedFolders 调用。
+     *
+     * @return true 如果ETag实际发生了变化并被更新
+     */
+    private boolean calculateAndUpdateSingleFolderEtagInternal(FileDocument folderDoc, String workerId) {
+        String folderPath = folderDoc.getPath();
+        String oldEtag = folderDoc.getEtag();
+        String userId = folderDoc.getUserId();
+        String newCalculatedEtag;
+
+        String currentFolderNormalizedPath = folderDoc.getPath() + folderDoc.getName() + "/";
+        Query childrenQuery = Query.query(Criteria.where(Constants.PATH_FIELD).is(currentFolderNormalizedPath));
+        childrenQuery.addCriteria(Criteria.where(IUserService.USER_ID).is(userId));
+        childrenQuery.fields().include(Constants.FILENAME_FIELD).include(Constants.ETAG).include(Constants.IS_FOLDER).include(IUserService.USER_ID).include(Constants.PATH_FIELD);
+        List<FileDocument> children = mongoTemplate.find(childrenQuery, FileDocument.class);
+
+        if (children.isEmpty()) {
+            newCalculatedEtag = SecureUtil.sha256(EMPTY_FOLDER_ETAG_BASE_STRING);
+        } else {
+            List<String> childRepresentations = children.stream().sorted(Comparator.comparing(FileDocument::getName)) // 按名称排序
+                    .map(child -> formatChildRepresentation(workerId, child)).toList();
+            StringBuilder combinedRepresentation = new StringBuilder();
+            for (String rep : childRepresentations) {
+                if (rep == null) {
+                    return false;
path: src/main/java/com/jmal/clouddisk/lucene/EtagService.java
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

In `calculateAndUpdateSingleFolderEtagInternal`, if `formatChildRepresentation` returns `null` (which happens if a child is a folder and its ETag is `null`, see line 503), the entire ETag calculation for the current `folderDoc` is aborted by `return false;`.

While the sub-folder might get its ETag calculated eventually, this behavior means that a parent folder's ETag update could be stalled or repeatedly attempted if one of its sub-folders is slow to get its ETag or consistently fails its ETag calculation. 

Could this lead to inefficiencies or scenarios where parent ETags remain outdated for extended periods? Perhaps consider if there's a strategy to either: 
1. Log a more specific warning when this happens. 
2. Allow the parent ETag to be calculated based on available child ETags, possibly noting that it's a partial calculation. 
3. Ensure a stricter processing order (though this might be complex with the current marking system).
created_at: 2025-05-22T10:02:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: jamebal
created_at: 2025-05-22T09:41:58Z
body: /gemini review
author_association: OWNER
user: login: jamebal
created_at: 2025-05-22T09:52:06Z
body: /gemini review
author_association: OWNER
user: login: jamebal
created_at: 2025-05-22T09:59:59Z
body: /gemini review
author_association: OWNER