fix(file-service): stream upload to disk to fix OOM on large files

file.getBytes() loaded the entire APK into JVM heap, causing
OutOfMemoryError on files >~50MB. Now streams to a temp file while
computing SHA-256 via DigestInputStream, then atomically moves to the
final path. Zero heap cost regardless of file size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-18 16:31:15 +08:00
父节点 9c51e666f8
当前提交 1ec7f2e35d

查看文件

@ -14,11 +14,14 @@ import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
@ -62,39 +65,49 @@ public class FileStorageService {
throw new BusinessException(400, "File must not be empty");
}
byte[] bytes;
String originalName = file.getOriginalFilename();
String mimeType = resolveMimeType(file);
String ext = resolveExtension(originalName, mimeType);
Path storageDir = Paths.get(uploadDir);
ensureDirectory(storageDir);
// Stream to a temp file while computing SHA-256 to avoid loading the whole file into heap
Path tempPath = storageDir.resolve("tmp-" + UUID.randomUUID() + ".tmp");
String hash;
long fileSize;
try {
bytes = file.getBytes();
} catch (IOException e) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = new DigestInputStream(file.getInputStream(), digest);
OutputStream out = Files.newOutputStream(tempPath)) {
fileSize = in.transferTo(out);
}
hash = HexFormat.of().formatHex(digest.digest());
} catch (IOException | NoSuchAlgorithmException e) {
try { Files.deleteIfExists(tempPath); } catch (IOException ignored) {}
throw new BusinessException(500, "Failed to read uploaded file");
}
String hash = sha256Hex(bytes);
String mimeType = resolveMimeType(file);
String originalName = file.getOriginalFilename();
Optional<FileEntity> existing = fileRepository.findByHash(hash);
if (existing.isPresent()) {
FileEntity entity = existing.get();
// 检查磁盘文件是否仍然存在若不存在则重新写入
if (Files.exists(Paths.get(entity.getStoragePath()))) {
// File already on disk drop temp and return existing record
try { Files.deleteIfExists(tempPath); } catch (IOException ignored) {}
entity.setLastAccessedAt(Instant.now());
fileRepository.save(entity);
return toUploadResult(entity);
}
// 文件记录存在但磁盘文件已丢失删除旧记录后重新上传
// 文件记录存在但磁盘文件已丢失删除旧记录保留 temp 继续写入
fileRepository.delete(entity);
fileRepository.flush();
}
String ext = resolveExtension(originalName, mimeType);
Path storageDir = Paths.get(uploadDir);
ensureDirectory(storageDir);
Path storagePath = storageDir.resolve(hash + ext);
try {
Files.write(storagePath, bytes);
Files.move(tempPath, storagePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
try { Files.deleteIfExists(tempPath); } catch (IOException ignored) {}
throw new BusinessException(500, "Failed to save uploaded file");
}
@ -111,7 +124,7 @@ public class FileStorageService {
}
}
} else if (mimeType != null && mimeType.startsWith("image/")) {
thumbnailStoragePath = generateImageThumbnail(bytes, hash);
thumbnailStoragePath = generateImageThumbnail(storagePath, hash);
if (thumbnailStoragePath != null) {
try {
thumbnailSize = Files.size(Paths.get(thumbnailStoragePath));
@ -127,7 +140,7 @@ public class FileStorageService {
entity.setHash(hash);
entity.setOriginalName(originalName);
entity.setMimeType(mimeType);
entity.setSize(bytes.length);
entity.setSize(fileSize);
entity.setExt(ext);
entity.setStoragePath(storagePath.toAbsolutePath().toString());
entity.setThumbnailPath(thumbnailStoragePath);
@ -193,9 +206,9 @@ public class FileStorageService {
}
}
private String generateImageThumbnail(byte[] imageBytes, String hash) {
private String generateImageThumbnail(Path imagePath, String hash) {
try {
BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes));
BufferedImage original = ImageIO.read(imagePath.toFile());
if (original == null) {
return null;
}
@ -230,16 +243,6 @@ public class FileStorageService {
}
}
private String sha256Hex(byte[] data) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(data);
return HexFormat.of().formatHex(hashBytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
private String resolveMimeType(MultipartFile file) {
String ct = file.getContentType();
if (ct != null && !ct.isBlank() && !ct.equals("application/octet-stream")) {