Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/api-specs/philosopher-voice-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@

### 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`
- 요청 본문(`PhilosopherVoiceRequest`):
- `name` (필수, 유니크)
- `referenceId` (필수, Fish Audio 보이스 모델 ID)
- `voiceLabel` (선택, 표시용 라벨. 예: `"미호크 장정진"`)
- `imageKey` (선택, 철학자 이미지 저장 키. 예: `"images/philosophers/rousseau.png"`)
- `note` (선택)
- 이름 중복 시 `PHILOSOPHER_VOICE_409_DUP`

Expand All @@ -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` 이어도 되며, 랜덤 배정 로직으로 대신하지 않는다
7 changes: 7 additions & 0 deletions docs/erd/philosopher-voice.puml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ entity "philosopher_voice" as philosopher_voice {
name : VARCHAR(50) <<UK>>
reference_id : VARCHAR(64)
voice_label : VARCHAR(100) <<nullable>>
image_key : VARCHAR(500) <<nullable>>
note : VARCHAR(255) <<nullable>>
created_at : TIMESTAMP
updated_at : TIMESTAMP
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ public record PhilosopherVoiceRequest(
@NotBlank String name,
@NotBlank String referenceId,
String voiceLabel,
String imageKey,
String note
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public record PhilosopherVoiceResponse(
String name,
String referenceId,
String voiceLabel,
String imageKey,
String note
) {
public static PhilosopherVoiceResponse from(PhilosopherVoice entity) {
Expand All @@ -15,6 +16,7 @@ public static PhilosopherVoiceResponse from(PhilosopherVoice entity) {
entity.getName(),
entity.getReferenceId(),
entity.getVoiceLabel(),
entity.getImageKey(),
entity.getNote()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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");
Expand All @@ -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);
}
Expand All @@ -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);
Expand Down
Loading