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>
这个提交包含在:
父节点
9c51e666f8
当前提交
1ec7f2e35d
@ -14,11 +14,14 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.security.DigestInputStream;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@ -62,39 +65,49 @@ public class FileStorageService {
|
|||||||
throw new BusinessException(400, "File must not be empty");
|
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 {
|
try {
|
||||||
bytes = file.getBytes();
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
} catch (IOException e) {
|
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");
|
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);
|
Optional<FileEntity> existing = fileRepository.findByHash(hash);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
FileEntity entity = existing.get();
|
FileEntity entity = existing.get();
|
||||||
// 检查磁盘文件是否仍然存在,若不存在则重新写入
|
|
||||||
if (Files.exists(Paths.get(entity.getStoragePath()))) {
|
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());
|
entity.setLastAccessedAt(Instant.now());
|
||||||
fileRepository.save(entity);
|
fileRepository.save(entity);
|
||||||
return toUploadResult(entity);
|
return toUploadResult(entity);
|
||||||
}
|
}
|
||||||
// 文件记录存在但磁盘文件已丢失,删除旧记录后重新上传
|
// 文件记录存在但磁盘文件已丢失,删除旧记录,保留 temp 继续写入
|
||||||
fileRepository.delete(entity);
|
fileRepository.delete(entity);
|
||||||
fileRepository.flush();
|
fileRepository.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
String ext = resolveExtension(originalName, mimeType);
|
|
||||||
Path storageDir = Paths.get(uploadDir);
|
|
||||||
ensureDirectory(storageDir);
|
|
||||||
|
|
||||||
Path storagePath = storageDir.resolve(hash + ext);
|
Path storagePath = storageDir.resolve(hash + ext);
|
||||||
try {
|
try {
|
||||||
Files.write(storagePath, bytes);
|
Files.move(tempPath, storagePath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
try { Files.deleteIfExists(tempPath); } catch (IOException ignored) {}
|
||||||
throw new BusinessException(500, "Failed to save uploaded file");
|
throw new BusinessException(500, "Failed to save uploaded file");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +124,7 @@ public class FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (mimeType != null && mimeType.startsWith("image/")) {
|
} else if (mimeType != null && mimeType.startsWith("image/")) {
|
||||||
thumbnailStoragePath = generateImageThumbnail(bytes, hash);
|
thumbnailStoragePath = generateImageThumbnail(storagePath, hash);
|
||||||
if (thumbnailStoragePath != null) {
|
if (thumbnailStoragePath != null) {
|
||||||
try {
|
try {
|
||||||
thumbnailSize = Files.size(Paths.get(thumbnailStoragePath));
|
thumbnailSize = Files.size(Paths.get(thumbnailStoragePath));
|
||||||
@ -127,7 +140,7 @@ public class FileStorageService {
|
|||||||
entity.setHash(hash);
|
entity.setHash(hash);
|
||||||
entity.setOriginalName(originalName);
|
entity.setOriginalName(originalName);
|
||||||
entity.setMimeType(mimeType);
|
entity.setMimeType(mimeType);
|
||||||
entity.setSize(bytes.length);
|
entity.setSize(fileSize);
|
||||||
entity.setExt(ext);
|
entity.setExt(ext);
|
||||||
entity.setStoragePath(storagePath.toAbsolutePath().toString());
|
entity.setStoragePath(storagePath.toAbsolutePath().toString());
|
||||||
entity.setThumbnailPath(thumbnailStoragePath);
|
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 {
|
try {
|
||||||
BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes));
|
BufferedImage original = ImageIO.read(imagePath.toFile());
|
||||||
if (original == null) {
|
if (original == null) {
|
||||||
return 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) {
|
private String resolveMimeType(MultipartFile file) {
|
||||||
String ct = file.getContentType();
|
String ct = file.getContentType();
|
||||||
if (ct != null && !ct.isBlank() && !ct.equals("application/octet-stream")) {
|
if (ct != null && !ct.isBlank() && !ct.equals("application/octet-stream")) {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户