diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java index e24fc754..92dc43bd 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java @@ -1,6 +1,9 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse; +import com.swyp.picke.domain.battle.thumbnail.BattleThumbnailCandidateService; import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseRequest; import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -32,6 +35,7 @@ public class AdminBattleController { private final AdminBattleService adminBattleService; + private final BattleThumbnailCandidateService battleThumbnailCandidateService; @Operation(summary = "배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함)") @PostMapping("/parse") @@ -41,6 +45,14 @@ public ApiResponse parseBattleScript( return ApiResponse.onSuccess(adminBattleService.parseBattleScript(request.rawText())); } + @Operation(summary = "배틀 썸네일 AI 후보 생성 (S3 업로드만, 배틀에는 저장 안 함)") + @PostMapping("/thumbnail-candidates") + public ApiResponse generateThumbnailCandidates( + @RequestBody @Valid AdminBattleThumbnailCandidateRequest request + ) { + return ApiResponse.onSuccess(battleThumbnailCandidateService.generate(request)); + } + @Operation(summary = "배틀 생성") @PostMapping public ApiResponse createBattle( diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleThumbnailCandidateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleThumbnailCandidateRequest.java new file mode 100644 index 00000000..34b51a56 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleThumbnailCandidateRequest.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import jakarta.validation.constraints.NotBlank; + +/** 저장 전 폼 내용으로도 후보를 만들 수 있게 battleId 대신 배틀 내용을 받는다. */ +public record AdminBattleThumbnailCandidateRequest( + @NotBlank String title, + String summary, + String description, + Side optionA, + Side optionB +) { + public record Side(String title, String stance) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleThumbnailCandidatesResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleThumbnailCandidatesResponse.java new file mode 100644 index 00000000..4d4314fd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleThumbnailCandidatesResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.admin.dto.battle.response; + +import java.util.List; + +/** + * 썸네일 후보 목록. + * key 는 배틀 저장 시 thumbnailUrl 로 그대로 넘기고, previewUrl 은 화면 미리보기용(presigned)이다. + */ +public record AdminBattleThumbnailCandidatesResponse(List candidates) { + + public record Candidate(String key, String previewUrl) {} +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java new file mode 100644 index 00000000..487315fe --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java @@ -0,0 +1,109 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest.Side; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse.Candidate; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 배틀 내용 + 공통 스타일로 썸네일 후보를 여러 장 만들어 S3 에 올린다. + * 후보는 동시에 생성하고, 일부가 실패해도 성공한 것만 돌려준다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BattleThumbnailCandidateService { + + static final int CANDIDATE_COUNT = 3; + + private final BattleThumbnailStyleSource styleSource; + private final OpenAiImageClient imageClient; + private final S3UploadService s3UploadService; + private final S3PresignedUrlService s3PresignedUrlService; + + public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandidateRequest request) { + String prompt = buildPrompt(styleSource.promptTemplate(), request); + List references = styleSource.referenceImages(); + + List candidates; + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = IntStream.range(0, CANDIDATE_COUNT) + .mapToObj(i -> CompletableFuture.supplyAsync(() -> generateOne(prompt, references), executor)) + .toList(); + candidates = futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList(); + } + + if (candidates.isEmpty()) { + throw new CustomException(ErrorCode.BATTLE_THUMBNAIL_GENERATION_FAILED); + } + return new AdminBattleThumbnailCandidatesResponse(candidates); + } + + private Candidate generateOne(String prompt, List references) { + try { + byte[] image = imageClient.generate(prompt, references); + String key = upload(image); + return new Candidate(key, s3PresignedUrlService.generatePresignedUrl(key)); + } catch (Exception e) { + log.warn("[Thumbnail] 썸네일 후보 1장 생성 실패", e); + return null; + } + } + + private String upload(byte[] image) throws IOException { + File temp = Files.createTempFile("battle-thumbnail-", ".png").toFile(); + try { + Files.write(temp.toPath(), image); + String key = FileCategory.BATTLE.getPath() + "/ai-" + UUID.randomUUID() + ".png"; + return s3UploadService.uploadFile(key, temp); + } finally { + Files.deleteIfExists(temp.toPath()); + } + } + + static String buildPrompt(String template, AdminBattleThumbnailCandidateRequest request) { + return template + .replace("{title}", orEmpty(request.title())) + .replace("{summary}", orEmpty(request.summary())) + .replace("{description}", orEmpty(request.description())) + .replace("{optionA}", describe(request.optionA())) + .replace("{optionB}", describe(request.optionB())); + } + + private static String describe(Side side) { + if (side == null) { + return ""; + } + String title = orEmpty(side.title()); + String stance = orEmpty(side.stance()); + if (stance.isEmpty()) { + return title; + } + return title.isEmpty() ? stance : title + " - " + stance; + } + + private static String orEmpty(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java new file mode 100644 index 00000000..16c17ca8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import java.util.List; + +/** + * 썸네일 생성에 쓰는 공통 스타일(프롬프트 템플릿 + 예시 이미지) 제공처. + * 지금은 리소스 파일 구현만 있고, 어드민에서 수정하게 되면 DB 구현으로 교체한다. + */ +public interface BattleThumbnailStyleSource { + + /** + * 프롬프트 템플릿. 다음 자리표시자를 배틀 내용으로 치환한다: + * {title} {summary} {description} {optionA} {optionB} + */ + String promptTemplate(); + + List referenceImages(); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java new file mode 100644 index 00000000..8db19323 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSource.java @@ -0,0 +1,76 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +/** + * resources/battle-thumbnail/ 아래 파일로 스타일을 읽는다. + * - prompt.txt : 프롬프트 템플릿 + * - references/ : 예시 썸네일(png/jpg/webp). 파일명 순서대로 image 1, image 2 ... 로 전달된다. + */ +@Slf4j +@Component +public class ClasspathBattleThumbnailStyleSource implements BattleThumbnailStyleSource { + + private static final String PROMPT_PATH = "battle-thumbnail/prompt.txt"; + private static final String REFERENCES_PATTERN = "classpath*:battle-thumbnail/references/*"; + // OpenAI edits 는 레퍼런스 이미지를 최대 16장까지 받는다. + private static final int MAX_REFERENCES = 16; + + private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + + @Override + public String promptTemplate() { + try (InputStream in = new ClassPathResource(PROMPT_PATH).getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("썸네일 프롬프트 파일을 읽을 수 없습니다: " + PROMPT_PATH, e); + } + } + + @Override + public List referenceImages() { + try { + Resource[] resources = resolver.getResources(REFERENCES_PATTERN); + List images = new ArrayList<>(); + Arrays.stream(resources) + .filter(r -> r.getFilename() != null && mediaTypeOf(r.getFilename()) != null) + .sorted(Comparator.comparing(Resource::getFilename)) + .limit(MAX_REFERENCES) + .forEach(r -> images.add(read(r))); + return images; + } catch (IOException e) { + log.warn("[Thumbnail] 예시 이미지 목록 조회 실패 - 레퍼런스 없이 생성", e); + return List.of(); + } + } + + private ReferenceImage read(Resource resource) { + try (InputStream in = resource.getInputStream()) { + String fileName = resource.getFilename(); + return new ReferenceImage(fileName, in.readAllBytes(), mediaTypeOf(fileName)); + } catch (IOException e) { + throw new IllegalStateException("예시 이미지를 읽을 수 없습니다: " + resource.getFilename(), e); + } + } + + private static MediaType mediaTypeOf(String fileName) { + String lower = fileName.toLowerCase(Locale.ROOT); + if (lower.endsWith(".png")) return MediaType.IMAGE_PNG; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return MediaType.IMAGE_JPEG; + if (lower.endsWith(".webp")) return MediaType.parseMediaType("image/webp"); + return null; + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/OpenAiImageClient.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/OpenAiImageClient.java new file mode 100644 index 00000000..c3cc5329 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/OpenAiImageClient.java @@ -0,0 +1,122 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import java.util.Base64; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * OpenAI Images API 로 이미지 한 장을 생성한다. + * 레퍼런스 이미지가 있으면 edits(이미지 + 프롬프트), 없으면 generations(프롬프트만)를 호출한다. + */ +@Slf4j +@Component +public class OpenAiImageClient { + + private static final int CONNECT_TIMEOUT_MS = 5000; + // 이미지 생성은 수십 초가 걸릴 수 있어 넉넉히 둔다. + private static final int READ_TIMEOUT_MS = 180000; + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.image.generations-url}") + private String generationsUrl; + + @Value("${openai.image.edits-url}") + private String editsUrl; + + @Value("${openai.image.model}") + private String model; + + @Value("${openai.image.quality}") + private String quality; + + @Value("${openai.image.size}") + private String size; + + public byte[] generate(String prompt, List references) { + Map response = references.isEmpty() + ? callGenerations(prompt) + : callEdits(prompt, references); + return decodeFirstImage(response); + } + + private Map callGenerations(String prompt) { + Map body = Map.of( + "model", model, + "prompt", prompt, + "n", 1, + "size", size, + "quality", quality); + + return restClient().post() + .uri(generationsUrl) + .header("Authorization", "Bearer " + apiKey) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(Map.class); + } + + private Map callEdits(String prompt, List references) { + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("model", model); + builder.part("prompt", prompt); + builder.part("n", "1"); + builder.part("size", size); + builder.part("quality", quality); + for (ReferenceImage reference : references) { + builder.part("image[]", new NamedByteArrayResource(reference.bytes(), reference.fileName())) + .contentType(reference.mediaType()); + } + + return restClient().post() + .uri(editsUrl) + .header("Authorization", "Bearer " + apiKey) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(builder.build()) + .retrieve() + .body(Map.class); + } + + private byte[] decodeFirstImage(Map response) { + if (response == null || !(response.get("data") instanceof List data) || data.isEmpty()) { + throw new IllegalStateException("OpenAI 이미지 응답에 data 가 없습니다."); + } + Object b64 = ((Map) data.get(0)).get("b64_json"); + if (!(b64 instanceof String encoded) || encoded.isBlank()) { + throw new IllegalStateException("OpenAI 이미지 응답에 b64_json 이 없습니다."); + } + return Base64.getDecoder().decode(encoded); + } + + private RestClient restClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + return RestClient.builder().requestFactory(factory).build(); + } + + /** multipart 파트로 보낼 때 파일명이 있어야 OpenAI 가 파일로 인식한다. */ + private static class NamedByteArrayResource extends ByteArrayResource { + private final String fileName; + + NamedByteArrayResource(byte[] bytes, String fileName) { + super(bytes); + this.fileName = fileName; + } + + @Override + public String getFilename() { + return fileName; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java new file mode 100644 index 00000000..bc51366c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ReferenceImage.java @@ -0,0 +1,6 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import org.springframework.http.MediaType; + +/** 썸네일 스타일 레퍼런스로 함께 보내는 예시 이미지. */ +public record ReferenceImage(String fileName, byte[] bytes, MediaType mediaType) {} diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 201c9c9c..cb7ed79c 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -65,6 +65,7 @@ public enum ErrorCode { BATTLE_CANNOT_REVERT_TO_PENDING(HttpStatus.BAD_REQUEST, "BATTLE_400_REVERT", "발행된 시나리오는 다시 대기(PENDING) 상태로 되돌릴 수 없습니다."), BATTLE_NOT_QUIZ(HttpStatus.BAD_REQUEST, "BATTLE_400_QUIZ", "해당 배틀은 퀴즈 타입이 아닙니다."), BATTLE_NOT_POLL(HttpStatus.BAD_REQUEST, "BATTLE_400_POLL", "해당 배틀은 투표 타입이 아닙니다."), + BATTLE_THUMBNAIL_GENERATION_FAILED(HttpStatus.BAD_GATEWAY, "BATTLE_502_THUMB", "썸네일 후보 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."), // Scenario SCENARIO_NOT_FOUND(HttpStatus.NOT_FOUND, "SCENARIO_404", "존재하지 않는 시나리오입니다."), diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5b8ca136..f8bb7dbc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -91,6 +91,12 @@ openai: tts: url: https://api.openai.com/v1/audio/speech model: gpt-4o-mini-tts + image: + generations-url: https://api.openai.com/v1/images/generations + edits-url: https://api.openai.com/v1/images/edits + model: ${OPENAI_IMAGE_MODEL:gpt-image-2} + quality: ${OPENAI_IMAGE_QUALITY:medium} + size: 1024x1024 fishaudio: api-key: ${FISHAUDIO_API_KEY} diff --git a/src/main/resources/battle-thumbnail/prompt.txt b/src/main/resources/battle-thumbnail/prompt.txt new file mode 100644 index 00000000..e9409497 --- /dev/null +++ b/src/main/resources/battle-thumbnail/prompt.txt @@ -0,0 +1,14 @@ +미니멀한 플랫 그래픽 포스터 스타일의 일러스트. 강한 명암 대비와 실루엣 중심의 형태, 경계가 선명한 하드 섀도우를 활용해 영화 포스터처럼 상징적인 구도로 표현해주세요. 디테일은 최소화하고, 세련되고 트렌디한 에디토리얼 일러스트의 분위기를 살려주세요. + +캔버스는 1:1 정사각형으로 설정해주세요. 주요 피사체와 핵심 시각 요소는 프레임 중앙의 약 55% 영역(1080×1080 기준 600×600px) 안에 배치하고, 해당 영역을 충분히 채우도록 구성해주세요. 바깥쪽 상하좌우에는 각각 약 22%(1080 기준 240px)의 넓고 균형 잡힌 빈 여백을 확보하고, 장식 요소 없이 깔끔하게 유지해주세요. + +첨부한 1~3번째 이미지는 스타일 예시입니다. 화풍, 명암 처리, 구도를 참고하되 예시의 소재(인물, 사물)를 그대로 따라 그리지는 마세요. +마지막(4번째) 첨부 이미지는 색상 팔레트입니다. 색상과 전체 색감은 이 팔레트의 색만 사용해 맞춰주세요. 팔레트의 색상 칸 모양이나 숫자, 글자는 그리지 마세요. +텍스트, 글자, 로고, 워터마크는 포함하지 마세요. + +아래 철학 토론 배틀의 핵심 갈등을 하나의 상징적인 장면으로 표현해주세요. +- 주제: {title} +- 한 줄 요약: {summary} +- 설명: {description} +- A 입장: {optionA} +- B 입장: {optionB} diff --git a/src/main/resources/battle-thumbnail/references/image1.png b/src/main/resources/battle-thumbnail/references/image1.png new file mode 100644 index 00000000..9bfe6776 Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/image1.png differ diff --git a/src/main/resources/battle-thumbnail/references/image2.png b/src/main/resources/battle-thumbnail/references/image2.png new file mode 100644 index 00000000..5fff9485 Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/image2.png differ diff --git a/src/main/resources/battle-thumbnail/references/image3.png b/src/main/resources/battle-thumbnail/references/image3.png new file mode 100644 index 00000000..2410e508 Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/image3.png differ diff --git a/src/main/resources/battle-thumbnail/references/palette.jpg b/src/main/resources/battle-thumbnail/references/palette.jpg new file mode 100644 index 00000000..508cb08d Binary files /dev/null and b/src/main/resources/battle-thumbnail/references/palette.jpg differ diff --git a/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java new file mode 100644 index 00000000..262e2eea --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java @@ -0,0 +1,113 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest.Side; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import java.io.File; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BattleThumbnailCandidateServiceTest { + + private static final String TEMPLATE = "T={title} S={summary} D={description} A={optionA} B={optionB}"; + + @Mock + private BattleThumbnailStyleSource styleSource; + @Mock + private OpenAiImageClient imageClient; + @Mock + private S3UploadService s3UploadService; + @Mock + private S3PresignedUrlService s3PresignedUrlService; + + private BattleThumbnailCandidateService service; + + private final AdminBattleThumbnailCandidateRequest request = new AdminBattleThumbnailCandidateRequest( + "트롤리 딜레마", "다섯을 살릴까", null, + new Side("레버를 당긴다", "다수를 구해야 한다"), + new Side("가만히 있는다", null)); + + @BeforeEach + void setUp() { + service = new BattleThumbnailCandidateService(styleSource, imageClient, s3UploadService, s3PresignedUrlService); + lenient().when(styleSource.promptTemplate()).thenReturn(TEMPLATE); + lenient().when(styleSource.referenceImages()).thenReturn(List.of()); + } + + @Test + void 배틀_내용으로_프롬프트_자리표시자를_채운다() { + String prompt = BattleThumbnailCandidateService.buildPrompt(TEMPLATE, request); + + assertThat(prompt).isEqualTo( + "T=트롤리 딜레마 S=다섯을 살릴까 D= A=레버를 당긴다 - 다수를 구해야 한다 B=가만히 있는다"); + } + + @Test + void 후보를_3장_생성해_S3_key와_미리보기_URL을_돌려준다() { + AtomicInteger seq = new AtomicInteger(); + when(imageClient.generate(anyString(), anyList())).thenReturn(new byte[]{1, 2, 3}); + when(s3UploadService.uploadFile(anyString(), any(File.class))) + .thenAnswer(inv -> "images/battles/ai-" + seq.incrementAndGet() + ".png"); + when(s3PresignedUrlService.generatePresignedUrl(anyString())) + .thenAnswer(inv -> "https://signed/" + inv.getArgument(0)); + + AdminBattleThumbnailCandidatesResponse response = service.generate(request); + + assertThat(response.candidates()).hasSize(BattleThumbnailCandidateService.CANDIDATE_COUNT); + assertThat(response.candidates()) + .allSatisfy(c -> { + assertThat(c.key()).startsWith("images/battles/ai-"); + assertThat(c.previewUrl()).isEqualTo("https://signed/" + c.key()); + }); + verify(imageClient, times(BattleThumbnailCandidateService.CANDIDATE_COUNT)) + .generate(eq(BattleThumbnailCandidateService.buildPrompt(TEMPLATE, request)), anyList()); + } + + @Test + void 일부_실패하면_성공한_후보만_돌려준다() { + AtomicInteger calls = new AtomicInteger(); + when(imageClient.generate(anyString(), anyList())).thenAnswer(inv -> { + if (calls.incrementAndGet() == 1) { + throw new IllegalStateException("boom"); + } + return new byte[]{1}; + }); + when(s3UploadService.uploadFile(anyString(), any(File.class))).thenAnswer(inv -> inv.getArgument(0)); + when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://signed"); + + AdminBattleThumbnailCandidatesResponse response = service.generate(request); + + assertThat(response.candidates()).hasSize(BattleThumbnailCandidateService.CANDIDATE_COUNT - 1); + } + + @Test + void 전부_실패하면_예외를_던진다() { + when(imageClient.generate(anyString(), anyList())).thenThrow(new IllegalStateException("boom")); + + assertThatThrownBy(() -> service.generate(request)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.BATTLE_THUMBNAIL_GENERATION_FAILED); + } +} diff --git a/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java b/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java new file mode 100644 index 00000000..4c7b9e06 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java @@ -0,0 +1,29 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +class ClasspathBattleThumbnailStyleSourceTest { + + private final ClasspathBattleThumbnailStyleSource source = new ClasspathBattleThumbnailStyleSource(); + + @Test + void 프롬프트_템플릿에_배틀_자리표시자가_있다() { + assertThat(source.promptTemplate()) + .contains("{title}", "{summary}", "{description}", "{optionA}", "{optionB}"); + } + + @Test + void 예시_이미지와_팔레트를_파일명_순서대로_읽는다() { + List images = source.referenceImages(); + + assertThat(images).extracting(ReferenceImage::fileName) + .containsExactly("image1.png", "image2.png", "image3.png", "palette.jpg"); + assertThat(images).extracting(ReferenceImage::mediaType) + .containsExactly(MediaType.IMAGE_PNG, MediaType.IMAGE_PNG, MediaType.IMAGE_PNG, MediaType.IMAGE_JPEG); + assertThat(images).allSatisfy(image -> assertThat(image.bytes()).isNotEmpty()); + } +}