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 81477bc..24cf2e9 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 @@ -63,6 +63,8 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_IMAGE_PUBLISH_POLICY(HttpStatus.BAD_REQUEST, "ADMIN4025", "잘못된 이미지 게시 정책입니다. 허용값: AUTO_AFTER_QA, MANUAL"), IMAGE_REFERENCE_CHARACTER_REQUIRED(HttpStatus.BAD_REQUEST, "ADMIN4026", "대표 이미지 후보로 사용할 인물을 찾을 수 없습니다."), BOOK_DELETE_JOB_IN_PROGRESS(HttpStatus.CONFLICT, "ADMIN4027", "진행 중인 처리 job이 있어 책을 삭제할 수 없습니다."), + IMAGE_FANOUT_JOB_ACTIVE(HttpStatus.CONFLICT, "ADMIN4028", "해당 책의 캐릭터 이미지 fan-out job이 이미 진행 중입니다."), + IMAGE_FANOUT_JOB_NOT_FOUND(HttpStatus.NOT_FOUND, "ADMIN4029", "캐릭터 이미지 fan-out 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 b2ed2d6..6536c03 100644 --- a/src/main/java/com/kw/readwith/config/AsyncConfig.java +++ b/src/main/java/com/kw/readwith/config/AsyncConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @@ -15,6 +16,7 @@ */ @Configuration @EnableAsync +@EnableScheduling public class AsyncConfig implements AsyncConfigurer { /** diff --git a/src/main/java/com/kw/readwith/config/CharacterImageProperties.java b/src/main/java/com/kw/readwith/config/CharacterImageProperties.java index 47669bc..0933c93 100644 --- a/src/main/java/com/kw/readwith/config/CharacterImageProperties.java +++ b/src/main/java/com/kw/readwith/config/CharacterImageProperties.java @@ -14,8 +14,8 @@ @ConfigurationProperties(prefix = "character-image") public class CharacterImageProperties { - private String model = "gpt-image-1"; - private String editModel = "gpt-image-1"; + private String model = "gpt-image-2"; + private String editModel = "gpt-image-2"; private String quality = "medium"; private int width = 1024; private int height = 1024; @@ -48,5 +48,10 @@ public class CharacterImageProperties { * 기본값: 10명 */ private int batchSize = 10; + + /** + * OpenAI Batch 상태를 다시 확인하는 간격입니다. + */ + private long batchPollIntervalMs = 30000; } diff --git a/src/main/java/com/kw/readwith/domain/CharacterImageAsset.java b/src/main/java/com/kw/readwith/domain/CharacterImageAsset.java index 929b91b..8fc7185 100644 --- a/src/main/java/com/kw/readwith/domain/CharacterImageAsset.java +++ b/src/main/java/com/kw/readwith/domain/CharacterImageAsset.java @@ -4,6 +4,7 @@ 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.processing.ProcessingJob; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -56,6 +57,10 @@ public class CharacterImageAsset extends BaseEntity { @JoinColumn(name = "source_reference_asset_id") private CharacterImageAsset sourceReferenceAsset; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "processing_job_id") + private ProcessingJob processingJob; + @Column(name = "reference_version", nullable = false) @Builder.Default private int referenceVersion = 0; @@ -147,7 +152,6 @@ public void beginReferenceCandidate(Character referenceCharacter, int slotNo) { this.referenceVersion = 0; this.slotNo = slotNo; this.status = CharacterImageAssetStatus.GENERATING; - this.s3Url = null; this.model = null; this.promptHash = null; this.qaResultJson = null; @@ -164,10 +168,10 @@ public void beginCharacterImage(Character targetCharacter, this.assetRole = CharacterImageAssetRole.CHARACTER_IMAGE; this.generationMode = CharacterImageGenerationMode.REFERENCE_EDIT; this.sourceReferenceAsset = sourceReferenceAsset; + this.processingJob = null; this.referenceVersion = referenceVersion; this.slotNo = null; this.status = CharacterImageAssetStatus.GENERATING; - this.s3Url = null; this.model = null; this.promptHash = null; this.qaResultJson = null; @@ -176,4 +180,10 @@ public void beginCharacterImage(Character targetCharacter, this.publishedAt = null; this.attemptNo += 1; } + + public void assignProcessingJob(ProcessingJob processingJob, String model, String promptHash) { + this.processingJob = processingJob; + this.model = model; + this.promptHash = promptHash; + } } 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 50aeda9..1be0e89 100644 --- a/src/main/java/com/kw/readwith/domain/enums/ProcessingPipelineType.java +++ b/src/main/java/com/kw/readwith/domain/enums/ProcessingPipelineType.java @@ -2,5 +2,6 @@ public enum ProcessingPipelineType { NORMALIZATION, - AI_ANALYSIS + AI_ANALYSIS, + IMAGE_GENERATION } diff --git a/src/main/java/com/kw/readwith/domain/processing/ProcessingJob.java b/src/main/java/com/kw/readwith/domain/processing/ProcessingJob.java index e1310d6..c4463d5 100644 --- a/src/main/java/com/kw/readwith/domain/processing/ProcessingJob.java +++ b/src/main/java/com/kw/readwith/domain/processing/ProcessingJob.java @@ -56,6 +56,18 @@ public class ProcessingJob extends BaseEntity { @Column(name = "artifact_path", length = 255) private String artifactPath; // 결과물이 저장된 루트 경로 + @Column(name = "external_job_id", length = 120) + private String externalJobId; + + @Column(name = "input_file_id", length = 120) + private String inputFileId; + + @Column(name = "output_file_id", length = 120) + private String outputFileId; + + @Column(name = "error_file_id", length = 120) + private String errorFileId; + @Column(name = "rule_version", length = 50) private String ruleVersion; // 이 job이 사용한 정규화 규칙 버전 @@ -114,4 +126,31 @@ public void assignNormalizationVersion(String ruleVersion, String locatorVersion this.ruleVersion = ruleVersion; this.locatorVersion = locatorVersion; } + + public void markExternalJobSubmitted(String externalJobId, String inputFileId, String externalStatus) { + this.externalJobId = externalJobId; + this.inputFileId = inputFileId; + this.status = ProcessingJobStatus.PROCESSING; + this.currentStep = "openai_" + externalStatus; + if (this.startedAt == null) { + this.startedAt = LocalDateTime.now(); + } + this.finishedAt = null; + this.failureCode = null; + this.failureMessage = null; + } + + public void updateExternalJobStatus(String externalStatus, String outputFileId, String errorFileId) { + this.currentStep = "openai_" + externalStatus; + this.outputFileId = outputFileId; + this.errorFileId = errorFileId; + } + + public void advanceProcessing(String currentStep) { + this.status = ProcessingJobStatus.PROCESSING; + this.currentStep = currentStep; + if (this.startedAt == null) { + this.startedAt = LocalDateTime.now(); + } + } } 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 877154e..eb4f341 100644 --- a/src/main/java/com/kw/readwith/dto/admin/AdminImageGenerationStatusResponseDTO.java +++ b/src/main/java/com/kw/readwith/dto/admin/AdminImageGenerationStatusResponseDTO.java @@ -45,6 +45,9 @@ public class AdminImageGenerationStatusResponseDTO { ) private String nextAction; + @Schema(description = "가장 최근 캐릭터 이미지 fan-out job입니다. 대표 후보 선택 전에는 null입니다.", nullable = true) + private ProcessingJobResponseDTO fanoutJob; + @Schema(description = "서버가 자동 지정한 대표 캐릭터입니다. 후보사진 4장은 이 캐릭터 기준으로 생성됩니다.", nullable = true) private CharacterSummary referenceCharacter; diff --git a/src/main/java/com/kw/readwith/dto/admin/CharacterImageAssetDTO.java b/src/main/java/com/kw/readwith/dto/admin/CharacterImageAssetDTO.java index 7583456..78ecfb5 100644 --- a/src/main/java/com/kw/readwith/dto/admin/CharacterImageAssetDTO.java +++ b/src/main/java/com/kw/readwith/dto/admin/CharacterImageAssetDTO.java @@ -59,7 +59,7 @@ public class CharacterImageAssetDTO { @Schema(description = "S3 공개 URL", nullable = true) private String s3Url; - @Schema(description = "사용 모델", nullable = true, example = "gpt-image-1") + @Schema(description = "사용 모델", nullable = true, example = "gpt-image-2") private String model; @Schema(description = "참조 asset ID", nullable = true) diff --git a/src/main/java/com/kw/readwith/dto/admin/ProcessingJobResponseDTO.java b/src/main/java/com/kw/readwith/dto/admin/ProcessingJobResponseDTO.java index 1530ca7..977f7e2 100644 --- a/src/main/java/com/kw/readwith/dto/admin/ProcessingJobResponseDTO.java +++ b/src/main/java/com/kw/readwith/dto/admin/ProcessingJobResponseDTO.java @@ -3,6 +3,7 @@ import com.kw.readwith.domain.enums.ProcessingJobStatus; import com.kw.readwith.domain.enums.ProcessingPipelineType; import com.kw.readwith.domain.processing.ProcessingJob; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -14,6 +15,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(description = "서버 비동기 처리 job 상태") public class ProcessingJobResponseDTO { private Long id; private Long bookId; @@ -22,6 +24,14 @@ public class ProcessingJobResponseDTO { private String runId; private String sourceVersion; private String artifactPath; + @Schema(description = "외부 처리 시스템의 job ID. 이미지 fan-out에서는 OpenAI Batch ID입니다.", nullable = true, example = "batch_abc123") + private String externalJobId; + @Schema(description = "OpenAI Batch 입력 JSONL file ID", nullable = true, example = "file-input123") + private String inputFileId; + @Schema(description = "OpenAI Batch 성공 결과 JSONL file ID", nullable = true, example = "file-output123") + private String outputFileId; + @Schema(description = "OpenAI Batch 실패 결과 JSONL file ID", nullable = true, example = "file-error123") + private String errorFileId; private ProcessingJobStatus status; private String currentStep; private String failureCode; @@ -42,6 +52,10 @@ public static ProcessingJobResponseDTO from(ProcessingJob job) { .runId(job.getRunId()) .sourceVersion(job.getSourceVersion()) .artifactPath(job.getArtifactPath()) + .externalJobId(job.getExternalJobId()) + .inputFileId(job.getInputFileId()) + .outputFileId(job.getOutputFileId()) + .errorFileId(job.getErrorFileId()) .status(job.getStatus()) .currentStep(job.getCurrentStep()) .failureCode(job.getFailureCode()) diff --git a/src/main/java/com/kw/readwith/repository/CharacterImageAssetRepository.java b/src/main/java/com/kw/readwith/repository/CharacterImageAssetRepository.java index 98292cd..90818cd 100644 --- a/src/main/java/com/kw/readwith/repository/CharacterImageAssetRepository.java +++ b/src/main/java/com/kw/readwith/repository/CharacterImageAssetRepository.java @@ -5,6 +5,7 @@ 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.processing.ProcessingJob; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -54,6 +55,8 @@ Optional findFirstByCharacterAndStatusOrderByCreatedAtDesc( List findByBookAndStatusIn(Book book, Collection statuses); + List findByProcessingJobOrderByIdAsc(ProcessingJob processingJob); + @Query("SELECT COALESCE(MAX(a.attemptNo), 0) FROM CharacterImageAsset a " + "WHERE a.character = :character AND a.assetRole = :assetRole") int findMaxAttemptNo(@Param("character") Character character, diff --git a/src/main/java/com/kw/readwith/repository/ProcessingJobLogRepository.java b/src/main/java/com/kw/readwith/repository/ProcessingJobLogRepository.java index c7687dc..7a51b26 100644 --- a/src/main/java/com/kw/readwith/repository/ProcessingJobLogRepository.java +++ b/src/main/java/com/kw/readwith/repository/ProcessingJobLogRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Pageable; import java.util.Collection; import java.util.List; +import java.util.Optional; public interface ProcessingJobLogRepository extends JpaRepository { @@ -15,6 +16,8 @@ public interface ProcessingJobLogRepository extends JpaRepository findAllByJobIdOrderBySeqAsc(Long jobId); + Optional findTopByJobIdOrderBySeqDesc(Long jobId); + List findAllByOrderByIdDesc(Pageable pageable); @Modifying diff --git a/src/main/java/com/kw/readwith/repository/ProcessingJobRepository.java b/src/main/java/com/kw/readwith/repository/ProcessingJobRepository.java index d667b5b..94473ea 100644 --- a/src/main/java/com/kw/readwith/repository/ProcessingJobRepository.java +++ b/src/main/java/com/kw/readwith/repository/ProcessingJobRepository.java @@ -6,7 +6,11 @@ import com.kw.readwith.domain.processing.ProcessingJob; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Pageable; import java.util.Collection; import java.util.List; @@ -30,6 +34,15 @@ List findAllByPipelineTypeOrderByCreatedAtDesc( Pageable pageable ); + List findAllByPipelineTypeAndStatusInOrderByCreatedAtAsc( + ProcessingPipelineType pipelineType, + Collection statuses + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT job FROM ProcessingJob job WHERE job.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + boolean existsByBookIdAndStatusIn(Long bookId, Collection statuses); List findAllByBook(Book book); diff --git a/src/main/java/com/kw/readwith/service/AdminImageGenerationService.java b/src/main/java/com/kw/readwith/service/AdminImageGenerationService.java index 4a544c6..0d8f210 100644 --- a/src/main/java/com/kw/readwith/service/AdminImageGenerationService.java +++ b/src/main/java/com/kw/readwith/service/AdminImageGenerationService.java @@ -12,11 +12,15 @@ import com.kw.readwith.domain.enums.CharacterImageAssetStatus; import com.kw.readwith.domain.enums.CharacterImageGenerationMode; import com.kw.readwith.domain.enums.ImageGenerationStatus; +import com.kw.readwith.domain.enums.ProcessingPipelineType; +import com.kw.readwith.domain.enums.ProcessingJobStatus; import com.kw.readwith.dto.admin.AdminImageGenerationStatusResponseDTO; +import com.kw.readwith.dto.admin.ProcessingJobResponseDTO; import com.kw.readwith.repository.BookCharacterImageProfileRepository; import com.kw.readwith.repository.BookRepository; 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 lombok.RequiredArgsConstructor; @@ -29,9 +33,11 @@ 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; @@ -50,6 +56,8 @@ public class AdminImageGenerationService { private final CharacterImageService characterImageService; private final OpenAiImageEditClient imageEditClient; private final CharacterImageProperties imageProperties; + private final CharacterImageFanoutJobService fanoutJobService; + private final ProcessingJobRepository processingJobRepository; private final RestTemplate restTemplate; private final CdnUrlService cdnUrlService; @@ -61,9 +69,13 @@ public AdminImageGenerationStatusResponseDTO getBookStatus(Long bookId) { @Transactional public AdminImageGenerationStatusResponseDTO generateReferenceCandidates(Long bookId) { Book book = getBook(bookId); + 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(), @@ -75,14 +87,22 @@ public AdminImageGenerationStatusResponseDTO generateReferenceCandidates(Long bo 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) + 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); @@ -102,12 +122,14 @@ public AdminImageGenerationStatusResponseDTO generateReferenceCandidates(Long bo @Transactional public AdminImageGenerationStatusResponseDTO selectReferenceCandidate(Long bookId, Long candidateId) { Book book = getBook(bookId); + ensureNoActiveFanoutJob(bookId); CharacterImageAsset candidate = assetRepository.findByIdAndBook(candidateId, book) .orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_ASSET_NOT_BELONG_TO_BOOK)); ensureReferenceCandidate(candidate); ensureReadyReferenceCandidate(candidate); BookCharacterImageProfile profile = getOrCreateProfile(book); + String previousProfileImageUrl = candidate.getCharacter().getProfileImage(); profile.selectReferenceCandidate(candidate, "admin"); candidate.publish(); characterRepository.updateProfileImageAndStatus( @@ -115,14 +137,16 @@ public AdminImageGenerationStatusResponseDTO selectReferenceCandidate(Long bookI candidate.getS3Url(), ImageGenerationStatus.COMPLETED ); + characterImageService.deleteReplacedGeneratedImage(previousProfileImageUrl, candidate.getS3Url()); - fanOutAllCharacters(book, profile, candidate); + fanoutJobService.queueFanout(book, profile, candidate); return buildStatusResponse(book); } @Transactional public AdminImageGenerationStatusResponseDTO regenerateCharacterImage(Long bookId, Long characterId) { Book book = getBook(bookId); + ensureNoActiveFanoutJob(bookId); Character character = getCharacter(characterId); if (!character.getBook().getId().equals(book.getId())) { throw new GeneralException(ErrorStatus.BOOK_CHARACTER_NOT_FOUND); @@ -142,32 +166,24 @@ public AdminImageGenerationStatusResponseDTO regenerateCharacterImage(Long bookI return buildStatusResponse(book); } - private void fanOutAllCharacters(Book book, - BookCharacterImageProfile profile, - CharacterImageAsset reference) { - List characters = characterRepository.findByBookOrderByIsMainCharacterDescNameAsc(book); - for (Character character : characters) { - if (profile.getReferenceCharacter() != null - && profile.getReferenceCharacter().getId().equals(character.getId())) { - continue; - } - generateCharacterImageFromReference(character, reference, profile.getReferenceVersion()); - } - } - private CharacterImageAsset generateCharacterImageFromReference(Character character, CharacterImageAsset reference, int referenceVersion) { CharacterImageAsset asset = getOrCreateCharacterImageAsset(character, reference, referenceVersion); try { + String previousS3Url = asset.getS3Url(); byte[] referenceImage = restTemplate.getForObject(cdnUrlService.toPublicUrl(reference.getS3Url()), byte[].class); - String prompt = buildReferenceEditPrompt(character); + String prompt = characterImageService.buildReferenceEditPrompt(character); GeneratedCharacterImage generated = imageEditClient.generate(referenceImage, prompt); String s3Url = characterImageService.uploadGeneratedImage( character, generated.imageData(), - characterImageService.buildPublishedS3KeyName(character) + characterImageService.buildPublishedS3KeyName( + character, + asset.getReferenceVersion(), + asset.getAttemptNo() + ) ); asset.generated(s3Url, generated.model(), generated.promptHash(), generated.requestId()); asset.markQaPassed("{\"passed\":true,\"mode\":\"ADMIN_CHARACTER_FANOUT\"}"); @@ -177,6 +193,7 @@ private CharacterImageAsset generateCharacterImageFromReference(Character charac s3Url, ImageGenerationStatus.COMPLETED ); + characterImageService.deleteReplacedGeneratedImage(previousS3Url, s3Url); } catch (Exception e) { log.error("Character image fan-out failed. characterId={}, referenceAssetId={}", character.getId(), reference.getId(), e); @@ -223,11 +240,19 @@ private AdminImageGenerationStatusResponseDTO buildStatusResponse(Book book) { Long referenceCharacterId = referenceCharacter != null ? referenceCharacter.getId() : null; String status = resolveBookStatus(profile, referenceCandidates, characterAssets); + ProcessingJobResponseDTO fanoutJob = processingJobRepository + .findTopByBookIdAndPipelineTypeOrderByCreatedAtDesc( + book.getId(), + ProcessingPipelineType.IMAGE_GENERATION + ) + .map(ProcessingJobResponseDTO::from) + .orElse(null); return AdminImageGenerationStatusResponseDTO.builder() .bookId(book.getId()) .status(status) .nextAction(resolveNextAction(status)) + .fanoutJob(fanoutJob) .referenceCharacter(AdminImageGenerationStatusResponseDTO.CharacterSummary.from(referenceCharacter)) .selectedReferenceCandidateId(selectedReferenceCandidateId) .referenceCandidates(referenceCandidates.stream() @@ -374,6 +399,17 @@ private Character getCharacter(Long characterId) { .orElseThrow(() -> new GeneralException(ErrorStatus.CHARACTER_NOT_FOUND)); } + private void ensureNoActiveFanoutJob(Long bookId) { + processingJobRepository.findFirstByBookIdAndPipelineTypeAndStatusInOrderByCreatedAtDesc( + bookId, + ProcessingPipelineType.IMAGE_GENERATION, + EnumSet.of(ProcessingJobStatus.QUEUED, ProcessingJobStatus.PROCESSING) + ) + .ifPresent(job -> { + throw new GeneralException(ErrorStatus.IMAGE_FANOUT_JOB_ACTIVE); + }); + } + private void ensureReferenceCandidate(CharacterImageAsset asset) { if (asset.getAssetRole() != CharacterImageAssetRole.REFERENCE_CANDIDATE) { throw new GeneralException( @@ -399,16 +435,9 @@ private boolean isReadyForSelection(CharacterImageAssetStatus status) { || status == CharacterImageAssetStatus.PUBLISHED; } - private String buildReferenceEditPrompt(Character character) { - return "Use the input image as the book-wide visual style reference. " + - "Keep the same illustration style, framing, background simplicity, palette, and rendering quality. " + - "Create only the target character below as a distinct person, without copying the reference person's identity. " + - characterImageService.buildImagePrompt(character); - } - private String resolveTextImageModel() { String configured = normalize(imageProperties.getModel()); - return configured != null ? configured : "gpt-image-1"; + return configured != null ? configured : "gpt-image-2"; } private String normalize(String value) { diff --git a/src/main/java/com/kw/readwith/service/AdminService.java b/src/main/java/com/kw/readwith/service/AdminService.java index 592c895..be4d454 100644 --- a/src/main/java/com/kw/readwith/service/AdminService.java +++ b/src/main/java/com/kw/readwith/service/AdminService.java @@ -892,11 +892,10 @@ public void deleteBook(Long bookId) { if (!jobs.isEmpty()) { processingJobLogRepository.deleteByJobIn(jobs); } - processingJobRepository.deleteByBook(book); - bookCharacterImageProfileRepository.deleteByBook(book); characterImageAssetRepository.clearSourceReferencesByBook(book); characterImageAssetRepository.deleteByBook(book); + processingJobRepository.deleteByBook(book); eventRelationshipEdgeRepository.deleteByBook(book); statRepository.deleteByBook(book); diff --git a/src/main/java/com/kw/readwith/service/CdnUrlService.java b/src/main/java/com/kw/readwith/service/CdnUrlService.java index 7506977..49ab9be 100644 --- a/src/main/java/com/kw/readwith/service/CdnUrlService.java +++ b/src/main/java/com/kw/readwith/service/CdnUrlService.java @@ -32,11 +32,31 @@ public String toPublicUrl(String value) { return normalized; } - return resolvePublicObjectKey(normalized) + return toPublicObjectKey(normalized) .map(key -> cloudFrontBaseUrl + "/" + key) .orElse(normalized); } + public Optional toPublicObjectKey(String value) { + if (value == null || value.isBlank()) { + return Optional.empty(); + } + + String normalized = value.trim(); + String cloudFrontBaseUrl = trimTrailingSlash(artifactStorageProperties.getCloudFrontBaseUrl()); + if (!cloudFrontBaseUrl.isBlank() && normalized.startsWith(cloudFrontBaseUrl + "/")) { + String key = normalized.substring(cloudFrontBaseUrl.length() + 1); + return isAllowedPublicKey(key) ? Optional.of(key) : Optional.empty(); + } + + String keyCandidate = stripLeadingSlash(normalized); + if (isAllowedPublicKey(keyCandidate)) { + return Optional.of(keyCandidate); + } + + return extractS3Key(normalized).filter(this::isAllowedPublicKey); + } + public boolean isPublicObjectKey(String value) { if (value == null || value.isBlank()) { return false; @@ -44,14 +64,14 @@ public boolean isPublicObjectKey(String value) { return isAllowedPublicKey(stripLeadingSlash(value.trim())); } - private Optional resolvePublicObjectKey(String value) { - String keyCandidate = stripLeadingSlash(value); - if (isAllowedPublicKey(keyCandidate)) { - return Optional.of(keyCandidate); + public boolean isCdnUrl(String value) { + if (value == null || value.isBlank()) { + return false; } - - Optional s3Key = extractS3Key(value); - return s3Key.filter(this::isAllowedPublicKey); + String cloudFrontBaseUrl = trimTrailingSlash(artifactStorageProperties.getCloudFrontBaseUrl()); + String normalized = value.trim(); + return !cloudFrontBaseUrl.isBlank() + && (normalized.equals(cloudFrontBaseUrl) || normalized.startsWith(cloudFrontBaseUrl + "/")); } private Optional extractS3Key(String value) { diff --git a/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobDispatcher.java b/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobDispatcher.java new file mode 100644 index 0000000..71e3d22 --- /dev/null +++ b/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobDispatcher.java @@ -0,0 +1,17 @@ +package com.kw.readwith.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CharacterImageFanoutJobDispatcher { + + private final CharacterImageFanoutJobService fanoutJobService; + + @Async("imageGenerationExecutor") + public void dispatch(Long jobId) { + fanoutJobService.submit(jobId); + } +} diff --git a/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobScheduler.java b/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobScheduler.java new file mode 100644 index 0000000..2bf6ba2 --- /dev/null +++ b/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobScheduler.java @@ -0,0 +1,30 @@ +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 CharacterImageFanoutJobScheduler { + + private final CharacterImageFanoutJobService fanoutJobService; + private final CharacterImageFanoutJobDispatcher dispatcher; + + @Scheduled( + fixedDelayString = "${character-image.batch-poll-interval-ms:30000}", + initialDelayString = "${character-image.batch-poll-interval-ms:30000}" + ) + public void resumeAndPoll() { + try { + fanoutJobService.findQueuedJobIds().forEach(dispatcher::dispatch); + fanoutJobService.findInterruptedSubmissionJobIds() + .forEach(fanoutJobService::failInterruptedSubmission); + fanoutJobService.findSubmittedJobIds().forEach(fanoutJobService::refresh); + } catch (Exception e) { + log.warn("Character image fan-out scheduler cycle failed.", e); + } + } +} diff --git a/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobService.java b/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobService.java new file mode 100644 index 0000000..6709727 --- /dev/null +++ b/src/main/java/com/kw/readwith/service/CharacterImageFanoutJobService.java @@ -0,0 +1,769 @@ +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.BookImageReferenceStatus; +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.ImageGenerationStatus; +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.CharacterRepository; +import com.kw.readwith.repository.ProcessingJobLogRepository; +import com.kw.readwith.repository.ProcessingJobRepository; +import com.kw.readwith.service.image.OpenAiImageBatchClient; +import com.kw.readwith.service.image.OpenAiImageBatchClient.OpenAiBatchImageResult; +import com.kw.readwith.service.image.OpenAiImageBatchClient.OpenAiBatchImageEditRequest; +import com.kw.readwith.service.image.OpenAiImageBatchClient.OpenAiBatchStatus; +import com.kw.readwith.service.image.OpenAiImageBatchClient.OpenAiBatchSubmission; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CharacterImageFanoutJobService { + + private static final String SOURCE_VERSION = "character-image-batch-v1"; + private static final String TRIGGERED_BY = "ADMIN_REFERENCE_SELECTION"; + private static final String CUSTOM_ID_PREFIX = "character-image-asset-"; + private static final EnumSet ACTIVE_JOB_STATUSES = + EnumSet.of(ProcessingJobStatus.QUEUED, ProcessingJobStatus.PROCESSING); + + private final CharacterRepository characterRepository; + private final CharacterImageAssetRepository assetRepository; + private final BookCharacterImageProfileRepository profileRepository; + private final ProcessingJobRepository processingJobRepository; + private final ProcessingJobLogRepository processingJobLogRepository; + private final CharacterImageService characterImageService; + private final CharacterImageProperties imageProperties; + private final CdnUrlService cdnUrlService; + private final OpenAiImageBatchClient batchClient; + private final ObjectMapper objectMapper; + private final PlatformTransactionManager transactionManager; + + @Transactional + public ProcessingJobResponseDTO queueFanout(Book book, + BookCharacterImageProfile profile, + CharacterImageAsset reference) { + processingJobRepository.findFirstByBookIdAndPipelineTypeAndStatusInOrderByCreatedAtDesc( + book.getId(), + ProcessingPipelineType.IMAGE_GENERATION, + ACTIVE_JOB_STATUSES + ) + .ifPresent(existing -> { + throw new GeneralException(ErrorStatus.IMAGE_FANOUT_JOB_ACTIVE); + }); + + List targets = characterRepository.findByBookOrderByIsMainCharacterDescNameAsc(book).stream() + .filter(character -> profile.getReferenceCharacter() == null + || !profile.getReferenceCharacter().getId().equals(character.getId())) + .toList(); + if (targets.isEmpty()) { + return null; + } + + String runId = "character-image-fanout-" + UUID.randomUUID(); + ProcessingJob job = processingJobRepository.save(ProcessingJob.builder() + .book(book) + .pipelineType(ProcessingPipelineType.IMAGE_GENERATION) + .runId(runId) + .sourceVersion(SOURCE_VERSION) + .artifactPath(buildArtifactPath(book.getId(), runId)) + .status(ProcessingJobStatus.QUEUED) + .currentStep("queued") + .triggeredBy(TRIGGERED_BY) + .build()); + + String model = resolveEditModel(); + for (Character character : targets) { + CharacterImageAsset asset = getOrCreateCharacterImageAsset( + character, + reference, + profile.getReferenceVersion() + ); + String prompt = characterImageService.buildReferenceEditPrompt(character); + asset.assignProcessingJob(job, model, characterImageService.buildPromptHash(prompt)); + characterRepository.updateImageGenerationStatus(character.getId(), ImageGenerationStatus.GENERATING); + } + + writeLog(job, ProcessingJobLogLevel.INFO, "queued", "Character image fan-out Batch job has been queued.", Map.of( + "targetCount", targets.size(), + "referenceAssetId", reference.getId(), + "referenceVersion", profile.getReferenceVersion(), + "model", model + )); + return ProcessingJobResponseDTO.from(job); + } + + @Transactional(readOnly = true) + public ProcessingJobResponseDTO getFanoutJob(Long jobId) { + return ProcessingJobResponseDTO.from(findImageJob(jobId)); + } + + @Transactional(readOnly = true) + public List getFanoutJobLogs(Long jobId) { + ProcessingJob job = findImageJob(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_GENERATION, + EnumSet.of(ProcessingJobStatus.QUEUED) + ).stream() + .map(ProcessingJob::getId) + .toList(); + } + + @Transactional(readOnly = true) + public List findSubmittedJobIds() { + return processingJobRepository.findAllByPipelineTypeAndStatusInOrderByCreatedAtAsc( + ProcessingPipelineType.IMAGE_GENERATION, + EnumSet.of(ProcessingJobStatus.PROCESSING) + ).stream() + .filter(job -> job.getExternalJobId() != null && !job.getExternalJobId().isBlank()) + .map(ProcessingJob::getId) + .toList(); + } + + @Transactional(readOnly = true) + public List findInterruptedSubmissionJobIds() { + LocalDateTime cutoff = LocalDateTime.now().minusMinutes(5); + return processingJobRepository.findAllByPipelineTypeAndStatusInOrderByCreatedAtAsc( + ProcessingPipelineType.IMAGE_GENERATION, + EnumSet.of(ProcessingJobStatus.PROCESSING) + ).stream() + .filter(job -> (job.getExternalJobId() == null || job.getExternalJobId().isBlank()) + && "submitting_openai_batch".equals(job.getCurrentStep()) + && job.getUpdatedAt() != null + && job.getUpdatedAt().isBefore(cutoff)) + .map(ProcessingJob::getId) + .toList(); + } + + public void failInterruptedSubmission(Long jobId) { + completeFailure( + jobId, + "IMAGE_BATCH_SUBMISSION_INTERRUPTED", + "Batch submission was interrupted before an external job ID was stored." + ); + } + + public void submit(Long jobId) { + try { + SubmissionContext context = transitionToSubmitting(jobId); + if (context == null) { + return; + } + + byte[] inputJsonl = batchClient.buildImageEditInput(context.targets().stream() + .map(target -> new OpenAiBatchImageEditRequest( + CUSTOM_ID_PREFIX + target.assetId(), + context.model(), + context.referenceUrl(), + target.prompt(), + context.size(), + context.quality() + )) + .toList()); + OpenAiBatchSubmission submission = batchClient.submit( + inputJsonl, + context.runId() + ".jsonl", + context.runId(), + Map.of( + "book_id", context.bookId().toString(), + "run_id", context.runId(), + "pipeline", SOURCE_VERSION + ) + ); + markSubmitted(jobId, submission); + } catch (Exception e) { + log.error("Character image fan-out Batch submission failed. jobId={}", jobId, e); + completeFailure(jobId, "IMAGE_BATCH_SUBMISSION_FAILED", messageOf(e)); + } + } + + public void refresh(Long jobId) { + try { + PollContext context = loadPollContext(jobId); + if (context == null) { + return; + } + + OpenAiBatchStatus batchStatus = batchClient.retrieve(context.externalJobId()); + updateExternalStatus(jobId, batchStatus); + if (batchStatus.isTerminal()) { + applyTerminalResults(jobId, batchStatus); + } + } catch (Exception e) { + log.error("Character image fan-out Batch refresh failed. jobId={}", jobId, e); + writeRefreshWarning(jobId, e); + } + } + + private SubmissionContext transitionToSubmitting(Long jobId) { + return writableTransaction().execute(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.QUEUED) { + return null; + } + + List assets = assetRepository.findByProcessingJobOrderByIdAsc(job); + if (assets.isEmpty()) { + throw new IllegalStateException("Character image fan-out job has no target assets."); + } + + CharacterImageAsset reference = assets.get(0).getSourceReferenceAsset(); + int referenceVersion = assets.get(0).getReferenceVersion(); + validateReference(job.getBook(), reference, referenceVersion, assets); + + String referenceUrl = cdnUrlService.toPublicUrl(reference.getS3Url()); + if (referenceUrl == null || referenceUrl.isBlank()) { + throw new IllegalStateException("Character image fan-out reference URL is missing."); + } + if (!cdnUrlService.isCdnUrl(referenceUrl)) { + throw new IllegalStateException( + "Character image fan-out requires a public CloudFront reference URL." + ); + } + + job.markProcessing("submitting_openai_batch"); + writeLog(job, ProcessingJobLogLevel.INFO, "submitting_openai_batch", "Submitting character images to OpenAI Batch.", Map.of( + "targetCount", assets.size(), + "referenceAssetId", reference.getId(), + "referenceVersion", referenceVersion, + "referenceUrl", referenceUrl + )); + + List targets = assets.stream() + .map(asset -> new BatchTarget( + asset.getId(), + characterImageService.buildReferenceEditPrompt(asset.getCharacter()) + )) + .toList(); + return new SubmissionContext( + job.getBook().getId(), + job.getRunId(), + referenceUrl, + resolveEditModel(), + imageProperties.getWidth() + "x" + imageProperties.getHeight(), + normalize(imageProperties.getQuality()), + targets + ); + }); + } + + private void validateReference(Book book, + CharacterImageAsset reference, + int referenceVersion, + List assets) { + if (reference == null || assets.stream().anyMatch(asset -> + asset.getSourceReferenceAsset() == null + || !reference.getId().equals(asset.getSourceReferenceAsset().getId()) + || asset.getReferenceVersion() != referenceVersion)) { + throw new IllegalStateException("Character image fan-out targets do not share one reference."); + } + + BookCharacterImageProfile profile = profileRepository.findByBook(book) + .orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_REFERENCE_NOT_APPROVED)); + if (profile.getReferenceStatus() != BookImageReferenceStatus.APPROVED + || profile.getActiveReferenceAsset() == null + || !reference.getId().equals(profile.getActiveReferenceAsset().getId()) + || profile.getReferenceVersion() != referenceVersion) { + throw new GeneralException(ErrorStatus.IMAGE_REFERENCE_NOT_APPROVED); + } + } + + private void markSubmitted(Long jobId, OpenAiBatchSubmission submission) { + writableTransaction().executeWithoutResult(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING || job.getExternalJobId() != null) { + return; + } + job.markExternalJobSubmitted(submission.batchId(), submission.inputFileId(), submission.status()); + writeLog(job, ProcessingJobLogLevel.INFO, job.getCurrentStep(), "OpenAI Batch has been registered.", Map.of( + "externalJobId", submission.batchId(), + "inputFileId", submission.inputFileId(), + "status", submission.status() + )); + }); + } + + private PollContext loadPollContext(Long jobId) { + return writableTransaction().execute(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING + || job.getExternalJobId() == null + || job.getExternalJobId().isBlank()) { + return null; + } + return new PollContext(job.getExternalJobId()); + }); + } + + private void updateExternalStatus(Long jobId, OpenAiBatchStatus batchStatus) { + writableTransaction().executeWithoutResult(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return; + } + + String previousStep = job.getCurrentStep(); + job.updateExternalJobStatus( + batchStatus.status(), + batchStatus.outputFileId(), + batchStatus.errorFileId() + ); + if (!job.getCurrentStep().equals(previousStep) || batchStatus.isTerminal()) { + Map payload = new LinkedHashMap<>(); + payload.put("externalJobId", batchStatus.batchId()); + payload.put("status", batchStatus.status()); + payload.put("totalCount", batchStatus.totalCount()); + payload.put("completedCount", batchStatus.completedCount()); + payload.put("failedCount", batchStatus.failedCount()); + if (batchStatus.errorsJson() != null) { + payload.put("errors", batchStatus.errorsJson()); + } + writeLog(job, ProcessingJobLogLevel.INFO, job.getCurrentStep(), "OpenAI Batch status has changed.", payload); + } + }); + } + + private void applyTerminalResults(Long jobId, OpenAiBatchStatus batchStatus) { + if (!beginApplyingResults(jobId)) { + return; + } + try { + batchClient.streamResults(batchStatus.outputFileId(), result -> processResultSafely(jobId, result)); + batchClient.streamResults(batchStatus.errorFileId(), result -> processResultSafely(jobId, result)); + finalizeResults(jobId, batchStatus.status()); + } catch (Exception e) { + completeFailure(jobId, "IMAGE_BATCH_RESULT_APPLY_FAILED", messageOf(e)); + } + } + + private boolean beginApplyingResults(Long jobId) { + Boolean started = writableTransaction().execute(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return false; + } + job.advanceProcessing("applying_batch_results"); + writeLog(job, ProcessingJobLogLevel.INFO, "applying_batch_results", "Applying OpenAI Batch image results.", Map.of( + "outputFileId", Optional.ofNullable(job.getOutputFileId()).orElse(""), + "errorFileId", Optional.ofNullable(job.getErrorFileId()).orElse("") + )); + return true; + }); + return Boolean.TRUE.equals(started); + } + + private void processResultSafely(Long jobId, OpenAiBatchImageResult result) { + try { + Long assetId = parseAssetId(result.customId()); + if (!result.isSuccess()) { + markTargetFailure(jobId, assetId, normalizeFailureCode(result.errorCode()), result.errorMessage()); + return; + } + + TargetUploadContext target = loadTargetForUpload(jobId, assetId); + if (target == null) { + return; + } + String s3Url = characterImageService.uploadGeneratedImage( + target.character(), + result.imageData(), + characterImageService.buildPublishedS3KeyName( + target.character(), + target.referenceVersion(), + target.attemptNo() + ) + ); + completeTargetSuccess(jobId, assetId, s3Url, result.requestId()); + } catch (Exception e) { + log.error("Failed to apply OpenAI Batch image result. jobId={}, customId={}", jobId, result.customId(), e); + try { + markTargetFailure( + jobId, + parseAssetId(result.customId()), + "BATCH_IMAGE_PUBLISH_FAILED", + messageOf(e) + ); + } catch (Exception nested) { + writeRefreshWarning(jobId, nested); + } + } + } + + private TargetUploadContext loadTargetForUpload(Long jobId, Long assetId) { + return writableTransaction().execute(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return null; + } + CharacterImageAsset asset = findJobAsset(job, assetId); + if (asset.getStatus() == CharacterImageAssetStatus.PUBLISHED) { + return null; + } + return new TargetUploadContext( + asset.getCharacter(), + asset.getReferenceVersion(), + asset.getAttemptNo() + ); + }); + } + + private void completeTargetSuccess(Long jobId, Long assetId, String s3Url, String requestId) { + writableTransaction().executeWithoutResult(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return; + } + CharacterImageAsset asset = findJobAsset(job, assetId); + if (asset.getStatus() == CharacterImageAssetStatus.PUBLISHED) { + return; + } + + String previousS3Url = asset.getS3Url(); + asset.generated(s3Url, asset.getModel(), asset.getPromptHash(), requestId); + asset.markQaPassed(serializePayload(Map.of( + "passed", true, + "mode", "OPENAI_BATCH_REFERENCE_EDIT", + "batchId", job.getExternalJobId(), + "referenceAssetId", asset.getSourceReferenceAsset().getId(), + "referenceVersion", asset.getReferenceVersion() + ))); + asset.publish(); + characterRepository.updateProfileImageAndStatus( + asset.getCharacter().getId(), + s3Url, + ImageGenerationStatus.COMPLETED + ); + characterImageService.deleteReplacedGeneratedImage(previousS3Url, s3Url); + writeLog(job, ProcessingJobLogLevel.INFO, "character_published", "Character image has been published from Batch output.", Map.of( + "assetId", asset.getId(), + "characterId", asset.getCharacter().getId(), + "requestId", Optional.ofNullable(requestId).orElse("") + )); + }); + } + + private void markTargetFailure(Long jobId, Long assetId, String failureCode, String failureMessage) { + writableTransaction().executeWithoutResult(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return; + } + CharacterImageAsset asset = findJobAsset(job, assetId); + if (asset.getStatus() == CharacterImageAssetStatus.PUBLISHED) { + return; + } + asset.fail(failureCode); + characterRepository.updateImageGenerationStatus(asset.getCharacter().getId(), ImageGenerationStatus.FAILED); + + Map payload = new LinkedHashMap<>(); + payload.put("assetId", asset.getId()); + payload.put("characterId", asset.getCharacter().getId()); + payload.put("failureCode", failureCode); + if (failureMessage != null) { + payload.put("error", failureMessage); + } + writeLog(job, ProcessingJobLogLevel.WARN, "character_failed", "Character image Batch request failed.", payload); + }); + } + + private void finalizeResults(Long jobId, String externalStatus) { + writableTransaction().executeWithoutResult(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return; + } + + List assets = assetRepository.findByProcessingJobOrderByIdAsc(job); + if (assets.isEmpty()) { + job.markFailed("completed_with_failures", "IMAGE_BATCH_TARGETS_MISSING", + "Character image fan-out job has no target assets."); + writeLog(job, ProcessingJobLogLevel.ERROR, "completed_with_failures", + "Character image fan-out Batch targets are missing.", Map.of( + "externalStatus", externalStatus + )); + return; + } + for (CharacterImageAsset asset : assets) { + if (asset.getStatus() == CharacterImageAssetStatus.GENERATING + || asset.getStatus() == CharacterImageAssetStatus.QA_PENDING) { + asset.fail("BATCH_RESULT_MISSING"); + characterRepository.updateImageGenerationStatus(asset.getCharacter().getId(), ImageGenerationStatus.FAILED); + } + } + + long successCount = assets.stream() + .filter(asset -> asset.getStatus() == CharacterImageAssetStatus.PUBLISHED) + .count(); + long failedCount = assets.size() - successCount; + Map payload = Map.of( + "externalStatus", externalStatus, + "targetCount", assets.size(), + "successCount", successCount, + "failedCount", failedCount + ); + + if (failedCount == 0) { + job.markReady(job.getArtifactPath(), "completed"); + writeLog(job, ProcessingJobLogLevel.INFO, "completed", "Character image fan-out Batch job completed.", payload); + } else { + job.markFailed("completed_with_failures", "IMAGE_BATCH_PARTIAL_FAILURE", + "One or more character images were not published."); + writeLog(job, ProcessingJobLogLevel.ERROR, "completed_with_failures", + "Character image fan-out Batch job completed with failures.", payload); + } + }); + } + + private void completeFailure(Long jobId, String failureCode, String failureMessage) { + try { + writableTransaction().executeWithoutResult(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() == ProcessingJobStatus.READY || job.getStatus() == ProcessingJobStatus.FAILED) { + return; + } + for (CharacterImageAsset asset : assetRepository.findByProcessingJobOrderByIdAsc(job)) { + if (asset.getStatus() != CharacterImageAssetStatus.PUBLISHED) { + asset.fail(failureCode); + characterRepository.updateImageGenerationStatus( + asset.getCharacter().getId(), + ImageGenerationStatus.FAILED + ); + } + } + String step = job.getCurrentStep() == null ? "failed" : job.getCurrentStep(); + job.markFailed(step, failureCode, failureMessage); + writeLog(job, ProcessingJobLogLevel.ERROR, step, "Character image fan-out Batch job failed.", Map.of( + "failureCode", failureCode, + "error", failureMessage + )); + }); + } catch (Exception nested) { + log.error("Failed to persist character image fan-out failure. jobId={}", jobId, nested); + } + } + + private void writeRefreshWarning(Long jobId, Exception exception) { + try { + writableTransaction().executeWithoutResult(status -> { + ProcessingJob job = findImageJobForUpdate(jobId); + if (job.getStatus() != ProcessingJobStatus.PROCESSING) { + return; + } + if (processingJobLogRepository.findTopByJobIdOrderBySeqDesc(jobId) + .map(ProcessingJobLog::getStep) + .filter("batch_poll_failed"::equals) + .isPresent()) { + return; + } + writeLog(job, ProcessingJobLogLevel.WARN, "batch_poll_failed", "OpenAI Batch status check failed and will be retried.", Map.of( + "error", messageOf(exception) + )); + }); + } catch (Exception nested) { + log.warn("Failed to persist OpenAI Batch poll warning. jobId={}", jobId, nested); + } + } + + private CharacterImageAsset getOrCreateCharacterImageAsset(Character character, + CharacterImageAsset reference, + int referenceVersion) { + Optional existing = assetRepository + .findFirstByCharacterAndAssetRoleOrderByCreatedAtDesc( + character, + CharacterImageAssetRole.CHARACTER_IMAGE + ); + if (existing.isPresent()) { + CharacterImageAsset asset = existing.get(); + asset.beginCharacterImage(character, reference, referenceVersion); + return asset; + } + + return assetRepository.save(CharacterImageAsset.builder() + .book(character.getBook()) + .character(character) + .assetRole(CharacterImageAssetRole.CHARACTER_IMAGE) + .generationMode(CharacterImageGenerationMode.REFERENCE_EDIT) + .sourceReferenceAsset(reference) + .referenceVersion(referenceVersion) + .status(CharacterImageAssetStatus.GENERATING) + .attemptNo(1) + .build()); + } + + private CharacterImageAsset findJobAsset(ProcessingJob job, Long assetId) { + CharacterImageAsset asset = assetRepository.findById(assetId) + .orElseThrow(() -> new IllegalStateException("Character image Batch asset not found: " + assetId)); + if (asset.getProcessingJob() == null || !job.getId().equals(asset.getProcessingJob().getId())) { + throw new IllegalStateException("Character image asset does not belong to Batch job: " + assetId); + } + return asset; + } + + private ProcessingJob findImageJob(Long jobId) { + ProcessingJob job = processingJobRepository.findById(jobId) + .orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_FANOUT_JOB_NOT_FOUND)); + ensureImageJob(job); + return job; + } + + private ProcessingJob findImageJobForUpdate(Long jobId) { + ProcessingJob job = processingJobRepository.findByIdForUpdate(jobId) + .orElseThrow(() -> new GeneralException(ErrorStatus.IMAGE_FANOUT_JOB_NOT_FOUND)); + ensureImageJob(job); + return job; + } + + private void ensureImageJob(ProcessingJob job) { + if (job.getPipelineType() != ProcessingPipelineType.IMAGE_GENERATION) { + throw new GeneralException(ErrorStatus.IMAGE_FANOUT_JOB_NOT_FOUND); + } + } + + private Long parseAssetId(String customId) { + if (customId == null || !customId.startsWith(CUSTOM_ID_PREFIX)) { + throw new IllegalArgumentException("Invalid character image Batch custom_id: " + customId); + } + try { + return Long.parseLong(customId.substring(CUSTOM_ID_PREFIX.length())); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid character image Batch custom_id: " + customId, e); + } + } + + private String resolveEditModel() { + String editModel = normalize(imageProperties.getEditModel()); + if (editModel != null) { + return editModel; + } + String model = normalize(imageProperties.getModel()); + return model != null ? model : "gpt-image-2"; + } + + private String normalize(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + private String normalizeFailureCode(String value) { + String normalized = normalize(value); + if (normalized == null) { + return "OPENAI_BATCH_REQUEST_FAILED"; + } + normalized = normalized.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9_]+", "_"); + return normalized.length() <= 80 ? normalized : normalized.substring(0, 80); + } + + private String buildArtifactPath(Long bookId, String runId) { + return "character-images/" + bookId + "/fanout-jobs/" + runId; + } + + private String messageOf(Exception exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; + } + + 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}"; + } + } + + private TransactionTemplate writableTransaction() { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return transactionTemplate; + } + + private record BatchTarget(Long assetId, String prompt) { + } + + private record SubmissionContext( + Long bookId, + String runId, + String referenceUrl, + String model, + String size, + String quality, + List targets + ) { + } + + private record PollContext(String externalJobId) { + } + + private record TargetUploadContext(Character character, int referenceVersion, int attemptNo) { + } +} diff --git a/src/main/java/com/kw/readwith/service/CharacterImageService.java b/src/main/java/com/kw/readwith/service/CharacterImageService.java index c8f6bb4..ec431e0 100644 --- a/src/main/java/com/kw/readwith/service/CharacterImageService.java +++ b/src/main/java/com/kw/readwith/service/CharacterImageService.java @@ -18,6 +18,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.io.IOException; import java.io.InputStream; @@ -28,6 +30,7 @@ import java.util.Base64; import java.util.List; import java.util.Locale; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -52,6 +55,15 @@ public class CharacterImageService { "no split composition, no diptych, no mirrored duplicate, no reflected second face, no twin, " + "no interaction scene, no full body, no wide shot, no props, no scenery, no ornate background"; + private static final String SERIES_LOCK_PROMPT = + "Treat the input image as the immutable visual bible for this book series. " + + "Preserve its medium and rendering technique, line weight, brush and paper texture, color temperature, " + + "saturation, lighting direction and softness, background tone, crop, camera angle, facial proportions, " + + "and level of detail. Keep these style attributes consistent across every character portrait. " + + "Change only the person's identity and the character-specific physical traits and clothing described below. " + + "Do not copy the reference person's face, expression, hair, or clothing. " + + "Render exactly one distinct target character with no text, border, emblem, or decorative object. TARGET: "; + private static final List DISALLOWED_PROMPT_SEGMENTS = List.of( "duo portrait", "pair portrait", @@ -81,6 +93,7 @@ public class CharacterImageService { private final CharacterRepository characterRepository; private final CharacterImageProperties imageProperties; private final CharacterImageTransactionService transactionService; + private final CdnUrlService cdnUrlService; @Async("imageGenerationExecutor") public CompletableFuture generateImagesAsync(List characterIds) { @@ -151,6 +164,7 @@ public void generateAndSaveImage(Long characterId) { GeneratedCharacterImage generatedImage = generateTextImage(character); String s3Url = uploadGeneratedImage(character, generatedImage.imageData(), buildPublishedS3KeyName(character)); updateImageUrlOnly(characterId, s3Url); + deleteReplacedGeneratedImage(character.getProfileImage(), s3Url); log.info("Character image generation completed. characterId={}, s3Url={}", characterId, s3Url); } catch (Exception e) { @@ -218,10 +232,11 @@ public String buildReferenceCandidateS3KeyName(Character character, Long assetId UUID.randomUUID()); } - public String buildReferenceCandidateSlotS3KeyName(Character character, int slotNo) { - return String.format("%s/%d/reference/slot-%d.png", + public String buildReferenceCandidateSlotS3KeyName(Character character, int slotNo, int attemptNo) { + return String.format("%s/%d/reference/attempt-%d/slot-%d.png", imageProperties.getS3Path(), character.getBook().getId(), + attemptNo, slotNo); } @@ -236,7 +251,7 @@ public String buildCandidateS3KeyName(Character character, Long assetId) { private String resolveImageModel() { String configured = normalizePromptSegment(imageProperties.getModel()); - return configured != null ? configured : "gpt-image-1"; + return configured != null ? configured : "gpt-image-2"; } private String resolveImageQuality() { @@ -260,6 +275,14 @@ public String buildImagePrompt(Character character) { return prompt.toString(); } + public String buildReferenceEditPrompt(Character character) { + return SERIES_LOCK_PROMPT + buildImagePrompt(character); + } + + public String buildPromptHash(String prompt) { + return sha256(prompt); + } + private String resolveBookPrompt(Book book) { return book == null ? null : book.getBookPrompt(); } @@ -372,10 +395,63 @@ private byte[] downloadImageFromUrl(String imageUrl) throws IOException { } public String buildPublishedS3KeyName(Character character) { - return String.format("%s/%d/%d.png", + return String.format("%s/%d/%d/standalone/%s.png", imageProperties.getS3Path(), character.getBook().getId(), - character.getId()); + character.getId(), + UUID.randomUUID()); + } + + public String buildPublishedS3KeyName(Character character, int referenceVersion, int attemptNo) { + return String.format("%s/%d/%d/reference-v%d/attempt-%d.png", + imageProperties.getS3Path(), + character.getBook().getId(), + character.getId(), + referenceVersion, + attemptNo); + } + + public void deleteReplacedGeneratedImage(String previousUrl, String replacementUrl) { + Optional previousKey = cdnUrlService.toPublicObjectKey(previousUrl); + Optional replacementKey = cdnUrlService.toPublicObjectKey(replacementUrl); + if (previousKey.isEmpty() + || replacementKey.isEmpty() + || !isManagedCharacterImageKey(previousKey.get()) + || !isManagedCharacterImageKey(replacementKey.get()) + || previousKey.equals(replacementKey)) { + return; + } + + Runnable cleanup = () -> { + try { + s3Manager.deleteKeys(List.of(previousKey.get())); + } catch (RuntimeException e) { + log.warn("Failed to delete replaced character image from S3. key={}", previousKey.get(), e); + } + }; + if (TransactionSynchronizationManager.isActualTransactionActive() + && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + cleanup.run(); + } + }); + return; + } + cleanup.run(); + } + + private boolean isManagedCharacterImageKey(String key) { + String configuredPath = normalizePromptSegment(imageProperties.getS3Path()); + String imagePath = configuredPath == null ? "character-images" : configuredPath; + while (imagePath.startsWith("/")) { + imagePath = imagePath.substring(1); + } + while (imagePath.endsWith("/")) { + imagePath = imagePath.substring(0, imagePath.length() - 1); + } + return !imagePath.isBlank() && key.startsWith(imagePath + "/"); } private String sha256(String value) { diff --git a/src/main/java/com/kw/readwith/service/image/OpenAiImageBatchClient.java b/src/main/java/com/kw/readwith/service/image/OpenAiImageBatchClient.java new file mode 100644 index 0000000..30c61a7 --- /dev/null +++ b/src/main/java/com/kw/readwith/service/image/OpenAiImageBatchClient.java @@ -0,0 +1,330 @@ +package com.kw.readwith.service.image; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; + +@Component +@RequiredArgsConstructor +public class OpenAiImageBatchClient { + + private static final String FILES_URL = "https://api.openai.com/v1/files"; + private static final String BATCHES_URL = "https://api.openai.com/v1/batches"; + private static final String IMAGE_EDIT_ENDPOINT = "/v1/images/edits"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${spring.ai.openai.api-key:}") + private String apiKey; + + public byte[] buildImageEditInput(List requests) { + StringBuilder jsonl = new StringBuilder(); + for (OpenAiBatchImageEditRequest request : requests) { + Map body = new LinkedHashMap<>(); + body.put("model", request.model()); + body.put("images", List.of(Map.of("image_url", request.referenceImageUrl()))); + body.put("prompt", request.prompt()); + body.put("size", request.size()); + if (request.quality() != null && !request.quality().isBlank()) { + body.put("quality", request.quality()); + } + body.put("output_format", "png"); + body.put("n", 1); + + Map line = new LinkedHashMap<>(); + line.put("custom_id", request.customId()); + line.put("method", "POST"); + line.put("url", IMAGE_EDIT_ENDPOINT); + line.put("body", body); + try { + jsonl.append(objectMapper.writeValueAsString(line)).append('\n'); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to serialize OpenAI Batch input.", e); + } + } + return jsonl.toString().getBytes(StandardCharsets.UTF_8); + } + + public OpenAiBatchSubmission submit(byte[] inputJsonl, + String fileName, + String idempotencyKey, + Map metadata) { + requireApiKey(); + if (inputJsonl == null || inputJsonl.length == 0) { + throw new IllegalArgumentException("OpenAI Batch input JSONL is required."); + } + + String inputFileId = uploadInputFile(inputJsonl, fileName); + HttpHeaders headers = jsonHeaders(); + headers.set("Idempotency-Key", idempotencyKey); + + Map request = new LinkedHashMap<>(); + request.put("input_file_id", inputFileId); + request.put("endpoint", IMAGE_EDIT_ENDPOINT); + request.put("completion_window", "24h"); + request.put("metadata", metadata); + + ResponseEntity response = restTemplate.exchange( + BATCHES_URL, + HttpMethod.POST, + new HttpEntity<>(request, headers), + String.class + ); + JsonNode root = parseBody(response.getBody(), "OpenAI Batch creation"); + String batchId = requiredText(root, "id", "OpenAI Batch creation response has no id."); + String status = requiredText(root, "status", "OpenAI Batch creation response has no status."); + return new OpenAiBatchSubmission(inputFileId, batchId, normalizeStatus(status)); + } + + public OpenAiBatchStatus retrieve(String batchId) { + requireApiKey(); + if (batchId == null || batchId.isBlank()) { + throw new IllegalArgumentException("OpenAI Batch id is required."); + } + + ResponseEntity response = restTemplate.exchange( + BATCHES_URL + "/" + batchId, + HttpMethod.GET, + new HttpEntity<>(jsonHeaders()), + String.class + ); + JsonNode root = parseBody(response.getBody(), "OpenAI Batch retrieval"); + JsonNode requestCounts = root.path("request_counts"); + return new OpenAiBatchStatus( + requiredText(root, "id", "OpenAI Batch response has no id."), + normalizeStatus(requiredText(root, "status", "OpenAI Batch response has no status.")), + optionalText(root, "output_file_id"), + optionalText(root, "error_file_id"), + requestCounts.path("total").asInt(0), + requestCounts.path("completed").asInt(0), + requestCounts.path("failed").asInt(0), + nullableJson(root.path("errors")) + ); + } + + public void streamResults(String fileId, Consumer consumer) { + requireApiKey(); + if (fileId == null || fileId.isBlank()) { + return; + } + + restTemplate.execute( + FILES_URL + "/" + fileId + "/content", + HttpMethod.GET, + request -> request.getHeaders().setBearerAuth(apiKey), + response -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (!line.isBlank()) { + consumer.accept(parseResult(line)); + } + } + } + return null; + } + ); + } + + private String uploadInputFile(byte[] inputJsonl, String fileName) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(apiKey); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("purpose", "batch"); + body.add("file", new ByteArrayResource(inputJsonl) { + @Override + public String getFilename() { + return fileName; + } + }); + + ResponseEntity response = restTemplate.exchange( + FILES_URL, + HttpMethod.POST, + new HttpEntity<>(body, headers), + String.class + ); + JsonNode root = parseBody(response.getBody(), "OpenAI Batch file upload"); + return requiredText(root, "id", "OpenAI Batch file upload response has no id."); + } + + private OpenAiBatchImageResult parseResult(String line) { + JsonNode root = parseBody(line, "OpenAI Batch output line"); + String customId = requiredText(root, "custom_id", "OpenAI Batch output has no custom_id."); + JsonNode topLevelError = root.path("error"); + if (!topLevelError.isMissingNode() && !topLevelError.isNull()) { + return failedResult(customId, 0, null, topLevelError); + } + + JsonNode response = root.path("response"); + int statusCode = response.path("status_code").asInt(0); + String requestId = optionalText(response, "request_id"); + JsonNode responseBody = response.path("body"); + if (statusCode < 200 || statusCode >= 300) { + return failedResult(customId, statusCode, requestId, responseBody.path("error")); + } + + JsonNode data = responseBody.path("data"); + String b64Json = data.isArray() && !data.isEmpty() + ? data.get(0).path("b64_json").asText(null) + : null; + if (b64Json == null || b64Json.isBlank()) { + return new OpenAiBatchImageResult( + customId, + statusCode, + requestId, + null, + "MISSING_IMAGE_DATA", + "OpenAI Batch image response has no b64_json." + ); + } + + try { + return new OpenAiBatchImageResult( + customId, + statusCode, + requestId, + Base64.getDecoder().decode(b64Json.replaceAll("\\s+", "")), + null, + null + ); + } catch (IllegalArgumentException e) { + return new OpenAiBatchImageResult( + customId, + statusCode, + requestId, + null, + "INVALID_IMAGE_DATA", + "OpenAI Batch image response contains invalid base64 data." + ); + } + } + + private OpenAiBatchImageResult failedResult(String customId, + int statusCode, + String requestId, + JsonNode error) { + String code = error.path("code").asText("OPENAI_BATCH_REQUEST_FAILED"); + String message = error.path("message").asText("OpenAI Batch request failed."); + return new OpenAiBatchImageResult(customId, statusCode, requestId, null, code, message); + } + + private HttpHeaders jsonHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(apiKey); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private JsonNode parseBody(String body, String operation) { + if (body == null || body.isBlank()) { + throw new IllegalStateException(operation + " returned an empty response."); + } + try { + return objectMapper.readTree(body); + } catch (Exception e) { + throw new IllegalStateException(operation + " returned invalid JSON.", e); + } + } + + private String requiredText(JsonNode node, String field, String message) { + String value = optionalText(node, field); + if (value == null) { + throw new IllegalStateException(message); + } + return value; + } + + private String optionalText(JsonNode node, String field) { + JsonNode value = node.path(field); + if (value.isMissingNode() || value.isNull()) { + return null; + } + String text = value.asText(null); + return text == null || text.isBlank() ? null : text; + } + + private String nullableJson(JsonNode node) { + return node.isMissingNode() || node.isNull() ? null : node.toString(); + } + + private String normalizeStatus(String status) { + return status.toLowerCase(Locale.ROOT); + } + + private void requireApiKey() { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("OpenAI API key is not configured."); + } + } + + public record OpenAiBatchSubmission(String inputFileId, String batchId, String status) { + } + + public record OpenAiBatchImageEditRequest( + String customId, + String model, + String referenceImageUrl, + String prompt, + String size, + String quality + ) { + } + + public record OpenAiBatchStatus( + String batchId, + String status, + String outputFileId, + String errorFileId, + int totalCount, + int completedCount, + int failedCount, + String errorsJson + ) { + public boolean isTerminal() { + return "completed".equals(status) + || "failed".equals(status) + || "expired".equals(status) + || "cancelled".equals(status); + } + } + + public record OpenAiBatchImageResult( + String customId, + int statusCode, + String requestId, + byte[] imageData, + String errorCode, + String errorMessage + ) { + public boolean isSuccess() { + return imageData != null && imageData.length > 0 && errorCode == null; + } + } +} diff --git a/src/main/java/com/kw/readwith/service/image/OpenAiImageEditClient.java b/src/main/java/com/kw/readwith/service/image/OpenAiImageEditClient.java index 1f5a254..7e9dcab 100644 --- a/src/main/java/com/kw/readwith/service/image/OpenAiImageEditClient.java +++ b/src/main/java/com/kw/readwith/service/image/OpenAiImageEditClient.java @@ -103,7 +103,7 @@ private String resolveEditModel() { return editModel; } String model = normalize(imageProperties.getModel()); - return model != null ? model : "gpt-image-1"; + return model != null ? model : "gpt-image-2"; } private String normalize(String value) { 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 cc5ab2e..056ecb7 100644 --- a/src/main/java/com/kw/readwith/web/controller/AdminImageGenerationController.java +++ b/src/main/java/com/kw/readwith/web/controller/AdminImageGenerationController.java @@ -2,7 +2,12 @@ import com.kw.readwith.apiPayload.ApiResponse; import com.kw.readwith.dto.admin.AdminImageGenerationStatusResponseDTO; +import com.kw.readwith.dto.admin.ProcessingJobLogResponseDTO; +import com.kw.readwith.dto.admin.ProcessingJobResponseDTO; import com.kw.readwith.service.AdminImageGenerationService; +import com.kw.readwith.service.CharacterImageFanoutJobDispatcher; +import com.kw.readwith.service.CharacterImageFanoutJobService; +import com.kw.readwith.domain.enums.ProcessingJobStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -16,6 +21,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v2/admin/image-generation") @@ -30,6 +37,8 @@ public class AdminImageGenerationController { private final AdminImageGenerationService adminImageGenerationService; + private final CharacterImageFanoutJobService fanoutJobService; + private final CharacterImageFanoutJobDispatcher fanoutJobDispatcher; @Operation( summary = "책 이미지 생성 상태 조회", @@ -78,6 +87,10 @@ public ApiResponse getBookImageGeneration responseCode = "400", description = "ADMIN4026: 대표 후보사진을 만들 캐릭터가 없습니다." ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "ADMIN4028: 해당 책의 fan-out job이 진행 중이라 후보를 다시 생성할 수 없습니다." + ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", description = "PROGRESS4001: 해당 책을 찾을 수 없습니다." @@ -92,26 +105,31 @@ public ApiResponse generateReferenceCandi } @Operation( - summary = "대표 후보사진 1장 선택 및 전체 fan-out", + summary = "대표 후보사진 1장 선택 및 Batch fan-out 등록", description = """ 관리자가 대표 후보사진 4장 중 마음에 드는 1장을 선택할 때 호출합니다. candidateId에는 상태 조회 또는 후보 생성 응답의 referenceCandidates[].id에 담긴 asset DB ID를 전달합니다. referenceCandidates[].slotNo(1~4)는 화면 표시용 슬롯 번호이며 candidateId로 사용할 수 없습니다. 선택된 후보는 책의 active reference image가 되고, 대표 캐릭터의 게시 이미지로 즉시 반영됩니다. - 이후 서버가 같은 reference image를 입력 이미지로 사용해 나머지 모든 캐릭터 이미지를 fan-out 생성합니다. + 이후 서버는 선택된 한 장을 canonical reference로 고정하고, 같은 reference URL과 GPT Image 2 series-lock 프롬프트를 사용하는 OpenAI Batch job을 등록합니다. + 응답의 fanoutJob.status=QUEUED는 등록 대기 상태이며 이미지 생성 완료를 뜻하지 않습니다. fanoutJob.status=READY가 되어야 모든 대상 이미지의 DB/S3 반영이 끝난 상태입니다. 후보가 생성 완료 상태가 아니거나 다른 책의 asset이면 ADMIN4021 또는 ADMIN4022 오류가 발생합니다. """ ) @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "대표 후보 선택 및 나머지 캐릭터 fan-out 처리 성공. 개별 실패는 characters[].imageStatus/failureCode로 확인합니다.", + description = "대표 후보 선택 및 fan-out Batch job 큐 등록 성공. fanoutJob.id로 상태와 로그를 조회합니다.", content = @Content(schema = @Schema(implementation = AdminImageGenerationStatusResponseDTO.class)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "ADMIN4021: 후보사진이 요청한 책에 속하지 않습니다. ADMIN4022: 선택 가능한 대표 후보사진 상태가 아닙니다." ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "ADMIN4028: 해당 책의 fan-out job이 이미 진행 중입니다." + ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", description = "PROGRESS4001: 해당 책을 찾을 수 없습니다." @@ -128,9 +146,56 @@ public ApiResponse selectReferenceCandida ) @PathVariable Long candidateId) { AdminImageGenerationStatusResponseDTO response = adminImageGenerationService.selectReferenceCandidate(bookId, candidateId); + ProcessingJobResponseDTO fanoutJob = response.getFanoutJob(); + if (fanoutJob != null && fanoutJob.getStatus() == ProcessingJobStatus.QUEUED) { + fanoutJobDispatcher.dispatch(fanoutJob.getId()); + } return ApiResponse.onSuccess(response); } + @Operation( + summary = "캐릭터 이미지 fan-out job 조회", + description = "대표 후보 선택으로 등록된 OpenAI Batch fan-out job의 처리 상태와 외부 Batch/file ID를 조회합니다. status=READY여야 모든 대상 이미지의 게시가 완료된 상태입니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "fan-out job 조회 성공", + content = @Content(schema = @Schema(implementation = ProcessingJobResponseDTO.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "ADMIN4029: 캐릭터 이미지 fan-out job을 찾을 수 없습니다." + ) + }) + @GetMapping("/fanout-jobs/{jobId}") + public ApiResponse getFanoutJob( + @Parameter(description = "상태를 조회할 fan-out job ID", required = true, example = "123") + @PathVariable Long jobId) { + return ApiResponse.onSuccess(fanoutJobService.getFanoutJob(jobId)); + } + + @Operation( + summary = "캐릭터 이미지 fan-out job 로그 조회", + description = "OpenAI Batch 등록, 상태 변경, 캐릭터별 게시 또는 실패 내역을 순서대로 조회합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "fan-out job 로그 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "ADMIN4029: 캐릭터 이미지 fan-out job을 찾을 수 없습니다." + ) + }) + @GetMapping("/fanout-jobs/{jobId}/logs") + public ApiResponse> getFanoutJobLogs( + @Parameter(description = "로그를 조회할 fan-out job ID", required = true, example = "123") + @PathVariable Long jobId) { + return ApiResponse.onSuccess(fanoutJobService.getFanoutJobLogs(jobId)); + } + @Operation( summary = "개별 캐릭터 이미지 재생성", description = """ @@ -150,6 +215,10 @@ public ApiResponse selectReferenceCandida responseCode = "400", description = "ADMIN4022: 대표 캐릭터는 후보 4장 재생성 후 다시 선택해야 합니다. ADMIN4023: 선택된 대표 후보사진이 없습니다." ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "ADMIN4028: fan-out job 진행 중에는 개별 재생성을 시작할 수 없습니다." + ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", description = "PROGRESS4001: 해당 책을 찾을 수 없습니다. ADMIN4004: 해당 캐릭터를 찾을 수 없습니다. ADMIN4006: 캐릭터가 해당 책에 속하지 않습니다." diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 16fc83e..d8630de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -66,7 +66,7 @@ spring: api-key: ${OPENAI_API_KEY} image: options: - model: ${CHARACTER_IMAGE_MODEL:gpt-image-1} + model: ${CHARACTER_IMAGE_MODEL:gpt-image-2} size: ${CHARACTER_IMAGE_SIZE:1024x1024} quality: ${CHARACTER_IMAGE_QUALITY:medium} n: ${CHARACTER_IMAGE_COUNT:1} @@ -127,8 +127,8 @@ cloud: # 캐릭터 이미지 생성 설정 character-image: fallback-url: https://readwith-s3-bucket.s3.ap-northeast-2.amazonaws.com/character-images/default-character.jpg - model: ${CHARACTER_IMAGE_MODEL:gpt-image-1} - edit-model: ${CHARACTER_IMAGE_EDIT_MODEL:${CHARACTER_IMAGE_MODEL:gpt-image-1}} + model: ${CHARACTER_IMAGE_MODEL:gpt-image-2} + edit-model: ${CHARACTER_IMAGE_EDIT_MODEL:${CHARACTER_IMAGE_MODEL:gpt-image-2}} quality: ${CHARACTER_IMAGE_QUALITY:medium} width: ${CHARACTER_IMAGE_WIDTH:1024} height: ${CHARACTER_IMAGE_HEIGHT:1024} @@ -215,6 +215,7 @@ character-image: REMEMBER: This is a SIMPLE HEAD PORTRAIT ONLY. The person's face is THE ONLY element. Everything else must be plain beige paper. If the image model tries to add ANY decoration, pattern, symbol, or ornament - REJECT and keep background COMPLETELY PLAIN." s3-path: character-images + batch-poll-interval-ms: ${CHARACTER_IMAGE_BATCH_POLL_INTERVAL_MS:30000} # OpenAI API Rate Limit 방지 설정 delay-between-requests-ms: 2000 # 각 요청 사이 2초 대기 diff --git a/src/main/resources/db/migration/V9__character_image_batch_jobs.sql b/src/main/resources/db/migration/V9__character_image_batch_jobs.sql new file mode 100644 index 0000000..8cc170f --- /dev/null +++ b/src/main/resources/db/migration/V9__character_image_batch_jobs.sql @@ -0,0 +1,16 @@ +ALTER TABLE processing_job + ADD COLUMN external_job_id VARCHAR(120) NULL AFTER artifact_path, + ADD COLUMN input_file_id VARCHAR(120) NULL AFTER external_job_id, + ADD COLUMN output_file_id VARCHAR(120) NULL AFTER input_file_id, + ADD COLUMN error_file_id VARCHAR(120) NULL AFTER output_file_id; + +CREATE INDEX idx_processing_job_external_job + ON processing_job (external_job_id); + +ALTER TABLE character_image_asset + ADD COLUMN processing_job_id BIGINT NULL AFTER source_reference_asset_id, + ADD CONSTRAINT fk_character_image_asset_processing_job + FOREIGN KEY (processing_job_id) REFERENCES processing_job (id); + +CREATE INDEX idx_character_image_asset_processing_job + ON character_image_asset (processing_job_id); diff --git a/src/test/java/com/kw/readwith/service/AdminImageGenerationServiceTest.java b/src/test/java/com/kw/readwith/service/AdminImageGenerationServiceTest.java index 4382c66..2197c4e 100644 --- a/src/test/java/com/kw/readwith/service/AdminImageGenerationServiceTest.java +++ b/src/test/java/com/kw/readwith/service/AdminImageGenerationServiceTest.java @@ -11,11 +11,15 @@ import com.kw.readwith.domain.enums.CharacterImageAssetStatus; import com.kw.readwith.domain.enums.CharacterImageGenerationMode; import com.kw.readwith.domain.enums.ImageGenerationStatus; +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.dto.admin.AdminImageGenerationStatusResponseDTO; import com.kw.readwith.repository.BookCharacterImageProfileRepository; import com.kw.readwith.repository.BookRepository; 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; @@ -66,6 +70,10 @@ class AdminImageGenerationServiceTest { @Mock private CharacterImageProperties imageProperties; @Mock + private CharacterImageFanoutJobService fanoutJobService; + @Mock + private ProcessingJobRepository processingJobRepository; + @Mock private RestTemplate restTemplate; @Mock private CdnUrlService cdnUrlService; @@ -114,7 +122,7 @@ void generateReferenceCandidates_createsFourSlots() throws Exception { AtomicLong idSequence = new AtomicLong(100L); given(bookRepository.findById(1L)).willReturn(Optional.of(book)); - given(imageProperties.getModel()).willReturn("gpt-image-1"); + given(imageProperties.getModel()).willReturn("gpt-image-2"); given(imageProperties.getBaseStylePrompt()).willReturn("editorial gouache portrait"); given(characterRepository.findByBookOrderByIsMainCharacterDescNameAsc(book)) .willReturn(List.of(mainCharacter, sideCharacter)); @@ -140,9 +148,13 @@ void generateReferenceCandidates_createsFourSlots() throws Exception { CharacterImageAssetRole.CHARACTER_IMAGE )).willReturn(List.of()); given(characterImageService.generateTextImage(mainCharacter)) - .willReturn(new GeneratedCharacterImage(new byte[]{1}, "gpt-image-1", "prompt", "hash", "req-1")); - when(characterImageService.buildReferenceCandidateSlotS3KeyName(eq(mainCharacter), anyInt())) - .thenAnswer(invocation -> "character-images/1/reference/slot-" + invocation.getArgument(1) + ".png"); + .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)); @@ -161,7 +173,7 @@ void generateReferenceCandidates_createsFourSlots() throws Exception { verify(characterImageService).uploadGeneratedImage( eq(mainCharacter), any(byte[].class), - eq("character-images/1/reference/slot-1.png") + eq("character-images/1/reference/attempt-1/slot-1.png") ); } @@ -205,4 +217,26 @@ void regenerateCharacterImage_requiresSelectedReference() { assertThat(exception.getErrorCode()).isEqualTo(ErrorStatus.IMAGE_REFERENCE_NOT_APPROVED); } + + @Test + @DisplayName("individual regeneration is blocked while a fan-out Batch is active") + void regenerateCharacterImage_rejectsActiveFanoutJob() { + ProcessingJob activeJob = ProcessingJob.builder() + .id(500L) + .book(book) + .pipelineType(ProcessingPipelineType.IMAGE_GENERATION) + .status(ProcessingJobStatus.PROCESSING) + .build(); + given(bookRepository.findById(1L)).willReturn(Optional.of(book)); + given(processingJobRepository.findFirstByBookIdAndPipelineTypeAndStatusInOrderByCreatedAtDesc( + eq(1L), eq(ProcessingPipelineType.IMAGE_GENERATION), any() + )).willReturn(Optional.of(activeJob)); + + GeneralException exception = assertThrows( + GeneralException.class, + () -> adminImageGenerationService.regenerateCharacterImage(1L, 11L) + ); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorStatus.IMAGE_FANOUT_JOB_ACTIVE); + } } diff --git a/src/test/java/com/kw/readwith/service/CdnUrlServiceTest.java b/src/test/java/com/kw/readwith/service/CdnUrlServiceTest.java index 8f4215e..46ab3a1 100644 --- a/src/test/java/com/kw/readwith/service/CdnUrlServiceTest.java +++ b/src/test/java/com/kw/readwith/service/CdnUrlServiceTest.java @@ -57,4 +57,24 @@ void toPublicUrl_keepsExternalUrl() { assertThat(result).isEqualTo("https://example.com/image.png"); } + + @Test + void isCdnUrl_acceptsOnlyConfiguredCloudFrontOrigin() { + assertThat(cdnUrlService.isCdnUrl("https://cdn.readwith.store/character-images/1/10.png")).isTrue(); + assertThat(cdnUrlService.isCdnUrl( + "https://readwith-s3-bucket.s3.ap-northeast-2.amazonaws.com/character-images/1/10.png" + )).isFalse(); + } + + @Test + void toPublicObjectKey_extractsCharacterImageKeyFromCdnUrl() { + assertThat(cdnUrlService.toPublicObjectKey( + "https://cdn.readwith.store/character-images/20/101/reference-v2/attempt-3.png" + )).contains("character-images/20/101/reference-v2/attempt-3.png"); + } + + @Test + void toPublicObjectKey_rejectsExternalObjectUrl() { + assertThat(cdnUrlService.toPublicObjectKey("https://example.com/image.png")).isEmpty(); + } } diff --git a/src/test/java/com/kw/readwith/service/CharacterImageFanoutJobServiceTest.java b/src/test/java/com/kw/readwith/service/CharacterImageFanoutJobServiceTest.java new file mode 100644 index 0000000..827f609 --- /dev/null +++ b/src/test/java/com/kw/readwith/service/CharacterImageFanoutJobServiceTest.java @@ -0,0 +1,297 @@ +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.BookImageReferenceStatus; +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.ImageGenerationStatus; +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.dto.admin.ProcessingJobResponseDTO; +import com.kw.readwith.repository.BookCharacterImageProfileRepository; +import com.kw.readwith.repository.CharacterImageAssetRepository; +import com.kw.readwith.repository.CharacterRepository; +import com.kw.readwith.repository.ProcessingJobLogRepository; +import com.kw.readwith.repository.ProcessingJobRepository; +import com.kw.readwith.service.image.OpenAiImageBatchClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; +import tools.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +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.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doAnswer; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CharacterImageFanoutJobService") +class CharacterImageFanoutJobServiceTest { + + @Mock + private CharacterRepository characterRepository; + @Mock + private CharacterImageAssetRepository assetRepository; + @Mock + private BookCharacterImageProfileRepository profileRepository; + @Mock + private ProcessingJobRepository processingJobRepository; + @Mock + private ProcessingJobLogRepository processingJobLogRepository; + @Mock + private CharacterImageService characterImageService; + @Mock + private CharacterImageProperties imageProperties; + @Mock + private CdnUrlService cdnUrlService; + @Mock + private OpenAiImageBatchClient batchClient; + @Mock + private PlatformTransactionManager transactionManager; + + @InjectMocks + private CharacterImageFanoutJobService service; + + private Book book; + private Character referenceCharacter; + private Character targetCharacter; + private CharacterImageAsset referenceAsset; + private BookCharacterImageProfile profile; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + book = Book.builder().id(20L).title("The Adventures of Sherlock Holmes").build(); + referenceCharacter = Character.builder() + .id(100L) + .book(book) + .characterId(1L) + .name("Sherlock Holmes") + .isMainCharacter(true) + .imageGenerationStatus(ImageGenerationStatus.COMPLETED) + .build(); + targetCharacter = Character.builder() + .id(101L) + .book(book) + .characterId(2L) + .name("Dr. Watson") + .imageGenerationStatus(ImageGenerationStatus.PENDING) + .build(); + referenceAsset = CharacterImageAsset.builder() + .id(500L) + .book(book) + .character(referenceCharacter) + .assetRole(CharacterImageAssetRole.REFERENCE_CANDIDATE) + .generationMode(CharacterImageGenerationMode.TEXT_TO_IMAGE) + .status(CharacterImageAssetStatus.PUBLISHED) + .s3Url("https://cdn.readwith.store/reference.png") + .model("gpt-image-2") + .build(); + profile = BookCharacterImageProfile.builder() + .book(book) + .activeReferenceAsset(referenceAsset) + .referenceCharacter(referenceCharacter) + .referenceVersion(1) + .referenceStatus(BookImageReferenceStatus.APPROVED) + .build(); + } + + @Test + @DisplayName("queues one GPT Image 2 Batch target for every non-reference character") + void queueFanout_preparesBatchTargets() { + AtomicLong idSequence = new AtomicLong(700L); + given(processingJobRepository.findFirstByBookIdAndPipelineTypeAndStatusInOrderByCreatedAtDesc( + eq(20L), eq(ProcessingPipelineType.IMAGE_GENERATION), any() + )).willReturn(Optional.empty()); + given(characterRepository.findByBookOrderByIsMainCharacterDescNameAsc(book)) + .willReturn(List.of(referenceCharacter, targetCharacter)); + when(processingJobRepository.save(any(ProcessingJob.class))).thenAnswer(invocation -> { + ProcessingJob job = invocation.getArgument(0); + ReflectionTestUtils.setField(job, "id", 600L); + return job; + }); + given(assetRepository.findFirstByCharacterAndAssetRoleOrderByCreatedAtDesc( + targetCharacter, CharacterImageAssetRole.CHARACTER_IMAGE + )).willReturn(Optional.empty()); + when(assetRepository.save(any(CharacterImageAsset.class))).thenAnswer(invocation -> { + CharacterImageAsset asset = invocation.getArgument(0); + ReflectionTestUtils.setField(asset, "id", idSequence.getAndIncrement()); + return asset; + }); + given(imageProperties.getEditModel()).willReturn("gpt-image-2"); + given(characterImageService.buildReferenceEditPrompt(targetCharacter)).willReturn("series lock: Watson"); + given(characterImageService.buildPromptHash("series lock: Watson")).willReturn("prompt-hash"); + given(processingJobLogRepository.countByJobId(600L)).willReturn(0L); + + ProcessingJobResponseDTO response = service.queueFanout(book, profile, referenceAsset); + + assertThat(response.getPipelineType()).isEqualTo(ProcessingPipelineType.IMAGE_GENERATION); + assertThat(response.getStatus()).isEqualTo(ProcessingJobStatus.QUEUED); + ArgumentCaptor assetCaptor = ArgumentCaptor.forClass(CharacterImageAsset.class); + verify(assetRepository).save(assetCaptor.capture()); + CharacterImageAsset targetAsset = assetCaptor.getValue(); + assertThat(targetAsset.getSourceReferenceAsset()).isSameAs(referenceAsset); + assertThat(targetAsset.getReferenceVersion()).isEqualTo(1); + assertThat(targetAsset.getProcessingJob().getId()).isEqualTo(600L); + assertThat(targetAsset.getModel()).isEqualTo("gpt-image-2"); + assertThat(targetAsset.getPromptHash()).isEqualTo("prompt-hash"); + verify(characterRepository).updateImageGenerationStatus(101L, ImageGenerationStatus.GENERATING); + } + + @Test + @DisplayName("rejects a second active fan-out job for the same book") + void queueFanout_rejectsActiveJob() { + ProcessingJob activeJob = ProcessingJob.builder() + .id(601L) + .book(book) + .pipelineType(ProcessingPipelineType.IMAGE_GENERATION) + .status(ProcessingJobStatus.PROCESSING) + .build(); + given(processingJobRepository.findFirstByBookIdAndPipelineTypeAndStatusInOrderByCreatedAtDesc( + eq(20L), eq(ProcessingPipelineType.IMAGE_GENERATION), any() + )).willReturn(Optional.of(activeJob)); + + GeneralException exception = assertThrows( + GeneralException.class, + () -> service.queueFanout(book, profile, referenceAsset) + ); + + assertThat(exception.getErrorCode()).isEqualTo(ErrorStatus.IMAGE_FANOUT_JOB_ACTIVE); + } + + @Test + @DisplayName("submits a queued fan-out job and stores the OpenAI Batch identifiers") + void submit_registersOpenAiBatch() { + ProcessingJob job = ProcessingJob.builder() + .id(610L) + .book(book) + .pipelineType(ProcessingPipelineType.IMAGE_GENERATION) + .runId("character-image-fanout-run-1") + .artifactPath("character-images/20/fanout-jobs/run-1") + .status(ProcessingJobStatus.QUEUED) + .currentStep("queued") + .build(); + CharacterImageAsset targetAsset = buildTargetAsset(710L, job); + given(transactionManager.getTransaction(any())).willAnswer(invocation -> new SimpleTransactionStatus()); + given(processingJobRepository.findByIdForUpdate(610L)).willReturn(Optional.of(job)); + given(assetRepository.findByProcessingJobOrderByIdAsc(job)).willReturn(List.of(targetAsset)); + given(profileRepository.findByBook(book)).willReturn(Optional.of(profile)); + given(cdnUrlService.toPublicUrl(referenceAsset.getS3Url())) + .willReturn("https://cdn.readwith.store/character-images/20/reference/slot-1.png"); + given(cdnUrlService.isCdnUrl(any())).willReturn(true); + given(characterImageService.buildReferenceEditPrompt(targetCharacter)).willReturn("series lock: Watson"); + given(imageProperties.getEditModel()).willReturn("gpt-image-2"); + given(imageProperties.getWidth()).willReturn(1024); + given(imageProperties.getHeight()).willReturn(1024); + given(imageProperties.getQuality()).willReturn("medium"); + given(batchClient.buildImageEditInput(any())).willReturn(new byte[]{1}); + given(batchClient.submit(any(), any(), any(), any())).willReturn( + new OpenAiImageBatchClient.OpenAiBatchSubmission("file-input", "batch-610", "validating") + ); + + service.submit(610L); + + assertThat(job.getStatus()).isEqualTo(ProcessingJobStatus.PROCESSING); + assertThat(job.getExternalJobId()).isEqualTo("batch-610"); + assertThat(job.getInputFileId()).isEqualTo("file-input"); + assertThat(job.getCurrentStep()).isEqualTo("openai_validating"); + } + + @Test + @DisplayName("publishes streamed Batch output and marks the fan-out job ready") + void refresh_publishesCompletedBatchOutput() { + ProcessingJob job = ProcessingJob.builder() + .id(620L) + .book(book) + .pipelineType(ProcessingPipelineType.IMAGE_GENERATION) + .runId("character-image-fanout-run-2") + .artifactPath("character-images/20/fanout-jobs/run-2") + .externalJobId("batch-620") + .inputFileId("file-input") + .status(ProcessingJobStatus.PROCESSING) + .currentStep("openai_in_progress") + .build(); + CharacterImageAsset targetAsset = buildTargetAsset(720L, job); + given(transactionManager.getTransaction(any())).willAnswer(invocation -> new SimpleTransactionStatus()); + given(processingJobRepository.findByIdForUpdate(620L)).willReturn(Optional.of(job)); + given(assetRepository.findByProcessingJobOrderByIdAsc(job)).willReturn(List.of(targetAsset)); + given(assetRepository.findById(720L)).willReturn(Optional.of(targetAsset)); + given(batchClient.retrieve("batch-620")).willReturn(new OpenAiImageBatchClient.OpenAiBatchStatus( + "batch-620", "completed", "file-output", null, 1, 1, 0, null + )); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer consumer = invocation.getArgument(1); + consumer.accept(new OpenAiImageBatchClient.OpenAiBatchImageResult( + "character-image-asset-720", 200, "req-720", new byte[]{1, 2, 3}, null, null + )); + return null; + }).when(batchClient).streamResults(eq("file-output"), any()); + given(characterImageService.buildPublishedS3KeyName(targetCharacter, 1, 1)) + .willReturn("character-images/20/101/reference-v1/attempt-1.png"); + given(characterImageService.uploadGeneratedImage( + eq(targetCharacter), + any(byte[].class), + eq("character-images/20/101/reference-v1/attempt-1.png") + )).willReturn("https://cdn.readwith.store/character-images/20/101/reference-v1/attempt-1.png"); + + service.refresh(620L); + + assertThat(job.getStatus()).isEqualTo(ProcessingJobStatus.READY); + assertThat(job.getOutputFileId()).isEqualTo("file-output"); + assertThat(targetAsset.getStatus()).isEqualTo(CharacterImageAssetStatus.PUBLISHED); + assertThat(targetAsset.getS3Url()) + .isEqualTo("https://cdn.readwith.store/character-images/20/101/reference-v1/attempt-1.png"); + verify(characterRepository).updateProfileImageAndStatus( + 101L, + "https://cdn.readwith.store/character-images/20/101/reference-v1/attempt-1.png", + ImageGenerationStatus.COMPLETED + ); + verify(characterImageService).deleteReplacedGeneratedImage( + "https://cdn.readwith.store/character-images/20/101/reference-v0/attempt-1.png", + "https://cdn.readwith.store/character-images/20/101/reference-v1/attempt-1.png" + ); + } + + private CharacterImageAsset buildTargetAsset(Long assetId, ProcessingJob job) { + return CharacterImageAsset.builder() + .id(assetId) + .book(book) + .character(targetCharacter) + .assetRole(CharacterImageAssetRole.CHARACTER_IMAGE) + .generationMode(CharacterImageGenerationMode.REFERENCE_EDIT) + .sourceReferenceAsset(referenceAsset) + .processingJob(job) + .referenceVersion(1) + .status(CharacterImageAssetStatus.GENERATING) + .s3Url("https://cdn.readwith.store/character-images/20/101/reference-v0/attempt-1.png") + .model("gpt-image-2") + .promptHash("prompt-hash") + .build(); + } +} diff --git a/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java b/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java index d04e37b..dd952e0 100644 --- a/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java +++ b/src/test/java/com/kw/readwith/service/CharacterImageServiceTest.java @@ -58,6 +58,9 @@ class CharacterImageServiceTest { @Mock private CharacterImageTransactionService transactionService; + @Mock + private CdnUrlService cdnUrlService; + @InjectMocks private CharacterImageService characterImageService; @@ -85,7 +88,7 @@ void setUp() { .build(); when(imageProperties.getFallbackUrl()).thenReturn("https://cdn.readwith.store/character/default.png"); - when(imageProperties.getModel()).thenReturn("gpt-image-1"); + when(imageProperties.getModel()).thenReturn("gpt-image-2"); when(imageProperties.getQuality()).thenReturn("medium"); when(imageProperties.getWidth()).thenReturn(1024); when(imageProperties.getHeight()).thenReturn(1024); @@ -154,6 +157,18 @@ void buildImagePrompt_sanitizesRiskyPromptSegments() { assertThat(prompt).doesNotContain("crowd behind him"); } + @Test + @DisplayName("reference edit prompt locks the book-wide visual style while preserving target identity") + void buildReferenceEditPrompt_appliesSeriesLock() { + String prompt = characterImageService.buildReferenceEditPrompt(testCharacter); + + assertThat(prompt) + .contains("immutable visual bible for this book series") + .contains("Keep these style attributes consistent across every character portrait") + .contains("Do not copy the reference person's face") + .contains("middle-aged man, pale complexion"); + } + @Test @DisplayName("generateAndSaveImage uploads generated image using current bookPrompt") void generateAndSaveImage_succeeds() throws Exception { @@ -161,8 +176,8 @@ void generateAndSaveImage_succeeds() throws Exception { Files.write(tempImage, new byte[]{1, 2, 3, 4}); when(characterRepository.findByIdWithBook(testCharacter.getId())).thenReturn(Optional.of(testCharacter)); - when(s3Manager.getObjectUrl("character-images/1/10.png")) - .thenReturn("https://cdn.readwith.store/character-images/1/10.png"); + when(s3Manager.getObjectUrl(anyString())) + .thenAnswer(invocation -> "https://cdn.readwith.store/" + invocation.getArgument(0)); ImageResponse imageResponse = mock(ImageResponse.class); ImageGeneration generation = mock(ImageGeneration.class); @@ -178,27 +193,31 @@ void generateAndSaveImage_succeeds() throws Exception { ArgumentCaptor imagePromptCaptor = ArgumentCaptor.forClass(ImagePrompt.class); verify(imageModel).call(imagePromptCaptor.capture()); OpenAiImageOptions imageOptions = (OpenAiImageOptions) imagePromptCaptor.getValue().getOptions(); - assertThat(imageOptions.getModel()).isEqualTo("gpt-image-1"); + assertThat(imageOptions.getModel()).isEqualTo("gpt-image-2"); assertThat(imageOptions.getQuality()).isEqualTo("medium"); assertThat(imageOptions.getWidth()).isEqualTo(1024); assertThat(imageOptions.getHeight()).isEqualTo(1024); assertThat(imageOptions.getN()).isEqualTo(1); - verify(s3Manager).uploadBytes(eq("character-images/1/10.png"), any(byte[].class), eq("image/png")); + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + verify(s3Manager).uploadBytes(keyCaptor.capture(), any(byte[].class), eq("image/png")); + assertThat(keyCaptor.getValue()) + .startsWith("character-images/1/10/standalone/") + .endsWith(".png"); verify(transactionService).updateImageAndStatus( testCharacter.getId(), - "https://cdn.readwith.store/character-images/1/10.png", + "https://cdn.readwith.store/" + keyCaptor.getValue(), ImageGenerationStatus.COMPLETED ); } @Test - @DisplayName("generateAndSaveImage uploads gpt-image-1 base64 response") + @DisplayName("generateAndSaveImage uploads gpt-image-2 base64 response") void generateAndSaveImage_uploadsBase64JsonResponse() { byte[] generatedImage = new byte[]{9, 8, 7, 6}; when(characterRepository.findByIdWithBook(testCharacter.getId())).thenReturn(Optional.of(testCharacter)); - when(s3Manager.getObjectUrl("character-images/1/10.png")) - .thenReturn("https://cdn.readwith.store/character-images/1/10.png"); + when(s3Manager.getObjectUrl(anyString())) + .thenAnswer(invocation -> "https://cdn.readwith.store/" + invocation.getArgument(0)); ImageResponse imageResponse = mock(ImageResponse.class); ImageGeneration generation = mock(ImageGeneration.class); @@ -211,19 +230,60 @@ void generateAndSaveImage_uploadsBase64JsonResponse() { characterImageService.generateAndSaveImage(testCharacter.getId()); ArgumentCaptor imageCaptor = ArgumentCaptor.forClass(byte[].class); + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); verify(s3Manager).uploadBytes( - eq("character-images/1/10.png"), + keyCaptor.capture(), imageCaptor.capture(), eq("image/png") ); assertThat(imageCaptor.getValue()).containsExactly(generatedImage); + assertThat(keyCaptor.getValue()) + .startsWith("character-images/1/10/standalone/") + .endsWith(".png"); verify(transactionService).updateImageAndStatus( testCharacter.getId(), - "https://cdn.readwith.store/character-images/1/10.png", + "https://cdn.readwith.store/" + keyCaptor.getValue(), ImageGenerationStatus.COMPLETED ); } + @Test + @DisplayName("reference-based published images use the reference and attempt versions") + void buildPublishedS3KeyName_usesImmutableVersionedPath() { + assertThat(characterImageService.buildPublishedS3KeyName(testCharacter, 2, 3)) + .isEqualTo("character-images/1/10/reference-v2/attempt-3.png"); + } + + @Test + @DisplayName("replaced character image is deleted from S3") + void deleteReplacedGeneratedImage_deletesPreviousObject() { + String previousUrl = "https://cdn.readwith.store/character-images/1/10.png"; + String replacementUrl = "https://cdn.readwith.store/character-images/1/10/reference-v2/attempt-3.png"; + when(cdnUrlService.toPublicObjectKey(previousUrl)) + .thenReturn(Optional.of("character-images/1/10.png")); + when(cdnUrlService.toPublicObjectKey(replacementUrl)) + .thenReturn(Optional.of("character-images/1/10/reference-v2/attempt-3.png")); + + characterImageService.deleteReplacedGeneratedImage(previousUrl, replacementUrl); + + verify(s3Manager).deleteKeys(List.of("character-images/1/10.png")); + } + + @Test + @DisplayName("shared public assets are not deleted when a character image is replaced") + void deleteReplacedGeneratedImage_keepsSharedPublicObject() { + String previousUrl = "https://cdn.readwith.store/public/default-character.png"; + String replacementUrl = "https://cdn.readwith.store/character-images/1/10/reference-v2/attempt-3.png"; + when(cdnUrlService.toPublicObjectKey(previousUrl)) + .thenReturn(Optional.of("public/default-character.png")); + when(cdnUrlService.toPublicObjectKey(replacementUrl)) + .thenReturn(Optional.of("character-images/1/10/reference-v2/attempt-3.png")); + + characterImageService.deleteReplacedGeneratedImage(previousUrl, replacementUrl); + + verify(s3Manager, never()).deleteKeys(any()); + } + @Test @DisplayName("generateImagesAsync stores fallback image when generation fails") void generateImagesAsync_storesFallbackImageOnFailure() { diff --git a/src/test/java/com/kw/readwith/service/image/OpenAiImageBatchClientTest.java b/src/test/java/com/kw/readwith/service/image/OpenAiImageBatchClientTest.java new file mode 100644 index 0000000..0d6f463 --- /dev/null +++ b/src/test/java/com/kw/readwith/service/image/OpenAiImageBatchClientTest.java @@ -0,0 +1,127 @@ +package com.kw.readwith.service.image; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; +import tools.jackson.databind.ObjectMapper; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@DisplayName("OpenAiImageBatchClient") +class OpenAiImageBatchClientTest { + + private RestTemplate restTemplate; + private MockRestServiceServer server; + private OpenAiImageBatchClient client; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + restTemplate = new RestTemplate(); + server = MockRestServiceServer.bindTo(restTemplate).build(); + objectMapper = new ObjectMapper(); + client = new OpenAiImageBatchClient(restTemplate, objectMapper); + ReflectionTestUtils.setField(client, "apiKey", "test-api-key"); + } + + @Test + @DisplayName("builds GPT Image 2 edit lines with one shared reference URL") + void buildImageEditInput_usesSharedCanonicalReference() throws Exception { + String referenceUrl = "https://cdn.readwith.store/character-images/20/reference/slot-1.png"; + + byte[] input = client.buildImageEditInput(List.of( + new OpenAiImageBatchClient.OpenAiBatchImageEditRequest( + "character-image-asset-10", + "gpt-image-2", + referenceUrl, + "series lock: Holmes", + "1024x1024", + "medium" + ), + new OpenAiImageBatchClient.OpenAiBatchImageEditRequest( + "character-image-asset-11", + "gpt-image-2", + referenceUrl, + "series lock: Watson", + "1024x1024", + "medium" + ) + )); + + String[] lines = new String(input, StandardCharsets.UTF_8).trim().split("\\R"); + assertThat(lines).hasSize(2); + for (String line : lines) { + var root = objectMapper.readTree(line); + assertThat(root.path("url").asText()).isEqualTo("/v1/images/edits"); + assertThat(root.path("body").path("model").asText()).isEqualTo("gpt-image-2"); + assertThat(root.path("body").path("images").get(0).path("image_url").asText()) + .isEqualTo(referenceUrl); + assertThat(root.path("body").has("input_fidelity")).isFalse(); + } + } + + @Test + @DisplayName("uploads JSONL and creates an image edit Batch") + void submit_createsImageEditBatch() { + server.expect(requestTo("https://api.openai.com/v1/files")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess("{\"id\":\"file-input\"}", MediaType.APPLICATION_JSON)); + server.expect(requestTo("https://api.openai.com/v1/batches")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess( + "{\"id\":\"batch-123\",\"status\":\"validating\"}", + MediaType.APPLICATION_JSON + )); + + OpenAiImageBatchClient.OpenAiBatchSubmission result = client.submit( + "{\"custom_id\":\"character-image-asset-1\"}\n".getBytes(StandardCharsets.UTF_8), + "fanout.jsonl", + "run-1", + Map.of("book_id", "20") + ); + + assertThat(result.inputFileId()).isEqualTo("file-input"); + assertThat(result.batchId()).isEqualTo("batch-123"); + assertThat(result.status()).isEqualTo("validating"); + server.verify(); + } + + @Test + @DisplayName("streams successful and failed image results by custom id") + void streamResults_matchesCustomIds() { + String encodedImage = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3}); + String output = "{\"custom_id\":\"character-image-asset-10\",\"response\":{\"status_code\":200,\"request_id\":\"req-1\",\"body\":{\"data\":[{\"b64_json\":\"" + + encodedImage + + "\"}]}},\"error\":null}\n" + + "{\"custom_id\":\"character-image-asset-11\",\"response\":{\"status_code\":429,\"request_id\":\"req-2\",\"body\":{\"error\":{\"code\":\"rate_limit\",\"message\":\"slow down\"}}},\"error\":null}\n"; + server.expect(requestTo("https://api.openai.com/v1/files/file-output/content")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(output, new MediaType("application", "jsonl"))); + + List results = new ArrayList<>(); + client.streamResults("file-output", results::add); + + assertThat(results).hasSize(2); + assertThat(results.get(0).customId()).isEqualTo("character-image-asset-10"); + assertThat(results.get(0).imageData()).containsExactly(1, 2, 3); + assertThat(results.get(0).isSuccess()).isTrue(); + assertThat(results.get(1).customId()).isEqualTo("character-image-asset-11"); + assertThat(results.get(1).errorCode()).isEqualTo("rate_limit"); + assertThat(results.get(1).isSuccess()).isFalse(); + server.verify(); + } +}