Skip to content
Open
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
72 changes: 72 additions & 0 deletions docs/db/20260910_seed_philosopher_voice.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- 철학자 → Fish Audio 보이스(reference_id) 초기 매핑 시드
-- 관련 설계: 배틀_발행_시나리오_현행_vs_개선.md 2.5
--
-- 테이블 자체는 spring.jpa.hibernate.ddl-auto=update 가 PhilosopherVoice 엔티티를 보고 생성한다.
-- 이 스크립트는 초기 데이터만 넣는다. 환경별(dev / prod / local)로 1회 실행.
-- ON CONFLICT DO NOTHING 이라 재실행해도 안전하고, 어드민에서 수정한 값을 덮지 않는다.
-- 이후 매핑 변경/추가는 어드민 CRUD(/api/v1/admin/philosopher-voices)로 한다.

INSERT INTO philosopher_voice (name, reference_id, voice_label, created_at, updated_at) VALUES
('소크라테스', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()),
('스마일즈', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()),
('플라톤', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()),
('톨스토이', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()),
('맹자', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()),
('공자', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()),
('칙센트미하이', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()),
('히포크라테스', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()),

('아리스토텔레스', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('벤담', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('한비자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('피터싱어', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('볼테르', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('푸코', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('토크빌', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('베카리아', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('스피노자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('애덤 그랜트', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('버크', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('쇼펜하우어', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('노자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('보르헤스', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),
('월드런', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()),

('홉스', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('비트겐슈타인', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('도스토옙스키', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('니체', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('밀', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('뒤르켐', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('베이컨', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('존 로크', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('루소', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('에피쿠로스', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('사르트르', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('간디', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('사마천', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('프로이트', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('카뮈', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),
('데카르트', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()),

('칸트', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('애들러', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('마키아벨리', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('엘리아데', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('프롬', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('융', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('롤스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('왈처', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('암베드카르', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('라이프니츠', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('카너먼', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('도킨스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('흄', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('보드리야르', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('몽테스키외', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('마이어스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('마르크스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),
('바흐', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()),

('아렌트', '90597c459f7a4438801589a5268c1628', '차분하고 지적인(여성)', now(), now())
ON CONFLICT (name) DO NOTHING;
43 changes: 43 additions & 0 deletions docs/erd/philosopher-voice.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
@startuml philosopher-voice
hide circle
hide methods
skinparam linetype ortho

entity "philosopher_voice" as philosopher_voice {
* id : BIGINT <<PK>>
--
name : VARCHAR(50) <<UK>>
reference_id : VARCHAR(64)
voice_label : VARCHAR(100) <<nullable>>
note : VARCHAR(255) <<nullable>>
created_at : TIMESTAMP
updated_at : TIMESTAMP
}

entity "scenarios" as scenarios {
* id : BIGINT <<PK>>
}

entity "scenario_voice_settings" as scenario_voice_settings {
* scenario_id : BIGINT <<FK>>
* speaker_type : VARCHAR(20)
--
voice_code : VARCHAR(200)
}

scenarios ||--o{ scenario_voice_settings : has
philosopher_voice ..> scenario_voice_settings : 파서가 voice_code 채움

note bottom of philosopher_voice
철학자 이름 → Fish Audio 보이스(reference_id) 매핑.
붙여넣기 파서가 시나리오 발화자(옵션 A/B 철학자)에 맞는
reference_id 를 조회해 scenario_voice_settings.voice_code 에 채운다.
NARRATOR / USER 고정 보이스는 이 테이블이 아니라 config.

FK 없음: scenario_voice_settings 가 name 이 아닌 완성된
voice_code(reference_id) 를 저장하므로 논리적 참조만 존재.
매핑 변경/추가는 어드민(/api/v1/admin/philosopher-voices)에서 한다.
초기 데이터는 docs/db/20260910_seed_philosopher_voice.sql.
end note

@enduml
10 changes: 10 additions & 0 deletions docs/erd/scenario.puml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ entity "scenario_scripts" as scenario_scripts {
speaker_type : VARCHAR(20)
speaker_name : VARCHAR(100)
text : TEXT
tone : VARCHAR(20)
audio_url : VARCHAR(500)
script_order : INT
created_at : TIMESTAMP
Expand Down Expand Up @@ -84,4 +85,13 @@ note right of scenarios
voice_settings는 화자별 보이스 코드 저장
end note

note bottom of scenario_scripts
tone : 대사 한 줄의 감성적인 톤 (Tone enum)
NEUTRAL | ANGRY | SAD | EMBARRASSED | EMPHASIS
| WHISPERING | SOFT_TONE | BREATHY | EXCITED
NEUTRAL 이면 TTS 전송 시 톤 태그를 붙이지 않음.
오디오 효과 태그([sighing] 등)는 text 인라인에 그대로 둔다.
tone 이 바뀌면 audio_url 을 비워 재합성한다.
end note

@enduml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.swyp.picke.domain.admin.controller;

import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest;
import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse;
import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService;
import com.swyp.picke.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "관리자 철학자 보이스 API", description = "철학자 이름 → Fish Audio 보이스(reference_id) 매핑 관리")
@RestController
@RequestMapping("/api/v1/admin/philosopher-voices")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
public class AdminPhilosopherVoiceController {

private final PhilosopherVoiceService philosopherVoiceService;

@Operation(summary = "철학자 보이스 매핑 목록")
@GetMapping
public ApiResponse<List<PhilosopherVoiceResponse>> getAll() {
return ApiResponse.onSuccess(philosopherVoiceService.getAll());
}

@Operation(summary = "철학자 보이스 매핑 생성")
@PostMapping
public ApiResponse<PhilosopherVoiceResponse> create(@Valid @RequestBody PhilosopherVoiceRequest request) {
return ApiResponse.onSuccess(philosopherVoiceService.create(request));
}

@Operation(summary = "철학자 보이스 매핑 수정")
@PatchMapping("/{id}")
public ApiResponse<PhilosopherVoiceResponse> update(
@PathVariable Long id,
@Valid @RequestBody PhilosopherVoiceRequest request
) {
return ApiResponse.onSuccess(philosopherVoiceService.update(id, request));
}

@Operation(summary = "철학자 보이스 매핑 삭제")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
philosopherVoiceService.delete(id);
return ApiResponse.onSuccess(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.swyp.picke.domain.admin.dto.philosophervoice.request;

import jakarta.validation.constraints.NotBlank;

public record PhilosopherVoiceRequest(
@NotBlank String name,
@NotBlank String referenceId,
String voiceLabel,
String note
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.swyp.picke.domain.admin.dto.philosophervoice.response;

import com.swyp.picke.domain.scenario.entity.PhilosopherVoice;

public record PhilosopherVoiceResponse(
Long id,
String name,
String referenceId,
String voiceLabel,
String note
) {
public static PhilosopherVoiceResponse from(PhilosopherVoice entity) {
return new PhilosopherVoiceResponse(
entity.getId(),
entity.getName(),
entity.getReferenceId(),
entity.getVoiceLabel(),
entity.getNote()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.swyp.picke.domain.admin.dto.scenario.request;

import com.swyp.picke.domain.scenario.enums.SpeakerType;
import com.swyp.picke.domain.scenario.enums.Tone;

public record AdminScenarioScriptRequest(
String speakerName,
SpeakerType speakerType,
String text
String text,
Tone tone
) {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.swyp.picke.domain.admin.dto.scenario.response;

import com.swyp.picke.domain.scenario.enums.SpeakerType;
import com.swyp.picke.domain.scenario.enums.Tone;
import lombok.Builder;

@Builder
Expand All @@ -9,5 +10,6 @@ public record AdminScenarioScriptResponse(
Integer startTimeMs,
SpeakerType speakerType,
String speakerName,
String text
String text,
Tone tone
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ private List<ScriptRequest> toScriptRequests(List<AdminScenarioScriptRequest> sc
.map(scriptRequest -> new ScriptRequest(
scriptRequest.speakerName(),
scriptRequest.speakerType(),
scriptRequest.text()
scriptRequest.text(),
scriptRequest.tone()
))
.toList();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ private ScriptResponse toUserScriptResponse(Script script) {
.speakerType(script.getSpeakerType())
.speakerName(script.getSpeakerName())
.text(cleanText)
.tone(script.getTone())
.build();
}

Expand All @@ -120,6 +121,7 @@ private AdminScenarioScriptResponse toAdminScriptResponse(Script script) {
.speakerType(script.getSpeakerType())
.speakerName(script.getSpeakerName())
.text(script.getText())
.tone(script.getTone())
.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.swyp.picke.domain.scenario.dto.request;

import com.swyp.picke.domain.scenario.enums.SpeakerType;
import com.swyp.picke.domain.scenario.enums.Tone;

public record ScriptRequest(
String speakerName,
SpeakerType speakerType,
String text
) {}
String text,
Tone tone
) {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.swyp.picke.domain.scenario.dto.response;

import com.swyp.picke.domain.scenario.enums.SpeakerType;
import com.swyp.picke.domain.scenario.enums.Tone;
import lombok.Builder;

@Builder
Expand All @@ -9,5 +10,6 @@ public record ScriptResponse(
Integer startTimeMs,
SpeakerType speakerType,
String speakerName,
String text
) {}
String text,
Tone tone
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.swyp.picke.domain.scenario.entity;

import com.swyp.picke.global.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 철학자 이름 → Fish Audio 보이스(reference_id) 매핑.
* 붙여넣기 파서가 시나리오 발화자에 맞는 보이스를 채울 때 조회한다.
*/
@Getter
@Entity
@Table(name = "philosopher_voice")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PhilosopherVoice extends BaseEntity {

@Column(nullable = false, unique = true, length = 50)
private String name;

@Column(name = "reference_id", nullable = false, length = 64)
private String referenceId;

@Column(name = "voice_label", length = 100)
private String voiceLabel;

@Column(length = 255)
private String note;

@Builder
public PhilosopherVoice(String name, String referenceId, String voiceLabel, String note) {
this.name = name;
this.referenceId = referenceId;
this.voiceLabel = voiceLabel;
this.note = note;
}

public void update(String referenceId, String voiceLabel, String note) {
if (referenceId != null && !referenceId.isBlank()) {
this.referenceId = referenceId;
}
if (voiceLabel != null) {
this.voiceLabel = voiceLabel;
}
if (note != null) {
this.note = note;
}
}
}
Loading
Loading