Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package ditda.backend.domain.commission.draft.event;

public record DraftFilesSubmittedEvent(Long draftId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,6 +63,9 @@ public DraftSubmitResponse submitDraft(
// 모든 지원자가 제출을 완료했는지 판단 및 처리
handleAllSubmittedIfLast(commission);

// 워터마크 진행
eventPublisher.publishEvent(new DraftFilesSubmittedEvent(draft.getId()));

} catch (Exception original) {
try {
designerDraftFileService.deleteFiles(permanentKeys);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<CommissionDraftFile, Long> {

Expand All @@ -22,4 +26,38 @@ public interface CommissionDraftFileRepository extends JpaRepository<CommissionD
+ "WHERE f.commissionDraft.id = :draftId "
+ "AND f.fileOrder = 0")
Optional<CommissionDraftFile> findThumbnail(@Param("draftId") Long draftId);

List<CommissionDraftFile> 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<Long> 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<Long> ids,
@Param("processing") WatermarkStatus processing,
@Param("completed") WatermarkStatus completed,
@Param("maxRetry") int maxRetry,
@Param("now") LocalDateTime now
);
}
Original file line number Diff line number Diff line change
@@ -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("워터마크 재처리 스케줄 완료");
}
}
Original file line number Diff line number Diff line change
@@ -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<CommissionDraftFile> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading