diff --git a/docs/api-specs/philosopher-voice-api.md b/docs/api-specs/philosopher-voice-api.md index 4cff9003..26bdc9a0 100644 --- a/docs/api-specs/philosopher-voice-api.md +++ b/docs/api-specs/philosopher-voice-api.md @@ -12,7 +12,7 @@ ### 1.1 목록 조회 - `GET /api/v1/admin/philosopher-voices` -- 응답(`PhilosopherVoiceResponse[]`): `id`, `name`, `referenceId`, `voiceLabel`, `note` +- 응답(`PhilosopherVoiceResponse[]`): `id`, `name`, `referenceId`, `voiceLabel`, `imageKey`, `note` ### 1.2 생성 - `POST /api/v1/admin/philosopher-voices` @@ -20,6 +20,7 @@ - `name` (필수, 유니크) - `referenceId` (필수, Fish Audio 보이스 모델 ID) - `voiceLabel` (선택, 표시용 라벨. 예: `"미호크 장정진"`) + - `imageKey` (선택, 철학자 이미지 저장 키. 예: `"images/philosophers/rousseau.png"`) - `note` (선택) - 이름 중복 시 `PHILOSOPHER_VOICE_409_DUP` @@ -39,3 +40,4 @@ - **NARRATOR / USER 고정 보이스는 이 테이블이 아니라 config** (`fishaudio.voice-id.narrator`, `fishaudio.voice-id.user`). 이 API 는 A/B(철학자) 보이스 전용 - 붙여넣기 파서가 발화자 이름으로 조회했는데 매핑이 없으면 `MISSING_VOICE` warning(blocking) 을 내고, 관리자가 미리보기에서 보이스를 고르거나 이 API 로 매핑을 추가한 뒤 다시 파싱해야 한다 - 배틀별로 다른 보이스를 쓰고 싶으면 이 테이블을 바꾸지 않고 시나리오의 `voiceSettings` 를 직접 오버라이드하면 된다(이 테이블은 파서가 채우는 기본값 소스일 뿐) +- **`imageKey`**: `PhilosopherType` enum(철학자 유형 10인)만 실제 이미지가 있고, 그 밖의 철학자(예: 루소, 홉스, 쇼펜하우어)는 원래 이름을 해시해서 10개 이미지 중 하나를 무작위로 배정하는 폴백이 있었다(`PhilosopherType.resolveImageKey`) — 엉뚱한 철학자 이미지가 뜨는 문제가 있었음. 프론트는 배틀 생성 화면에서 이 API의 `imageKey` 로 정확한 이미지를 조회해 쓰고, 값이 없으면(아직 등록 안 된 철학자) 기본 이미지로 대체해야 한다. `imageKey` 는 `null` 이어도 되며, 랜덤 배정 로직으로 대신하지 않는다 diff --git a/docs/erd/philosopher-voice.puml b/docs/erd/philosopher-voice.puml index c7d2923f..fc66a171 100644 --- a/docs/erd/philosopher-voice.puml +++ b/docs/erd/philosopher-voice.puml @@ -9,6 +9,7 @@ entity "philosopher_voice" as philosopher_voice { name : VARCHAR(50) <> reference_id : VARCHAR(64) voice_label : VARCHAR(100) <> + image_key : VARCHAR(500) <> note : VARCHAR(255) <> created_at : TIMESTAMP updated_at : TIMESTAMP @@ -38,6 +39,12 @@ note bottom of philosopher_voice voice_code(reference_id) 를 저장하므로 논리적 참조만 존재. 매핑 변경/추가는 어드민(/api/v1/admin/philosopher-voices)에서 한다. 초기 데이터는 docs/db/20260910_seed_philosopher_voice.sql. + + image_key : 철학자 이미지 저장 키(S3/Railway Bucket). + PhilosopherType enum(철학자 유형 10인) 밖 철학자는 원래 + 이름 해시로 10개 이미지 중 하나를 무작위 배정했는데, + 그 대신 프론트가 이 값으로 정확한 이미지를 쓰게 하려고 추가. + 없으면 프론트가 기본 이미지로 대체해야 한다. end note @enduml diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java index c88d0f54..10be818c 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java @@ -6,5 +6,6 @@ public record PhilosopherVoiceRequest( @NotBlank String name, @NotBlank String referenceId, String voiceLabel, + String imageKey, String note ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java index a76bc534..ab894014 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java @@ -7,6 +7,7 @@ public record PhilosopherVoiceResponse( String name, String referenceId, String voiceLabel, + String imageKey, String note ) { public static PhilosopherVoiceResponse from(PhilosopherVoice entity) { @@ -15,6 +16,7 @@ public static PhilosopherVoiceResponse from(PhilosopherVoice entity) { entity.getName(), entity.getReferenceId(), entity.getVoiceLabel(), + entity.getImageKey(), entity.getNote() ); } diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java b/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java index ea659781..83fc07cb 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java @@ -28,24 +28,32 @@ public class PhilosopherVoice extends BaseEntity { @Column(name = "voice_label", length = 100) private String voiceLabel; + /** 철학자 이미지 저장 키(S3/Railway Bucket). 없으면 프론트가 기본 이미지로 대체해야 한다. */ + @Column(name = "image_key", length = 500) + private String imageKey; + @Column(length = 255) private String note; @Builder - public PhilosopherVoice(String name, String referenceId, String voiceLabel, String note) { + public PhilosopherVoice(String name, String referenceId, String voiceLabel, String imageKey, String note) { this.name = name; this.referenceId = referenceId; this.voiceLabel = voiceLabel; + this.imageKey = imageKey; this.note = note; } - public void update(String referenceId, String voiceLabel, String note) { + public void update(String referenceId, String voiceLabel, String imageKey, String note) { if (referenceId != null && !referenceId.isBlank()) { this.referenceId = referenceId; } if (voiceLabel != null) { this.voiceLabel = voiceLabel; } + if (imageKey != null) { + this.imageKey = imageKey; + } if (note != null) { this.note = note; } diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java b/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java index 9e762064..5a4449a4 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java +++ b/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java @@ -44,6 +44,7 @@ public PhilosopherVoiceResponse create(PhilosopherVoiceRequest request) { .name(request.name().trim()) .referenceId(request.referenceId().trim()) .voiceLabel(request.voiceLabel()) + .imageKey(request.imageKey()) .note(request.note()) .build()); return PhilosopherVoiceResponse.from(saved); @@ -53,7 +54,7 @@ public PhilosopherVoiceResponse create(PhilosopherVoiceRequest request) { public PhilosopherVoiceResponse update(Long id, PhilosopherVoiceRequest request) { PhilosopherVoice entity = philosopherVoiceRepository.findById(id) .orElseThrow(() -> new CustomException(ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND)); - entity.update(request.referenceId(), request.voiceLabel(), request.note()); + entity.update(request.referenceId(), request.voiceLabel(), request.imageKey(), request.note()); return PhilosopherVoiceResponse.from(entity); } diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java index 49635abd..f1935832 100644 --- a/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java @@ -60,7 +60,7 @@ private PhilosopherVoice entity(String name, String referenceId) { when(philosopherVoiceRepository.existsByName("칸트")).thenReturn(true); assertThatThrownBy(() -> philosopherVoiceService.create( - new PhilosopherVoiceRequest("칸트", "voice-x", null, null))) + new PhilosopherVoiceRequest("칸트", "voice-x", null, null, null))) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_DUPLICATED); @@ -74,7 +74,7 @@ private PhilosopherVoice entity(String name, String referenceId) { .thenAnswer(invocation -> invocation.getArgument(0)); PhilosopherVoiceResponse response = philosopherVoiceService.create( - new PhilosopherVoiceRequest(" 칸트 ", " voice-x ", "Meursault", null)); + new PhilosopherVoiceRequest(" 칸트 ", " voice-x ", "Meursault", null, null)); assertThat(response.name()).isEqualTo("칸트"); assertThat(response.referenceId()).isEqualTo("voice-x"); @@ -86,7 +86,7 @@ private PhilosopherVoice entity(String name, String referenceId) { when(philosopherVoiceRepository.findById(99L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> philosopherVoiceService.update(99L, - new PhilosopherVoiceRequest("칸트", "voice-x", null, null))) + new PhilosopherVoiceRequest("칸트", "voice-x", null, null, null))) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); } @@ -97,12 +97,32 @@ private PhilosopherVoice entity(String name, String referenceId) { when(philosopherVoiceRepository.findById(1L)).thenReturn(Optional.of(found)); PhilosopherVoiceResponse response = philosopherVoiceService.update(1L, - new PhilosopherVoiceRequest("칸트", "voice-new", "새 라벨", "메모")); + new PhilosopherVoiceRequest("칸트", "voice-new", "새 라벨", null, "메모")); assertThat(response.referenceId()).isEqualTo("voice-new"); assertThat(found.getReferenceId()).isEqualTo("voice-new"); } + @Test + void create_및_update가_imageKey를_저장한다() { + when(philosopherVoiceRepository.existsByName("루소")).thenReturn(false); + when(philosopherVoiceRepository.save(any(PhilosopherVoice.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PhilosopherVoiceResponse created = philosopherVoiceService.create( + new PhilosopherVoiceRequest("루소", "voice-x", "미호크 장정진", + "images/philosophers/rousseau.png", null)); + assertThat(created.imageKey()).isEqualTo("images/philosophers/rousseau.png"); + + PhilosopherVoice found = entity("루소", "voice-x"); + when(philosopherVoiceRepository.findById(2L)).thenReturn(Optional.of(found)); + + PhilosopherVoiceResponse updated = philosopherVoiceService.update(2L, + new PhilosopherVoiceRequest("루소", "voice-x", null, + "images/philosophers/rousseau-v2.png", null)); + assertThat(updated.imageKey()).isEqualTo("images/philosophers/rousseau-v2.png"); + } + @Test void delete_대상이_없으면_예외() { when(philosopherVoiceRepository.existsById(99L)).thenReturn(false);