diff --git a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java index 6bc4c6f9..9e03b6e0 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java +++ b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java @@ -37,6 +37,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CommissionDraftFile extends BaseEntity { + public static final int MAX_WATERMARK_RETRY = 3; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "commission_draft_file_id") @@ -59,6 +61,9 @@ public class CommissionDraftFile extends BaseEntity { @Column(name = "watermark_status", length = 20, nullable = false) private WatermarkStatus watermarkStatus; + @Column(name = "watermark_retry_count", nullable = false) + private int watermarkRetryCount; + public static CommissionDraftFile create( CommissionDraft commissionDraft, int fileOrder, @@ -72,7 +77,30 @@ public static CommissionDraftFile create( .build(); } + // 시안 조회 가능 여부 (워터마크 완료 상태) public boolean isWatermarkCompleted() { return this.watermarkStatus == WatermarkStatus.COMPLETED; } + + // 워터마크 처리 완료 + public void completeWatermark(String watermarkedFileUrl) { + this.watermarkedFileUrl = watermarkedFileUrl; + this.watermarkStatus = WatermarkStatus.COMPLETED; + } + + // 워터마크 처리 실패 + public void markWatermarkFailed() { + this.watermarkStatus = WatermarkStatus.FAILED; + } + + // 재시도 가능 여부 확인 + public boolean isWatermarkRetryable() { + return watermarkRetryCount < MAX_WATERMARK_RETRY; + } + + // 재시도 불가 실패 (이미지 문제) + public void markWatermarkFailedPermanently() { + this.watermarkRetryCount = MAX_WATERMARK_RETRY; + this.watermarkStatus = WatermarkStatus.FAILED; + } } diff --git a/src/main/java/ditda/backend/domain/commission/draft/event/DraftFilesSubmittedEvent.java b/src/main/java/ditda/backend/domain/commission/draft/event/DraftFilesSubmittedEvent.java new file mode 100644 index 00000000..21d4241a --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/event/DraftFilesSubmittedEvent.java @@ -0,0 +1,4 @@ +package ditda.backend.domain.commission.draft.event; + +public record DraftFilesSubmittedEvent(Long draftId) { +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/facade/DesignerDraftFacade.java b/src/main/java/ditda/backend/domain/commission/draft/facade/DesignerDraftFacade.java index 0716e13d..df1f00d4 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/facade/DesignerDraftFacade.java +++ b/src/main/java/ditda/backend/domain/commission/draft/facade/DesignerDraftFacade.java @@ -16,6 +16,7 @@ import ditda.backend.domain.commission.draft.dto.request.DraftSubmitRequest; import ditda.backend.domain.commission.draft.dto.response.DraftSubmitResponse; import ditda.backend.domain.commission.draft.entity.CommissionDraft; +import ditda.backend.domain.commission.draft.event.DraftFilesSubmittedEvent; import ditda.backend.domain.commission.draft.service.DesignerDraftFileService; import ditda.backend.domain.commission.draft.service.DesignerDraftService; import lombok.RequiredArgsConstructor; @@ -62,6 +63,9 @@ public DraftSubmitResponse submitDraft( // 모든 지원자가 제출을 완료했는지 판단 및 처리 handleAllSubmittedIfLast(commission); + // 워터마크 진행 + eventPublisher.publishEvent(new DraftFilesSubmittedEvent(draft.getId())); + } catch (Exception original) { try { designerDraftFileService.deleteFiles(permanentKeys); diff --git a/src/main/java/ditda/backend/domain/commission/draft/listener/DraftWatermarkListener.java b/src/main/java/ditda/backend/domain/commission/draft/listener/DraftWatermarkListener.java new file mode 100644 index 00000000..7bc4914b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/listener/DraftWatermarkListener.java @@ -0,0 +1,29 @@ +package ditda.backend.domain.commission.draft.listener; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import ditda.backend.domain.commission.draft.event.DraftFilesSubmittedEvent; +import ditda.backend.domain.commission.draft.service.DraftWatermarkService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DraftWatermarkListener { + + private final DraftWatermarkService draftWatermarkService; + + @Async("watermarkExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onDraftFilesSubmitted(DraftFilesSubmittedEvent event) { + try { + draftWatermarkService.watermarkDraftFiles(event.draftId()); + } catch (Exception exception) { + log.error("워터마크 파이프라인 실패. draftId={}", event.draftId(), exception); + } + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java index efe24742..0f6f5762 100644 --- a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java +++ b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java @@ -1,13 +1,17 @@ package ditda.backend.domain.commission.draft.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; public interface CommissionDraftFileRepository extends JpaRepository { @@ -22,4 +26,38 @@ public interface CommissionDraftFileRepository extends JpaRepository findThumbnail(@Param("draftId") Long draftId); + + List findAllByCommissionDraftIdAndWatermarkStatus( + Long draftId, + WatermarkStatus watermarkStatus + ); + + @Query("SELECT f.id FROM CommissionDraftFile f " + + "WHERE (f.watermarkStatus = :failed AND f.watermarkRetryCount < :maxRetry) " + + "OR (f.watermarkStatus = :processing " + + " AND f.updatedAt < :stuckBefore AND f.watermarkRetryCount < :maxRetry) " + + "ORDER BY f.updatedAt ASC") + List findWatermarkRetryTargetIds( + @Param("failed") WatermarkStatus failed, + @Param("processing") WatermarkStatus processing, + @Param("maxRetry") int maxRetry, + @Param("stuckBefore") LocalDateTime stuckBefore, + Pageable pageable + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE CommissionDraftFile f " + + "SET f.watermarkStatus = :processing, " + + " f.watermarkRetryCount = f.watermarkRetryCount + 1, " + + " f.updatedAt = :now " + + "WHERE f.id IN :ids " + + "AND f.watermarkStatus <> :completed " + + "AND f.watermarkRetryCount < :maxRetry") + int claimForRetry( + @Param("ids") List ids, + @Param("processing") WatermarkStatus processing, + @Param("completed") WatermarkStatus completed, + @Param("maxRetry") int maxRetry, + @Param("now") LocalDateTime now + ); } diff --git a/src/main/java/ditda/backend/domain/commission/draft/scheduler/WatermarkRetryScheduler.java b/src/main/java/ditda/backend/domain/commission/draft/scheduler/WatermarkRetryScheduler.java new file mode 100644 index 00000000..4feb2acf --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/scheduler/WatermarkRetryScheduler.java @@ -0,0 +1,25 @@ +package ditda.backend.domain.commission.draft.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import ditda.backend.domain.commission.draft.service.WatermarkRetryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WatermarkRetryScheduler { + + private final WatermarkRetryService watermarkRetryService; + + // 10분마다 미완료 워터마크 재처리 + @Scheduled(cron = "0 */10 * * * *", zone = "Asia/Seoul") + public void retryIncompleteWatermarks() { + + log.info("워터마크 재처리 스케줄 시작"); + watermarkRetryService.retryIncompleteFiles(); + log.info("워터마크 재처리 스케줄 완료"); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java new file mode 100644 index 00000000..e5e45f74 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkService.java @@ -0,0 +1,98 @@ +package ditda.backend.domain.commission.draft.service; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.image.WatermarkImageProcessor; +import ditda.backend.global.image.dto.WatermarkedImage; +import ditda.backend.global.s3.enums.BucketType; +import ditda.backend.global.s3.enums.S3ContentType; +import ditda.backend.global.s3.manager.S3FileManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DraftWatermarkService { + + private static final BucketType BUCKET = BucketType.PRIVATE; + private static final String WATERMARK_DIR = "wm"; + + private final CommissionDraftFileRepository commissionDraftFileRepository; + private final WatermarkImageProcessor watermarkImageProcessor; + private final S3FileManager s3FileManager; + private final DraftWatermarkTransitionService draftWatermarkTransitionService; + + public void watermarkDraftFiles(Long draftId) { + + List files = + commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + draftId, + WatermarkStatus.PROCESSING + ); + + files.forEach(f -> process(f.getId(), f.getFileUrl())); + } + + // 워터마크 재처리 + @Async("watermarkExecutor") + public void reprocessFile(Long draftFileId) { + + String originalKey = draftWatermarkTransitionService.getOriginalKey(draftFileId); + process(draftFileId, originalKey); + } + + private String createWatermarked(String originalKey) throws IOException { + + byte[] watermarked; + // 원본 s3 다운로드 후 워터마크 진행 + try (InputStream original = s3FileManager.download(BUCKET, originalKey)) { + WatermarkedImage image = watermarkImageProcessor.createWatermarkedPreview(original); + watermarked = image.bytes(); + } + + // 워터마크 진행된 파일 s3 업로드 (commission/draft/{uuid}.png -> commission/draft/wm/{uuid}.png) + int lastSlash = originalKey.lastIndexOf('/'); + if (lastSlash < 0) { + throw new IllegalArgumentException("유효하지 않은 S3 키: " + originalKey); + } + String dir = originalKey.substring(0, lastSlash); + String filename = originalKey.substring(lastSlash + 1); + int lastDot = filename.lastIndexOf('.'); + String baseName = lastDot > 0 ? filename.substring(0, lastDot) : filename; + String watermarkedKey = dir + "/" + WATERMARK_DIR + "/" + baseName + ".png"; + + s3FileManager.upload(BUCKET, watermarkedKey, watermarked, S3ContentType.PNG.getContentType()); + + return watermarkedKey; + } + + private void process(Long fileId, String originalKey) { + + long start = System.nanoTime(); + try { + String watermarkedKey = createWatermarked(originalKey); + draftWatermarkTransitionService.complete(fileId, watermarkedKey); + log.info("워터마크 완료. draftFileId={}, elapsedMs={}", fileId, elapsedMs(start)); + } catch (GeneralException e) { + log.error("워터마크 영구 실패(이미지 문제). draftFileId={}", fileId, e); + draftWatermarkTransitionService.failPermanently(fileId); + } catch (Exception e) { + log.error("워터마크 실패. draftFileId={}, elapsedMs={}", fileId, elapsedMs(start), e); + draftWatermarkTransitionService.fail(fileId); + } + } + + private long elapsedMs(long start) { + return (System.nanoTime() - start) / 1_000_000; + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java new file mode 100644 index 00000000..2f39f5fe --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftWatermarkTransitionService.java @@ -0,0 +1,68 @@ +package ditda.backend.domain.commission.draft.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DraftWatermarkTransitionService { + + private final CommissionDraftFileRepository commissionDraftFileRepository; + + // 워터마크 완료 전이 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void complete(Long draftFileId, String watermarkedKey) { + + commissionDraftFileRepository.findById(draftFileId) + .ifPresentOrElse( + file -> file.completeWatermark(watermarkedKey), + () -> log.warn("워터마크 완료 전이 대상 없음. draftFileId={}, key={}", draftFileId, watermarkedKey) + ); + } + + // 워터마크 영구 실패 전이 (이미지 문제) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void failPermanently(Long draftFileId) { + + commissionDraftFileRepository.findById(draftFileId) + .ifPresentOrElse(file -> { + file.markWatermarkFailedPermanently(); + notifyPermanentFailure(draftFileId); + }, () -> log.warn("워터마크 영구 실패 전이 대상 없음. draftFileId={}", draftFileId)); + } + + // 워터마크 실패 전이 + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void fail(Long draftFileId) { + + commissionDraftFileRepository.findById(draftFileId) + .ifPresentOrElse(file -> { + file.markWatermarkFailed(); + if (!file.isWatermarkRetryable()) { + notifyPermanentFailure(draftFileId); + } + }, () -> log.warn("워터마크 실패 전이 대상 없음. draftFileId={}", draftFileId)); + } + + // 워터마크 재처리 전이 + @Transactional(readOnly = true) + public String getOriginalKey(Long draftFileId) { + + return commissionDraftFileRepository.findById(draftFileId) + .map(CommissionDraftFile::getFileUrl) + .orElseThrow(() -> new IllegalStateException("워터마크 재처리 대상 없음: " + draftFileId)); + } + + // TODO: 디스코드 웹훅 - 영구 실패 알림 (draftFileId + 사유) + private void notifyPermanentFailure(Long draftFileId) { + + log.error("워터마크 영구 실패. draftFileId={}", draftFileId); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java b/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java new file mode 100644 index 00000000..5d85dc9b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/service/WatermarkRetryService.java @@ -0,0 +1,59 @@ +package ditda.backend.domain.commission.draft.service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WatermarkRetryService { + + private static final Duration STUCK_THRESHOLD = Duration.ofMinutes(30); + private static final int BATCH_SIZE = 20; + + private final CommissionDraftFileRepository commissionDraftFileRepository; + private final DraftWatermarkService draftWatermarkService; + + // 미완료 워터마크 파일 조회 후 워터마크 큐에 투입 + @Transactional + public void retryIncompleteFiles() { + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime stuckBefore = now.minus(STUCK_THRESHOLD); + + List targetIds = commissionDraftFileRepository.findWatermarkRetryTargetIds( + WatermarkStatus.FAILED, + WatermarkStatus.PROCESSING, + CommissionDraftFile.MAX_WATERMARK_RETRY, + stuckBefore, + PageRequest.of(0, BATCH_SIZE) + ); + + if (targetIds.isEmpty()) { + return; + } + + int claimed = commissionDraftFileRepository.claimForRetry( + targetIds, + WatermarkStatus.PROCESSING, + WatermarkStatus.COMPLETED, + CommissionDraftFile.MAX_WATERMARK_RETRY, + now + ); + + targetIds.forEach(draftWatermarkService::reprocessFile); + + log.info("워터마크 재처리 대상 선점 {}건 / 큐잉 {}건", claimed, targetIds.size()); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/facade/DesignerRevisionFacade.java b/src/main/java/ditda/backend/domain/commission/revision/facade/DesignerRevisionFacade.java index a3990312..5100ec48 100644 --- a/src/main/java/ditda/backend/domain/commission/revision/facade/DesignerRevisionFacade.java +++ b/src/main/java/ditda/backend/domain/commission/revision/facade/DesignerRevisionFacade.java @@ -15,6 +15,7 @@ import ditda.backend.domain.commission.core.service.DesignerCommissionService; import ditda.backend.domain.commission.draft.entity.CommissionDraft; import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.event.DraftFilesSubmittedEvent; import ditda.backend.domain.commission.draft.service.DesignerDraftFileService; import ditda.backend.domain.commission.draft.service.DesignerDraftService; import ditda.backend.domain.commission.draft.service.DraftQueryService; @@ -126,6 +127,10 @@ public RevisionSubmitResponse submitRevision( newDraft, request.designerComment() ); + + // 워터마크 진행 + eventPublisher.publishEvent(new DraftFilesSubmittedEvent(newDraft.getId())); + } catch (Exception original) { try { designerDraftFileService.deleteFiles(permanentKeys); diff --git a/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java b/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java new file mode 100644 index 00000000..1e551be3 --- /dev/null +++ b/src/main/java/ditda/backend/global/config/WatermarkAsyncConfig.java @@ -0,0 +1,23 @@ +package ditda.backend.global.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class WatermarkAsyncConfig { + + @Bean(name = "watermarkExecutor") + public Executor watermarkExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + executor.setThreadNamePrefix("watermark-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java new file mode 100644 index 00000000..d7cac60a --- /dev/null +++ b/src/main/java/ditda/backend/global/image/WatermarkImageProcessor.java @@ -0,0 +1,141 @@ +package ditda.backend.global.image; + +import java.awt.AlphaComposite; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReadParam; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; + +import org.springframework.stereotype.Component; + +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.image.dto.WatermarkedImage; +import ditda.backend.global.image.exception.ImageErrorCode; +import ditda.backend.global.s3.enums.S3ContentType; + +@Component +public class WatermarkImageProcessor { + + private static final String LOGO_PATH = "/images/watermark-logo.png"; // 워터마크 이미지 파일 경로 + private static final int TARGET_LONG_SIDE = 1200; // 출력물의 최대 길이 + private static final long MAX_PIXELS = 200_000_000L; // 이미지 픽셀 제한 + private static final float OPACITY = 0.25f; // 워터마크 투명도 + private static final double ROTATION_DEGREES = -45; // 워터마크 텍스트 기울기 + private static final int LOGO_WIDTH_RATIO = 15; // 로고 폭 + + private final BufferedImage logo = loadLogo(); + + public WatermarkedImage createWatermarkedPreview(InputStream source) throws IOException { + + // 1. 이미지 디코딩 + BufferedImage preview = readSubsampled(source); + + // 2. 워터마크 처리 + drawWatermark(preview); + + // 3. PNG로 재압축 + ByteArrayOutputStream out = new ByteArrayOutputStream(); + if (!ImageIO.write(preview, "png", out)) { + throw new IOException("PNG 인코딩에 실패했습니다."); + } + + // 4. S3 업로드용 바이트 + return new WatermarkedImage(out.toByteArray(), S3ContentType.PNG); + } + + // 워터마크 로고 로드 + private BufferedImage loadLogo() { + + try (InputStream logoStream = getClass().getResourceAsStream(LOGO_PATH)) { + if (logoStream == null) { + throw new IllegalStateException("워터마크 로고 리소스가 없습니다: " + LOGO_PATH); + } + + BufferedImage image = ImageIO.read(logoStream); + if (image == null) { + throw new IllegalStateException("워터마크 로고 이미지를 읽을 수 없습니다: " + LOGO_PATH); + } + + return image; + } catch (IOException exception) { + throw new IllegalStateException("워터마크 로고 로드 실패: " + LOGO_PATH, exception); + } + + } + + // 서브샘플링 + private BufferedImage readSubsampled(InputStream source) throws IOException { + + try (ImageInputStream iis = ImageIO.createImageInputStream(source)) { + Iterator readers = ImageIO.getImageReaders(iis); + + // 이미지가 아니거나 심하게 손상될 경우 + if (!readers.hasNext()) { + throw new GeneralException(ImageErrorCode.IMAGE_NOT_READABLE); + } + + ImageReader reader = readers.next(); + try { + reader.setInput(iis); + + int width = reader.getWidth(0); + int height = reader.getHeight(0); + + // 픽셀 수 계산 + if ((long)width * height > MAX_PIXELS) { + throw new GeneralException(ImageErrorCode.IMAGE_RESOLUTION_EXCEEDED); + } + + // 샘플링 간격 + int sample = Math.max(1, Math.max(width, height) / TARGET_LONG_SIDE); + + ImageReadParam param = reader.getDefaultReadParam(); + param.setSourceSubsampling(sample, sample, 0, 0); + + return reader.read(0, param); + } finally { + reader.dispose(); + } + } + } + + // 로고 기반 워터마크 + private void drawWatermark(BufferedImage image) { + + int logoWidth = Math.max(1, image.getWidth() / LOGO_WIDTH_RATIO); + int logoHeight = Math.max(1, logoWidth * logo.getHeight() / logo.getWidth()); // 원본 비율 유지 + + Graphics2D graphics = image.createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + + graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, OPACITY)); + graphics.rotate( + Math.toRadians(ROTATION_DEGREES), + image.getWidth() / 2.0, + image.getHeight() / 2.0 + ); + + int stepX = logoWidth * 4; // 타일 가로 간격 + int stepY = logoHeight * 5; // 타일 세로 간격 + + for (int y = -image.getHeight(); y < image.getHeight() * 2; y += stepY) { + for (int x = -image.getWidth(); x < image.getWidth() * 2; x += stepX) { + graphics.drawImage(logo, x, y, logoWidth, logoHeight, null); + } + } + } finally { + graphics.dispose(); + } + } +} diff --git a/src/main/java/ditda/backend/global/image/dto/WatermarkedImage.java b/src/main/java/ditda/backend/global/image/dto/WatermarkedImage.java new file mode 100644 index 00000000..1347b87c --- /dev/null +++ b/src/main/java/ditda/backend/global/image/dto/WatermarkedImage.java @@ -0,0 +1,6 @@ +package ditda.backend.global.image.dto; + +import ditda.backend.global.s3.enums.S3ContentType; + +public record WatermarkedImage(byte[] bytes, S3ContentType contentType) { +} diff --git a/src/main/java/ditda/backend/global/image/exception/ImageErrorCode.java b/src/main/java/ditda/backend/global/image/exception/ImageErrorCode.java new file mode 100644 index 00000000..1a776012 --- /dev/null +++ b/src/main/java/ditda/backend/global/image/exception/ImageErrorCode.java @@ -0,0 +1,19 @@ +package ditda.backend.global.image.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageErrorCode implements BaseErrorCode { + + IMAGE_NOT_READABLE(HttpStatus.BAD_REQUEST, "IMAGE_400_01", "이미지 파일을 읽을 수 없습니다."), + IMAGE_RESOLUTION_EXCEEDED(HttpStatus.CONTENT_TOO_LARGE, "IMAGE_413_01", "이미지 해상도가 제한을 초과했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java b/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java index 59481263..0c27cd1c 100644 --- a/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java +++ b/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java @@ -1,5 +1,6 @@ package ditda.backend.global.s3.manager; +import java.io.InputStream; import java.util.List; import org.springframework.stereotype.Component; @@ -8,6 +9,7 @@ import ditda.backend.global.s3.enums.BucketType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.S3Exception; @@ -57,4 +59,17 @@ public void copy(BucketType bucketType, String sourceKey, String destinationKey) .destinationBucket(bucket) .destinationKey(destinationKey)); } + + public InputStream download(BucketType bucketType, String key) { + String bucket = s3Properties.getBucket(bucketType); + return s3Client.getObject(req -> req.bucket(bucket).key(key)); + } + + public void upload(BucketType bucketType, String key, byte[] bytes, String contentType) { + String bucket = s3Properties.getBucket(bucketType); + s3Client.putObject( + req -> req.bucket(bucket).key(key).contentType(contentType), + RequestBody.fromBytes(bytes) + ); + } } diff --git a/src/main/resources/images/watermark-logo.png b/src/main/resources/images/watermark-logo.png new file mode 100644 index 00000000..ce4b936d Binary files /dev/null and b/src/main/resources/images/watermark-logo.png differ diff --git a/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java b/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java new file mode 100644 index 00000000..0f598443 --- /dev/null +++ b/src/test/java/ditda/backend/domain/commission/draft/service/DraftWatermarkServiceTest.java @@ -0,0 +1,144 @@ +package ditda.backend.domain.commission.draft.service; + +import static org.mockito.BDDMockito.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import ditda.backend.global.image.WatermarkImageProcessor; +import ditda.backend.global.image.dto.WatermarkedImage; +import ditda.backend.global.s3.enums.BucketType; +import ditda.backend.global.s3.enums.S3ContentType; +import ditda.backend.global.s3.manager.S3FileManager; + +@ExtendWith(MockitoExtension.class) +class DraftWatermarkServiceTest { + + private static final Long DRAFT_ID = 1L; + + @Mock + private CommissionDraftFileRepository commissionDraftFileRepository; + + @Mock + private WatermarkImageProcessor watermarkImageProcessor; + + @Mock + private S3FileManager s3FileManager; + + @Mock + private DraftWatermarkTransitionService draftWatermarkTransitionService; + + @InjectMocks + private DraftWatermarkService draftWatermarkService; + + @Test + @DisplayName("원본 key로 wm 경로를 파생해 업로드하고 COMPLETED로 전이 - 성공") + void watermarkDraftFiles_success() throws IOException { + + // given + CommissionDraftFile file = draftFile(10L, "commission/draft/abc.png"); + given(commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + DRAFT_ID, WatermarkStatus.PROCESSING)).willReturn(List.of(file)); + given(s3FileManager.download(BucketType.PRIVATE, "commission/draft/abc.png")) + .willReturn(new ByteArrayInputStream(new byte[0])); + + WatermarkedImage watermarked = new WatermarkedImage(new byte[]{1, 2, 3}, S3ContentType.PNG); + given(watermarkImageProcessor.createWatermarkedPreview(any())).willReturn(watermarked); + + // when + draftWatermarkService.watermarkDraftFiles(DRAFT_ID); + + // then + then(s3FileManager).should().upload( + BucketType.PRIVATE, + "commission/draft/wm/abc.png", + watermarked.bytes(), + S3ContentType.PNG.getContentType() + ); + then(draftWatermarkTransitionService).should().complete(10L, "commission/draft/wm/abc.png"); + then(draftWatermarkTransitionService).should(never()).fail(anyLong()); + } + + @Test + @DisplayName("워터마크 생성 실패 시 업로드 없이 FAILED로 전이") + void watermarkDraftFiles_markFailedOnError() throws IOException { + + // given + CommissionDraftFile file = draftFile(10L, "commission/draft/abc.png"); + given(commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + DRAFT_ID, WatermarkStatus.PROCESSING)).willReturn(List.of(file)); + given(s3FileManager.download(any(), anyString())) + .willReturn(new ByteArrayInputStream(new byte[0])); + given(watermarkImageProcessor.createWatermarkedPreview(any())) + .willThrow(new IOException("이미지 처리 실패")); + + // when + draftWatermarkService.watermarkDraftFiles(DRAFT_ID); + + // then + then(s3FileManager).should(never()).upload(any(), anyString(), any(), anyString()); + then(draftWatermarkTransitionService).should().fail(10L); + then(draftWatermarkTransitionService).should(never()).complete(anyLong(), anyString()); + } + + @Test + @DisplayName("한 파일이 실패해도 나머지 파일은 계속 처리") + void watermarkDraftFiles_isolatesFailurePerFile() throws IOException { + + // given + CommissionDraftFile failing = draftFile(10L, "commission/draft/aaa.png"); + CommissionDraftFile succeeding = draftFile(20L, "commission/draft/bbb.png"); + given(commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + DRAFT_ID, WatermarkStatus.PROCESSING)).willReturn(List.of(failing, succeeding)); + given(s3FileManager.download(any(), anyString())) + .willAnswer(invocation -> new ByteArrayInputStream(new byte[0])); + + WatermarkedImage watermarked = new WatermarkedImage(new byte[]{1, 2, 3}, S3ContentType.PNG); + given(watermarkImageProcessor.createWatermarkedPreview(any())) + .willThrow(new IOException("첫 번째 파일 실패")) + .willReturn(watermarked); + + // when + draftWatermarkService.watermarkDraftFiles(DRAFT_ID); + + // then + then(draftWatermarkTransitionService).should().fail(10L); + then(draftWatermarkTransitionService).should().complete(20L, "commission/draft/wm/bbb.png"); + } + + @Test + @DisplayName("PROCESSING 파일이 없으면 아무 작업도 하지 않음") + void watermarkDraftFiles_noopWhenEmpty() { + + // given + given(commissionDraftFileRepository.findAllByCommissionDraftIdAndWatermarkStatus( + DRAFT_ID, WatermarkStatus.PROCESSING)).willReturn(List.of()); + + // when + draftWatermarkService.watermarkDraftFiles(DRAFT_ID); + + // then + then(s3FileManager).shouldHaveNoInteractions(); + then(draftWatermarkTransitionService).shouldHaveNoInteractions(); + } + + private CommissionDraftFile draftFile(Long id, String fileUrl) { + return CommissionDraftFile.builder() + .id(id) + .fileOrder(0) + .fileUrl(fileUrl) + .watermarkStatus(WatermarkStatus.PROCESSING) + .build(); + } +} diff --git a/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java b/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java new file mode 100644 index 00000000..dc120780 --- /dev/null +++ b/src/test/java/ditda/backend/global/image/WatermarkImageProcessorTest.java @@ -0,0 +1,120 @@ +package ditda.backend.global.image; + +import static org.assertj.core.api.Assertions.*; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.image.dto.WatermarkedImage; + +class WatermarkImageProcessorTest { + + private final WatermarkImageProcessor watermarkImageProcessor = new WatermarkImageProcessor(); + + @Test + @DisplayName("목표보다 큰 이미지는 긴 변이 목표 크기로 축소된다") + void createWatermarkedPreview_downscalesLargeImage() throws IOException { + + // given: 긴 변이 목표(1600)의 2배인 원본 + byte[] source = pngBytes(3200, 2400); + + // when + WatermarkedImage image = watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(source)); + byte[] result = image.bytes(); + + // then: sample=2로 절반 크기 + BufferedImage preview = ImageIO.read(new ByteArrayInputStream(result)); + assertThat(preview.getWidth()).isEqualTo(1600); + assertThat(preview.getHeight()).isEqualTo(1200); + } + + @Test + @DisplayName("목표보다 작은 이미지는 축소 없이 처리된다") + void createWatermarkedPreview_keepsSmallImageSize() throws IOException { + + // given + byte[] source = pngBytes(800, 600); + + // when + WatermarkedImage image = watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(source)); + byte[] result = image.bytes(); + + // then + BufferedImage preview = ImageIO.read(new ByteArrayInputStream(result)); + assertThat(preview.getWidth()).isEqualTo(800); + assertThat(preview.getHeight()).isEqualTo(600); + } + + @Test + @DisplayName("이미지가 아닌 파일은 예외가 발생한다") + void createWatermarkedPreview_rejectsNonImage() { + + // given: png로 위장한 텍스트 + byte[] fake = "this is not an image".getBytes(); + + // when & then + assertThatThrownBy(() -> + watermarkImageProcessor.createWatermarkedPreview(new ByteArrayInputStream(fake))) + .isInstanceOf(GeneralException.class); + } + + @Disabled("로컬 확인 및 처리 시간 측정용 - 입력 폴더 경로를 맞추고 @Disabled를 지운 뒤 실행") + @Test + void manualPreviewForEyeCheck() throws IOException { + + // 입력: 이 폴더 안의 모든 png / 출력: 하위 out 폴더에 같은 이름으로 + Path inputDir = Path.of("/Users/jong/Desktop/watermark-test"); + Path outputDir = inputDir.resolve("out"); + Files.createDirectories(outputDir); + + List sources; + try (Stream paths = Files.list(inputDir)) { + sources = paths + .filter(path -> path.toString().endsWith(".png")) + .sorted() + .toList(); + } + + long totalStart = System.nanoTime(); + + for (Path source : sources) { + long start = System.nanoTime(); + + byte[] result; + try (InputStream in = Files.newInputStream(source)) { + WatermarkedImage image = watermarkImageProcessor.createWatermarkedPreview(in); + result = image.bytes(); + } + Files.write(outputDir.resolve(source.getFileName()), result); + + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + System.out.printf("%s: 원본 %,dKB -> 결과 %,dKB, %,dms%n", + source.getFileName(), Files.size(source) / 1024, result.length / 1024, elapsedMs); + } + + long totalMs = (System.nanoTime() - totalStart) / 1_000_000; + System.out.printf("총 %d개 처리, %,dms (평균 %,dms)%n", + sources.size(), totalMs, sources.isEmpty() ? 0 : totalMs / sources.size()); + } + + private byte[] pngBytes(int width, int height) throws IOException { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "png", out); + return out.toByteArray(); + } +}