# Prompt - 文件存储和解压处理实现 ## 输入引用 引用相关的 docs 文档链接: - [REQ-010-7.md](../requirements/REQ-010-7.md) - 文件存储和解压处理需求文档 - [0016-file-storage-and-extract.md](../decisions/adr/0016-file-storage-and-extract.md) - 文件存储和解压处理架构决策 - [REQ-010-6.md](../requirements/REQ-010-6.md) - 元数据拉取核心功能需求文档 - [file/index.md](../api-docs/file/index.md) - 文件模块 API 文档索引(唯一真源) ## Context Maps 强制列出本次 Prompt 依赖的 Canvas 文件: - [Authentication.canvas](../../Authentication.canvas) - 项目架构视觉化展示 - **相关节点**: [集成核心](node_integration_core) - 提供与Salesforce的各种连接方式 ## 目标 实现文件存储和解压处理功能,包括: 1. 实现 Zip 文件存储功能,支持本地文件系统和 OSS 存储 2. 实现文件解压功能,支持大文件处理和流式解压 3. 实现文件路径管理,支持路径规范化和安全性验证 4. 实现存储空间管理,支持存储空间监控和告警 5. 实现文件清理功能,支持过期文件自动清理和手动清理 ## 输出格式 ### 代码结构 ``` datai-salesforce-metadata/ ├── src/main/java/com/datai/salesforce/metadata/file/ │ ├── service/ │ │ ├── IFileStorageService.java # 文件存储服务接口 │ │ ├── LocalStorageService.java # 本地文件系统存储实现 │ │ ├── OssStorageService.java # OSS 存储实现 │ │ ├── IFileExtractService.java # 文件解压服务接口 │ │ ├── FileExtractServiceImpl.java # 文件解压服务实现 │ │ ├── IPathValidator.java # 路径验证接口 │ │ ├── PathValidator.java # 路径验证实现 │ │ ├── IStorageMonitorService.java # 存储空间监控服务接口 │ │ ├── StorageMonitorServiceImpl.java # 存储空间监控服务实现 │ │ ├── IFileCleanupService.java # 文件清理服务接口 │ │ └── FileCleanupServiceImpl.java # 文件清理服务实现 │ ├── dto/ │ │ ├── FileStorageRequest.java # 文件存储请求 │ │ ├── FileStorageResponse.java # 文件存储响应 │ │ ├── FileExtractRequest.java # 文件解压请求 │ │ ├── FileExtractResponse.java # 文件解压响应 │ │ ├── ExtractProgressCallback.java # 解压进度回调 │ │ ├── StorageSpaceInfo.java # 存储空间信息 │ │ ├── FileCleanupRequest.java # 文件清理请求 │ │ └── FileCleanupResponse.java # 文件清理响应 │ ├── enums/ │ │ ├── StorageType.java # 存储类型枚举 │ │ └── ExtractStatus.java # 解压状态枚举 │ ├── config/ │ │ ├── FileStorageConfig.java # 文件存储配置 │ │ └── OssConfig.java # OSS 配置 │ ├── exception/ │ │ ├── FileStorageException.java # 文件存储异常 │ │ ├── FileExtractException.java # 文件解压异常 │ │ └── PathSecurityException.java # 路径安全异常 │ └── util/ │ ├── FileUtil.java # 文件工具类 │ └── ZipUtil.java # Zip 工具类 ├── src/main/resources/ │ ├── application.yml # 应用配置 │ └── logback-spring.xml # 日志配置 └── src/test/java/com/datai/salesforce/metadata/file/ ├── service/ │ ├── LocalStorageServiceTest.java # 本地存储服务测试 │ ├── FileExtractServiceTest.java # 文件解压服务测试 │ ├── PathValidatorTest.java # 路径验证测试 │ ├── StorageMonitorServiceTest.java # 存储监控服务测试 │ └── FileCleanupServiceTest.java # 文件清理服务测试 └── util/ ├── FileUtilTest.java # 文件工具类测试 └── ZipUtilTest.java # Zip 工具类测试 ``` ### 代码示例 #### 1. IFileStorageService.java ```java package com.datai.salesforce.metadata.file.service; import java.io.InputStream; import java.nio.file.Path; public interface IFileStorageService { String store(String fileName, InputStream inputStream, String subPath); InputStream retrieve(String filePath); boolean delete(String filePath); boolean exists(String filePath); Path getStoragePath(String subPath); } ``` #### 2. LocalStorageService.java ```java package com.datai.salesforce.metadata.file.service; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import java.io.*; import java.nio.file.*; @Slf4j @Service @ConditionalOnProperty(name = "file.storage.type", havingValue = "local") public class LocalStorageService implements IFileStorageService { @Value("${file.storage.local.base-path}") private String basePath; @Override public String store(String fileName, InputStream inputStream, String subPath) { try { Path targetPath = Paths.get(basePath, subPath); Files.createDirectories(targetPath); Path filePath = targetPath.resolve(fileName); Files.copy(inputStream, filePath, StandardCopyOption.REPLACE_EXISTING); log.info("File stored successfully: {}", filePath); return filePath.toString(); } catch (IOException e) { log.error("Failed to store file: {}", fileName, e); throw new RuntimeException("Failed to store file", e); } } @Override public InputStream retrieve(String filePath) { try { return Files.newInputStream(Paths.get(filePath)); } catch (IOException e) { log.error("Failed to retrieve file: {}", filePath, e); throw new RuntimeException("Failed to retrieve file", e); } } @Override public boolean delete(String filePath) { try { boolean deleted = Files.deleteIfExists(Paths.get(filePath)); log.info("File deleted: {}, result: {}", filePath, deleted); return deleted; } catch (IOException e) { log.error("Failed to delete file: {}", filePath, e); return false; } } @Override public boolean exists(String filePath) { return Files.exists(Paths.get(filePath)); } @Override public Path getStoragePath(String subPath) { return Paths.get(basePath, subPath); } } ``` #### 3. IFileExtractService.java ```java package com.datai.salesforce.metadata.file.service; import java.io.InputStream; public interface IFileExtractService { void extract(InputStream zipInputStream, String targetPath); void extract(InputStream zipInputStream, String targetPath, ExtractProgressCallback callback); void extract(InputStream zipInputStream, String targetPath, ExtractProgressCallback callback, boolean cancelFlag); } ``` #### 4. FileExtractServiceImpl.java ```java package com.datai.salesforce.metadata.file.service; import com.datai.salesforce.metadata.file.dto.ExtractProgressCallback; import com.datai.salesforce.metadata.file.exception.FileExtractException; import com.datai.salesforce.metadata.file.util.PathValidator; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.io.*; import java.nio.file.*; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @Slf4j @Service public class FileExtractServiceImpl implements IFileExtractService { @Autowired private PathValidator pathValidator; private static final int BUFFER_SIZE = 8192; @Override public void extract(InputStream zipInputStream, String targetPath) { extract(zipInputStream, targetPath, null); } @Override public void extract(InputStream zipInputStream, String targetPath, ExtractProgressCallback callback) { extract(zipInputStream, targetPath, callback, false); } @Override public void extract(InputStream zipInputStream, String targetPath, ExtractProgressCallback callback, boolean cancelFlag) { Path basePath = Paths.get(targetPath); try { Files.createDirectories(basePath); try (ZipInputStream zis = new ZipInputStream(new BufferedInputStream(zipInputStream))) { ZipEntry entry; int totalEntries = 0; int processedEntries = 0; while ((entry = zis.getNextEntry()) != null) { if (cancelFlag) { log.info("Extract cancelled"); break; } totalEntries++; Path entryPath = basePath.resolve(entry.getName()); pathValidator.validatePath(basePath, entryPath); if (entry.isDirectory()) { Files.createDirectories(entryPath); } else { Files.createDirectories(entryPath.getParent()); try (BufferedOutputStream bos = new BufferedOutputStream( new FileOutputStream(entryPath.toFile()), BUFFER_SIZE)) { byte[] buffer = new byte[BUFFER_SIZE]; int len; while ((len = zis.read(buffer)) > 0) { bos.write(buffer, 0, len); } } } processedEntries++; if (callback != null) { callback.onProgress(processedEntries, totalEntries, entry.getName()); } zis.closeEntry(); } } log.info("Extract completed: {} entries processed", processedEntries); if (callback != null) { callback.onComplete(processedEntries); } } catch (IOException e) { log.error("Failed to extract file", e); throw new FileExtractException("Failed to extract file", e); } } } ``` #### 5. PathValidator.java ```java package com.datai.salesforce.metadata.file.util; import com.datai.salesforce.metadata.file.exception.PathSecurityException; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.nio.file.Path; import java.nio.file.Paths; import java.util.regex.Pattern; @Slf4j @Component public class PathValidator { private static final Pattern FILENAME_PATTERN = Pattern.compile( "^[a-zA-Z0-9._\\-]+$" ); private static final Pattern PATH_PATTERN = Pattern.compile( "^[a-zA-Z0-9._\\-/\\\\]+$" ); public void validatePath(Path basePath, Path targetPath) { Path normalizedPath = targetPath.normalize(); if (!normalizedPath.startsWith(basePath.normalize())) { log.error("Path traversal attempt detected: {}", targetPath); throw new PathSecurityException("Path traversal attempt detected"); } if (targetPath.toString().contains("..")) { log.error("Path contains '..': {}", targetPath); throw new PathSecurityException("Path contains '..'"); } String fileName = targetPath.getFileName().toString(); if (!FILENAME_PATTERN.matcher(fileName).matches()) { log.error("Invalid filename: {}", fileName); throw new PathSecurityException("Invalid filename"); } } public void validatePathString(String path) { if (!PATH_PATTERN.matcher(path).matches()) { log.error("Invalid path: {}", path); throw new PathSecurityException("Invalid path"); } if (path.contains("..")) { log.error("Path contains '..': {}", path); throw new PathSecurityException("Path contains '..'"); } } public Path normalizePath(Path path) { return path.normalize(); } } ``` #### 6. StorageMonitorServiceImpl.java ```java package com.datai.salesforce.metadata.file.service; import com.datai.salesforce.metadata.file.dto.StorageSpaceInfo; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.io.File; import java.nio.file.FileStore; import java.nio.file.Files; @Slf4j @Service public class StorageMonitorServiceImpl implements IStorageMonitorService { @Autowired private INotificationService notificationService; @Value("${file.storage.warning-threshold:80}") private int warningThreshold; @Value("${file.storage.local.base-path}") private String basePath; @Override public StorageSpaceInfo getStorageSpaceInfo() { try { File file = new File(basePath); FileStore store = Files.getFileStore(file.toPath()); long totalSpace = store.getTotalSpace(); long usableSpace = store.getUsableSpace(); long usedSpace = totalSpace - usableSpace; double usagePercentage = (double) usedSpace / totalSpace * 100; StorageSpaceInfo info = new StorageSpaceInfo(); info.setTotalSpace(totalSpace); info.setUsedSpace(usedSpace); info.setUsableSpace(usableSpace); info.setUsagePercentage(usagePercentage); info.setWarning(usagePercentage >= warningThreshold); return info; } catch (IOException e) { log.error("Failed to get storage space info", e); throw new RuntimeException("Failed to get storage space info", e); } } @Scheduled(cron = "${file.storage.monitor.cron:0 0 * * * ?}") public void monitorStorageSpace() { StorageSpaceInfo info = getStorageSpaceInfo(); if (info.isWarning()) { log.warn("Storage space usage is high: {}%", info.getUsagePercentage()); notificationService.sendNotification( "Storage space warning", String.format("Storage space usage is %.2f%%", info.getUsagePercentage()) ); } } } ``` #### 7. FileCleanupServiceImpl.java ```java package com.datai.salesforce.metadata.file.service; import com.datai.salesforce.metadata.file.dto.FileCleanupRequest; import com.datai.salesforce.metadata.file.dto.FileCleanupResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; 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.nio.file.attribute.FileTime; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @Slf4j @Service public class FileCleanupServiceImpl implements IFileCleanupService { @Autowired private IFileStorageService fileStorageService; @Value("${file.storage.local.base-path}") private String basePath; @Value("${file.cleanup.retention-days:30}") private int retentionDays; @Override public FileCleanupResponse cleanupFiles(FileCleanupRequest request) { FileCleanupResponse response = new FileCleanupResponse(); List deletedFiles = new ArrayList<>(); long totalSize = 0; try { Path baseDir = Paths.get(basePath); Instant cutoffTime = Instant.now().minus(retentionDays, ChronoUnit.DAYS); Files.walk(baseDir) .filter(Files::isRegularFile) .filter(path -> { try { FileTime lastModifiedTime = Files.getLastModifiedTime(path); return lastModifiedTime.toInstant().isBefore(cutoffTime); } catch (IOException e) { log.error("Failed to get last modified time for: {}", path, e); return false; } }) .forEach(path -> { try { long size = Files.size(path); if (fileStorageService.delete(path.toString())) { deletedFiles.add(path.toString()); totalSize += size; log.info("Deleted file: {}", path); } } catch (IOException e) { log.error("Failed to delete file: {}", path, e); } }); response.setDeletedCount(deletedFiles.size()); response.setDeletedFiles(deletedFiles); response.setTotalSize(totalSize); response.setSuccess(true); log.info("File cleanup completed: {} files deleted, total size: {} bytes", deletedFiles.size(), totalSize); } catch (IOException e) { log.error("Failed to cleanup files", e); response.setSuccess(false); response.setErrorMessage(e.getMessage()); } return response; } @Scheduled(cron = "${file.cleanup.cron:0 0 2 * * ?}") public void scheduledCleanup() { log.info("Starting scheduled file cleanup"); FileCleanupRequest request = new FileCleanupRequest(); request.setRetentionDays(retentionDays); cleanupFiles(request); } } ``` #### 8. application.yml ```yaml file: storage: type: local # local or oss local: base-path: /data/metadata oss: endpoint: https://oss-cn-hangzhou.aliyuncs.com access-key-id: ${OSS_ACCESS_KEY_ID} access-key-secret: ${OSS_ACCESS_KEY_SECRET} bucket-name: datai-metadata base-path: metadata/ warning-threshold: 80 monitor: cron: "0 0 * * * ?" cleanup: retention-days: 30 cron: "0 0 2 * * ?" ``` ## 约束 ### 技术栈约束 - 必须基于 Spring Boot 3 - 必须使用 Java 17+ - 必须使用 MyBatis Plus - 必须使用 MySQL ### 性能约束 - 文件存储速度:本地文件系统 > 50MB/s,OSS > 20MB/s - 文件解压速度 > 30MB/s - 内存使用 < 512MB - 支持大文件处理(> 100MB) ### 安全性约束 - 必须验证路径安全性,防止路径遍历攻击 - 必须验证文件名格式,防止文件名注入攻击 - 必须限制文件大小,防止 DoS 攻击 - 必须记录所有文件操作日志 ### 兼容性约束 - 必须支持 Windows 和 Linux 操作系统 - 必须支持 Java NIO 的 Path API - 必须支持 OSS SDK ## Rule Set "请严格参考 @Authentication.canvas 中的状态机转移逻辑,不要自行发挥。" **具体规则**: - 必须使用 Canvas 中定义的类名和方法名 - 必须遵循 Canvas 中定义的调用关系 - 必须参考 Canvas 中的流程图逻辑 - 必须使用策略模式实现文件存储 - 必须使用流式处理实现文件解压 - 必须使用 Path API 处理路径 - 必须使用定时任务实现存储空间监控和文件清理 ## 验收标准 ### 功能验收标准 - [ ] 本地文件系统存储成功 - [ ] OSS 存储成功 - [ ] 文件解压成功 - [ ] 支持大文件处理(> 100MB) - [ ] 支持流式解压 - [ ] 内存使用合理(< 512MB) - [ ] 文件路径管理正常工作 - [ ] 路径安全性验证通过 - [ ] 存储空间监控正常工作 - [ ] 文件清理功能正常工作 ### 性能验收标准 - [ ] 本地文件系统存储速度 > 50MB/s - [ ] OSS 存储速度 > 20MB/s - [ ] 解压速度 > 30MB/s - [ ] 并发存储 10 个文件无性能下降 - [ ] 并发解压 5 个文件无性能下降 ### 安全性验收标准 - [ ] 防止路径遍历攻击 - [ ] 防止文件名注入攻击 - [ ] 防止符号链接攻击 - [ ] 文件操作日志记录完整 ### 代码质量验收标准 - [ ] 代码符合项目编码规范 - [ ] 有清晰的注释 - [ ] 单元测试覆盖率 > 80% - [ ] 集成测试覆盖主要场景 - [ ] 通过代码审查 ## 风险 ### 大文件处理风险 - **风险描述**: 大文件处理不当可能导致内存溢出 - **缓解措施**: 使用流式处理,限制内存占用 - **监控指标**: 监控 JVM 内存使用情况 ### 路径安全风险 - **风险描述**: 路径验证不完善可能导致路径遍历攻击 - **缓解措施**: 使用 Path API 和正则表达式双重验证 - **监控指标**: 记录所有文件操作日志 ### OSS 存储风险 - **风险描述**: OSS 存储不稳定可能导致文件存储失败 - **缓解措施**: 实现重试机制和降级策略 - **监控指标**: 监控 OSS 存储成功率和响应时间 ### 存储空间风险 - **风险描述**: 存储空间不足可能导致文件存储失败 - **缓解措施**: 实现存储空间监控和告警机制 - **监控指标**: 监控存储空间使用率 ### 文件清理风险 - **风险描述**: 文件清理策略不当可能导致重要文件被删除 - **缓解措施**: 清理前记录日志,支持文件恢复 - **监控指标**: 记录清理操作日志 ## 使用记录 | 日期 | 使用场景 | 输入参数 | 输出结果 | 反馈 | 改进措施 | |------|---------|---------|---------|------|----------| | 2026-01-19 | 实现文件存储和解压处理功能 | REQ-010-7 需求文档、ADR-0016 决策文档 | 完整的代码实现和测试用例 | 待使用 | 待改进 |