검증게시판 기능 추가
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.VerificationIssueImageDto;
|
||||
import com.goldenchart.entity.GcVerificationIssueImage;
|
||||
import com.goldenchart.repository.GcVerificationIssueImageRepository;
|
||||
import com.goldenchart.repository.GcVerificationIssueRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class VerificationIssueImageService {
|
||||
|
||||
private static final Set<String> ALLOWED_TYPES = Set.of(
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"
|
||||
);
|
||||
|
||||
private final GcVerificationIssueImageRepository imageRepository;
|
||||
private final GcVerificationIssueRepository issueRepository;
|
||||
private final Path uploadRoot;
|
||||
private final long maxFileSizeBytes;
|
||||
private final int maxImagesPerIssue;
|
||||
|
||||
public VerificationIssueImageService(
|
||||
GcVerificationIssueImageRepository imageRepository,
|
||||
GcVerificationIssueRepository issueRepository,
|
||||
@Value("${goldenchart.verification.upload-dir:data/verification-images}") String uploadDir,
|
||||
@Value("${goldenchart.verification.max-image-size-mb:5}") int maxImageSizeMb,
|
||||
@Value("${goldenchart.verification.max-images-per-issue:30}") int maxImagesPerIssue) throws IOException {
|
||||
this.imageRepository = imageRepository;
|
||||
this.issueRepository = issueRepository;
|
||||
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
|
||||
this.maxFileSizeBytes = maxImageSizeMb * 1024L * 1024L;
|
||||
this.maxImagesPerIssue = maxImagesPerIssue;
|
||||
Files.createDirectories(this.uploadRoot);
|
||||
}
|
||||
|
||||
public List<VerificationIssueImageDto> listByIssue(Long issueId) {
|
||||
return imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<VerificationIssueImageDto> upload(Long issueId, List<MultipartFile> files) throws IOException {
|
||||
if (!issueRepository.existsById(issueId)) {
|
||||
throw new IllegalArgumentException("issue not found: " + issueId);
|
||||
}
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new IllegalArgumentException("no files");
|
||||
}
|
||||
|
||||
long current = imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).size();
|
||||
if (current + files.size() > maxImagesPerIssue) {
|
||||
throw new IllegalArgumentException("max images exceeded: " + maxImagesPerIssue);
|
||||
}
|
||||
|
||||
Path issueDir = uploadRoot.resolve("issue_" + issueId);
|
||||
Files.createDirectories(issueDir);
|
||||
|
||||
int sort = (int) current;
|
||||
List<VerificationIssueImageDto> saved = new java.util.ArrayList<>();
|
||||
|
||||
for (MultipartFile file : files) {
|
||||
if (file == null || file.isEmpty()) continue;
|
||||
|
||||
byte[] data = file.getBytes();
|
||||
String contentType = resolveContentType(file, data);
|
||||
validateFile(data.length, contentType);
|
||||
|
||||
String original = sanitizeFileName(file.getOriginalFilename());
|
||||
String storedName = UUID.randomUUID() + "_" + original;
|
||||
Path target = issueDir.resolve(storedName);
|
||||
Files.write(target, data);
|
||||
|
||||
String relative = uploadRoot.relativize(target).toString().replace('\\', '/');
|
||||
GcVerificationIssueImage entity = GcVerificationIssueImage.builder()
|
||||
.issueId(issueId)
|
||||
.fileName(original)
|
||||
.contentType(contentType)
|
||||
.storagePath(relative)
|
||||
.fileSize((long) data.length)
|
||||
.sortOrder(sort++)
|
||||
.build();
|
||||
saved.add(toDto(imageRepository.save(entity)));
|
||||
}
|
||||
|
||||
if (saved.isEmpty()) {
|
||||
throw new IllegalArgumentException("no valid image files");
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Optional<Resource> loadFile(Long issueId, Long imageId) {
|
||||
return imageRepository.findById(imageId)
|
||||
.filter(img -> issueId.equals(img.getIssueId()))
|
||||
.map(img -> {
|
||||
try {
|
||||
Path path = uploadRoot.resolve(img.getStoragePath()).normalize();
|
||||
if (!path.startsWith(uploadRoot) || !Files.exists(path)) {
|
||||
return null;
|
||||
}
|
||||
Resource resource = new UrlResource(path.toUri());
|
||||
return resource.exists() && resource.isReadable() ? resource : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("image load failed id={}: {}", imageId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<String> contentType(Long issueId, Long imageId) {
|
||||
return imageRepository.findById(imageId)
|
||||
.filter(img -> issueId.equals(img.getIssueId()))
|
||||
.map(GcVerificationIssueImage::getContentType);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean delete(Long issueId, Long imageId) throws IOException {
|
||||
Optional<GcVerificationIssueImage> opt = imageRepository.findById(imageId)
|
||||
.filter(img -> issueId.equals(img.getIssueId()));
|
||||
if (opt.isEmpty()) return false;
|
||||
GcVerificationIssueImage img = opt.get();
|
||||
deletePhysicalFile(img.getStoragePath());
|
||||
imageRepository.delete(img);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAllForIssue(Long issueId) throws IOException {
|
||||
List<GcVerificationIssueImage> images =
|
||||
imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId);
|
||||
for (GcVerificationIssueImage img : images) {
|
||||
deletePhysicalFile(img.getStoragePath());
|
||||
}
|
||||
imageRepository.deleteByIssueId(issueId);
|
||||
Path issueDir = uploadRoot.resolve("issue_" + issueId);
|
||||
if (Files.isDirectory(issueDir)) {
|
||||
try { Files.deleteIfExists(issueDir); } catch (IOException ignored) { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void validateFile(long sizeBytes, String contentType) {
|
||||
if (contentType == null) {
|
||||
throw new IllegalArgumentException("unsupported image type");
|
||||
}
|
||||
if (sizeBytes > maxFileSizeBytes) {
|
||||
throw new IllegalArgumentException("file too large");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveContentType(MultipartFile file, byte[] data) {
|
||||
String type = file.getContentType();
|
||||
if (type != null && !type.isBlank()) {
|
||||
type = type.split(";")[0].trim().toLowerCase();
|
||||
if ("image/jpg".equals(type) || "image/pjpeg".equals(type)) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if ("image/x-png".equals(type)) {
|
||||
return "image/png";
|
||||
}
|
||||
if (ALLOWED_TYPES.contains(type)) {
|
||||
return type;
|
||||
}
|
||||
// application/octet-stream 등 — 매직 바이트로 판별
|
||||
if ("application/octet-stream".equals(type) || type.startsWith("image/")) {
|
||||
String sniffed = sniffImageType(data);
|
||||
if (sniffed != null) return sniffed;
|
||||
}
|
||||
}
|
||||
|
||||
String name = file.getOriginalFilename();
|
||||
if (name != null) {
|
||||
String lower = name.toLowerCase();
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".bmp")) return "image/bmp";
|
||||
}
|
||||
|
||||
return sniffImageType(data);
|
||||
}
|
||||
|
||||
private String sniffImageType(byte[] header) {
|
||||
if (header == null) return null;
|
||||
if (header.length >= 8
|
||||
&& header[0] == (byte) 0x89 && header[1] == 0x50
|
||||
&& header[2] == 0x4E && header[3] == 0x47) {
|
||||
return "image/png";
|
||||
}
|
||||
if (header.length >= 3
|
||||
&& header[0] == (byte) 0xFF && header[1] == (byte) 0xD8 && header[2] == (byte) 0xFF) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (header.length >= 6
|
||||
&& header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46) {
|
||||
return "image/gif";
|
||||
}
|
||||
if (header.length >= 12
|
||||
&& header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46
|
||||
&& header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50) {
|
||||
return "image/webp";
|
||||
}
|
||||
if (header.length >= 2 && header[0] == 0x42 && header[1] == 0x4D) {
|
||||
return "image/bmp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void deletePhysicalFile(String storagePath) throws IOException {
|
||||
Path path = uploadRoot.resolve(storagePath).normalize();
|
||||
if (path.startsWith(uploadRoot)) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFileName(String name) {
|
||||
if (name == null || name.isBlank()) return "image.png";
|
||||
String base = Paths.get(name).getFileName().toString();
|
||||
return base.replaceAll("[^a-zA-Z0-9._\\-가-힣]", "_");
|
||||
}
|
||||
|
||||
private VerificationIssueImageDto toDto(GcVerificationIssueImage e) {
|
||||
return VerificationIssueImageDto.builder()
|
||||
.id(e.getId())
|
||||
.issueId(e.getIssueId())
|
||||
.fileName(e.getFileName())
|
||||
.contentType(e.getContentType())
|
||||
.fileSize(e.getFileSize())
|
||||
.sortOrder(e.getSortOrder())
|
||||
.url("/verification-issues/" + e.getIssueId() + "/images/" + e.getId() + "/file")
|
||||
.createdAt(e.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user