diff --git a/src/main/java/com/kw/readwith/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/kw/readwith/apiPayload/code/status/ErrorStatus.java index ae69abc..de61ee9 100644 --- a/src/main/java/com/kw/readwith/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/kw/readwith/apiPayload/code/status/ErrorStatus.java @@ -67,6 +67,8 @@ public enum ErrorStatus implements BaseErrorCode { IMAGE_FANOUT_JOB_NOT_FOUND(HttpStatus.NOT_FOUND, "ADMIN4029", "캐릭터 이미지 fan-out job을 찾을 수 없습니다."), IMAGE_FANOUT_RETRY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "ADMIN4030", "결과 게시를 재시도할 수 없는 캐릭터 이미지 fan-out job 상태입니다."), IMAGE_FANOUT_RESULT_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "ADMIN4031", "재적용할 OpenAI Batch 결과 파일이 없습니다."), + IMAGE_REFERENCE_JOB_ACTIVE(HttpStatus.CONFLICT, "ADMIN4032", "해당 책의 대표 후보사진 생성 job이 이미 진행 중입니다."), + IMAGE_REFERENCE_JOB_NOT_FOUND(HttpStatus.NOT_FOUND, "ADMIN4033", "대표 후보사진 생성 job을 찾을 수 없습니다."), IMAGE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ADMIN5020", "캐릭터 이미지 생성에 실패했습니다."), // Bookmark diff --git a/src/main/java/com/kw/readwith/config/AsyncConfig.java b/src/main/java/com/kw/readwith/config/AsyncConfig.java index 6536c03..92402c7 100644 --- a/src/main/java/com/kw/readwith/config/AsyncConfig.java +++ b/src/main/java/com/kw/readwith/config/AsyncConfig.java @@ -61,6 +61,34 @@ public Executor normalizationJobExecutor() { executor.initialize(); return executor; } + + @Bean(name = "referenceCandidateJobExecutor") + public Executor referenceCandidateJobExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + executor.setQueueCapacity(20); + executor.setThreadNamePrefix("reference-job-"); + executor.setAllowCoreThreadTimeOut(true); + executor.setKeepAliveSeconds(60); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.initialize(); + return executor; + } + + @Bean(name = "referenceCandidateSlotExecutor") + public Executor referenceCandidateSlotExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(2); + executor.setThreadNamePrefix("reference-slot-"); + executor.setAllowCoreThreadTimeOut(true); + executor.setKeepAliveSeconds(60); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.initialize(); + return executor; + } @Override public Executor getAsyncExecutor() { diff --git a/src/main/java/com/kw/readwith/config/CharacterImageProperties.java b/src/main/java/com/kw/readwith/config/CharacterImageProperties.java index 0933c93..398bef6 100644 --- a/src/main/java/com/kw/readwith/config/CharacterImageProperties.java +++ b/src/main/java/com/kw/readwith/config/CharacterImageProperties.java @@ -53,5 +53,15 @@ public class CharacterImageProperties { * OpenAI Batch 상태를 다시 확인하는 간격입니다. */ private long batchPollIntervalMs = 30000; + + /** + * 관리자 기준 이미지 후보 생성 개수입니다. + */ + private int referenceCandidateCount = 2; + + /** + * 기준 이미지 후보 한 슬롯의 최대 처리 시간입니다. + */ + private long referenceCandidateTimeoutMs = 420000; } diff --git a/src/main/java/com/kw/readwith/domain/enums/ProcessingPipelineType.java b/src/main/java/com/kw/readwith/domain/enums/ProcessingPipelineType.java index 1be0e89..c222986 100644 --- a/src/main/java/com/kw/readwith/domain/enums/ProcessingPipelineType.java +++ b/src/main/java/com/kw/readwith/domain/enums/ProcessingPipelineType.java @@ -3,5 +3,6 @@ public enum ProcessingPipelineType { NORMALIZATION, AI_ANALYSIS, + IMAGE_REFERENCE_GENERATION, IMAGE_GENERATION } diff --git a/src/main/java/com/kw/readwith/dto/admin/AdminImageGenerationStatusResponseDTO.java b/src/main/java/com/kw/readwith/dto/admin/AdminImageGenerationStatusResponseDTO.java index eb4f341..ac26c79 100644 --- a/src/main/java/com/kw/readwith/dto/admin/AdminImageGenerationStatusResponseDTO.java +++ b/src/main/java/com/kw/readwith/dto/admin/AdminImageGenerationStatusResponseDTO.java @@ -14,7 +14,7 @@ @Schema( description = """ 관리자 책 단위 이미지 생성 콘솔 상태 응답입니다. - 대표 캐릭터 후보사진 4장과 캐릭터별 현재 이미지 상태를 한 번에 내려줍니다. + 대표 캐릭터 후보사진과 캐릭터별 현재 이미지 상태를 한 번에 내려줍니다. 관리자 페이지는 이 응답의 status와 nextAction만 보고 다음 버튼을 결정할 수 있습니다. """ ) @@ -27,7 +27,7 @@ public class AdminImageGenerationStatusResponseDTO { description = """ 책 단위 이미지 생성 상태입니다. EMPTY: 아직 후보사진이 없습니다. REFERENCE_GENERATING: 대표 후보사진 생성 중입니다. - REFERENCE_READY: 후보사진 4장 중 선택 대기 상태입니다. FANOUT_GENERATING: 선택된 대표 후보로 나머지 캐릭터 생성 중입니다. + REFERENCE_READY: 생성된 후보사진 중 선택 대기 상태입니다. FANOUT_GENERATING: 선택된 대표 후보로 나머지 캐릭터 생성 중입니다. READY: 관리자 검토 가능한 상태입니다. FAILED: 후보 또는 캐릭터 이미지 중 실패 항목이 있습니다. """, allowableValues = {"EMPTY", "REFERENCE_GENERATING", "REFERENCE_READY", "FANOUT_GENERATING", "READY", "FAILED"}, @@ -45,16 +45,19 @@ public class AdminImageGenerationStatusResponseDTO { ) private String nextAction; + @Schema(description = "가장 최근 대표 후보사진 생성 job입니다. status=READY 또는 FAILED가 되면 처리가 끝난 상태입니다.", nullable = true) + private ProcessingJobResponseDTO referenceCandidateJob; + @Schema(description = "가장 최근 캐릭터 이미지 fan-out job입니다. 대표 후보 선택 전에는 null입니다.", nullable = true) private ProcessingJobResponseDTO fanoutJob; - @Schema(description = "서버가 자동 지정한 대표 캐릭터입니다. 후보사진 4장은 이 캐릭터 기준으로 생성됩니다.", nullable = true) + @Schema(description = "서버가 자동 지정한 대표 캐릭터입니다. 후보사진은 이 캐릭터 기준으로 생성됩니다.", nullable = true) private CharacterSummary referenceCharacter; @Schema(description = "관리자가 선택한 대표 후보사진 asset ID입니다. 선택 전에는 null입니다.", nullable = true, example = "102") private Long selectedReferenceCandidateId; - @Schema(description = "책에 저장된 대표 캐릭터 후보사진 목록입니다. slotNo 1~4만 유지되며 재생성 시 같은 slot을 덮어씁니다.") + @Schema(description = "책에 저장된 대표 캐릭터 후보사진 목록입니다. 기본 slotNo는 1~2이며 재생성 시 같은 slot을 덮어씁니다.") private List referenceCandidates; @Schema(description = "책에 등록된 캐릭터별 현재 이미지 생성 상태 목록입니다.") @@ -96,7 +99,7 @@ public static class ReferenceCandidate { @Schema(description = "후보사진 asset DB ID입니다. 후보 선택 API의 candidateId로 이 값을 전달합니다.", example = "101") private Long id; - @Schema(description = "화면 표시용 후보 슬롯 번호입니다. 책마다 1~4만 유지되며 candidateId로 사용하지 않습니다.", example = "1") + @Schema(description = "화면 표시용 후보 슬롯 번호입니다. 기본값은 1~2이며 candidateId로 사용하지 않습니다.", example = "1") private Integer slotNo; @Schema( diff --git a/src/main/java/com/kw/readwith/service/AdminImageGenerationService.java b/src/main/java/com/kw/readwith/service/AdminImageGenerationService.java index 0d8f210..7b76357 100644 --- a/src/main/java/com/kw/readwith/service/AdminImageGenerationService.java +++ b/src/main/java/com/kw/readwith/service/AdminImageGenerationService.java @@ -29,15 +29,11 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.Comparator; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -47,8 +43,6 @@ @Transactional(readOnly = true) public class AdminImageGenerationService { - private static final int REFERENCE_CANDIDATE_COUNT = 4; - private final BookRepository bookRepository; private final CharacterRepository characterRepository; private final CharacterImageAssetRepository assetRepository; @@ -57,6 +51,7 @@ public class AdminImageGenerationService { private final OpenAiImageEditClient imageEditClient; private final CharacterImageProperties imageProperties; private final CharacterImageFanoutJobService fanoutJobService; + private final ReferenceCandidateJobService referenceCandidateJobService; private final ProcessingJobRepository processingJobRepository; private final RestTemplate restTemplate; private final CdnUrlService cdnUrlService; @@ -72,50 +67,7 @@ public AdminImageGenerationStatusResponseDTO generateReferenceCandidates(Long bo ensureNoActiveFanoutJob(bookId); Character referenceCharacter = resolveReferenceCharacter(book) .orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_REFERENCE_CHARACTER_REQUIRED)); - BookCharacterImageProfile profile = getOrCreateProfile(book); - Long previousActiveReferenceId = Optional.ofNullable(profile.getActiveReferenceAsset()) - .map(CharacterImageAsset::getId) - .orElse(null); - profile.markReferenceCandidatesGenerating( - referenceCharacter, - resolveTextImageModel(), - sha256(normalize(imageProperties.getBaseStylePrompt())), - sha256(normalize(book.getBookPrompt())) - ); - - int successCount = 0; - for (int slotNo = 1; slotNo <= REFERENCE_CANDIDATE_COUNT; slotNo++) { - CharacterImageAsset asset = getOrCreateReferenceCandidate(book, referenceCharacter, slotNo); - try { - String previousS3Url = asset.getS3Url(); - GeneratedCharacterImage generated = characterImageService.generateTextImage(referenceCharacter); - String s3Url = characterImageService.uploadGeneratedImage( - referenceCharacter, - generated.imageData(), - characterImageService.buildReferenceCandidateSlotS3KeyName( - referenceCharacter, - slotNo, - asset.getAttemptNo() - ) - ); - asset.generated(s3Url, generated.model(), generated.promptHash(), generated.requestId()); - asset.markQaPassed("{\"passed\":true,\"mode\":\"ADMIN_REFERENCE_CANDIDATE\"}"); - if (!Objects.equals(asset.getId(), previousActiveReferenceId)) { - characterImageService.deleteReplacedGeneratedImage(previousS3Url, s3Url); - } - successCount++; - } catch (Exception e) { - log.error("Reference candidate generation failed. bookId={}, slotNo={}", bookId, slotNo, e); - asset.fail("REFERENCE_GENERATION_FAILED"); - } - } - - if (successCount > 0) { - profile.markReferenceCandidatesReady(referenceCharacter); - } else { - profile.markQaFailed(referenceCharacter); - } - + referenceCandidateJobService.queue(book, referenceCharacter); return buildStatusResponse(book); } @@ -123,6 +75,7 @@ public AdminImageGenerationStatusResponseDTO generateReferenceCandidates(Long bo public AdminImageGenerationStatusResponseDTO selectReferenceCandidate(Long bookId, Long candidateId) { Book book = getBook(bookId); ensureNoActiveFanoutJob(bookId); + referenceCandidateJobService.ensureNoActiveJob(bookId); CharacterImageAsset candidate = assetRepository.findByIdAndBook(candidateId, book) .orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_ASSET_NOT_BELONG_TO_BOOK)); ensureReferenceCandidate(candidate); @@ -147,6 +100,7 @@ public AdminImageGenerationStatusResponseDTO selectReferenceCandidate(Long bookI public AdminImageGenerationStatusResponseDTO regenerateCharacterImage(Long bookId, Long characterId) { Book book = getBook(bookId); ensureNoActiveFanoutJob(bookId); + referenceCandidateJobService.ensureNoActiveJob(bookId); Character character = getCharacter(characterId); if (!character.getBook().getId().equals(book.getId())) { throw new GeneralException(ErrorStatus.BOOK_CHARACTER_NOT_FOUND); @@ -158,7 +112,7 @@ public AdminImageGenerationStatusResponseDTO regenerateCharacterImage(Long bookI && profile.getReferenceCharacter().getId().equals(character.getId())) { throw new GeneralException( ErrorStatus.IMAGE_ASSET_INVALID_STATUS, - "대표 캐릭터 이미지는 후보사진 4장을 재생성한 뒤 다시 선택해야 합니다." + "대표 캐릭터 이미지는 후보사진을 재생성한 뒤 다시 선택해야 합니다." ); } @@ -206,13 +160,20 @@ private CharacterImageAsset generateCharacterImageFromReference(Character charac private AdminImageGenerationStatusResponseDTO buildStatusResponse(Book book) { Optional profile = profileRepository.findByBook(book); + Long selectedReferenceCandidateId = profile + .map(BookCharacterImageProfile::getActiveReferenceAsset) + .map(CharacterImageAsset::getId) + .orElse(null); + int candidateCount = referenceCandidateCount(); List characters = characterRepository.findByBookOrderByIsMainCharacterDescNameAsc(book); List referenceCandidates = assetRepository .findByBookAndAssetRoleOrderBySlotNoAscCreatedAtAsc(book, CharacterImageAssetRole.REFERENCE_CANDIDATE) .stream() .filter(asset -> asset.getSlotNo() != null && asset.getSlotNo() >= 1 - && asset.getSlotNo() <= REFERENCE_CANDIDATE_COUNT) + && (asset.getSlotNo() <= candidateCount + || (selectedReferenceCandidateId != null + && selectedReferenceCandidateId.equals(asset.getId())))) .sorted(Comparator.comparing( CharacterImageAsset::getSlotNo, Comparator.nullsLast(Integer::compareTo) @@ -229,10 +190,6 @@ private AdminImageGenerationStatusResponseDTO buildStatusResponse(Book book) { LinkedHashMap::new )); - Long selectedReferenceCandidateId = profile - .map(BookCharacterImageProfile::getActiveReferenceAsset) - .map(CharacterImageAsset::getId) - .orElse(null); Character referenceCharacter = profile .map(BookCharacterImageProfile::getReferenceCharacter) .or(() -> resolveReferenceCharacter(book)) @@ -247,11 +204,19 @@ private AdminImageGenerationStatusResponseDTO buildStatusResponse(Book book) { ) .map(ProcessingJobResponseDTO::from) .orElse(null); + ProcessingJobResponseDTO referenceCandidateJob = processingJobRepository + .findTopByBookIdAndPipelineTypeOrderByCreatedAtDesc( + book.getId(), + ProcessingPipelineType.IMAGE_REFERENCE_GENERATION + ) + .map(ProcessingJobResponseDTO::from) + .orElse(null); return AdminImageGenerationStatusResponseDTO.builder() .bookId(book.getId()) .status(status) .nextAction(resolveNextAction(status)) + .referenceCandidateJob(referenceCandidateJob) .fanoutJob(fanoutJob) .referenceCharacter(AdminImageGenerationStatusResponseDTO.CharacterSummary.from(referenceCharacter)) .selectedReferenceCandidateId(selectedReferenceCandidateId) @@ -311,31 +276,6 @@ private String resolveNextAction(String status) { }; } - private CharacterImageAsset getOrCreateReferenceCandidate(Book book, - Character referenceCharacter, - int slotNo) { - Optional existing = assetRepository.findByBookAndAssetRoleAndSlotNo( - book, - CharacterImageAssetRole.REFERENCE_CANDIDATE, - slotNo - ); - if (existing.isPresent()) { - CharacterImageAsset asset = existing.get(); - asset.beginReferenceCandidate(referenceCharacter, slotNo); - return asset; - } - - return assetRepository.save(CharacterImageAsset.builder() - .book(book) - .character(referenceCharacter) - .assetRole(CharacterImageAssetRole.REFERENCE_CANDIDATE) - .generationMode(CharacterImageGenerationMode.TEXT_TO_IMAGE) - .slotNo(slotNo) - .status(CharacterImageAssetStatus.GENERATING) - .attemptNo(1) - .build()); - } - private CharacterImageAsset getOrCreateCharacterImageAsset(Character character, CharacterImageAsset reference, int referenceVersion) { @@ -435,33 +375,7 @@ private boolean isReadyForSelection(CharacterImageAssetStatus status) { || status == CharacterImageAssetStatus.PUBLISHED; } - private String resolveTextImageModel() { - String configured = normalize(imageProperties.getModel()); - return configured != null ? configured : "gpt-image-2"; - } - - private String normalize(String value) { - if (value == null) { - return null; - } - String normalized = value.trim(); - return normalized.isEmpty() ? null : normalized; - } - - private String sha256(String value) { - if (value == null) { - return null; - } - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder builder = new StringBuilder(); - for (byte b : hash) { - builder.append(String.format("%02x", b)); - } - return builder.toString(); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 is not available.", e); - } + private int referenceCandidateCount() { + return Math.min(Math.max(imageProperties.getReferenceCandidateCount(), 1), 4); } } diff --git a/src/main/java/com/kw/readwith/service/CharacterImageService.java b/src/main/java/com/kw/readwith/service/CharacterImageService.java index 1e85bd7..afc85c7 100644 --- a/src/main/java/com/kw/readwith/service/CharacterImageService.java +++ b/src/main/java/com/kw/readwith/service/CharacterImageService.java @@ -244,6 +244,14 @@ public String buildReferenceCandidateSlotS3KeyName(Character character, int slot slotNo); } + public String buildReferenceCandidateJobS3KeyName(Character character, String runId, int slotNo) { + return String.format("%s/%d/reference-jobs/%s/slot-%d.png", + imageProperties.getS3Path(), + character.getBook().getId(), + runId, + slotNo); + } + public String buildCandidateS3KeyName(Character character, Long assetId) { return String.format("%s/%d/%d/candidates/%d-%s.png", imageProperties.getS3Path(), @@ -458,6 +466,18 @@ public void afterCommit() { cleanup.run(); } + public void deleteGeneratedImage(String imageUrl) { + Optional objectKey = cdnUrlService.toPublicObjectKey(imageUrl); + if (objectKey.isEmpty() || !isManagedCharacterImageKey(objectKey.get())) { + return; + } + try { + s3Manager.deleteKeys(List.of(objectKey.get())); + } catch (RuntimeException e) { + log.warn("Failed to delete unused character image from S3. key={}", objectKey.get(), e); + } + } + private boolean isManagedCharacterImageKey(String key) { String configuredPath = normalizePromptSegment(imageProperties.getS3Path()); String imagePath = configuredPath == null ? "character-images" : configuredPath; diff --git a/src/main/java/com/kw/readwith/service/ReferenceCandidateJobDispatcher.java b/src/main/java/com/kw/readwith/service/ReferenceCandidateJobDispatcher.java new file mode 100644 index 0000000..7a5865c --- /dev/null +++ b/src/main/java/com/kw/readwith/service/ReferenceCandidateJobDispatcher.java @@ -0,0 +1,44 @@ +package com.kw.readwith.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; + +@Slf4j +@Service +public class ReferenceCandidateJobDispatcher { + + private final ReferenceCandidateJobService jobService; + private final Executor jobExecutor; + private final Set dispatchedJobIds = ConcurrentHashMap.newKeySet(); + + public ReferenceCandidateJobDispatcher( + ReferenceCandidateJobService jobService, + @Qualifier("referenceCandidateJobExecutor") Executor jobExecutor + ) { + this.jobService = jobService; + this.jobExecutor = jobExecutor; + } + + public void dispatch(Long jobId) { + if (!dispatchedJobIds.add(jobId)) { + return; + } + try { + jobExecutor.execute(() -> { + try { + jobService.process(jobId); + } finally { + dispatchedJobIds.remove(jobId); + } + }); + } catch (RuntimeException e) { + dispatchedJobIds.remove(jobId); + log.warn("Reference candidate job dispatch was deferred. jobId={}", jobId, e); + } + } +} diff --git a/src/main/java/com/kw/readwith/service/ReferenceCandidateJobScheduler.java b/src/main/java/com/kw/readwith/service/ReferenceCandidateJobScheduler.java new file mode 100644 index 0000000..4e5ee3d --- /dev/null +++ b/src/main/java/com/kw/readwith/service/ReferenceCandidateJobScheduler.java @@ -0,0 +1,41 @@ +package com.kw.readwith.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReferenceCandidateJobScheduler { + + private final ReferenceCandidateJobService jobService; + private final ReferenceCandidateJobDispatcher dispatcher; + + @Scheduled(fixedDelayString = "${character-image.reference-candidate-scheduler-interval-ms:30000}") + public void dispatchQueuedJobs() { + try { + jobService.findQueuedJobIds().forEach(this::dispatchSafely); + jobService.findInterruptedJobIds().forEach(this::failInterruptedSafely); + } catch (Exception e) { + log.warn("Reference candidate job scheduler failed.", e); + } + } + + private void dispatchSafely(Long jobId) { + try { + dispatcher.dispatch(jobId); + } catch (RuntimeException e) { + log.warn("Reference candidate job dispatch was deferred. jobId={}", jobId, e); + } + } + + private void failInterruptedSafely(Long jobId) { + try { + jobService.failInterrupted(jobId); + } catch (RuntimeException e) { + log.warn("Failed to close interrupted reference candidate job. jobId={}", jobId, e); + } + } +} diff --git a/src/main/java/com/kw/readwith/service/ReferenceCandidateJobService.java b/src/main/java/com/kw/readwith/service/ReferenceCandidateJobService.java new file mode 100644 index 0000000..e77702b --- /dev/null +++ b/src/main/java/com/kw/readwith/service/ReferenceCandidateJobService.java @@ -0,0 +1,201 @@ +package com.kw.readwith.service; + +import com.kw.readwith.apiPayload.code.status.ErrorStatus; +import com.kw.readwith.apiPayload.exception.GeneralException; +import com.kw.readwith.domain.Book; +import com.kw.readwith.domain.Character; +import com.kw.readwith.dto.admin.ProcessingJobLogResponseDTO; +import com.kw.readwith.dto.admin.ProcessingJobResponseDTO; +import com.kw.readwith.repository.CharacterRepository; +import com.kw.readwith.service.ReferenceCandidateJobTransactionService.CandidateJobStart; +import com.kw.readwith.service.ReferenceCandidateJobTransactionService.SlotContext; +import com.kw.readwith.service.image.GeneratedCharacterImage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +@Service +public class ReferenceCandidateJobService { + + private final ReferenceCandidateJobTransactionService transactionService; + private final CharacterRepository characterRepository; + private final CharacterImageService characterImageService; + private final Executor slotExecutor; + + public ReferenceCandidateJobService( + ReferenceCandidateJobTransactionService transactionService, + CharacterRepository characterRepository, + CharacterImageService characterImageService, + @Qualifier("referenceCandidateSlotExecutor") Executor slotExecutor + ) { + this.transactionService = transactionService; + this.characterRepository = characterRepository; + this.characterImageService = characterImageService; + this.slotExecutor = slotExecutor; + } + + public ProcessingJobResponseDTO queue(Book book, Character referenceCharacter) { + return transactionService.queue(book, referenceCharacter); + } + + public void process(Long jobId) { + try { + CandidateJobStart start = transactionService.start(jobId); + if (start == null) { + return; + } + + List> slotFutures = start.assetIds().stream() + .map(assetId -> generateSlotWithTimeout(jobId, assetId)) + .toList(); + CompletableFuture.allOf(slotFutures.toArray(CompletableFuture[]::new)).join(); + transactionService.finalizeJob(jobId); + } catch (Exception e) { + log.error("Reference candidate generation job failed. jobId={}", jobId, e); + try { + transactionService.failJob(jobId, "REFERENCE_GENERATION_JOB_FAILED", e); + } catch (Exception nested) { + log.error("Failed to persist reference candidate job failure. jobId={}", jobId, nested); + } + } + } + + public ProcessingJobResponseDTO getJob(Long jobId) { + return transactionService.getJob(jobId); + } + + public List getJobLogs(Long jobId) { + return transactionService.getJobLogs(jobId); + } + + public List findQueuedJobIds() { + return transactionService.findQueuedJobIds(); + } + + public List findInterruptedJobIds() { + return transactionService.findInterruptedJobIds(); + } + + public void failInterrupted(Long jobId) { + transactionService.failInterrupted(jobId); + } + + public void ensureNoActiveJob(Long bookId) { + transactionService.ensureNoActiveJob(bookId); + } + + private CompletableFuture generateSlotWithTimeout(Long jobId, Long assetId) { + AtomicBoolean cancelled = new AtomicBoolean(false); + long startedAtNanos = System.nanoTime(); + CompletableFuture generation = CompletableFuture.runAsync( + () -> generateSlot(jobId, assetId, cancelled, startedAtNanos), + slotExecutor + ); + + return generation + .orTimeout(transactionService.slotTimeoutMs(), TimeUnit.MILLISECONDS) + .exceptionally(exception -> { + if (cancelled.compareAndSet(false, true)) { + long elapsedMs = elapsedMillis(startedAtNanos); + Throwable failure = isTimeout(exception) + ? new TimeoutException("Reference candidate slot exceeded " + + transactionService.slotTimeoutMs() + " ms.") + : exception; + transactionService.completeSlotFailure( + jobId, + assetId, + isTimeout(exception) + ? "REFERENCE_GENERATION_TIMEOUT" + : "REFERENCE_GENERATION_FAILED", + failure, + elapsedMs + ); + } + return null; + }); + } + + private void generateSlot(Long jobId, + Long assetId, + AtomicBoolean cancelled, + long startedAtNanos) { + String uploadedUrl = null; + try { + SlotContext context = transactionService.loadSlotContext(jobId, assetId); + if (context == null || cancelled.get()) { + return; + } + Character character = characterRepository.findByIdWithBook(context.characterId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.CHARACTER_NOT_FOUND)); + + GeneratedCharacterImage generated = characterImageService.generateTextImage(character); + if (cancelled.get()) { + return; + } + + uploadedUrl = characterImageService.uploadGeneratedImage( + character, + generated.imageData(), + characterImageService.buildReferenceCandidateJobS3KeyName( + character, + context.runId(), + context.slotNo() + ) + ); + if (cancelled.get()) { + characterImageService.deleteGeneratedImage(uploadedUrl); + return; + } + + boolean committed = transactionService.completeSlotSuccess( + jobId, + assetId, + uploadedUrl, + generated.model(), + generated.promptHash(), + generated.requestId(), + elapsedMillis(startedAtNanos) + ); + if (!committed) { + characterImageService.deleteGeneratedImage(uploadedUrl); + } + } catch (Exception e) { + if (uploadedUrl != null) { + characterImageService.deleteGeneratedImage(uploadedUrl); + } + if (!cancelled.get()) { + log.error("Reference candidate slot generation failed. jobId={}, assetId={}", jobId, assetId, e); + transactionService.completeSlotFailure( + jobId, + assetId, + "REFERENCE_GENERATION_FAILED", + e, + elapsedMillis(startedAtNanos) + ); + } + } + } + + private boolean isTimeout(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof TimeoutException) { + return true; + } + current = current.getCause(); + } + return false; + } + + private long elapsedMillis(long startedAtNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos); + } +} diff --git a/src/main/java/com/kw/readwith/service/ReferenceCandidateJobTransactionService.java b/src/main/java/com/kw/readwith/service/ReferenceCandidateJobTransactionService.java new file mode 100644 index 0000000..7e79321 --- /dev/null +++ b/src/main/java/com/kw/readwith/service/ReferenceCandidateJobTransactionService.java @@ -0,0 +1,528 @@ +package com.kw.readwith.service; + +import com.kw.readwith.apiPayload.code.status.ErrorStatus; +import com.kw.readwith.apiPayload.exception.GeneralException; +import com.kw.readwith.config.CharacterImageProperties; +import com.kw.readwith.domain.Book; +import com.kw.readwith.domain.BookCharacterImageProfile; +import com.kw.readwith.domain.Character; +import com.kw.readwith.domain.CharacterImageAsset; +import com.kw.readwith.domain.enums.CharacterImageAssetRole; +import com.kw.readwith.domain.enums.CharacterImageAssetStatus; +import com.kw.readwith.domain.enums.CharacterImageGenerationMode; +import com.kw.readwith.domain.enums.ProcessingJobLogLevel; +import com.kw.readwith.domain.enums.ProcessingJobStatus; +import com.kw.readwith.domain.enums.ProcessingPipelineType; +import com.kw.readwith.domain.processing.ProcessingJob; +import com.kw.readwith.domain.processing.ProcessingJobLog; +import com.kw.readwith.dto.admin.ProcessingJobLogResponseDTO; +import com.kw.readwith.dto.admin.ProcessingJobResponseDTO; +import com.kw.readwith.repository.BookCharacterImageProfileRepository; +import com.kw.readwith.repository.CharacterImageAssetRepository; +import com.kw.readwith.repository.ProcessingJobLogRepository; +import com.kw.readwith.repository.ProcessingJobRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ReferenceCandidateJobTransactionService { + + private static final String SOURCE_VERSION = "reference-candidate-v2"; + private static final String TRIGGERED_BY = "ADMIN_REFERENCE_GENERATION"; + private static final int MAX_CANDIDATE_COUNT = 4; + private static final EnumSet ACTIVE_JOB_STATUSES = + EnumSet.of(ProcessingJobStatus.QUEUED, ProcessingJobStatus.PROCESSING); + + private final BookCharacterImageProfileRepository profileRepository; + private final CharacterImageAssetRepository assetRepository; + private final ProcessingJobRepository processingJobRepository; + private final ProcessingJobLogRepository processingJobLogRepository; + private final CharacterImageService characterImageService; + private final CharacterImageProperties imageProperties; + private final ObjectMapper objectMapper; + + @Transactional + public ProcessingJobResponseDTO queue(Book book, Character referenceCharacter) { + ensureNoActiveReferenceJob(book.getId()); + processingJobRepository.findFirstByBookIdAndPipelineTypeAndStatusInOrderByCreatedAtDesc( + book.getId(), + ProcessingPipelineType.IMAGE_GENERATION, + ACTIVE_JOB_STATUSES + ) + .ifPresent(job -> { + throw new GeneralException(ErrorStatus.IMAGE_FANOUT_JOB_ACTIVE); + }); + + BookCharacterImageProfile profile = profileRepository.findByBook(book) + .orElseGet(() -> profileRepository.save(BookCharacterImageProfile.builder() + .book(book) + .build())); + String model = resolveTextImageModel(); + String prompt = characterImageService.buildImagePrompt(referenceCharacter); + profile.markReferenceCandidatesGenerating( + referenceCharacter, + model, + sha256(normalize(imageProperties.getBaseStylePrompt())), + sha256(normalize(book.getBookPrompt())) + ); + + String runId = "reference-candidates-" + UUID.randomUUID(); + ProcessingJob job = processingJobRepository.save(ProcessingJob.builder() + .book(book) + .pipelineType(ProcessingPipelineType.IMAGE_REFERENCE_GENERATION) + .runId(runId) + .sourceVersion(SOURCE_VERSION) + .artifactPath(buildArtifactPath(book.getId(), runId)) + .status(ProcessingJobStatus.QUEUED) + .currentStep("queued") + .triggeredBy(TRIGGERED_BY) + .build()); + + int candidateCount = candidateCount(); + String promptHash = characterImageService.buildPromptHash(prompt); + for (int slotNo = 1; slotNo <= candidateCount; slotNo++) { + CharacterImageAsset asset = getOrCreateReferenceCandidate(book, referenceCharacter, slotNo); + asset.assignProcessingJob(job, model, promptHash); + } + + writeLog(job, ProcessingJobLogLevel.INFO, "queued", "Reference candidate generation job has been queued.", Map.of( + "candidateCount", candidateCount, + "concurrency", 2, + "model", model, + "referenceCharacterId", referenceCharacter.getId(), + "slotTimeoutMs", slotTimeoutMs() + )); + return ProcessingJobResponseDTO.from(job); + } + + @Transactional + public CandidateJobStart start(Long jobId) { + ProcessingJob job = findReferenceJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.QUEUED) { + return null; + } + + List assets = assetRepository.findByProcessingJobOrderByIdAsc(job); + if (assets.isEmpty()) { + throw new IllegalStateException("Reference candidate job has no target assets."); + } + + job.markProcessing("generating_candidates"); + writeLog(job, ProcessingJobLogLevel.INFO, "generating_candidates", "Reference candidate generation has started.", Map.of( + "candidateCount", assets.size(), + "concurrency", 2, + "slotTimeoutMs", slotTimeoutMs() + )); + return new CandidateJobStart(job.getId(), assets.stream().map(CharacterImageAsset::getId).toList()); + } + + @Transactional(readOnly = true) + public SlotContext loadSlotContext(Long jobId, Long assetId) { + ProcessingJob job = findReferenceJob(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return null; + } + + CharacterImageAsset asset = findJobAsset(job, assetId); + if (asset.getStatus() != CharacterImageAssetStatus.GENERATING) { + return null; + } + return new SlotContext( + asset.getId(), + asset.getCharacter().getId(), + job.getRunId(), + asset.getSlotNo() + ); + } + + @Transactional + public boolean completeSlotSuccess(Long jobId, + Long assetId, + String s3Url, + String model, + String promptHash, + String requestId, + long elapsedMs) { + ProcessingJob job = findReferenceJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return false; + } + + CharacterImageAsset asset = findJobAsset(job, assetId); + if (asset.getStatus() != CharacterImageAssetStatus.GENERATING) { + return false; + } + + String previousS3Url = asset.getS3Url(); + asset.generated(s3Url, model, promptHash, requestId); + asset.markQaPassed("{\"passed\":true,\"mode\":\"ADMIN_REFERENCE_CANDIDATE\"}"); + updateProgress(job); + + Map payload = new LinkedHashMap<>(); + payload.put("assetId", asset.getId()); + payload.put("slotNo", asset.getSlotNo()); + payload.put("elapsedMs", elapsedMs); + payload.put("model", model); + if (requestId != null && !requestId.isBlank()) { + payload.put("openAiRequestId", requestId); + } + writeLog(job, ProcessingJobLogLevel.INFO, "candidate_ready", "Reference candidate slot is ready.", payload); + characterImageService.deleteReplacedGeneratedImage(previousS3Url, s3Url); + return true; + } + + @Transactional + public boolean completeSlotFailure(Long jobId, + Long assetId, + String failureCode, + Throwable throwable, + long elapsedMs) { + ProcessingJob job = findReferenceJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return false; + } + + CharacterImageAsset asset = findJobAsset(job, assetId); + if (asset.getStatus() != CharacterImageAssetStatus.GENERATING + && asset.getStatus() != CharacterImageAssetStatus.QA_PENDING) { + return false; + } + + asset.fail(failureCode); + updateProgress(job); + Throwable rootCause = rootCause(throwable); + writeLog(job, ProcessingJobLogLevel.ERROR, "candidate_failed", "Reference candidate slot generation failed.", Map.of( + "assetId", asset.getId(), + "slotNo", asset.getSlotNo(), + "elapsedMs", elapsedMs, + "failureCode", failureCode, + "exceptionType", rootCause.getClass().getName(), + "error", messageOf(rootCause) + )); + return true; + } + + @Transactional + public void finalizeJob(Long jobId) { + ProcessingJob job = findReferenceJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return; + } + finalizeLocked(job, "REFERENCE_GENERATION_INCOMPLETE"); + } + + @Transactional + public void failJob(Long jobId, String failureCode, Throwable throwable) { + ProcessingJob job = findReferenceJobForUpdate(jobId); + if (job.getStatus() == ProcessingJobStatus.READY || job.getStatus() == ProcessingJobStatus.FAILED) { + return; + } + + List assets = assetRepository.findByProcessingJobOrderByIdAsc(job); + assets.stream() + .filter(this::isInProgress) + .forEach(asset -> asset.fail(failureCode)); + + Throwable rootCause = rootCause(throwable); + String step = job.getCurrentStep() == null ? "failed" : job.getCurrentStep(); + writeLog(job, ProcessingJobLogLevel.ERROR, step, "Reference candidate generation job failed.", Map.of( + "failureCode", failureCode, + "exceptionType", rootCause.getClass().getName(), + "error", messageOf(rootCause) + )); + finalizeLocked(job, failureCode); + } + + @Transactional + public void failInterrupted(Long jobId) { + ProcessingJob job = findReferenceJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return; + } + finalizeLocked(job, "REFERENCE_GENERATION_INTERRUPTED"); + } + + @Transactional(readOnly = true) + public ProcessingJobResponseDTO getJob(Long jobId) { + return ProcessingJobResponseDTO.from(findReferenceJob(jobId)); + } + + @Transactional(readOnly = true) + public List getJobLogs(Long jobId) { + ProcessingJob job = findReferenceJob(jobId); + return processingJobLogRepository.findAllByJobIdOrderBySeqAsc(jobId).stream() + .map(logEntry -> ProcessingJobLogResponseDTO.builder() + .id(logEntry.getId()) + .jobId(job.getId()) + .bookTitle(job.getBook().getTitle()) + .seq(logEntry.getSeq()) + .level(logEntry.getLevel()) + .step(logEntry.getStep()) + .message(logEntry.getMessage()) + .payloadJson(logEntry.getPayloadJson()) + .createdAt(logEntry.getCreatedAt()) + .build()) + .toList(); + } + + @Transactional(readOnly = true) + public List findQueuedJobIds() { + return processingJobRepository.findAllByPipelineTypeAndStatusInOrderByCreatedAtAsc( + ProcessingPipelineType.IMAGE_REFERENCE_GENERATION, + EnumSet.of(ProcessingJobStatus.QUEUED) + ).stream() + .map(ProcessingJob::getId) + .toList(); + } + + @Transactional(readOnly = true) + public List findInterruptedJobIds() { + LocalDateTime cutoff = LocalDateTime.now().minus( + Duration.ofMillis(slotTimeoutMs() + 120000) + ); + return processingJobRepository.findAllByPipelineTypeAndStatusInOrderByCreatedAtAsc( + ProcessingPipelineType.IMAGE_REFERENCE_GENERATION, + EnumSet.of(ProcessingJobStatus.PROCESSING) + ).stream() + .filter(job -> job.getUpdatedAt() != null && job.getUpdatedAt().isBefore(cutoff)) + .map(ProcessingJob::getId) + .toList(); + } + + @Transactional(readOnly = true) + public void ensureNoActiveJob(Long bookId) { + ensureNoActiveReferenceJob(bookId); + } + + public long slotTimeoutMs() { + return Math.max(imageProperties.getReferenceCandidateTimeoutMs(), 1000); + } + + private void finalizeLocked(ProcessingJob job, String incompleteFailureCode) { + List assets = assetRepository.findByProcessingJobOrderByIdAsc(job); + assets.stream() + .filter(this::isInProgress) + .forEach(asset -> asset.fail(incompleteFailureCode)); + + long successCount = assets.stream().filter(this::isReadyForSelection).count(); + long failedCount = assets.size() - successCount; + Character referenceCharacter = assets.stream() + .findFirst() + .map(CharacterImageAsset::getCharacter) + .orElse(null); + Optional profile = profileRepository.findByBook(job.getBook()); + + Map payload = Map.of( + "candidateCount", assets.size(), + "successCount", successCount, + "failedCount", failedCount + ); + if (successCount > 0) { + profile.ifPresent(value -> value.markReferenceCandidatesReady(referenceCharacter)); + String step = failedCount > 0 ? "completed_with_partial_failures" : "completed"; + job.markReady(job.getArtifactPath(), step); + ProcessingJobLogLevel level = failedCount > 0 + ? ProcessingJobLogLevel.WARN + : ProcessingJobLogLevel.INFO; + writeLog(job, level, step, "Reference candidate generation job completed.", payload); + return; + } + + profile.ifPresent(value -> value.markQaFailed(referenceCharacter)); + job.markFailed( + "completed_with_failures", + "REFERENCE_GENERATION_FAILED", + "All reference candidate slots failed." + ); + writeLog(job, ProcessingJobLogLevel.ERROR, "completed_with_failures", + "Reference candidate generation job completed without a usable candidate.", payload); + } + + private void updateProgress(ProcessingJob job) { + List assets = assetRepository.findByProcessingJobOrderByIdAsc(job); + long completedCount = assets.stream().filter(asset -> !isInProgress(asset)).count(); + job.advanceProcessing("completed_" + completedCount + "_of_" + assets.size()); + } + + private CharacterImageAsset getOrCreateReferenceCandidate(Book book, + Character referenceCharacter, + int slotNo) { + Optional existing = assetRepository.findByBookAndAssetRoleAndSlotNo( + book, + CharacterImageAssetRole.REFERENCE_CANDIDATE, + slotNo + ); + if (existing.isPresent()) { + CharacterImageAsset asset = existing.get(); + asset.beginReferenceCandidate(referenceCharacter, slotNo); + return asset; + } + + return assetRepository.save(CharacterImageAsset.builder() + .book(book) + .character(referenceCharacter) + .assetRole(CharacterImageAssetRole.REFERENCE_CANDIDATE) + .generationMode(CharacterImageGenerationMode.TEXT_TO_IMAGE) + .slotNo(slotNo) + .status(CharacterImageAssetStatus.GENERATING) + .attemptNo(1) + .build()); + } + + private CharacterImageAsset findJobAsset(ProcessingJob job, Long assetId) { + CharacterImageAsset asset = assetRepository.findById(assetId) + .orElseThrow(() -> new IllegalStateException("Reference candidate asset not found: " + assetId)); + if (asset.getProcessingJob() == null || !job.getId().equals(asset.getProcessingJob().getId())) { + throw new IllegalStateException("Reference candidate asset does not belong to job: " + assetId); + } + return asset; + } + + private ProcessingJob findReferenceJob(Long jobId) { + ProcessingJob job = processingJobRepository.findById(jobId) + .orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_REFERENCE_JOB_NOT_FOUND)); + ensureReferenceJob(job); + return job; + } + + private ProcessingJob findReferenceJobForUpdate(Long jobId) { + ProcessingJob job = processingJobRepository.findByIdForUpdate(jobId) + .orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_REFERENCE_JOB_NOT_FOUND)); + ensureReferenceJob(job); + return job; + } + + private void ensureReferenceJob(ProcessingJob job) { + if (job.getPipelineType() != ProcessingPipelineType.IMAGE_REFERENCE_GENERATION) { + throw new GeneralException(ErrorStatus.IMAGE_REFERENCE_JOB_NOT_FOUND); + } + } + + private void ensureNoActiveReferenceJob(Long bookId) { + processingJobRepository.findFirstByBookIdAndPipelineTypeAndStatusInOrderByCreatedAtDesc( + bookId, + ProcessingPipelineType.IMAGE_REFERENCE_GENERATION, + ACTIVE_JOB_STATUSES + ) + .ifPresent(job -> { + throw new GeneralException(ErrorStatus.IMAGE_REFERENCE_JOB_ACTIVE); + }); + } + + private boolean isReadyForSelection(CharacterImageAsset asset) { + return asset.getStatus() == CharacterImageAssetStatus.QA_PASSED + || asset.getStatus() == CharacterImageAssetStatus.APPROVED + || asset.getStatus() == CharacterImageAssetStatus.PUBLISHED; + } + + private boolean isInProgress(CharacterImageAsset asset) { + return asset.getStatus() == CharacterImageAssetStatus.GENERATING + || asset.getStatus() == CharacterImageAssetStatus.QA_PENDING; + } + + private int candidateCount() { + return Math.min(Math.max(imageProperties.getReferenceCandidateCount(), 1), MAX_CANDIDATE_COUNT); + } + + private String resolveTextImageModel() { + String configured = normalize(imageProperties.getModel()); + return configured != null ? configured : "gpt-image-2"; + } + + private String buildArtifactPath(Long bookId, String runId) { + return "character-images/" + bookId + "/reference-jobs/" + runId; + } + + private String normalize(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + private String sha256(String value) { + if (value == null) { + return null; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(); + for (byte b : hash) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available.", e); + } + } + + private Throwable rootCause(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + private String messageOf(Throwable throwable) { + String message = throwable.getMessage(); + String value = message == null || message.isBlank() + ? throwable.getClass().getSimpleName() + : message; + return value.length() <= 2000 ? value : value.substring(0, 2000); + } + + private void writeLog(ProcessingJob job, + ProcessingJobLogLevel level, + String step, + String message, + Map payload) { + int sequence = (int) processingJobLogRepository.countByJobId(job.getId()) + 1; + processingJobLogRepository.save(ProcessingJobLog.builder() + .job(job) + .seq(sequence) + .level(level) + .step(step) + .message(message) + .payloadJson(serializePayload(payload)) + .build()); + } + + private String serializePayload(Map payload) { + if (payload == null || payload.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(payload); + } catch (JacksonException e) { + return "{\"serializationError\":true}"; + } + } + + public record CandidateJobStart(Long jobId, List assetIds) { + } + + public record SlotContext(Long assetId, + Long characterId, + String runId, + Integer slotNo) { + } +} diff --git a/src/main/java/com/kw/readwith/web/controller/AdminImageGenerationController.java b/src/main/java/com/kw/readwith/web/controller/AdminImageGenerationController.java index bb7c3fb..7ca84f0 100644 --- a/src/main/java/com/kw/readwith/web/controller/AdminImageGenerationController.java +++ b/src/main/java/com/kw/readwith/web/controller/AdminImageGenerationController.java @@ -7,6 +7,8 @@ import com.kw.readwith.service.AdminImageGenerationService; import com.kw.readwith.service.CharacterImageFanoutJobDispatcher; import com.kw.readwith.service.CharacterImageFanoutJobService; +import com.kw.readwith.service.ReferenceCandidateJobDispatcher; +import com.kw.readwith.service.ReferenceCandidateJobService; import com.kw.readwith.domain.enums.ProcessingJobStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -30,8 +32,8 @@ name = "관리자 이미지 생성", description = """ 책 단위 캐릭터 이미지 생성 콘솔 API입니다. - 관리자는 상태 조회 -> 대표 캐릭터 후보사진 4장 생성 -> 후보 1장 선택 -> 개별 캐릭터 재생성 순서로 사용합니다. - 구버전 후보/승인/fan-out API를 직접 조합하지 않고, 이 태그의 4개 API만으로 관리자 페이지 플로우를 구성합니다. + 관리자는 상태 조회 -> 대표 캐릭터 후보사진 2장 생성 -> 후보 1장 선택 -> 개별 캐릭터 재생성 순서로 사용합니다. + 후보 생성과 fan-out은 비동기 job으로 처리되며 각 job의 상태 및 로그 조회 API를 제공합니다. """ ) public class AdminImageGenerationController { @@ -39,12 +41,14 @@ public class AdminImageGenerationController { private final AdminImageGenerationService adminImageGenerationService; private final CharacterImageFanoutJobService fanoutJobService; private final CharacterImageFanoutJobDispatcher fanoutJobDispatcher; + private final ReferenceCandidateJobService referenceCandidateJobService; + private final ReferenceCandidateJobDispatcher referenceCandidateJobDispatcher; @Operation( summary = "책 이미지 생성 상태 조회", description = """ 관리자 페이지가 책 상세 진입 시 가장 먼저 호출하는 상태 조회 API입니다. - 서버가 자동 지정한 대표 캐릭터, 책에 저장된 대표 후보사진 4장, 선택된 후보사진, 캐릭터별 현재 이미지 상태를 함께 반환합니다. + 서버가 자동 지정한 대표 캐릭터, 책에 저장된 대표 후보사진, 선택된 후보사진, 캐릭터별 현재 이미지 상태를 함께 반환합니다. status는 EMPTY, REFERENCE_GENERATING, REFERENCE_READY, FANOUT_GENERATING, READY, FAILED 중 하나이며 nextAction으로 다음 버튼 동작을 판단할 수 있습니다. 이미지 URL이 null이면 아직 생성 중이거나 실패한 상태이므로 failureCode와 imageStatus를 함께 표시해야 합니다. """ @@ -69,18 +73,19 @@ public ApiResponse getBookImageGeneration } @Operation( - summary = "대표 캐릭터 후보사진 4장 생성", + summary = "대표 캐릭터 후보사진 2장 생성 job 등록", description = """ - 서버가 책의 주요 캐릭터를 우선으로 대표 캐릭터를 자동 지정하고, 해당 캐릭터의 후보사진 4장을 생성합니다. - 책에는 slotNo 1~4 후보만 유지되며, 이미 후보가 있으면 같은 slot을 덮어써서 관리자 페이지에는 항상 최대 4장만 노출됩니다. + 서버가 책의 주요 캐릭터를 우선으로 대표 캐릭터를 자동 지정하고, 해당 캐릭터의 후보사진 2장 생성 job을 등록합니다. + 두 slot은 동시성 2로 처리되며, 이미 후보가 있으면 같은 slot을 덮어씁니다. 이 API는 나머지 캐릭터 이미지를 생성하지 않습니다. 관리자가 후보 1장을 선택해야 fan-out 생성이 시작되어 비용 낭비를 줄입니다. - 일부 slot 생성이 실패하면 성공한 후보는 READY로 반환하고 실패 slot은 FAILED 및 failureCode=REFERENCE_GENERATION_FAILED로 반환합니다. + 응답은 생성 완료가 아니라 job 등록 결과입니다. referenceCandidateJob.status=READY 또는 FAILED가 될 때까지 책 상태나 job 상태를 조회해야 합니다. + 슬롯별 최대 처리시간은 기본 7분이며, 일부 slot 생성이 실패해도 하나 이상 성공하면 job은 READY가 됩니다. """ ) @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "대표 캐릭터 후보사진 4장 생성 처리 성공. 개별 생성 실패는 응답의 후보 status/failureCode로 확인합니다.", + description = "대표 캐릭터 후보사진 생성 job 등록 성공. referenceCandidateJob.id로 상태와 로그를 조회합니다.", content = @Content(schema = @Schema(implementation = AdminImageGenerationStatusResponseDTO.class)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -89,7 +94,7 @@ public ApiResponse getBookImageGeneration ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "409", - description = "ADMIN4028: 해당 책의 fan-out job이 진행 중이라 후보를 다시 생성할 수 없습니다." + description = "ADMIN4028: fan-out job이 진행 중입니다. ADMIN4032: 대표 후보사진 생성 job이 이미 진행 중입니다." ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", @@ -101,15 +106,62 @@ public ApiResponse generateReferenceCandi @Parameter(description = "대표 캐릭터 후보사진을 생성할 책 ID", required = true, example = "1") @PathVariable Long bookId) { AdminImageGenerationStatusResponseDTO response = adminImageGenerationService.generateReferenceCandidates(bookId); + ProcessingJobResponseDTO referenceJob = response.getReferenceCandidateJob(); + if (referenceJob != null && referenceJob.getStatus() == ProcessingJobStatus.QUEUED) { + referenceCandidateJobDispatcher.dispatch(referenceJob.getId()); + } return ApiResponse.onSuccess(response); } + @Operation( + summary = "대표 후보사진 생성 job 조회", + description = "대표 후보사진 2개를 병렬 생성하는 job의 상태를 조회합니다. status=READY면 하나 이상의 후보를 선택할 수 있고 FAILED면 모든 slot이 실패한 상태입니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "대표 후보사진 생성 job 조회 성공", + content = @Content(schema = @Schema(implementation = ProcessingJobResponseDTO.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "ADMIN4033: 대표 후보사진 생성 job을 찾을 수 없습니다." + ) + }) + @GetMapping("/reference-candidate-jobs/{jobId}") + public ApiResponse getReferenceCandidateJob( + @Parameter(description = "상태를 조회할 대표 후보사진 생성 job ID", required = true, example = "123") + @PathVariable Long jobId) { + return ApiResponse.onSuccess(referenceCandidateJobService.getJob(jobId)); + } + + @Operation( + summary = "대표 후보사진 생성 job 로그 조회", + description = "slot별 시작, 처리시간, 성공 또는 상세 실패 원인을 순서대로 조회합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "대표 후보사진 생성 job 로그 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "ADMIN4033: 대표 후보사진 생성 job을 찾을 수 없습니다." + ) + }) + @GetMapping("/reference-candidate-jobs/{jobId}/logs") + public ApiResponse> getReferenceCandidateJobLogs( + @Parameter(description = "로그를 조회할 대표 후보사진 생성 job ID", required = true, example = "123") + @PathVariable Long jobId) { + return ApiResponse.onSuccess(referenceCandidateJobService.getJobLogs(jobId)); + } + @Operation( summary = "대표 후보사진 1장 선택 및 Batch fan-out 등록", description = """ - 관리자가 대표 후보사진 4장 중 마음에 드는 1장을 선택할 때 호출합니다. + 관리자가 생성된 대표 후보사진 중 마음에 드는 1장을 선택할 때 호출합니다. candidateId에는 상태 조회 또는 후보 생성 응답의 referenceCandidates[].id에 담긴 asset DB ID를 전달합니다. - referenceCandidates[].slotNo(1~4)는 화면 표시용 슬롯 번호이며 candidateId로 사용할 수 없습니다. + referenceCandidates[].slotNo는 화면 표시용 슬롯 번호이며 candidateId로 사용할 수 없습니다. 선택된 후보는 책의 active reference image가 되고, 대표 캐릭터의 게시 이미지로 즉시 반영됩니다. 이후 서버는 선택된 한 장을 canonical reference로 고정하고, 같은 reference URL과 GPT Image 2 series-lock 프롬프트를 사용하는 OpenAI Batch job을 등록합니다. 응답의 fanoutJob.status=QUEUED는 등록 대기 상태이며 이미지 생성 완료를 뜻하지 않습니다. fanoutJob.status=READY가 되어야 모든 대상 이미지의 DB/S3 반영이 끝난 상태입니다. @@ -140,7 +192,7 @@ public ApiResponse selectReferenceCandida @Parameter(description = "대표 후보사진을 선택할 책 ID", required = true, example = "1") @PathVariable Long bookId, @Parameter( - description = "선택할 대표 후보사진의 asset DB ID. referenceCandidates[].id 값을 전달하며 slotNo(1~4)가 아닙니다.", + description = "선택할 대표 후보사진의 asset DB ID. referenceCandidates[].id 값을 전달하며 slotNo가 아닙니다.", required = true, example = "101" ) @@ -233,7 +285,7 @@ public ApiResponse retryFanoutResultPublish( description = """ fan-out 이후 특정 캐릭터 이미지가 마음에 들지 않을 때 그 캐릭터 1명만 다시 생성합니다. 책에 선택된 대표 후보사진(active reference image)이 있어야 하며, 기존 대표 후보사진을 입력 이미지로 재사용합니다. - 대표 캐릭터 본인의 이미지는 이 API로 재생성하지 않습니다. 대표 후보사진 4장을 다시 생성한 뒤 새 후보를 선택해야 합니다. + 대표 캐릭터 본인의 이미지는 이 API로 재생성하지 않습니다. 대표 후보사진을 다시 생성한 뒤 새 후보를 선택해야 합니다. 성공 시 해당 캐릭터의 게시 이미지 경로를 덮어쓰고, 실패 시 imageStatus=FAILED 및 failureCode=REFERENCE_EDIT_FAILED로 반환합니다. """ ) @@ -245,7 +297,7 @@ public ApiResponse retryFanoutResultPublish( ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", - description = "ADMIN4022: 대표 캐릭터는 후보 4장 재생성 후 다시 선택해야 합니다. ADMIN4023: 선택된 대표 후보사진이 없습니다." + description = "ADMIN4022: 대표 캐릭터는 후보사진 재생성 후 다시 선택해야 합니다. ADMIN4023: 선택된 대표 후보사진이 없습니다." ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "409", diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d8630de..63733d4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -216,6 +216,8 @@ character-image: s3-path: character-images batch-poll-interval-ms: ${CHARACTER_IMAGE_BATCH_POLL_INTERVAL_MS:30000} + reference-candidate-count: ${CHARACTER_IMAGE_REFERENCE_CANDIDATE_COUNT:2} + reference-candidate-timeout-ms: ${CHARACTER_IMAGE_REFERENCE_CANDIDATE_TIMEOUT_MS:420000} # OpenAI API Rate Limit 방지 설정 delay-between-requests-ms: 2000 # 각 요청 사이 2초 대기 diff --git a/src/test/java/com/kw/readwith/controller/AdminSwaggerDocumentationTest.java b/src/test/java/com/kw/readwith/controller/AdminSwaggerDocumentationTest.java index bbc41f6..e8ada14 100644 --- a/src/test/java/com/kw/readwith/controller/AdminSwaggerDocumentationTest.java +++ b/src/test/java/com/kw/readwith/controller/AdminSwaggerDocumentationTest.java @@ -44,11 +44,11 @@ void distinguishesCandidateAssetIdFromSlotNumber() throws ReflectiveOperationExc Operation operation = method.getAnnotation(Operation.class); assertThat(operation.description()) - .contains("referenceCandidates[].id", "asset DB ID", "slotNo(1~4)"); + .contains("referenceCandidates[].id", "asset DB ID", "slotNo"); Parameter candidateId = method.getParameters()[1].getAnnotation(Parameter.class); assertThat(candidateId.description()) - .contains("referenceCandidates[].id", "slotNo(1~4)가 아닙니다"); + .contains("referenceCandidates[].id", "slotNo가 아닙니다"); Schema idSchema = AdminImageGenerationStatusResponseDTO.ReferenceCandidate.class .getDeclaredField("id") @@ -60,4 +60,28 @@ void distinguishesCandidateAssetIdFromSlotNumber() throws ReflectiveOperationExc assertThat(idSchema.description()).contains("candidateId"); assertThat(slotSchema.description()).contains("candidateId로 사용하지 않습니다"); } + + @Test + void documentsAsynchronousReferenceCandidateJobContract() throws NoSuchMethodException { + Method method = AdminImageGenerationController.class.getDeclaredMethod( + "generateReferenceCandidates", + Long.class + ); + + Operation operation = method.getAnnotation(Operation.class); + assertThat(operation.summary()).contains("후보사진 2장", "job 등록"); + assertThat(operation.description()) + .contains("동시성 2", "referenceCandidateJob.status=READY", "기본 7분"); + + Method jobStatus = AdminImageGenerationController.class.getDeclaredMethod( + "getReferenceCandidateJob", + Long.class + ); + Method jobLogs = AdminImageGenerationController.class.getDeclaredMethod( + "getReferenceCandidateJobLogs", + Long.class + ); + assertThat(jobStatus.getAnnotation(Operation.class).description()).contains("status=READY", "FAILED"); + assertThat(jobLogs.getAnnotation(Operation.class).description()).contains("처리시간", "상세 실패 원인"); + } } diff --git a/src/test/java/com/kw/readwith/controller/ApiRouteAliasMappingTest.java b/src/test/java/com/kw/readwith/controller/ApiRouteAliasMappingTest.java index 61802ff..ba23d72 100644 --- a/src/test/java/com/kw/readwith/controller/ApiRouteAliasMappingTest.java +++ b/src/test/java/com/kw/readwith/controller/ApiRouteAliasMappingTest.java @@ -49,6 +49,8 @@ void exposesV2AliasesForBookFavoriteAndPovSummaryApis() { assertThat(paths).contains("/api/v2/favorites/{bookId}"); assertThat(paths).contains("/api/v2/admin/image-generation/books/{bookId}"); assertThat(paths).contains("/api/v2/admin/image-generation/books/{bookId}/reference-candidates"); + assertThat(paths).contains("/api/v2/admin/image-generation/reference-candidate-jobs/{jobId}"); + assertThat(paths).contains("/api/v2/admin/image-generation/reference-candidate-jobs/{jobId}/logs"); assertThat(paths).contains("/api/v2/admin/image-generation/books/{bookId}/reference-candidates/{candidateId}/select"); assertThat(paths).contains("/api/v2/admin/image-generation/books/{bookId}/characters/{characterId}/regenerate"); assertThat(paths).contains("/api/v2/admin/books/{bookId}"); diff --git a/src/test/java/com/kw/readwith/service/AdminImageGenerationServiceTest.java b/src/test/java/com/kw/readwith/service/AdminImageGenerationServiceTest.java index 2197c4e..e2574c9 100644 --- a/src/test/java/com/kw/readwith/service/AdminImageGenerationServiceTest.java +++ b/src/test/java/com/kw/readwith/service/AdminImageGenerationServiceTest.java @@ -7,6 +7,7 @@ import com.kw.readwith.domain.BookCharacterImageProfile; import com.kw.readwith.domain.Character; import com.kw.readwith.domain.CharacterImageAsset; +import com.kw.readwith.domain.enums.BookImageReferenceStatus; import com.kw.readwith.domain.enums.CharacterImageAssetRole; import com.kw.readwith.domain.enums.CharacterImageAssetStatus; import com.kw.readwith.domain.enums.CharacterImageGenerationMode; @@ -20,7 +21,6 @@ import com.kw.readwith.repository.CharacterImageAssetRepository; import com.kw.readwith.repository.CharacterRepository; import com.kw.readwith.repository.ProcessingJobRepository; -import com.kw.readwith.service.image.GeneratedCharacterImage; import com.kw.readwith.service.image.OpenAiImageEditClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -29,24 +29,19 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.client.RestTemplate; -import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) @DisplayName("AdminImageGenerationService") @@ -72,6 +67,8 @@ class AdminImageGenerationServiceTest { @Mock private CharacterImageFanoutJobService fanoutJobService; @Mock + private ReferenceCandidateJobService referenceCandidateJobService; + @Mock private ProcessingJobRepository processingJobRepository; @Mock private RestTemplate restTemplate; @@ -113,68 +110,64 @@ void setUp() { } @Test - @DisplayName("representative candidate generation creates four book-scoped slots") - void generateReferenceCandidates_createsFourSlots() throws Exception { + @DisplayName("representative candidate generation queues two book-scoped slots without blocking") + void generateReferenceCandidates_queuesTwoSlots() { + ProcessingJob referenceJob = ProcessingJob.builder() + .id(500L) + .book(book) + .pipelineType(ProcessingPipelineType.IMAGE_REFERENCE_GENERATION) + .runId("reference-candidates-run-1") + .status(ProcessingJobStatus.QUEUED) + .currentStep("queued") + .build(); BookCharacterImageProfile profile = BookCharacterImageProfile.builder() .book(book) + .referenceCharacter(mainCharacter) + .referenceStatus(BookImageReferenceStatus.CANDIDATE_GENERATING) .build(); - List savedAssets = new ArrayList<>(); - AtomicLong idSequence = new AtomicLong(100L); + List candidates = List.of( + referenceCandidate(100L, 1, referenceJob), + referenceCandidate(101L, 2, referenceJob) + ); given(bookRepository.findById(1L)).willReturn(Optional.of(book)); - given(imageProperties.getModel()).willReturn("gpt-image-2"); - given(imageProperties.getBaseStylePrompt()).willReturn("editorial gouache portrait"); + given(imageProperties.getReferenceCandidateCount()).willReturn(2); given(characterRepository.findByBookOrderByIsMainCharacterDescNameAsc(book)) .willReturn(List.of(mainCharacter, sideCharacter)); - given(profileRepository.findByBook(book)).willReturn(Optional.empty(), Optional.of(profile)); - when(profileRepository.save(any(BookCharacterImageProfile.class))).thenReturn(profile); - given(assetRepository.findByBookAndAssetRoleAndSlotNo( - eq(book), - eq(CharacterImageAssetRole.REFERENCE_CANDIDATE), - any() - )).willReturn(Optional.empty()); - when(assetRepository.save(any(CharacterImageAsset.class))).thenAnswer(invocation -> { - CharacterImageAsset asset = invocation.getArgument(0); - ReflectionTestUtils.setField(asset, "id", idSequence.getAndIncrement()); - savedAssets.add(asset); - return asset; - }); + given(profileRepository.findByBook(book)).willReturn(Optional.of(profile)); given(assetRepository.findByBookAndAssetRoleOrderBySlotNoAscCreatedAtAsc( book, CharacterImageAssetRole.REFERENCE_CANDIDATE - )).willAnswer(invocation -> savedAssets); + )).willReturn(candidates); given(assetRepository.findByBookAndAssetRoleOrderByCreatedAtDesc( book, CharacterImageAssetRole.CHARACTER_IMAGE )).willReturn(List.of()); - given(characterImageService.generateTextImage(mainCharacter)) - .willReturn(new GeneratedCharacterImage(new byte[]{1}, "gpt-image-2", "prompt", "hash", "req-1")); - when(characterImageService.buildReferenceCandidateSlotS3KeyName( - eq(mainCharacter), - anyInt(), - anyInt() - )).thenAnswer(invocation -> "character-images/1/reference/attempt-" - + invocation.getArgument(2) + "/slot-" + invocation.getArgument(1) + ".png"); - when(characterImageService.uploadGeneratedImage(eq(mainCharacter), any(byte[].class), anyString())) - .thenAnswer(invocation -> "https://cdn.test/" + invocation.getArgument(2)); + given(processingJobRepository.findTopByBookIdAndPipelineTypeOrderByCreatedAtDesc( + 1L, + ProcessingPipelineType.IMAGE_REFERENCE_GENERATION + )).willReturn(Optional.of(referenceJob)); + lenient().when(processingJobRepository.findTopByBookIdAndPipelineTypeOrderByCreatedAtDesc( + 1L, + ProcessingPipelineType.IMAGE_GENERATION + )).thenReturn(Optional.empty()); AdminImageGenerationStatusResponseDTO response = adminImageGenerationService.generateReferenceCandidates(1L); - assertThat(response.getStatus()).isEqualTo("REFERENCE_READY"); - assertThat(response.getNextAction()).isEqualTo("SELECT_REFERENCE_CANDIDATE"); + assertThat(response.getStatus()).isEqualTo("REFERENCE_GENERATING"); + assertThat(response.getNextAction()).isEqualTo("WAIT_REFERENCE_CANDIDATES"); + assertThat(response.getReferenceCandidateJob().getId()).isEqualTo(500L); + assertThat(response.getReferenceCandidateJob().getStatus()).isEqualTo(ProcessingJobStatus.QUEUED); assertThat(response.getReferenceCharacter().getId()).isEqualTo(10L); - assertThat(response.getReferenceCandidates()).hasSize(4); + assertThat(response.getReferenceCandidates()).hasSize(2); assertThat(response.getReferenceCandidates()) .extracting(AdminImageGenerationStatusResponseDTO.ReferenceCandidate::getSlotNo) - .containsExactly(1, 2, 3, 4); + .containsExactly(1, 2); assertThat(response.getReferenceCandidates()) .extracting(AdminImageGenerationStatusResponseDTO.ReferenceCandidate::getStatus) - .containsOnly("READY"); - verify(characterImageService).uploadGeneratedImage( - eq(mainCharacter), - any(byte[].class), - eq("character-images/1/reference/attempt-1/slot-1.png") - ); + .containsOnly("GENERATING"); + verify(referenceCandidateJobService).queue(book, mainCharacter); + verifyNoInteractions(characterImageService); } @Test @@ -239,4 +232,18 @@ void regenerateCharacterImage_rejectsActiveFanoutJob() { assertThat(exception.getErrorCode()).isEqualTo(ErrorStatus.IMAGE_FANOUT_JOB_ACTIVE); } + + private CharacterImageAsset referenceCandidate(Long id, int slotNo, ProcessingJob job) { + return CharacterImageAsset.builder() + .id(id) + .book(book) + .character(mainCharacter) + .assetRole(CharacterImageAssetRole.REFERENCE_CANDIDATE) + .generationMode(CharacterImageGenerationMode.TEXT_TO_IMAGE) + .processingJob(job) + .slotNo(slotNo) + .status(CharacterImageAssetStatus.GENERATING) + .attemptNo(1) + .build(); + } } diff --git a/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java b/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java index dd952e0..169647e 100644 --- a/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java +++ b/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java @@ -254,6 +254,16 @@ void buildPublishedS3KeyName_usesImmutableVersionedPath() { .isEqualTo("character-images/1/10/reference-v2/attempt-3.png"); } + @Test + @DisplayName("reference candidates use the processing job artifact path") + void buildReferenceCandidateJobS3KeyName_usesJobPath() { + assertThat(characterImageService.buildReferenceCandidateJobS3KeyName( + testCharacter, + "reference-candidates-run-1", + 2 + )).isEqualTo("character-images/1/reference-jobs/reference-candidates-run-1/slot-2.png"); + } + @Test @DisplayName("replaced character image is deleted from S3") void deleteReplacedGeneratedImage_deletesPreviousObject() { @@ -284,6 +294,18 @@ void deleteReplacedGeneratedImage_keepsSharedPublicObject() { verify(s3Manager, never()).deleteKeys(any()); } + @Test + @DisplayName("unused generated character image is deleted from S3") + void deleteGeneratedImage_deletesManagedObject() { + String imageUrl = "https://cdn.readwith.store/character-images/1/reference-jobs/run-1/slot-1.png"; + when(cdnUrlService.toPublicObjectKey(imageUrl)) + .thenReturn(Optional.of("character-images/1/reference-jobs/run-1/slot-1.png")); + + characterImageService.deleteGeneratedImage(imageUrl); + + verify(s3Manager).deleteKeys(List.of("character-images/1/reference-jobs/run-1/slot-1.png")); + } + @Test @DisplayName("generateImagesAsync stores fallback image when generation fails") void generateImagesAsync_storesFallbackImageOnFailure() { diff --git a/src/test/java/com/kw/readwith/service/ReferenceCandidateJobDispatcherTest.java b/src/test/java/com/kw/readwith/service/ReferenceCandidateJobDispatcherTest.java new file mode 100644 index 0000000..45a2631 --- /dev/null +++ b/src/test/java/com/kw/readwith/service/ReferenceCandidateJobDispatcherTest.java @@ -0,0 +1,40 @@ +package com.kw.readwith.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.Executor; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReferenceCandidateJobDispatcherTest { + + @Mock + private ReferenceCandidateJobService jobService; + @Mock + private Executor jobExecutor; + + @Test + void dispatchSuppressesDuplicateQueueEntriesUntilCurrentTaskFinishes() { + ReferenceCandidateJobDispatcher dispatcher = new ReferenceCandidateJobDispatcher( + jobService, + jobExecutor + ); + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class); + + dispatcher.dispatch(900L); + dispatcher.dispatch(900L); + + verify(jobExecutor).execute(taskCaptor.capture()); + taskCaptor.getValue().run(); + verify(jobService).process(900L); + + dispatcher.dispatch(900L); + verify(jobExecutor, times(2)).execute(taskCaptor.capture()); + } +} diff --git a/src/test/java/com/kw/readwith/service/ReferenceCandidateJobServiceTest.java b/src/test/java/com/kw/readwith/service/ReferenceCandidateJobServiceTest.java new file mode 100644 index 0000000..de9bbc1 --- /dev/null +++ b/src/test/java/com/kw/readwith/service/ReferenceCandidateJobServiceTest.java @@ -0,0 +1,124 @@ +package com.kw.readwith.service; + +import com.kw.readwith.domain.Book; +import com.kw.readwith.domain.Character; +import com.kw.readwith.repository.CharacterRepository; +import com.kw.readwith.service.ReferenceCandidateJobTransactionService.CandidateJobStart; +import com.kw.readwith.service.ReferenceCandidateJobTransactionService.SlotContext; +import com.kw.readwith.service.image.GeneratedCharacterImage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ReferenceCandidateJobServiceTest { + + @Mock + private ReferenceCandidateJobTransactionService transactionService; + @Mock + private CharacterRepository characterRepository; + @Mock + private CharacterImageService characterImageService; + + private ExecutorService slotExecutor; + + @AfterEach + void tearDown() { + if (slotExecutor != null) { + slotExecutor.shutdownNow(); + } + } + + @Test + void processGeneratesTwoSlotsConcurrentlyAndFinalizesJob() throws Exception { + Book book = Book.builder().id(20L).title("Dracula").build(); + Character character = Character.builder() + .id(200L) + .book(book) + .characterId(1L) + .name("Van Helsing") + .build(); + slotExecutor = Executors.newFixedThreadPool(2); + ReferenceCandidateJobService service = new ReferenceCandidateJobService( + transactionService, + characterRepository, + characterImageService, + slotExecutor + ); + CountDownLatch bothStarted = new CountDownLatch(2); + AtomicInteger activeCalls = new AtomicInteger(); + AtomicInteger maxActiveCalls = new AtomicInteger(); + + given(transactionService.start(900L)).willReturn(new CandidateJobStart(900L, List.of(10L, 11L))); + given(transactionService.slotTimeoutMs()).willReturn(5000L); + given(transactionService.loadSlotContext(eq(900L), anyLong())).willAnswer(invocation -> { + Long assetId = invocation.getArgument(1); + int slotNo = assetId.equals(10L) ? 1 : 2; + return new SlotContext(assetId, 200L, "reference-candidates-run", slotNo); + }); + given(characterRepository.findByIdWithBook(200L)).willReturn(Optional.of(character)); + given(characterImageService.generateTextImage(character)).willAnswer(invocation -> { + int active = activeCalls.incrementAndGet(); + maxActiveCalls.accumulateAndGet(active, Math::max); + bothStarted.countDown(); + assertThat(bothStarted.await(2, TimeUnit.SECONDS)).isTrue(); + activeCalls.decrementAndGet(); + return new GeneratedCharacterImage(new byte[]{1}, "gpt-image-2", "prompt", "hash", "req"); + }); + given(characterImageService.buildReferenceCandidateJobS3KeyName( + eq(character), + eq("reference-candidates-run"), + anyInt() + )).willAnswer(invocation -> "character-images/20/reference-jobs/reference-candidates-run/slot-" + + invocation.getArgument(2) + ".png"); + given(characterImageService.uploadGeneratedImage( + eq(character), + any(byte[].class), + anyString() + )).willAnswer(invocation -> "https://cdn.test/" + invocation.getArgument(2)); + given(transactionService.completeSlotSuccess( + eq(900L), + anyLong(), + anyString(), + eq("gpt-image-2"), + eq("hash"), + eq("req"), + anyLong() + )).willReturn(true); + + service.process(900L); + + assertThat(maxActiveCalls).hasValue(2); + verify(characterImageService, times(2)).generateTextImage(character); + verify(transactionService, times(2)).completeSlotSuccess( + eq(900L), + anyLong(), + anyString(), + eq("gpt-image-2"), + eq("hash"), + eq("req"), + anyLong() + ); + verify(transactionService).finalizeJob(900L); + } +} diff --git a/src/test/java/com/kw/readwith/service/ReferenceCandidateJobTransactionServiceTest.java b/src/test/java/com/kw/readwith/service/ReferenceCandidateJobTransactionServiceTest.java new file mode 100644 index 0000000..9118c9b --- /dev/null +++ b/src/test/java/com/kw/readwith/service/ReferenceCandidateJobTransactionServiceTest.java @@ -0,0 +1,188 @@ +package com.kw.readwith.service; + +import com.kw.readwith.config.CharacterImageProperties; +import com.kw.readwith.domain.Book; +import com.kw.readwith.domain.BookCharacterImageProfile; +import com.kw.readwith.domain.Character; +import com.kw.readwith.domain.CharacterImageAsset; +import com.kw.readwith.domain.enums.BookImageReferenceStatus; +import com.kw.readwith.domain.enums.CharacterImageAssetRole; +import com.kw.readwith.domain.enums.CharacterImageAssetStatus; +import com.kw.readwith.domain.enums.ProcessingJobLogLevel; +import com.kw.readwith.domain.enums.ProcessingJobStatus; +import com.kw.readwith.domain.enums.ProcessingPipelineType; +import com.kw.readwith.domain.processing.ProcessingJob; +import com.kw.readwith.domain.processing.ProcessingJobLog; +import com.kw.readwith.dto.admin.ProcessingJobResponseDTO; +import com.kw.readwith.repository.BookCharacterImageProfileRepository; +import com.kw.readwith.repository.CharacterImageAssetRepository; +import com.kw.readwith.repository.ProcessingJobLogRepository; +import com.kw.readwith.repository.ProcessingJobRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import tools.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReferenceCandidateJobTransactionServiceTest { + + @InjectMocks + private ReferenceCandidateJobTransactionService service; + + @Mock + private BookCharacterImageProfileRepository profileRepository; + @Mock + private CharacterImageAssetRepository assetRepository; + @Mock + private ProcessingJobRepository processingJobRepository; + @Mock + private ProcessingJobLogRepository processingJobLogRepository; + @Mock + private CharacterImageService characterImageService; + @Mock + private CharacterImageProperties imageProperties; + @Mock + private ObjectMapper objectMapper; + + private Book book; + private Character referenceCharacter; + + @BeforeEach + void setUp() { + book = Book.builder() + .id(20L) + .title("Dracula") + .author("Bram Stoker") + .language("en") + .bookPrompt("Victorian gothic portrait") + .build(); + referenceCharacter = Character.builder() + .id(200L) + .book(book) + .characterId(1L) + .name("Van Helsing") + .profileText("elderly professor") + .isMainCharacter(true) + .build(); + } + + @Test + void queueCreatesTwoGeneratingSlotsAndReturnsImmediately() throws Exception { + BookCharacterImageProfile profile = BookCharacterImageProfile.builder() + .book(book) + .build(); + List savedAssets = new ArrayList<>(); + AtomicLong assetIds = new AtomicLong(1L); + + given(profileRepository.findByBook(book)).willReturn(Optional.empty()); + when(profileRepository.save(any(BookCharacterImageProfile.class))).thenReturn(profile); + given(imageProperties.getModel()).willReturn("gpt-image-2"); + given(imageProperties.getBaseStylePrompt()).willReturn("editorial gouache"); + given(imageProperties.getReferenceCandidateCount()).willReturn(2); + given(imageProperties.getReferenceCandidateTimeoutMs()).willReturn(420000L); + given(characterImageService.buildImagePrompt(referenceCharacter)).willReturn("prompt"); + given(characterImageService.buildPromptHash("prompt")).willReturn("prompt-hash"); + when(processingJobRepository.save(any(ProcessingJob.class))).thenAnswer(invocation -> { + ProcessingJob job = invocation.getArgument(0); + ReflectionTestUtils.setField(job, "id", 900L); + return job; + }); + when(assetRepository.save(any(CharacterImageAsset.class))).thenAnswer(invocation -> { + CharacterImageAsset asset = invocation.getArgument(0); + ReflectionTestUtils.setField(asset, "id", assetIds.getAndIncrement()); + savedAssets.add(asset); + return asset; + }); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + ProcessingJobResponseDTO response = service.queue(book, referenceCharacter); + + assertThat(response.getId()).isEqualTo(900L); + assertThat(response.getPipelineType()).isEqualTo(ProcessingPipelineType.IMAGE_REFERENCE_GENERATION); + assertThat(response.getStatus()).isEqualTo(ProcessingJobStatus.QUEUED); + assertThat(profile.getReferenceStatus()).isEqualTo(BookImageReferenceStatus.CANDIDATE_GENERATING); + assertThat(savedAssets).hasSize(2); + assertThat(savedAssets).extracting(CharacterImageAsset::getSlotNo).containsExactly(1, 2); + assertThat(savedAssets).extracting(CharacterImageAsset::getStatus) + .containsOnly(CharacterImageAssetStatus.GENERATING); + assertThat(savedAssets).allSatisfy(asset -> { + assertThat(asset.getProcessingJob().getId()).isEqualTo(900L); + assertThat(asset.getModel()).isEqualTo("gpt-image-2"); + assertThat(asset.getPromptHash()).isEqualTo("prompt-hash"); + }); + verify(assetRepository, times(2)).save(any(CharacterImageAsset.class)); + verify(processingJobLogRepository).save(any(ProcessingJobLog.class)); + } + + @Test + @SuppressWarnings("unchecked") + void slotFailureLogContainsElapsedTimeAndRootCause() throws Exception { + ProcessingJob job = ProcessingJob.builder() + .id(901L) + .book(book) + .pipelineType(ProcessingPipelineType.IMAGE_REFERENCE_GENERATION) + .runId("reference-candidates-run") + .status(ProcessingJobStatus.PROCESSING) + .currentStep("generating_candidates") + .build(); + CharacterImageAsset asset = CharacterImageAsset.builder() + .id(10L) + .book(book) + .character(referenceCharacter) + .assetRole(CharacterImageAssetRole.REFERENCE_CANDIDATE) + .processingJob(job) + .slotNo(1) + .status(CharacterImageAssetStatus.GENERATING) + .attemptNo(1) + .build(); + + given(processingJobRepository.findByIdForUpdate(901L)).willReturn(Optional.of(job)); + given(assetRepository.findById(10L)).willReturn(Optional.of(asset)); + given(assetRepository.findByProcessingJobOrderByIdAsc(job)).willReturn(List.of(asset)); + given(objectMapper.writeValueAsString(any())).willReturn("{}"); + + boolean completed = service.completeSlotFailure( + 901L, + 10L, + "REFERENCE_GENERATION_FAILED", + new IllegalStateException("OpenAI upstream failure"), + 1234L + ); + + assertThat(completed).isTrue(); + assertThat(asset.getStatus()).isEqualTo(CharacterImageAssetStatus.FAILED); + assertThat(asset.getFailureCode()).isEqualTo("REFERENCE_GENERATION_FAILED"); + assertThat(job.getCurrentStep()).isEqualTo("completed_1_of_1"); + + ArgumentCaptor> payloadCaptor = ArgumentCaptor.forClass(Map.class); + verify(objectMapper).writeValueAsString(payloadCaptor.capture()); + assertThat(payloadCaptor.getValue()) + .containsEntry("slotNo", 1) + .containsEntry("elapsedMs", 1234L) + .containsEntry("exceptionType", IllegalStateException.class.getName()) + .containsEntry("error", "OpenAI upstream failure"); + + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(ProcessingJobLog.class); + verify(processingJobLogRepository).save(logCaptor.capture()); + assertThat(logCaptor.getValue().getLevel()).isEqualTo(ProcessingJobLogLevel.ERROR); + assertThat(logCaptor.getValue().getStep()).isEqualTo("candidate_failed"); + } +}