Skip to content
2 changes: 2 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ services:

restart: unless-stopped

stop_grace_period: 90s

logging: *awslogs

healthcheck:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static MemberResponseDTO.TtsUpdateResponse toTtsUpdateResponse(Member mem
.ttsId(member.getVoiceType().getId())
.voiceCode(member.getVoiceType().getName())
.displayName(member.getVoiceType().getLabel())
.selectionVersion(member.getVoiceSelectionVersion())
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,15 @@ public record TtsUpdateResponse(
String voiceCode,

@Schema(description = "TTS 목소리 표시 이름", example = "민서")
String displayName
String displayName,

@Schema(description = """
이번 변경으로 올라간 최신 목소리 선택 버전. 목소리를 바꿀 때마다 1씩 증가한다.
이 응답 시점에 회원의 모든 루틴 TTS 는 PENDING 으로 초기화되며,
재합성이 끝난 스텝은 TTS 조회 응답의 selectionVersion 이 이 값과 같아진다.
두 값이 같고 ttsStatus 가 COMPLETED 인 스텝만 새 목소리로 만들어진 음원이다.""",
example = "3")
Long selectionVersion
) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public class Member extends BaseEntity {
@JoinColumn(name = "tts_id")
private TTS voiceType;

@Column(name = "voice_selection_version", nullable = false)
@Builder.Default
private Long voiceSelectionVersion = 0L;
Comment thread
youngsun0331 marked this conversation as resolved.

@Column(name = "profile_image_key", length = 500)
private String profileImageKey;

Expand All @@ -67,4 +71,8 @@ public void completeOnboarding() {
public void updateVoiceType(TTS voiceType) {
this.voiceType = voiceType;
}

public void bumpVoiceSelectionVersion() {
this.voiceSelectionVersion++;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,105 @@
import com.moru.server.domain.member.dto.MemberResponseDTO;
import com.moru.server.domain.member.entity.Member;
import com.moru.server.domain.member.repository.MemberRepository;
import com.moru.server.domain.routine.entity.RoutineTTS;
import com.moru.server.domain.routine.event.RoutineTtsVoiceChangedEvent;
import com.moru.server.domain.routine.repository.RoutineTTSRepository;
import com.moru.server.domain.tts.entity.TTS;
import com.moru.server.domain.tts.repository.TTSRepository;
import com.moru.server.global.exception.BusinessException;
import com.moru.server.global.response.code.status.ErrorStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.List;

@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class MemberCommandServiceImpl implements MemberCommandService {

private final MemberRepository memberRepository;
private final TTSRepository ttsRepository;
private final RoutineTTSRepository routineTTSRepository;
private final ApplicationEventPublisher eventPublisher;

@Value("${google.tts.enabled:false}")
private boolean ttsEnabled;

@Override
@Override
public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) {
Member member = memberRepository.findById(memberId)
Member member = memberRepository.findByIdForUpdate(memberId)
.orElseThrow(() -> new BusinessException(ErrorStatus.MEMBER_NOT_FOUND));

TTS tts = ttsRepository.findById(ttsId)
.orElseThrow(() -> new BusinessException(ErrorStatus.TTS_NOT_FOUND));

if (isSameVoice(member, ttsId)) {
log.info("[TTS] 동일한 목소리라 재합성하지 않음. memberId={}, ttsId={}", memberId, ttsId);
return MemberConverter.toTtsUpdateResponse(member);
}

member.updateVoiceType(tts);
member.bumpVoiceSelectionVersion();

if (!ttsEnabled) {
markAllFailed(memberId);
return MemberConverter.toTtsUpdateResponse(member);
}

requestRegeneration(memberId, resolveVoiceName(tts), member.getVoiceSelectionVersion());
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return MemberConverter.toTtsUpdateResponse(member);
}

/**
* 재합성을 수행할 워커가 없다. 버전만 올리고 두면 기존 음원이 새 버전인 것처럼 보이므로
* 생성 경로(RoutineGroupCommandServiceImpl.publishTtsEvent)와 동일하게 FAILED 로 종결한다.
*/
private void markAllFailed(Long memberId) {
List<RoutineTTS> targets = routineTTSRepository.findAllByMemberId(memberId);
for (RoutineTTS target : targets) {
target.markFailed();
}
log.info("[TTS] 기능이 비활성화되어 재합성을 건너뛰고 FAILED 로 종결. memberId={}, 건수={}",
memberId, targets.size());
}

private boolean isSameVoice(Member member, Long ttsId) {
TTS current = member.getVoiceType();
return current != null && current.getId().equals(ttsId);
}

private String resolveVoiceName(TTS tts) {
String voiceName = tts.getGoogleVoiceName();
if (voiceName == null || voiceName.isBlank()) {
log.error("[TTS] 목소리 프리셋에 googleVoiceName 이 없어 재합성을 중단. ttsId={}, name={}",
tts.getId(), tts.getName());
throw new BusinessException(ErrorStatus.TTS_VOICE_NAME_NOT_CONFIGURED);
}
return voiceName;
}

private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"재합성 이벤트는 트랜잭션 안에서 발행해야 한다. memberId=" + memberId);
}

List<RoutineTTS> targets = routineTTSRepository.findAllByMemberId(memberId);
for (RoutineTTS target : targets) {
target.markPending();
eventPublisher.publishEvent(
new RoutineTtsVoiceChangedEvent(target.getId(), voiceName, voiceVersion));
}

log.info("[TTS] 목소리 변경으로 재합성 요청. memberId={}, 건수={}, voiceVersion={}",
memberId, targets.size(), voiceVersion);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@ public record RoutineTTSStep(
String ttsStatus,

@Schema(description = "음원 파일 S3 URL", example = "https://.../101.mp3", nullable = true)
String s3Url
String s3Url,

@Schema(description = """
이 음원이 만들어진 목소리 선택 버전. 합성에 성공한 시점에만 기록된다.
PATCH /members/me/tts 응답의 selectionVersion 과 이 값이 같고
ttsStatus 가 COMPLETED 일 때만 최신 목소리로 만들어진 음원이다.
값이 더 작으면 아직 재합성이 끝나지 않아 이전 목소리의 음원이 내려가는 중이다.""",
example = "3", nullable = true)
Long selectionVersion
) {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,26 @@ public class RoutineTTS extends BaseEntity {
@Column(name = "s3_url")
private String s3Url;

public void markCompleted(String s3Url, String ttsIntro, String ttsDone) {
@Column(name = "voice_version")
private Long voiceVersion;

public void markCompleted(String s3Url, String ttsIntro, String ttsDone, Long voiceVersion) {
this.ttsStatus = TtsStatus.COMPLETED;
this.s3Url = s3Url;
this.ttsIntro = truncate(ttsIntro);
this.ttsDone = truncate(ttsDone);
this.voiceVersion = voiceVersion;
}

public void markFailed() {
this.ttsStatus = TtsStatus.FAILED;
}


public void markPending() {
this.ttsStatus = TtsStatus.PENDING;
}

public static String truncate(String text) {
if (text == null || text.length() <= CONTENT_MAX_LENGTH) {
return text;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public record RoutineTtsCreatedEvent(
Long routineTtsId,
String voiceName) {
String voiceName,
Long voiceVersion) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.moru.server.domain.routine.event;

public record RoutineTtsVoiceChangedEvent(
Long routineTtsId,
String voiceName,
Long voiceVersion) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface RoutineTTSRepository extends JpaRepository<RoutineTTS, Long> {

Expand All @@ -29,6 +30,23 @@ public interface RoutineTTSRepository extends JpaRepository<RoutineTTS, Long> {
""")
List<RoutineTTS> findAllByRoutineIdsOrdered(@Param("routineIds") List<Long> routineIds);

@Query("""
select t from RoutineTTS t
join t.routine r
join r.routineGroup g
where g.member.id = :memberId
order by t.id asc
""")
List<RoutineTTS> findAllByMemberId(@Param("memberId") Long memberId);

@Query("""
select g.member.voiceSelectionVersion from RoutineTTS t
join t.routine r
join r.routineGroup g
where t.id = :routineTtsId
""")
Optional<Long> findCurrentVoiceVersion(@Param("routineTtsId") Long routineTtsId);

@Query("""
select t.s3Url from RoutineTTS t
where t.routine.routineGroup.member.id = :memberId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ private RoutineGroupResponseDTO.DetailResponse doCreateRoutineGroup(

for (Routine routine : saved.getRoutines()) {
for (RoutineTTS tts : routine.getTtsList()) {
publishTtsEvent(tts, voiceName);
publishTtsEvent(tts, voiceName, member.getVoiceSelectionVersion());
}
}

Expand Down Expand Up @@ -199,7 +199,7 @@ private void attachSteps(Routine routine, List<String> contents) {
}


private void publishTtsEvent(RoutineTTS tts,String voiceName) {
private void publishTtsEvent(RoutineTTS tts,String voiceName,Long voiceVersion) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException("TTS 이벤트는 트랜잭션 안에서 발행해야 한다.");
}
Expand All @@ -208,7 +208,7 @@ private void publishTtsEvent(RoutineTTS tts,String voiceName) {
tts.markFailed();
return;
}
eventPublisher.publishEvent(new RoutineTtsCreatedEvent(tts.getId(),voiceName));
eventPublisher.publishEvent(new RoutineTtsCreatedEvent(tts.getId(),voiceName,voiceVersion));
}


Expand Down Expand Up @@ -355,7 +355,7 @@ private RoutineGroupResponseDTO.RoutineResponse doAddRoutine(
Routine saved = routineRepository.save(routine);

for (RoutineTTS tts : saved.getTtsList()) {
publishTtsEvent(tts, voiceName);
publishTtsEvent(tts, voiceName, member.getVoiceSelectionVersion());
}

return saved;
Expand Down
Loading
Loading