Skip to content

Commit 53582bc

Browse files
authored
#152 [Fix] 시나리오 발행 파이프라인 및 TTS/S3 처리 개선 (#156)
1 parent e522658 commit 53582bc

23 files changed

+330
-468
lines changed
Lines changed: 8 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,19 @@
11
package com.swyp.picke.domain.scenario.controller;
22

33
import com.swyp.picke.domain.battle.service.BattleService;
4-
import com.swyp.picke.domain.scenario.dto.request.ScenarioCreateRequest;
5-
import com.swyp.picke.domain.scenario.dto.request.ScenarioStatusUpdateRequest;
6-
import com.swyp.picke.domain.scenario.dto.response.AdminDeleteResponse;
7-
import com.swyp.picke.domain.scenario.dto.response.AdminScenarioDetailResponse;
8-
import com.swyp.picke.domain.scenario.dto.response.AdminScenarioResponse;
94
import com.swyp.picke.domain.scenario.dto.response.UserScenarioResponse;
105
import com.swyp.picke.domain.scenario.service.ScenarioService;
116
import com.swyp.picke.global.common.response.ApiResponse;
127
import io.swagger.v3.oas.annotations.Operation;
138
import io.swagger.v3.oas.annotations.tags.Tag;
149
import lombok.RequiredArgsConstructor;
15-
import org.springframework.http.HttpStatus;
16-
import org.springframework.security.access.prepost.PreAuthorize;
17-
import org.springframework.web.bind.annotation.*;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.PathVariable;
12+
import org.springframework.web.bind.annotation.RequestAttribute;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RestController;
1815

19-
import java.util.Map;
20-
21-
@Tag(name = "시나리오 (Scenario)", description = "시나리오 API")
16+
@Tag(name = "시나리오 API", description = "사용자 시나리오 조회")
2217
@RestController
2318
@RequestMapping("/api/v1")
2419
@RequiredArgsConstructor
@@ -27,81 +22,20 @@ public class ScenarioController {
2722
private final ScenarioService scenarioService;
2823
private final BattleService battleService;
2924

30-
@Operation(summary = "시나리오 통합 조회")
25+
@Operation(summary = "배틀 시나리오 조회")
3126
@GetMapping("/battles/{battleId}/scenario")
3227
public ApiResponse<UserScenarioResponse> getBattleScenario(
3328
@PathVariable Long battleId,
3429
@RequestAttribute(value = "userId", required = false) Long userId
3530
) {
36-
// 1. 배틀 데이터 조회 (제목, 철학자 리스트)
3731
var battleInfo = battleService.getBattleScenario(battleId);
38-
39-
// 2. 시나리오 데이터 조회 (노드, 대사, 오디오 등)
4032
var scenarioInfo = scenarioService.getScenarioForUser(battleId, userId);
4133

42-
// 3. UserScenarioResponse 최상단에 바로 값 세팅
4334
UserScenarioResponse response = scenarioInfo.toBuilder()
4435
.title(battleInfo.title())
4536
.philosophers(battleInfo.philosophers())
4637
.build();
4738

4839
return ApiResponse.onSuccess(response);
4940
}
50-
51-
@Operation(summary = "관리자용 배틀 시나리오 조회 (수정용)")
52-
@PreAuthorize("hasRole('ADMIN')")
53-
@GetMapping("/admin/battles/{battleId}/scenario")
54-
public ApiResponse<AdminScenarioDetailResponse> getAdminBattleScenario(
55-
@PathVariable Long battleId) {
56-
return ApiResponse.onSuccess(scenarioService.getScenarioForAdmin(battleId));
57-
}
58-
59-
@Operation(summary = "시나리오 생성")
60-
@PreAuthorize("hasRole('ADMIN')")
61-
@PostMapping("/admin/scenarios")
62-
@ResponseStatus(HttpStatus.CREATED)
63-
public ApiResponse<Map<String, Object>> createScenario(
64-
@RequestBody ScenarioCreateRequest request) {
65-
66-
Long scenarioId = scenarioService.createScenario(request);
67-
68-
// Map.of 대신 null에도 안전한 HashMap 사용
69-
Map<String, Object> response = new java.util.HashMap<>();
70-
response.put("scenarioId", scenarioId);
71-
72-
// 고정값 대신 프론트에서 보낸 상태값(PENDING 등)을 그대로 반환!
73-
response.put("status", request.status());
74-
75-
return ApiResponse.onSuccess(response);
76-
}
77-
78-
@Operation(summary = "시나리오 내용 수정")
79-
@PreAuthorize("hasRole('ADMIN')")
80-
@PutMapping("/admin/scenarios/{scenarioId}")
81-
public ApiResponse<Void> updateScenarioContent(
82-
@PathVariable Long scenarioId,
83-
@RequestBody ScenarioCreateRequest request) {
84-
85-
scenarioService.updateScenarioContent(scenarioId, request);
86-
return ApiResponse.onSuccess(null);
87-
}
88-
89-
@Operation(summary = "시나리오 상태 수정 (PUBLISHED 변경 시 자동 오디오 처리)")
90-
@PreAuthorize("hasRole('ADMIN')")
91-
@PatchMapping("/admin/scenarios/{scenarioId}")
92-
public ApiResponse<AdminScenarioResponse> updateScenarioStatus(
93-
@PathVariable Long scenarioId,
94-
@RequestBody ScenarioStatusUpdateRequest request) {
95-
96-
return ApiResponse.onSuccess(scenarioService.updateScenarioStatus(scenarioId, request.status()));
97-
}
98-
99-
@Operation(summary = "시나리오 삭제 (Soft Delete)")
100-
@PreAuthorize("hasRole('ADMIN')")
101-
@DeleteMapping("/admin/scenarios/{scenarioId}")
102-
public ApiResponse<AdminDeleteResponse> deleteScenario(
103-
@PathVariable Long scenarioId) {
104-
105-
return ApiResponse.onSuccess(scenarioService.deleteScenario(scenarioId));
106-
}
107-
}
41+
}

src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package com.swyp.picke.domain.scenario.converter;
22

3+
import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioDetailResponse;
4+
import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioNodeResponse;
5+
import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioOptionResponse;
6+
import com.swyp.picke.domain.admin.dto.scenario.response.AdminScenarioScriptResponse;
37
import com.swyp.picke.domain.scenario.dto.response.*;
48
import com.swyp.picke.domain.scenario.entity.InteractiveOption;
59
import com.swyp.picke.domain.scenario.entity.Scenario;
@@ -8,7 +12,6 @@
812
import com.swyp.picke.domain.scenario.enums.AudioPathType;
913
import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider;
1014
import lombok.RequiredArgsConstructor;
11-
import org.springframework.beans.factory.annotation.Value;
1215
import org.springframework.stereotype.Component;
1316

1417
import java.util.HashMap;
@@ -21,12 +24,8 @@
2124
public class ScenarioConverter {
2225

2326
private final ResourceUrlProvider resourceUrlProvider;
24-
private static final String BASE_SHARE_URL = "https://pique.app/battles/";
2527

26-
/**
27-
* [유저용] Scenario 엔티티를 프론트엔드 전달용 DTO로 변환합니다.
28-
*/
29-
public UserScenarioResponse toUserResponse(Scenario scenario, AudioPathType recommendedPathKey) {
28+
public UserScenarioResponse toUserResponse(Scenario scenario, AudioPathType recommendedPathKey) {
3029
Long startNodeId = scenario.getNodes().stream()
3130
.filter(node -> Boolean.TRUE.equals(node.getIsStartNode()))
3231
.map(ScenarioNode::getId)
@@ -56,22 +55,19 @@ public UserScenarioResponse toUserResponse(Scenario scenario, AudioPathType reco
5655
.build();
5756
}
5857

59-
/**
60-
* [관리자용] 시나리오 상세 변환 메서드
61-
*/
62-
public AdminScenarioDetailResponse toAdminDetailResponse(Scenario scenario) {
58+
public AdminScenarioDetailResponse toAdminDetailResponse(Scenario scenario) {
6359
return AdminScenarioDetailResponse.builder()
6460
.scenarioId(scenario.getId())
6561
.battleId(scenario.getBattle().getId())
6662
.title(scenario.getBattle().getTitle())
6763
.isInteractive(scenario.getIsInteractive())
64+
.voiceSettings(new HashMap<>(scenario.getVoiceSettings()))
6865
.nodes(scenario.getNodes().stream()
6966
.map(this::toAdminNodeResponse)
7067
.collect(Collectors.toList()))
7168
.build();
7269
}
7370

74-
// 유저용 변환 로직
7571
private NodeResponse toUserNodeResponse(ScenarioNode node) {
7672
return NodeResponse.builder()
7773
.nodeId(node.getId())
@@ -82,7 +78,7 @@ private NodeResponse toUserNodeResponse(ScenarioNode node) {
8278
.map(this::toUserScriptResponse)
8379
.collect(Collectors.toList()))
8480
.interactiveOptions(node.getOptions().stream()
85-
.map(this::toOptionResponse)
81+
.map(this::toUserOptionResponse)
8682
.collect(Collectors.toList()))
8783
.build();
8884
}
@@ -102,9 +98,8 @@ private ScriptResponse toUserScriptResponse(Script script) {
10298
.build();
10399
}
104100

105-
// 관리자용 변환 로직
106-
private NodeResponse toAdminNodeResponse(ScenarioNode node) {
107-
return NodeResponse.builder()
101+
private AdminScenarioNodeResponse toAdminNodeResponse(ScenarioNode node) {
102+
return AdminScenarioNodeResponse.builder()
108103
.nodeId(node.getId())
109104
.nodeName(node.getNodeName())
110105
.audioDuration(node.getAudioDuration())
@@ -113,13 +108,13 @@ private NodeResponse toAdminNodeResponse(ScenarioNode node) {
113108
.map(this::toAdminScriptResponse)
114109
.collect(Collectors.toList()))
115110
.interactiveOptions(node.getOptions().stream()
116-
.map(this::toOptionResponse)
111+
.map(this::toAdminOptionResponse)
117112
.collect(Collectors.toList()))
118113
.build();
119114
}
120115

121-
private ScriptResponse toAdminScriptResponse(Script script) {
122-
return ScriptResponse.builder()
116+
private AdminScenarioScriptResponse toAdminScriptResponse(Script script) {
117+
return AdminScenarioScriptResponse.builder()
123118
.scriptId(script.getId())
124119
.startTimeMs(script.getStartTimeMs())
125120
.speakerType(script.getSpeakerType())
@@ -128,10 +123,17 @@ private ScriptResponse toAdminScriptResponse(Script script) {
128123
.build();
129124
}
130125

131-
private OptionResponse toOptionResponse(InteractiveOption option) {
126+
private OptionResponse toUserOptionResponse(InteractiveOption option) {
132127
return OptionResponse.builder()
133128
.label(option.getLabel())
134129
.nextNodeId(option.getNextNodeId())
135130
.build();
136131
}
132+
133+
private AdminScenarioOptionResponse toAdminOptionResponse(InteractiveOption option) {
134+
return AdminScenarioOptionResponse.builder()
135+
.label(option.getLabel())
136+
.nextNodeId(option.getNextNodeId())
137+
.build();
138+
}
137139
}

src/main/java/com/swyp/picke/domain/scenario/dto/request/NodeRequest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
public record NodeRequest(
66
String nodeName,
77
Boolean isStartNode,
8-
String autoNextNode, // 자동 넘김 노드 이름 추가
8+
String autoNextNode,
99
List<ScriptRequest> scripts,
1010
List<OptionRequest> interactiveOptions
1111
) {}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package com.swyp.picke.domain.scenario.dto.request;
22

33
import com.swyp.picke.domain.scenario.enums.ScenarioStatus;
4+
import com.swyp.picke.domain.scenario.enums.SpeakerType;
45
import java.util.List;
6+
import java.util.Map;
57

68
public record ScenarioCreateRequest(
79
Long battleId,
810
Boolean isInteractive,
911
ScenarioStatus status,
10-
List<NodeRequest> nodes
12+
List<NodeRequest> nodes,
13+
Map<SpeakerType, String> voiceSettings
1114
) {}

src/main/java/com/swyp/picke/domain/scenario/dto/response/NodeResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
public record NodeResponse(
88
Long nodeId,
99
String nodeName,
10-
Integer audioDuration, // 프론트엔드 재생 시간 표시에 활용
10+
Integer audioDuration,
1111
Long autoNextNodeId,
1212
List<ScriptResponse> scripts,
1313
List<OptionResponse> interactiveOptions

src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
public record OptionResponse(
77
String label,
88
Long nextNodeId
9-
) {}
9+
) {}

src/main/java/com/swyp/picke/domain/scenario/entity/Scenario.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.swyp.picke.domain.scenario.enums.AudioPathType;
55
import com.swyp.picke.domain.scenario.enums.CreatorType;
66
import com.swyp.picke.domain.scenario.enums.ScenarioStatus;
7+
import com.swyp.picke.domain.scenario.enums.SpeakerType;
78
import com.swyp.picke.global.common.BaseEntity;
89
import jakarta.persistence.*;
910
import lombok.AccessLevel;
@@ -41,6 +42,14 @@ public class Scenario extends BaseEntity {
4142
@Column(name = "audio_url")
4243
private Map<AudioPathType, String> audios = new EnumMap<>(AudioPathType.class);
4344

45+
@ElementCollection
46+
@CollectionTable(name = "scenario_voice_settings", joinColumns = @JoinColumn(name = "scenario_id"))
47+
@MapKeyEnumerated(EnumType.STRING)
48+
@MapKeyColumn(name = "speaker_type")
49+
@Column(name = "voice_code")
50+
private Map<SpeakerType, String> voiceSettings = new EnumMap<>(SpeakerType.class);
51+
52+
@OrderColumn(name = "node_order")
4453
@OneToMany(mappedBy = "scenario", cascade = CascadeType.ALL, orphanRemoval = true)
4554
private List<ScenarioNode> nodes = new ArrayList<>();
4655

@@ -68,4 +77,15 @@ public void addNode(ScenarioNode node) {
6877
public void clearAudios() {
6978
this.audios.clear();
7079
}
71-
}
80+
81+
public void replaceVoiceSettings(Map<SpeakerType, String> voiceSettings) {
82+
this.voiceSettings.clear();
83+
if (voiceSettings != null) {
84+
this.voiceSettings.putAll(voiceSettings);
85+
}
86+
}
87+
88+
public String getVoiceCode(SpeakerType speakerType) {
89+
return this.voiceSettings.get(speakerType);
90+
}
91+
}

src/main/java/com/swyp/picke/domain/scenario/entity/ScenarioNode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ public class ScenarioNode extends BaseEntity {
3232
@Column(name = "auto_next_node_id")
3333
private Long autoNextNodeId;
3434

35+
@OrderColumn(name = "script_order")
3536
@OneToMany(mappedBy = "node", cascade = CascadeType.ALL, orphanRemoval = true)
3637
private List<Script> scripts = new ArrayList<>();
3738

39+
@OrderColumn(name = "option_order")
3840
@OneToMany(mappedBy = "node", cascade = CascadeType.ALL, orphanRemoval = true)
3941
private List<InteractiveOption> options = new ArrayList<>();
4042

@@ -76,4 +78,4 @@ public void updateAutoNextNodeId(Long autoNextNodeId) {
7678
public void updateAudioDuration(Integer audioDuration) {
7779
this.audioDuration = audioDuration;
7880
}
79-
}
81+
}

src/main/java/com/swyp/picke/domain/scenario/entity/Script.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public void updateContent(SpeakerType speakerType, String speakerName, String ne
5050
this.speakerType = speakerType;
5151
this.speakerName = speakerName;
5252
this.text = newText;
53+
this.audioUrl = null;
5354
}
5455

5556
public void assignNode(ScenarioNode node) {

src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ public void generateAndMergeAudioAsync(Long scenarioId) {
6060
// 2. 텍스트가 바뀌었거나 새로 추가되었다면? (새로 생성 후 S3에 저장)
6161
else {
6262
log.info(">> 새 오디오 생성 (TTS API 호출): 스크립트 ID {}", script.getId());
63-
audioFile = ttsService.generateTtsWithSsml(script.getText(), script.getSpeakerType());
63+
audioFile = ttsService.generateTtsWithSsml(
64+
script.getText(),
65+
script.getSpeakerType(),
66+
scenario.getVoiceCode(script.getSpeakerType())
67+
);
6468

6569
// 새로 만든 조각 파일을 다음 수정을 위해 S3에 업로드 (chunks 폴더)
6670
String chunkKey = FileCategory.SCENARIO.getPath() + "/chunks/" + UUID.randomUUID() + ".mp3";

0 commit comments

Comments
 (0)