From f9f1273062f56062f41325a9d19eeae8486f6383 Mon Sep 17 00:00:00 2001 From: si-zero Date: Sun, 4 Oct 2026 22:38:32 +0900 Subject: [PATCH] =?UTF-8?q?#519=20[Fix]=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=EB=A5=BC=20=EC=A0=9C=EB=AA=A9=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9E=A5=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=95=98=EA=B3=A0=20=EC=95=88=EC=A0=84=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EA=B1=B0=EC=A0=88=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminBattleThumbnailCandidateRequest.java | 13 +-- .../BattleThumbnailCandidateService.java | 64 ++++++----- .../thumbnail/BattleThumbnailStyleSource.java | 3 +- .../ImageModerationBlockedException.java | 9 ++ .../battle/thumbnail/OpenAiImageClient.java | 16 ++- .../thumbnail/ThumbnailSceneWriter.java | 106 ++++++++++++++++++ .../global/common/exception/ErrorCode.java | 1 + .../resources/battle-thumbnail/prompt.txt | 8 +- .../BattleThumbnailCandidateServiceTest.java | 89 +++++++++------ ...asspathBattleThumbnailStyleSourceTest.java | 4 +- 10 files changed, 226 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/swyp/picke/domain/battle/thumbnail/ImageModerationBlockedException.java create mode 100644 src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java 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 index 34b51a5..44793e7 100644 --- 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 @@ -2,13 +2,12 @@ import jakarta.validation.constraints.NotBlank; -/** 저장 전 폼 내용으로도 후보를 만들 수 있게 battleId 대신 배틀 내용을 받는다. */ +/** + * 저장 전 폼 내용으로도 후보를 만들 수 있게 battleId 대신 배틀 내용을 받는다. + * 썸네일은 제목(주제)을 그리므로 A/B 입장은 받지 않는다. 요약/설명은 제목의 맥락으로만 쓴다. + */ public record AdminBattleThumbnailCandidateRequest( @NotBlank String title, String summary, - String description, - Side optionA, - Side optionB -) { - public record Side(String title, String stance) {} -} + String description +) {} 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 index 487315f..895e227 100644 --- a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateService.java @@ -1,7 +1,6 @@ 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; @@ -18,6 +17,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +25,8 @@ /** * 배틀 내용 + 공통 스타일로 썸네일 후보를 여러 장 만들어 S3 에 올린다. - * 후보는 동시에 생성하고, 일부가 실패해도 성공한 것만 돌려준다. + * 배틀 원문은 먼저 장면 묘사로 바꿔 후보마다 다른 장면을 쓰고, 후보는 동시에 생성한다. + * 일부가 실패해도 성공한 것만 돌려준다. */ @Slf4j @Service @@ -35,18 +36,24 @@ public class BattleThumbnailCandidateService { static final int CANDIDATE_COUNT = 3; private final BattleThumbnailStyleSource styleSource; + private final ThumbnailSceneWriter sceneWriter; private final OpenAiImageClient imageClient; private final S3UploadService s3UploadService; private final S3PresignedUrlService s3PresignedUrlService; public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandidateRequest request) { - String prompt = buildPrompt(styleSource.promptTemplate(), request); + String template = styleSource.promptTemplate(); List references = styleSource.referenceImages(); + List prompts = scenesFor(request).stream() + .map(scene -> buildPrompt(template, scene)) + .toList(); + AtomicInteger blockedCount = new AtomicInteger(); List candidates; try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { - List> futures = IntStream.range(0, CANDIDATE_COUNT) - .mapToObj(i -> CompletableFuture.supplyAsync(() -> generateOne(prompt, references), executor)) + List> futures = prompts.stream() + .map(prompt -> CompletableFuture.supplyAsync( + () -> generateOne(prompt, references, blockedCount), executor)) .toList(); candidates = futures.stream() .map(CompletableFuture::join) @@ -55,16 +62,34 @@ public AdminBattleThumbnailCandidatesResponse generate(AdminBattleThumbnailCandi } if (candidates.isEmpty()) { - throw new CustomException(ErrorCode.BATTLE_THUMBNAIL_GENERATION_FAILED); + throw new CustomException(blockedCount.get() > 0 + ? ErrorCode.BATTLE_THUMBNAIL_MODERATION_BLOCKED + : ErrorCode.BATTLE_THUMBNAIL_GENERATION_FAILED); } return new AdminBattleThumbnailCandidatesResponse(candidates); } - private Candidate generateOne(String prompt, List references) { + /** 후보 수만큼 장면을 만든다. 장면 묘사에 실패하면 배틀 원문을 그대로 쓴다. */ + private List scenesFor(AdminBattleThumbnailCandidateRequest request) { + List scenes = sceneWriter.write(request, CANDIDATE_COUNT); + if (scenes.isEmpty()) { + scenes = List.of(ThumbnailSceneWriter.describeBattle(request)); + } + List source = scenes; + return IntStream.range(0, CANDIDATE_COUNT) + .mapToObj(i -> source.get(i % source.size())) + .toList(); + } + + private Candidate generateOne(String prompt, List references, AtomicInteger blockedCount) { try { byte[] image = imageClient.generate(prompt, references); String key = upload(image); return new Candidate(key, s3PresignedUrlService.generatePresignedUrl(key)); + } catch (ImageModerationBlockedException e) { + blockedCount.incrementAndGet(); + log.warn("[Thumbnail] 썸네일 후보 1장이 안전 필터에 막힘. prompt={}", prompt, e); + return null; } catch (Exception e) { log.warn("[Thumbnail] 썸네일 후보 1장 생성 실패", e); return null; @@ -82,28 +107,7 @@ private String upload(byte[] image) throws IOException { } } - 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(); + static String buildPrompt(String template, String scene) { + return template.replace("{scene}", scene); } } 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 index 16c17ca..9000533 100644 --- a/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailStyleSource.java @@ -9,8 +9,7 @@ public interface BattleThumbnailStyleSource { /** - * 프롬프트 템플릿. 다음 자리표시자를 배틀 내용으로 치환한다: - * {title} {summary} {description} {optionA} {optionB} + * 프롬프트 템플릿. {scene} 자리에 배틀 내용으로 만든 장면 묘사가 들어간다. */ String promptTemplate(); diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ImageModerationBlockedException.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ImageModerationBlockedException.java new file mode 100644 index 0000000..b9348b3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ImageModerationBlockedException.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.battle.thumbnail; + +/** OpenAI 안전 필터(moderation_blocked)가 이미지 생성 요청을 거절했다. */ +public class ImageModerationBlockedException extends RuntimeException { + + public ImageModerationBlockedException(String message, Throwable cause) { + super(message, cause); + } +} 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 index c3cc532..79e2d91 100644 --- a/src/main/java/com/swyp/picke/domain/battle/thumbnail/OpenAiImageClient.java +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/OpenAiImageClient.java @@ -10,6 +10,7 @@ import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; /** @@ -43,10 +44,17 @@ public class OpenAiImageClient { private String size; public byte[] generate(String prompt, List references) { - Map response = references.isEmpty() - ? callGenerations(prompt) - : callEdits(prompt, references); - return decodeFirstImage(response); + try { + Map response = references.isEmpty() + ? callGenerations(prompt) + : callEdits(prompt, references); + return decodeFirstImage(response); + } catch (HttpClientErrorException e) { + if (e.getResponseBodyAsString().contains("moderation_blocked")) { + throw new ImageModerationBlockedException("OpenAI 안전 필터가 이미지 생성을 거절했습니다.", e); + } + throw e; + } } private Map callGenerations(String prompt) { diff --git a/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java new file mode 100644 index 0000000..235e11a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/thumbnail/ThumbnailSceneWriter.java @@ -0,0 +1,106 @@ +package com.swyp.picke.domain.battle.thumbnail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * 배틀 제목(주제)을 썸네일로 그릴 상징적인 장면 묘사로 바꾼다. A/B 입장이 아니라 제목 자체를 그린다. + * 토론 원문(안락사, 전쟁 등)을 이미지 프롬프트에 그대로 넣지 않고, 위해를 직접 그리지 않는 은유적 장면으로 옮겨 + * 후보마다 서로 다른 장면을 쓰게 한다. {@code OpenAiEmotionClassifier} 와 같은 방식(RestClient + openai.* 설정)을 쓴다. + */ +@Slf4j +@Component +public class ThumbnailSceneWriter { + + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TIMEOUT_MS = 30000; + + private static final String SYSTEM = """ + 너는 철학 토론 배틀의 썸네일 일러스트 장면을 기획하는 아트 디렉터다. + 배틀 제목이 다루는 주제(대상, 상황, 질문) 자체를 상징적으로 보여주는 장면을 서로 다르게 %d개 묘사해라. + 한 줄 요약과 설명은 제목의 의미를 파악하는 맥락으로만 쓴다. + 규칙: + - 찬반 두 입장을 나눠 그리거나, 화면을 좌우로 분할하거나, 두 대상이 맞서는 대립 구도로 만들지 않는다. + - 각 장면은 2~3문장의 한국어로, 화면에 무엇이 어디에 어떻게 보이는지 구체적으로 쓴다. + - 실루엣, 사물, 빛과 그림자, 공간, 자연물 같은 은유로 표현한다. + - 부상, 피, 시신, 무기 사용, 자해, 죽어가는 사람 등 위해를 직접 묘사하지 않는다. + - 텍스트, 글자, 숫자, 로고는 장면에 넣지 않는다. + - 화풍이나 색상은 쓰지 않는다(별도로 지정된다). + 반드시 JSON 만 출력: {"scenes":["...","..."]} + """; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.url}") + private String openaiUrl; + + @Value("${openai.model}") + private String model; + + /** 실패하면 빈 목록을 돌려준다. 호출부는 배틀 원문으로 대체한다. */ + public List write(AdminBattleThumbnailCandidateRequest request, int count) { + try { + JsonNode root = call(SYSTEM.formatted(count), describeBattle(request)); + List scenes = new ArrayList<>(); + for (JsonNode scene : root.path("scenes")) { + String text = scene.asText("").trim(); + if (!text.isEmpty()) { + scenes.add(text); + } + } + return scenes; + } catch (Exception e) { + log.warn("[Thumbnail] 장면 묘사 생성 실패 - 배틀 원문으로 생성", e); + return List.of(); + } + } + + static String describeBattle(AdminBattleThumbnailCandidateRequest request) { + return "제목: " + orEmpty(request.title()) + + "\n한 줄 요약: " + orEmpty(request.summary()) + + "\n설명: " + orEmpty(request.description()); + } + + private static String orEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private JsonNode call(String systemPrompt, String userPrompt) throws Exception { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + RestClient restClient = RestClient.builder().requestFactory(factory).build(); + + Map requestBody = Map.of( + "model", model, + "response_format", Map.of("type", "json_object"), + "messages", List.of( + Map.of("role", "system", "content", systemPrompt), + Map.of("role", "user", "content", userPrompt))); + + Map response = restClient.post() + .uri(openaiUrl) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .body(requestBody) + .retrieve() + .body(Map.class); + + List choices = (List) response.get("choices"); + Map choice = (Map) choices.get(0); + Map message = (Map) choice.get("message"); + return objectMapper.readTree((String) message.get("content")); + } +} 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 cb7ed79..ec56f36 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_MODERATION_BLOCKED(HttpStatus.UNPROCESSABLE_ENTITY, "BATTLE_422_THUMB", "배틀 주제가 이미지 생성 안전 정책에 걸려 썸네일 후보를 만들지 못했습니다. 다시 생성하거나 직접 업로드해 주세요."), BATTLE_THUMBNAIL_GENERATION_FAILED(HttpStatus.BAD_GATEWAY, "BATTLE_502_THUMB", "썸네일 후보 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."), // Scenario diff --git a/src/main/resources/battle-thumbnail/prompt.txt b/src/main/resources/battle-thumbnail/prompt.txt index e940949..d4f43ea 100644 --- a/src/main/resources/battle-thumbnail/prompt.txt +++ b/src/main/resources/battle-thumbnail/prompt.txt @@ -6,9 +6,5 @@ 마지막(4번째) 첨부 이미지는 색상 팔레트입니다. 색상과 전체 색감은 이 팔레트의 색만 사용해 맞춰주세요. 팔레트의 색상 칸 모양이나 숫자, 글자는 그리지 마세요. 텍스트, 글자, 로고, 워터마크는 포함하지 마세요. -아래 철학 토론 배틀의 핵심 갈등을 하나의 상징적인 장면으로 표현해주세요. -- 주제: {title} -- 한 줄 요약: {summary} -- 설명: {description} -- A 입장: {optionA} -- B 입장: {optionB} +아래 장면을 하나의 상징적인 일러스트로 그려주세요. +{scene} 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 index 262e2ee..f3bbcae 100644 --- a/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/thumbnail/BattleThumbnailCandidateServiceTest.java @@ -3,6 +3,7 @@ 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.anyInt; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -12,7 +13,6 @@ 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; @@ -30,11 +30,13 @@ @ExtendWith(MockitoExtension.class) class BattleThumbnailCandidateServiceTest { - private static final String TEMPLATE = "T={title} S={summary} D={description} A={optionA} B={optionB}"; + private static final String TEMPLATE = "STYLE / {scene}"; @Mock private BattleThumbnailStyleSource styleSource; @Mock + private ThumbnailSceneWriter sceneWriter; + @Mock private OpenAiImageClient imageClient; @Mock private S3UploadService s3UploadService; @@ -43,58 +45,62 @@ class BattleThumbnailCandidateServiceTest { private BattleThumbnailCandidateService service; - private final AdminBattleThumbnailCandidateRequest request = new AdminBattleThumbnailCandidateRequest( - "트롤리 딜레마", "다섯을 살릴까", null, - new Side("레버를 당긴다", "다수를 구해야 한다"), - new Side("가만히 있는다", null)); + private final AdminBattleThumbnailCandidateRequest request = + new AdminBattleThumbnailCandidateRequest("트롤리 딜레마", "다섯을 살릴까", null); @BeforeEach void setUp() { - service = new BattleThumbnailCandidateService(styleSource, imageClient, s3UploadService, s3PresignedUrlService); + service = new BattleThumbnailCandidateService( + styleSource, sceneWriter, imageClient, s3UploadService, s3PresignedUrlService); lenient().when(styleSource.promptTemplate()).thenReturn(TEMPLATE); lenient().when(styleSource.referenceImages()).thenReturn(List.of()); + lenient().when(sceneWriter.write(any(), anyInt())).thenReturn(List.of("장면1", "장면2", "장면3")); } - @Test - void 배틀_내용으로_프롬프트_자리표시자를_채운다() { - String prompt = BattleThumbnailCandidateService.buildPrompt(TEMPLATE, request); - - assertThat(prompt).isEqualTo( - "T=트롤리 딜레마 S=다섯을 살릴까 D= A=레버를 당긴다 - 다수를 구해야 한다 B=가만히 있는다"); + private void stubUploadSuccess() { + AtomicInteger seq = new AtomicInteger(); + lenient().when(s3UploadService.uploadFile(anyString(), any(File.class))) + .thenAnswer(inv -> "images/battles/ai-" + seq.incrementAndGet() + ".png"); + lenient().when(s3PresignedUrlService.generatePresignedUrl(anyString())) + .thenAnswer(inv -> "https://signed/" + inv.getArgument(0)); } @Test - void 후보를_3장_생성해_S3_key와_미리보기_URL을_돌려준다() { - AtomicInteger seq = new AtomicInteger(); + void 후보마다_다른_장면으로_3장을_생성해_S3_key와_미리보기_URL을_돌려준다() { 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)); + stubUploadSuccess(); 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()); + assertThat(response.candidates()).allSatisfy(c -> { + assertThat(c.key()).startsWith("images/battles/ai-"); + assertThat(c.previewUrl()).isEqualTo("https://signed/" + c.key()); + }); + verify(imageClient).generate(eq("STYLE / 장면1"), anyList()); + verify(imageClient).generate(eq("STYLE / 장면2"), anyList()); + verify(imageClient).generate(eq("STYLE / 장면3"), anyList()); + } + + @Test + void 장면_묘사에_실패하면_제목_기반_원문으로_생성한다() { + when(sceneWriter.write(any(), anyInt())).thenReturn(List.of()); + when(imageClient.generate(anyString(), anyList())).thenReturn(new byte[]{1}); + stubUploadSuccess(); + + service.generate(request); + + String fallback = "STYLE / " + ThumbnailSceneWriter.describeBattle(request); + verify(imageClient, times(BattleThumbnailCandidateService.CANDIDATE_COUNT)).generate(eq(fallback), anyList()); + assertThat(fallback).contains("트롤리 딜레마").doesNotContain("A 입장", "B 입장"); } @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"); + when(imageClient.generate(eq("STYLE / 장면1"), anyList())).thenThrow(new IllegalStateException("boom")); + when(imageClient.generate(eq("STYLE / 장면2"), anyList())).thenReturn(new byte[]{1}); + when(imageClient.generate(eq("STYLE / 장면3"), anyList())).thenReturn(new byte[]{1}); + stubUploadSuccess(); AdminBattleThumbnailCandidatesResponse response = service.generate(request); @@ -102,7 +108,18 @@ void setUp() { } @Test - void 전부_실패하면_예외를_던진다() { + void 전부_안전_필터에_막히면_안전_정책_에러를_던진다() { + when(imageClient.generate(anyString(), anyList())) + .thenThrow(new ImageModerationBlockedException("blocked", null)); + + assertThatThrownBy(() -> service.generate(request)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.BATTLE_THUMBNAIL_MODERATION_BLOCKED); + } + + @Test + void 전부_일반_오류로_실패하면_생성_실패_에러를_던진다() { when(imageClient.generate(anyString(), anyList())).thenThrow(new IllegalStateException("boom")); assertThatThrownBy(() -> service.generate(request)) 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 index 4c7b9e0..d4a301e 100644 --- a/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/thumbnail/ClasspathBattleThumbnailStyleSourceTest.java @@ -11,9 +11,9 @@ class ClasspathBattleThumbnailStyleSourceTest { private final ClasspathBattleThumbnailStyleSource source = new ClasspathBattleThumbnailStyleSource(); @Test - void 프롬프트_템플릿에_배틀_자리표시자가_있다() { + void 프롬프트_템플릿에_장면_자리표시자가_있다() { assertThat(source.promptTemplate()) - .contains("{title}", "{summary}", "{description}", "{optionA}", "{optionB}"); + .contains("{scene}"); } @Test