在Springboot 加密Zip压缩文件和解密下载

173 阅读3分钟

以下是针对在Spring Boot中实现 解密并下载ZIP文件接口 的完整解决方案:


一、整体思路

  1. 接收加密ZIP文件 + 密码
  2. 解密到临时目录(确保自动清理)
  3. 打包解密后的文件(或直接返回单个文件)
  4. 流式返回给前端(避免内存溢出)

二、实现步骤

1. 添加依赖(pom.xml)

<dependency>
    <groupId>net.lingala.zip4j</groupId>
    <artifactId>zip4j</artifactId>
    <version>2.11.5</version>
</dependency>

2. 核心Service实现

@Service
public class ZipService {

    /**
     * 解密ZIP到指定目录
     * @param encryptedZipPath 加密文件路径
     * @param outputPath 解密输出目录
     * @param password 密码
     */
    public void decryptZip(String encryptedZipPath, String outputPath, String password) {
        try {
            ZipFile zipFile = new ZipFile(encryptedZipPath, password.toCharArray());
            if (!zipFile.isValidZipFile()) {
                throw new RuntimeException("Invalid ZIP file");
            }
            if (zipFile.isEncrypted()) {
                zipFile.setPassword(password.toCharArray());
            }
            zipFile.extractAll(outputPath);
        } catch (ZipException e) {
            throw new RuntimeException("Decryption failed: " + e.getMessage());
        }
    }
}

3. 解密下载Controller

@RestController
@RequestMapping("/api/zip")
public class ZipDownloadController {

    @Autowired
    private ZipService zipService;

    @PostMapping(value = "/decrypt-and-download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    public ResponseEntity<Resource> decryptAndDownload(
            @RequestParam("file") MultipartFile encryptedFile,
            @RequestParam String password) {

        Path tempDir = null;
        try {
            // 1. 创建临时工作目录(自动删除)
            tempDir = Files.createTempDirectory("zip-decrypt-");
            
            // 2. 保存上传文件
            Path encryptedPath = tempDir.resolve(Objects.requireNonNull(encryptedFile.getOriginalFilename()));
            encryptedFile.transferTo(encryptedPath);

            // 3. 执行解密
            Path decryptedDir = tempDir.resolve("decrypted");
            Files.createDirectory(decryptedDir);
            zipService.decryptZip(encryptedPath.toString(), decryptedDir.toString(), password);

            // 4. 获取解密后的文件
            File[] decryptedFiles = decryptedDir.toFile().listFiles();
            if (decryptedFiles == null || decryptedFiles.length == 0) {
                throw new RuntimeException("解密后未发现文件");
            }

            // 5. 打包结果文件(自动清理)
            return buildDownloadResponse(decryptedFiles);
            
        } catch (Exception e) {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
        } finally {
            // 确保清理临时文件
            if (tempDir != null) {
                try {
                    FileUtils.deleteDirectory(tempDir.toFile());
                } catch (IOException ignored) {}
            }
        }
    }

    private ResponseEntity<Resource> buildDownloadResponse(File[] decryptedFiles) throws IOException {
        // 如果是单文件直接返回
        if (decryptedFiles.length == 1) {
            Path filePath = decryptedFiles[0].toPath();
            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_DISPOSITION, 
                            "attachment; filename=\"" + decryptedFiles[0].getName() + "\"")
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .contentLength(decryptedFiles[0].length())
                    .body(new FileSystemResource(filePath));
        }

        // 多文件打包成新ZIP
        Path zipPath = Files.createTempFile("decrypted-", ".zip");
        try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipPath))) {
            for (File file : decryptedFiles) {
                ZipEntry entry = new ZipEntry(file.getName());
                zos.putNextEntry(entry);
                Files.copy(file.toPath(), zos);
                zos.closeEntry();
            }
        }

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"decrypted_files.zip\"")
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .contentLength(Files.size(zipPath))
                .body(new InputStreamResource(Files.newInputStream(zipPath)) {
                    @Override
                    public void close() throws IOException {
                        super.close();
                        Files.deleteIfExists(zipPath); // 删除临时ZIP
                    }
                });
    }
}

三、关键优化点

1. 安全防护措施

// 在Controller方法开头添加:
// 文件大小限制(最大100MB)
if (encryptedFile.getSize() > 100 * 1024 * 1024) {
    throw new RuntimeException("文件大小超过100MB限制");
}

// 文件名消毒
String sanitizedFilename = encryptedFile.getOriginalFilename()
        .replaceAll("[^a-zA-Z0-9\\-_.]", "_")
        .replaceAll("\\.\\.", "");

2. 流式处理大文件

// 修改buildDownloadResponse中的多文件打包逻辑:
return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"decrypted_files.zip\"")
        .body(new StreamingResponseBody() {
            @Override
            public void writeTo(OutputStream outputStream) throws IOException {
                try (ZipOutputStream zos = new ZipOutputStream(outputStream)) {
                    for (File file : decryptedFiles) {
                        try (InputStream is = new FileInputStream(file)) {
                            ZipEntry entry = new ZipEntry(file.getName());
                            zos.putNextEntry(entry);
                            IOUtils.copy(is, zos);
                            zos.closeEntry();
                        }
                    }
                }
            }
        });

3. 增强错误处理

@ExceptionHandler(ZipException.class)
public ResponseEntity<String> handleZipException(ZipException e) {
    String message = e.getMessage().contains("wrong password") ? 
            "密码错误" : "ZIP文件损坏";
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(message);
}

四、前端调用示例(React)

function DecryptDownload() {
  const [file, setFile] = useState(null);
  const [password, setPassword] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    const formData = new FormData();
    formData.append('file', file);
    formData.append('password', password);

    try {
      const response = await axios.post('/api/zip/decrypt-and-download', formData, {
        responseType: 'blob'
      });

      const url = window.URL.createObjectURL(new Blob([response.data]));
      const link = document.createElement('a');
      link.href = url;
      link.setAttribute('download', file.name.replace(/.zip$/i, '_decrypted.zip'));
      document.body.appendChild(link);
      link.click();
      link.remove();
    } catch (error) {
      alert(`下载失败: ${error.response?.data || error.message}`);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" onChange={e => setFile(e.target.files[0])} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      <button type="submit">解密并下载</button>
    </form>
  );
}

五、部署注意事项

  1. JCE策略文件
    如果使用AES-256加密,需在$JAVA_HOME/jre/lib/security中替换:

    • local_policy.jar
    • US_export_policy.jar
  2. 服务器配置(application.yml)

server:
  max-http-header-size: 16KB # 防止过长的文件名攻击

spring:
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB
  1. 监控指标
    添加Prometheus监控:
@Timed(value = "zip.download.time", description = "Time taken to process ZIP download")
@PostMapping("/decrypt-and-download")
public ResponseEntity<Resource> decryptAndDownload(...) {
    // ...
}

六、方案优势

特性说明
内存安全使用临时目录+流式处理,避免OOM
完整清理机制确保操作后无残留临时文件
防御性编程包含文件大小限制、文件名消毒等防护
多文件自动打包自动处理单文件/多文件场景
完善错误处理明确区分密码错误、文件损坏等情况

通过以上实现,可以安全高效地处理加密ZIP文件的解密下载需求。