From 17387e41927de54969ebef74bcde823f61b02cda Mon Sep 17 00:00:00 2001 From: youngsun Date: Mon, 10 Aug 2026 23:32:59 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=EB=AA=A9=EC=86=8C=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EB=A3=A8?= =?UTF-8?q?=ED=8B=B4=20TTS=20=EC=9E=AC=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/member/entity/Member.java | 5 + .../member/MemberCommandServiceImpl.java | 43 ++++++++- .../domain/routine/entity/RoutineTTS.java | 11 ++- .../routine/event/RoutineTtsCreatedEvent.java | 3 +- .../event/RoutineTtsVoiceChangedEvent.java | 7 ++ .../repository/RoutineTTSRepository.java | 18 ++++ .../RoutineGroupCommandServiceImpl.java | 8 +- .../command/RoutineTTS/TTSAsyncService.java | 91 +++++++++++++++++-- 8 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/moru/server/domain/routine/event/RoutineTtsVoiceChangedEvent.java diff --git a/src/main/java/com/moru/server/domain/member/entity/Member.java b/src/main/java/com/moru/server/domain/member/entity/Member.java index 789b2ff..b3c4c84 100644 --- a/src/main/java/com/moru/server/domain/member/entity/Member.java +++ b/src/main/java/com/moru/server/domain/member/entity/Member.java @@ -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; + @Column(name = "profile_image_key", length = 500) private String profileImageKey; @@ -66,5 +70,6 @@ public void completeOnboarding() { public void updateVoiceType(TTS voiceType) { this.voiceType = voiceType; + this.voiceSelectionVersion++; } } diff --git a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java index dad36ca..394a3f0 100644 --- a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java @@ -4,25 +4,40 @@ 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) @@ -30,6 +45,30 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) member.updateVoiceType(tts); + requestRegeneration(memberId, tts.getName(), member.getVoiceSelectionVersion()); + return MemberConverter.toTtsUpdateResponse(member); } + + private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) { + if (!ttsEnabled) { + // 처리할 워커가 없으므로 PENDING 으로 돌리면 영구히 멈춘다. 기존 음원을 그대로 둔다. + log.info("[TTS] 기능이 비활성화되어 재합성을 건너뜀. memberId={}", memberId); + return; + } + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException( + "재합성 이벤트는 트랜잭션 안에서 발행해야 한다. memberId=" + memberId); + } + + List 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); + } } diff --git a/src/main/java/com/moru/server/domain/routine/entity/RoutineTTS.java b/src/main/java/com/moru/server/domain/routine/entity/RoutineTTS.java index 94e5580..26d3e3d 100644 --- a/src/main/java/com/moru/server/domain/routine/entity/RoutineTTS.java +++ b/src/main/java/com/moru/server/domain/routine/entity/RoutineTTS.java @@ -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; diff --git a/src/main/java/com/moru/server/domain/routine/event/RoutineTtsCreatedEvent.java b/src/main/java/com/moru/server/domain/routine/event/RoutineTtsCreatedEvent.java index db24b82..5d02ac2 100644 --- a/src/main/java/com/moru/server/domain/routine/event/RoutineTtsCreatedEvent.java +++ b/src/main/java/com/moru/server/domain/routine/event/RoutineTtsCreatedEvent.java @@ -2,5 +2,6 @@ public record RoutineTtsCreatedEvent( Long routineTtsId, - String voiceName) { + String voiceName, + Long voiceVersion) { } diff --git a/src/main/java/com/moru/server/domain/routine/event/RoutineTtsVoiceChangedEvent.java b/src/main/java/com/moru/server/domain/routine/event/RoutineTtsVoiceChangedEvent.java new file mode 100644 index 0000000..9a3c962 --- /dev/null +++ b/src/main/java/com/moru/server/domain/routine/event/RoutineTtsVoiceChangedEvent.java @@ -0,0 +1,7 @@ +package com.moru.server.domain.routine.event; + +public record RoutineTtsVoiceChangedEvent( + Long routineTtsId, + String voiceName, + Long voiceVersion) { +} diff --git a/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java b/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java index 57e6493..be90420 100644 --- a/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java +++ b/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface RoutineTTSRepository extends JpaRepository { @@ -28,4 +29,21 @@ public interface RoutineTTSRepository extends JpaRepository { order by t.routine.id asc, t.orderIndex asc """) List findAllByRoutineIdsOrdered(@Param("routineIds") List 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 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 findCurrentVoiceVersion(@Param("routineTtsId") Long routineTtsId); } diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineGroup/RoutineGroupCommandServiceImpl.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineGroup/RoutineGroupCommandServiceImpl.java index 0ae6329..db6d61c 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineGroup/RoutineGroupCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineGroup/RoutineGroupCommandServiceImpl.java @@ -104,7 +104,7 @@ private RoutineGroupResponseDTO.DetailResponse doCreateRoutineGroup( for (Routine routine : saved.getRoutines()) { for (RoutineTTS tts : routine.getTtsList()) { - publishTtsEvent(tts, voiceName); + publishTtsEvent(tts, voiceName, member.getVoiceSelectionVersion()); } } @@ -196,7 +196,7 @@ private void attachSteps(Routine routine, List contents) { } - private void publishTtsEvent(RoutineTTS tts,String voiceName) { + private void publishTtsEvent(RoutineTTS tts,String voiceName,Long voiceVersion) { if (!TransactionSynchronizationManager.isActualTransactionActive()) { throw new IllegalStateException( "TTS 이벤트는 트랜잭션 안에서 발행해야 한다. routineTtsId=" + tts.getId()); @@ -206,7 +206,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)); } @@ -350,7 +350,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; diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java index 166b51e..961de79 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java @@ -2,6 +2,7 @@ import com.moru.server.domain.routine.entity.RoutineTTS; import com.moru.server.domain.routine.event.RoutineTtsCreatedEvent; +import com.moru.server.domain.routine.event.RoutineTtsVoiceChangedEvent; import com.moru.server.domain.routine.repository.RoutineTTSRepository; import com.moru.server.global.ai.AiClient; import com.moru.server.global.ai.dto.GeminiResponseDTO; @@ -12,6 +13,7 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; @@ -26,6 +28,7 @@ public class TTSAsyncService { private final RoutineTTSRepository routineTTSRepository; private final AiClient aiClient; private final Executor ttsExecutor; + private final Executor ttsRegenerateExecutor; private static final String CONTENT_TYPE = "audio/mpeg"; public TTSAsyncService( @@ -33,28 +36,44 @@ public TTSAsyncService( S3Uploader s3Uploader, RoutineTTSRepository routineTTSRepository, AiClient aiClient, - @Qualifier("ttsExecutor") Executor ttsExecutor + @Qualifier("ttsExecutor") Executor ttsExecutor, + @Qualifier("ttsRegenerateExecutor") Executor ttsRegenerateExecutor ) { this.googleTtsClient = googleTtsClient; this.s3Uploader = s3Uploader; this.routineTTSRepository = routineTTSRepository; this.aiClient = aiClient; this.ttsExecutor = ttsExecutor; + this.ttsRegenerateExecutor = ttsRegenerateExecutor; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onRoutineTtsCreated(RoutineTtsCreatedEvent event) { Long routineTtsId = event.routineTtsId(); String voiceName = event.voiceName(); + Long voiceVersion = event.voiceVersion(); try { - ttsExecutor.execute(() -> synthesizeAndUpload(routineTtsId,voiceName)); + ttsExecutor.execute(() -> synthesizeAndUpload(routineTtsId,voiceName,voiceVersion)); } catch (RejectedExecutionException e) { log.error("[TTS] 스레드풀 포화로 작업이 거절됨. routineTtsId={}", routineTtsId, e); markFailedQuietly(routineTtsId); } } - private void synthesizeAndUpload(Long routineTtsId,String voiceName) { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onRoutineTtsVoiceChanged(RoutineTtsVoiceChangedEvent event) { + Long routineTtsId = event.routineTtsId(); + String voiceName = event.voiceName(); + Long voiceVersion = event.voiceVersion(); + try { + ttsRegenerateExecutor.execute(() -> regenerate(routineTtsId,voiceName,voiceVersion)); + } catch (RejectedExecutionException e) { + log.error("[TTS] 재합성 스레드풀 포화로 작업이 거절됨. routineTtsId={}", routineTtsId, e); + markFailedQuietly(routineTtsId); + } + } + + private void synthesizeAndUpload(Long routineTtsId,String voiceName,Long voiceVersion) { log.info("[TTS] 백그라운드 작업 시작. routineTtsId={}, thread={}", routineTtsId, Thread.currentThread().getName()); @@ -82,7 +101,7 @@ private void synthesizeAndUpload(Long routineTtsId,String voiceName) { RoutineTTS entity = routineTTSRepository.findById(routineTtsId) .orElseThrow(() -> new IllegalStateException("RoutineTTS 행 없음: " + routineTtsId)); - entity.markCompleted(key, ttsScript.ttsIntro(), ttsScript.ttsDone()); + entity.markCompleted(key, ttsScript.ttsIntro(), ttsScript.ttsDone(), voiceVersion); routineTTSRepository.save(entity); log.info("[TTS] 완료. routineTtsId={}, key={}, {} bytes", routineTtsId, key, audio.length); @@ -95,15 +114,75 @@ private void synthesizeAndUpload(Long routineTtsId,String voiceName) { } } + private void regenerate(Long routineTtsId,String voiceName,Long voiceVersion) { + + log.info("[TTS] 재합성 시작. routineTtsId={}, voiceVersion={}, thread={}", + routineTtsId, voiceVersion, Thread.currentThread().getName()); + + RoutineTTS entity = routineTTSRepository.findById(routineTtsId).orElse(null); + if (entity == null) { + log.warn("[TTS] 재합성 대상 행이 없어 중단. routineTtsId={}", routineTtsId); + return; + } + + String previousKey = entity.getS3Url(); + String uploadedKey = null; + + try { + String intro = entity.getTtsIntro(); + String done = entity.getTtsDone(); + + // 최초 합성이 끝난 적 없는 행은 재사용할 멘트가 없다. 이때만 생성 경로와 동일하게 만든다. + if (intro == null || intro.isBlank()) { + GeminiResponseDTO.AiTtsResult ttsScript = aiClient.generateTtsScript(entity.getContent()); + intro = ttsScript.ttsIntro(); + done = ttsScript.ttsDone(); + } + + byte[] audio = googleTtsClient.synthesize(intro,voiceName); + + String key = "tts/%d-%s.mp3".formatted(routineTtsId, UUID.randomUUID()); + s3Uploader.upload(key, audio, CONTENT_TYPE); + uploadedKey = key; + + Long currentVersion = routineTTSRepository.findCurrentVoiceVersion(routineTtsId).orElse(null); + if (!Objects.equals(currentVersion, voiceVersion)) { + log.info("[TTS] 재합성 중 목소리가 다시 바뀌어 결과를 폐기. routineTtsId={}, 작업버전={}, 현재버전={}", + routineTtsId, voiceVersion, currentVersion); + deleteQuietly(uploadedKey); + return; + } + + RoutineTTS target = routineTTSRepository.findById(routineTtsId) + .orElseThrow(() -> new IllegalStateException("RoutineTTS 행 없음: " + routineTtsId)); + + target.markCompleted(key, intro, done, voiceVersion); + routineTTSRepository.save(target); + + // 교체가 확정된 뒤에야 옛 음원을 지운다. 먼저 지우면 실패 시 들려줄 음원이 사라진다. + if (previousKey != null && !previousKey.equals(key)) { + deleteQuietly(previousKey); + } + + log.info("[TTS] 재합성 완료. routineTtsId={}, key={}, {} bytes", routineTtsId, key, audio.length); + + } catch (Exception e) { + + log.error("[TTS] 재합성 실패. 기존 음원은 유지한다. routineTtsId={}", routineTtsId, e); + deleteQuietly(uploadedKey); + markFailedQuietly(routineTtsId); + } + } + private void deleteQuietly(String key) { if (key == null) { return; } try { s3Uploader.delete(key); - log.info("[TTS] 실패로 업로드된 객체를 삭제함. key={}", key); + log.info("[TTS] 사용되지 않는 객체를 삭제함. key={}", key); } catch (Exception e) { - log.error("[TTS] 업로드된 객체 삭제 실패. key={}", key, e); + log.error("[TTS] 객체 삭제 실패. key={}", key, e); } } From 283d5770f965fa4999499df18f1760975a352afa Mon Sep 17 00:00:00 2001 From: youngsun Date: Mon, 10 Aug 2026 23:33:12 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20TTS=20selectionVersion=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/converter/MemberConverter.java | 1 + .../server/domain/member/dto/MemberResponseDTO.java | 10 +++++++++- .../domain/routine/dto/RoutineTTSResponseDTO.java | 10 +++++++++- .../query/RoutineTTS/RoutineTTSQueryServiceImpl.java | 1 + 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/moru/server/domain/member/converter/MemberConverter.java b/src/main/java/com/moru/server/domain/member/converter/MemberConverter.java index e57d4a9..8915609 100644 --- a/src/main/java/com/moru/server/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/moru/server/domain/member/converter/MemberConverter.java @@ -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(); } diff --git a/src/main/java/com/moru/server/domain/member/dto/MemberResponseDTO.java b/src/main/java/com/moru/server/domain/member/dto/MemberResponseDTO.java index 33f9de4..421e318 100644 --- a/src/main/java/com/moru/server/domain/member/dto/MemberResponseDTO.java +++ b/src/main/java/com/moru/server/domain/member/dto/MemberResponseDTO.java @@ -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 ) { } diff --git a/src/main/java/com/moru/server/domain/routine/dto/RoutineTTSResponseDTO.java b/src/main/java/com/moru/server/domain/routine/dto/RoutineTTSResponseDTO.java index e402214..b5febf3 100644 --- a/src/main/java/com/moru/server/domain/routine/dto/RoutineTTSResponseDTO.java +++ b/src/main/java/com/moru/server/domain/routine/dto/RoutineTTSResponseDTO.java @@ -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 ) {} } } diff --git a/src/main/java/com/moru/server/domain/routine/service/query/RoutineTTS/RoutineTTSQueryServiceImpl.java b/src/main/java/com/moru/server/domain/routine/service/query/RoutineTTS/RoutineTTSQueryServiceImpl.java index dc727c2..c3f7921 100644 --- a/src/main/java/com/moru/server/domain/routine/service/query/RoutineTTS/RoutineTTSQueryServiceImpl.java +++ b/src/main/java/com/moru/server/domain/routine/service/query/RoutineTTS/RoutineTTSQueryServiceImpl.java @@ -84,6 +84,7 @@ private RoutineTTSResponseDTO.RoutineTTSRes.RoutineTTSStep toStep(RoutineTTS tts .ttsIntro(tts.getTtsIntro()) .ttsStatus(tts.getTtsStatus().name()) .s3Url(generatePresignedUrl(tts.getS3Url())) + .selectionVersion(tts.getVoiceVersion()) .build(); } From a6315ac910a00dae53c6430b14ef36fb833efbf1 Mon Sep 17 00:00:00 2001 From: youngsun Date: Mon, 10 Aug 2026 23:33:28 +0900 Subject: [PATCH 03/19] =?UTF-8?q?chore:=20TTS=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=ED=92=80=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=20=EC=9E=91=EC=97=85=20=EB=8C=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.prod.yml | 2 ++ .../server/global/config/AsyncConfig.java | 36 +++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d300f90..b09ec9e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -41,6 +41,8 @@ services: restart: unless-stopped + stop_grace_period: 90s + healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:${SERVER_PORT:-8080}/health || exit 1"] interval: 30s diff --git a/src/main/java/com/moru/server/global/config/AsyncConfig.java b/src/main/java/com/moru/server/global/config/AsyncConfig.java index 8a63100..3a047c0 100644 --- a/src/main/java/com/moru/server/global/config/AsyncConfig.java +++ b/src/main/java/com/moru/server/global/config/AsyncConfig.java @@ -12,27 +12,41 @@ public class AsyncConfig { - /** - * TTS 작업 전용 스레드풀. - * - *

이걸 정의하지 않으면 Spring 이 기본 실행기를 쓰는데, 기본값은 요청이 올 때마다 - * 스레드를 새로 만든다(상한 없음). 트래픽이 몰리면 스레드가 무한정 늘어나 서버가 죽는다. - * 그래서 개수를 정해둔 풀을 직접 만들어 쓴다. - */ + private static final int AWAIT_TERMINATION_SECONDS = 60; + + @Bean(name = "ttsExecutor") public Executor ttsExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(2); // 평소 유지할 스레드 수 - executor.setMaxPoolSize(4); // 큐가 가득 찼을 때까지 늘릴 수 있는 최대치 - executor.setQueueCapacity(50); // 대기열. 여기가 차야 비로소 스레드를 더 만든다 + executor.setCorePoolSize(4); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); - // 로그에서 "요청 스레드"와 "작업 스레드"를 눈으로 구분하기 위한 이름표. // 톰캣 요청 스레드는 http-nio-... 로 찍히고, 이쪽은 tts-1, tts-2 로 찍힌다. executor.setThreadNamePrefix("tts-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS); + executor.initialize(); return executor; } + @Bean(name = "ttsRegenerateExecutor") + public Executor ttsRegenerateExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(8); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(500); + + executor.setThreadNamePrefix("tts-regen-"); + + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS); + + executor.initialize(); + return executor; + } } From 9b2acce8d35b77310bfe344619db8031d54e3365 Mon Sep 17 00:00:00 2001 From: youngsun Date: Mon, 10 Aug 2026 23:50:05 +0900 Subject: [PATCH 04/19] =?UTF-8?q?fix=20:=20=EA=B0=99=EC=9D=80=20=EB=AA=A9?= =?UTF-8?q?=EC=86=8C=EB=A6=AC=20=EC=84=A0=ED=83=9D=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EC=A2=85=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/member/MemberCommandServiceImpl.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java index 394a3f0..217b081 100644 --- a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java @@ -43,6 +43,11 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) 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); requestRegeneration(memberId, tts.getName(), member.getVoiceSelectionVersion()); @@ -50,6 +55,11 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) return MemberConverter.toTtsUpdateResponse(member); } + private boolean isSameVoice(Member member, Long ttsId) { + TTS current = member.getVoiceType(); + return current != null && current.getId().equals(ttsId); + } + private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) { if (!ttsEnabled) { // 처리할 워커가 없으므로 PENDING 으로 돌리면 영구히 멈춘다. 기존 음원을 그대로 둔다. From 4b536d8c076bae9050b5562ca06e47fef35f5940 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sat, 15 Aug 2026 23:06:36 +0900 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20=EB=AA=A9=EC=86=8C=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=9E=AC=ED=95=A9=EC=84=B1=EC=9D=98=20?= =?UTF-8?q?=EB=AA=A9=EC=86=8C=EB=A6=AC=20=EC=9D=B4=EB=A6=84=C2=B7=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/member/entity/Member.java | 3 +++ .../member/MemberCommandServiceImpl.java | 23 ++++++++++++++----- .../response/code/status/ErrorStatus.java | 3 ++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/moru/server/domain/member/entity/Member.java b/src/main/java/com/moru/server/domain/member/entity/Member.java index b3c4c84..da75d1c 100644 --- a/src/main/java/com/moru/server/domain/member/entity/Member.java +++ b/src/main/java/com/moru/server/domain/member/entity/Member.java @@ -70,6 +70,9 @@ public void completeOnboarding() { public void updateVoiceType(TTS voiceType) { this.voiceType = voiceType; + } + + public void bumpVoiceSelectionVersion() { this.voiceSelectionVersion++; } } diff --git a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java index 217b081..53a0ca8 100644 --- a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java @@ -50,7 +50,13 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) member.updateVoiceType(tts); - requestRegeneration(memberId, tts.getName(), member.getVoiceSelectionVersion()); + if (!ttsEnabled) { + log.info("[TTS] 기능이 비활성화되어 재합성을 건너뜀. memberId={}", memberId); + return MemberConverter.toTtsUpdateResponse(member); + } + + member.bumpVoiceSelectionVersion(); + requestRegeneration(memberId, resolveVoiceName(tts), member.getVoiceSelectionVersion()); return MemberConverter.toTtsUpdateResponse(member); } @@ -60,12 +66,17 @@ private boolean isSameVoice(Member member, Long ttsId) { return current != null && current.getId().equals(ttsId); } - private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) { - if (!ttsEnabled) { - // 처리할 워커가 없으므로 PENDING 으로 돌리면 영구히 멈춘다. 기존 음원을 그대로 둔다. - log.info("[TTS] 기능이 비활성화되어 재합성을 건너뜀. memberId={}", memberId); - return; + 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); diff --git a/src/main/java/com/moru/server/global/response/code/status/ErrorStatus.java b/src/main/java/com/moru/server/global/response/code/status/ErrorStatus.java index d530d17..65d9363 100644 --- a/src/main/java/com/moru/server/global/response/code/status/ErrorStatus.java +++ b/src/main/java/com/moru/server/global/response/code/status/ErrorStatus.java @@ -60,7 +60,8 @@ public enum ErrorStatus implements BaseCode { // TTS 관련 에러 - TTS_NOT_FOUND(HttpStatus.BAD_REQUEST, "TTS4001", "존재하지 않는 목소리입니다."); + TTS_NOT_FOUND(HttpStatus.BAD_REQUEST, "TTS4001", "존재하지 않는 목소리입니다."), + TTS_VOICE_NAME_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, "TTS5001", "목소리 설정이 완료되지 않았습니다. 잠시 후 다시 시도해 주세요."); private final HttpStatus httpStatus; private final String code; From a8dbb7bbc8bfe58057a76484379bbb2e8fd67010 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sun, 16 Aug 2026 02:18:02 +0900 Subject: [PATCH 06/19] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=20=EB=A0=88?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/MemberCommandServiceImpl.java | 17 +++++++++-- .../command/RoutineTTS/TTSAsyncService.java | 30 +++++++++++-------- src/main/resources/application.yml | 4 +++ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java index 53a0ca8..aa1b0fd 100644 --- a/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/member/service/command/member/MemberCommandServiceImpl.java @@ -49,18 +49,31 @@ public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) } member.updateVoiceType(tts); + member.bumpVoiceSelectionVersion(); if (!ttsEnabled) { - log.info("[TTS] 기능이 비활성화되어 재합성을 건너뜀. memberId={}", memberId); + markAllFailed(memberId); return MemberConverter.toTtsUpdateResponse(member); } - member.bumpVoiceSelectionVersion(); requestRegeneration(memberId, resolveVoiceName(tts), member.getVoiceSelectionVersion()); return MemberConverter.toTtsUpdateResponse(member); } + /** + * 재합성을 수행할 워커가 없다. 버전만 올리고 두면 기존 음원이 새 버전인 것처럼 보이므로 + * 생성 경로(RoutineGroupCommandServiceImpl.publishTtsEvent)와 동일하게 FAILED 로 종결한다. + */ + private void markAllFailed(Long memberId) { + List 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); diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java index f6b719d..3907a86 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java @@ -148,22 +148,26 @@ private void regenerate(Long routineTtsId, String voiceName, Long voiceVersion) log.info("[TTS] 재합성 시작. routineTtsId={}, voiceVersion={}", routineTtsId, voiceVersion); - RoutineTTS entity = routineTTSRepository.findById(routineTtsId).orElse(null); - Long memberId = routineTTSRepository.findMemberIdByRoutineTtsId(routineTtsId); - - if (entity == null || memberId == null) { - log.warn("[TTS] 재합성 대상 행이 없어 중단. routineTtsId={}", routineTtsId); - return; - } - if (memberWithdrawalLock.isLocked(memberId)) { - log.info("[TTS] 회원탈퇴 처리 중이므로 재합성을 중단함. routineTtsId={}", routineTtsId); - return; - } - - String previousKey = entity.getS3Url(); + String previousKey = null; String uploadedKey = null; try { + // DB·Redis 조회도 실패할 수 있다. try 밖에 두면 예외가 executor 로 빠져나가 + // markFailedQuietly 가 실행되지 않고, 행이 PENDING 에 갇힌다. + RoutineTTS entity = routineTTSRepository.findById(routineTtsId).orElse(null); + Long memberId = routineTTSRepository.findMemberIdByRoutineTtsId(routineTtsId); + + if (entity == null || memberId == null) { + log.warn("[TTS] 재합성 대상 행이 없어 중단. routineTtsId={}", routineTtsId); + return; + } + if (memberWithdrawalLock.isLocked(memberId)) { + log.info("[TTS] 회원탈퇴 처리 중이므로 재합성을 중단함. routineTtsId={}", routineTtsId); + return; + } + + previousKey = entity.getS3Url(); + String intro = entity.getTtsIntro(); String done = entity.getTtsDone(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa2387f..5a41fac 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,3 +44,7 @@ member: aws: s3: enabled: ${AWS_S3_ENABLED:false} + +google: + tts: + enabled: ${GOOGLE_TTS_ENABLED:false} \ No newline at end of file From 6e9db94147455be3ece1f556be0ffd161b189e13 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sun, 16 Aug 2026 02:26:08 +0900 Subject: [PATCH 07/19] =?UTF-8?q?fix=20:=20CI=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- src/test/resources/application-test.yml | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5a41fac..be7983d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,7 +44,7 @@ member: aws: s3: enabled: ${AWS_S3_ENABLED:false} - + google: tts: enabled: ${GOOGLE_TTS_ENABLED:false} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 1942714..025b513 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -17,3 +17,11 @@ jwt: secret: moru-test-jwt-secret-key-must-be-at-least-32-bytes access-token-expiration: 1h refresh-token-expiration: 14d + +google: + tts: + enabled: false + +aws: + s3: + enabled: false \ No newline at end of file From 18ea50474cb2344e5e691d406bdcfdd9d89c52a8 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sun, 16 Aug 2026 02:28:18 +0900 Subject: [PATCH 08/19] =?UTF-8?q?fix=20:=20=EC=88=98=EC=A0=95=20=EC=9B=90?= =?UTF-8?q?=EC=83=81=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 3 --- src/test/resources/application-test.yml | 8 -------- 2 files changed, 11 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index be7983d..87e3b29 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,6 +45,3 @@ aws: s3: enabled: ${AWS_S3_ENABLED:false} -google: - tts: - enabled: ${GOOGLE_TTS_ENABLED:false} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 025b513..1942714 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -17,11 +17,3 @@ jwt: secret: moru-test-jwt-secret-key-must-be-at-least-32-bytes access-token-expiration: 1h refresh-token-expiration: 14d - -google: - tts: - enabled: false - -aws: - s3: - enabled: false \ No newline at end of file From 438de36a66d9f247d254f36babd9b51f477d5463 Mon Sep 17 00:00:00 2001 From: YoungSun Date: Sun, 16 Aug 2026 14:49:28 +0900 Subject: [PATCH 09/19] =?UTF-8?q?fix=20:=20=EB=A6=AC=EB=B7=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/RoutineTTS/TTSAsyncService.java | 17 +++++++++++++---- .../command/RoutineTTS/TTSAsyncServiceTest.java | 5 +++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java index 3907a86..c35bded 100644 --- a/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java +++ b/src/main/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncService.java @@ -12,8 +12,11 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.TransactionTemplate; import java.util.Objects; import java.util.UUID; @@ -32,6 +35,7 @@ public class TTSAsyncService { private final Executor ttsExecutor; private final Executor ttsRegenerateExecutor; private final MemberWithdrawalLock memberWithdrawalLock; + private final TransactionTemplate newTransaction; private static final String CONTENT_TYPE = "audio/mpeg"; public TTSAsyncService( @@ -40,6 +44,7 @@ public TTSAsyncService( RoutineTTSRepository routineTTSRepository, AiClient aiClient, MemberWithdrawalLock memberWithdrawalLock, + PlatformTransactionManager transactionManager, @Qualifier("ttsExecutor") Executor ttsExecutor, @Qualifier("ttsRegenerateExecutor") Executor ttsRegenerateExecutor ) { @@ -50,6 +55,9 @@ public TTSAsyncService( this.memberWithdrawalLock = memberWithdrawalLock; this.ttsExecutor = ttsExecutor; this.ttsRegenerateExecutor = ttsRegenerateExecutor; + + this.newTransaction = new TransactionTemplate(transactionManager); + this.newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -243,10 +251,11 @@ private void deleteQuietly(String key, Long routineTtsId) { private void markFailedQuietly(Long routineTtsId) { try { - routineTTSRepository.findById(routineTtsId).ifPresent(entity -> { - entity.markFailed(); - routineTTSRepository.save(entity); - }); + newTransaction.executeWithoutResult(status -> + routineTTSRepository.findById(routineTtsId).ifPresent(entity -> { + entity.markFailed(); + routineTTSRepository.save(entity); + })); } catch (Exception e) { log.error("[TTS] 실패 상태 기록조차 실패. routineTtsId={}, exceptionType={}", routineTtsId, diff --git a/src/test/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncServiceTest.java b/src/test/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncServiceTest.java index abc99ff..083e5e4 100644 --- a/src/test/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncServiceTest.java +++ b/src/test/java/com/moru/server/domain/routine/service/command/RoutineTTS/TTSAsyncServiceTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.PlatformTransactionManager; import com.moru.server.domain.member.service.MemberWithdrawalLock; import com.moru.server.domain.routine.entity.RoutineTTS; @@ -38,6 +39,9 @@ class TTSAsyncServiceTest { @Mock private MemberWithdrawalLock memberWithdrawalLock; + @Mock + private PlatformTransactionManager transactionManager; + private TTSAsyncService ttsAsyncService; @BeforeEach @@ -49,6 +53,7 @@ void setUp() { routineTTSRepository, aiClient, memberWithdrawalLock, + transactionManager, directExecutor, directExecutor ); From adf99b75f26d855ebbaa3818b81d68c956e7906c Mon Sep 17 00:00:00 2001 From: issuejong Date: Mon, 17 Aug 2026 20:17:48 +0900 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20Apple=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20identityToken=20=EC=9A=94=EC=B2=AD=20=ED=98=B8?= =?UTF-8?q?=ED=99=98=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/AuthController.java | 4 +- .../domain/member/dto/AuthRequestDTO.java | 4 +- .../member/controller/AuthControllerTest.java | 103 ++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/moru/server/domain/member/controller/AuthControllerTest.java diff --git a/src/main/java/com/moru/server/domain/member/controller/AuthController.java b/src/main/java/com/moru/server/domain/member/controller/AuthController.java index 7ed590c..2675ab7 100644 --- a/src/main/java/com/moru/server/domain/member/controller/AuthController.java +++ b/src/main/java/com/moru/server/domain/member/controller/AuthController.java @@ -39,7 +39,7 @@ public class AuthController { - Authorization 헤더 없이 호출합니다. - kakao: token에 카카오 accessToken을 전달합니다. - google: token에 Google idToken을 전달합니다. - - apple: token에 Apple identityToken을 전달합니다. + - apple: identityToken에 Apple identityToken을 전달합니다. token도 호환됩니다. """; private static final String KAKAO_REQUEST_EXAMPLE = """ { @@ -55,7 +55,7 @@ public class AuthController { """; private static final String APPLE_REQUEST_EXAMPLE = """ { - "token": "apple_identity_token", + "identityToken": "apple_identity_token", "authorizationCode": "apple_authorization_code" } """; diff --git a/src/main/java/com/moru/server/domain/member/dto/AuthRequestDTO.java b/src/main/java/com/moru/server/domain/member/dto/AuthRequestDTO.java index 257eb03..cd6e6b9 100644 --- a/src/main/java/com/moru/server/domain/member/dto/AuthRequestDTO.java +++ b/src/main/java/com/moru/server/domain/member/dto/AuthRequestDTO.java @@ -1,5 +1,6 @@ package com.moru.server.domain.member.dto; +import com.fasterxml.jackson.annotation.JsonAlias; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @@ -8,7 +9,8 @@ public record AuthRequestDTO() { @Schema(description = "소셜 로그인 요청") public record SocialLoginRequest( @NotBlank(message = "소셜 토큰은 필수입니다.") - @Schema(description = "소셜 플랫폼에서 발급받은 토큰. kakao는 accessToken, google은 idToken, apple은 identityToken을 전달합니다.", example = "kakao_access_token") + @JsonAlias("identityToken") + @Schema(description = "소셜 플랫폼에서 발급받은 토큰. kakao는 accessToken, google은 idToken, apple은 token 또는 identityToken을 전달합니다.", example = "kakao_access_token") String token, @Schema(description = "Apple 로그인 필수 인가 코드. kakao/google은 null로 전달합니다.", example = "apple_authorization_code") diff --git a/src/test/java/com/moru/server/domain/member/controller/AuthControllerTest.java b/src/test/java/com/moru/server/domain/member/controller/AuthControllerTest.java new file mode 100644 index 0000000..9b1e374 --- /dev/null +++ b/src/test/java/com/moru/server/domain/member/controller/AuthControllerTest.java @@ -0,0 +1,103 @@ +package com.moru.server.domain.member.controller; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import com.moru.server.domain.member.dto.AuthRequestDTO; +import com.moru.server.domain.member.dto.AuthResponseDTO; +import com.moru.server.domain.member.entity.enums.OAuthProvider; +import com.moru.server.domain.member.service.command.auth.AuthCommandService; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthCommandService authCommandService; + + @Test + void acceptsIdentityTokenForAppleLogin() throws Exception { + AuthRequestDTO.SocialLoginRequest request = + new AuthRequestDTO.SocialLoginRequest("apple-identity-token", "apple-authorization-code"); + when(authCommandService.loginWithSocial(OAuthProvider.APPLE, request)) + .thenReturn(socialLoginResponse()); + + mockMvc.perform(post("/auth/login/apple") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "identityToken": "apple-identity-token", + "authorizationCode": "apple-authorization-code" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.memberId").value(1)); + + verify(authCommandService).loginWithSocial(OAuthProvider.APPLE, request); + } + + @Test + void keepsSupportingTokenForAppleLogin() throws Exception { + AuthRequestDTO.SocialLoginRequest request = + new AuthRequestDTO.SocialLoginRequest("apple-identity-token", "apple-authorization-code"); + when(authCommandService.loginWithSocial(OAuthProvider.APPLE, request)) + .thenReturn(socialLoginResponse()); + + mockMvc.perform(post("/auth/login/apple") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "token": "apple-identity-token", + "authorizationCode": "apple-authorization-code" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + verify(authCommandService).loginWithSocial(OAuthProvider.APPLE, request); + } + + @Test + void rejectsAppleLoginWithoutSocialToken() throws Exception { + mockMvc.perform(post("/auth/login/apple") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "authorizationCode": "apple-authorization-code" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")); + + verifyNoInteractions(authCommandService); + } + + private AuthResponseDTO.SocialLoginResponse socialLoginResponse() { + return AuthResponseDTO.SocialLoginResponse.builder() + .memberId(1L) + .accessToken("access-token") + .refreshToken("refresh-token") + .isNewMember(true) + .onboardingCompleted(false) + .build(); + } +} From 7ac8b66f7fc801eea3d83836fa0fa7ac8cad4495 Mon Sep 17 00:00:00 2001 From: issuejong Date: Mon, 17 Aug 2026 21:04:51 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/OnboardingController.java | 19 +++ .../onboarding/dto/OnboardingRequestDTO.java | 15 +++ .../onboarding/dto/OnboardingResponseDTO.java | 2 +- .../command/OnboardingCommandService.java | 12 ++ .../command/OnboardingCommandServiceImpl.java | 43 +++++++ .../repository/RoutineGroupRepository.java | 2 + .../controller/OnboardingControllerTest.java | 54 +++++++++ .../OnboardingCommandServiceImplTest.java | 111 ++++++++++++++++++ 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/moru/server/domain/onboarding/dto/OnboardingRequestDTO.java create mode 100644 src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandService.java create mode 100644 src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java create mode 100644 src/test/java/com/moru/server/domain/onboarding/controller/OnboardingControllerTest.java create mode 100644 src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java diff --git a/src/main/java/com/moru/server/domain/onboarding/controller/OnboardingController.java b/src/main/java/com/moru/server/domain/onboarding/controller/OnboardingController.java index 041d4e7..17bb4be 100644 --- a/src/main/java/com/moru/server/domain/onboarding/controller/OnboardingController.java +++ b/src/main/java/com/moru/server/domain/onboarding/controller/OnboardingController.java @@ -1,6 +1,8 @@ package com.moru.server.domain.onboarding.controller; +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; +import com.moru.server.domain.onboarding.service.command.OnboardingCommandService; import com.moru.server.domain.onboarding.service.query.OnboardingQueryService; import com.moru.server.domain.routine.dto.RoutineGroupResponseDTO; import com.moru.server.domain.routine.entity.enums.RoutineGoalType; @@ -9,9 +11,12 @@ import com.moru.server.global.security.auth.AuthenticatedMember; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -25,8 +30,22 @@ public class OnboardingController { private final OnboardingQueryService onboardingQueryService; + private final OnboardingCommandService onboardingCommandService; private final RoutineGroupQueryService routineGroupQueryService; + @Operation( + summary = "온보딩 완료", + description = "로그인 후 서버에 동기화된 본인 소유 루틴 그룹을 기준으로 온보딩을 완료합니다." + ) + @PostMapping("/complete") + public ApiResponse completeOnboarding( + @AuthenticationPrincipal AuthenticatedMember member, + @Valid @RequestBody OnboardingRequestDTO.CompleteRequest request + ) { + return ApiResponse.onSuccess( + onboardingCommandService.completeOnboarding(member.memberId(), request)); + } + @Operation(summary = "온보딩 완료 여부 조회", description = "현재 로그인한 사용자의 온보딩 완료 여부를 조회합니다.") @GetMapping("/status") public ApiResponse getOnboardingStatus( diff --git a/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingRequestDTO.java b/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingRequestDTO.java new file mode 100644 index 0000000..d075aff --- /dev/null +++ b/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingRequestDTO.java @@ -0,0 +1,15 @@ +package com.moru.server.domain.onboarding.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record OnboardingRequestDTO() { + + @Schema(description = "온보딩 완료 요청") + public record CompleteRequest( + @NotNull(message = "routineGroupId는 필수입니다.") + @Schema(description = "동기화가 완료된 회원 소유 루틴 그룹 ID", example = "15") + Long routineGroupId + ) { + } +} diff --git a/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingResponseDTO.java b/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingResponseDTO.java index 5ffbe07..5d8cd45 100644 --- a/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingResponseDTO.java +++ b/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingResponseDTO.java @@ -4,7 +4,7 @@ public record OnboardingResponseDTO() { - @Schema(description = "온보딩 완료 여부 조회 응답") + @Schema(description = "온보딩 완료 상태 응답") public record StatusResponse( @Schema(description = "온보딩 완료 여부", example = "false") Boolean onboardingCompleted diff --git a/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandService.java b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandService.java new file mode 100644 index 0000000..2f8009c --- /dev/null +++ b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandService.java @@ -0,0 +1,12 @@ +package com.moru.server.domain.onboarding.service.command; + +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; +import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; + +public interface OnboardingCommandService { + + OnboardingResponseDTO.StatusResponse completeOnboarding( + Long memberId, + OnboardingRequestDTO.CompleteRequest request + ); +} diff --git a/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java new file mode 100644 index 0000000..19e527b --- /dev/null +++ b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java @@ -0,0 +1,43 @@ +package com.moru.server.domain.onboarding.service.command; + +import com.moru.server.domain.member.entity.Member; +import com.moru.server.domain.member.repository.MemberRepository; +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; +import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; +import com.moru.server.domain.routine.repository.RoutineGroupRepository; +import com.moru.server.global.exception.BusinessException; +import com.moru.server.global.response.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class OnboardingCommandServiceImpl implements OnboardingCommandService { + + private final MemberRepository memberRepository; + private final RoutineGroupRepository routineGroupRepository; + + @Override + public OnboardingResponseDTO.StatusResponse completeOnboarding( + Long memberId, + OnboardingRequestDTO.CompleteRequest request + ) { + Member member = memberRepository.findByIdForUpdate(memberId) + .orElseThrow(() -> new BusinessException(ErrorStatus.MEMBER_NOT_FOUND)); + + if (Boolean.TRUE.equals(member.getOnboardingCompleted())) { + return new OnboardingResponseDTO.StatusResponse(true); + } + + boolean ownsRoutineGroup = routineGroupRepository + .existsByIdAndMember_Id(request.routineGroupId(), memberId); + if (!ownsRoutineGroup) { + throw new BusinessException(ErrorStatus.ROUTINE_GROUP_NOT_FOUND); + } + + member.completeOnboarding(); + return new OnboardingResponseDTO.StatusResponse(true); + } +} diff --git a/src/main/java/com/moru/server/domain/routine/repository/RoutineGroupRepository.java b/src/main/java/com/moru/server/domain/routine/repository/RoutineGroupRepository.java index bb9b209..3b23c18 100644 --- a/src/main/java/com/moru/server/domain/routine/repository/RoutineGroupRepository.java +++ b/src/main/java/com/moru/server/domain/routine/repository/RoutineGroupRepository.java @@ -13,6 +13,8 @@ public interface RoutineGroupRepository extends JpaRepository { + boolean existsByIdAndMember_Id(Long routineGroupId, Long memberId); + @Query(""" select distinct rg from RoutineGroup rg left join fetch rg.routines diff --git a/src/test/java/com/moru/server/domain/onboarding/controller/OnboardingControllerTest.java b/src/test/java/com/moru/server/domain/onboarding/controller/OnboardingControllerTest.java new file mode 100644 index 0000000..a9306f1 --- /dev/null +++ b/src/test/java/com/moru/server/domain/onboarding/controller/OnboardingControllerTest.java @@ -0,0 +1,54 @@ +package com.moru.server.domain.onboarding.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moru.server.domain.member.entity.enums.Role; +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; +import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; +import com.moru.server.domain.onboarding.service.command.OnboardingCommandService; +import com.moru.server.domain.onboarding.service.query.OnboardingQueryService; +import com.moru.server.domain.routine.service.query.RoutineGroup.RoutineGroupQueryService; +import com.moru.server.global.response.ApiResponse; +import com.moru.server.global.security.auth.AuthenticatedMember; + +@ExtendWith(MockitoExtension.class) +class OnboardingControllerTest { + + @Mock + private OnboardingQueryService onboardingQueryService; + + @Mock + private OnboardingCommandService onboardingCommandService; + + @Mock + private RoutineGroupQueryService routineGroupQueryService; + + @InjectMocks + private OnboardingController onboardingController; + + @Test + void delegatesOnboardingCompletionToCommandService() { + AuthenticatedMember member = new AuthenticatedMember(1L, Role.MEMBER); + OnboardingRequestDTO.CompleteRequest request = + new OnboardingRequestDTO.CompleteRequest(15L); + OnboardingResponseDTO.StatusResponse serviceResponse = + new OnboardingResponseDTO.StatusResponse(true); + when(onboardingCommandService.completeOnboarding(1L, request)) + .thenReturn(serviceResponse); + + ApiResponse response = + onboardingController.completeOnboarding(member, request); + + assertThat(response.getIsSuccess()).isTrue(); + assertThat(response.getResult().onboardingCompleted()).isTrue(); + verify(onboardingCommandService).completeOnboarding(1L, request); + } +} diff --git a/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java new file mode 100644 index 0000000..56d7f64 --- /dev/null +++ b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java @@ -0,0 +1,111 @@ +package com.moru.server.domain.onboarding.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moru.server.domain.member.entity.Member; +import com.moru.server.domain.member.entity.enums.LoginType; +import com.moru.server.domain.member.entity.enums.Role; +import com.moru.server.domain.member.repository.MemberRepository; +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; +import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; +import com.moru.server.domain.routine.repository.RoutineGroupRepository; +import com.moru.server.global.exception.BusinessException; +import com.moru.server.global.response.code.status.ErrorStatus; + +@ExtendWith(MockitoExtension.class) +class OnboardingCommandServiceImplTest { + + private static final Long MEMBER_ID = 1L; + private static final Long ROUTINE_GROUP_ID = 15L; + + @Mock + private MemberRepository memberRepository; + + @Mock + private RoutineGroupRepository routineGroupRepository; + + @InjectMocks + private OnboardingCommandServiceImpl onboardingCommandService; + + @Test + void completesOnboardingWhenMemberOwnsRoutineGroup() { + Member member = createMember(false); + OnboardingRequestDTO.CompleteRequest request = createRequest(); + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + when(routineGroupRepository.existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID)) + .thenReturn(true); + + OnboardingResponseDTO.StatusResponse response = + onboardingCommandService.completeOnboarding(MEMBER_ID, request); + + assertThat(response.onboardingCompleted()).isTrue(); + assertThat(member.getOnboardingCompleted()).isTrue(); + } + + @Test + void returnsSuccessWhenOnboardingIsAlreadyCompleted() { + Member member = createMember(true); + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + + OnboardingResponseDTO.StatusResponse response = + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest()); + + assertThat(response.onboardingCompleted()).isTrue(); + verify(routineGroupRepository, never()) + .existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID); + } + + @Test + void rejectsRoutineGroupNotOwnedByMember() { + Member member = createMember(false); + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + when(routineGroupRepository.existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID)) + .thenReturn(false); + + assertThatThrownBy(() -> + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest())) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getBaseCode()) + .isEqualTo(ErrorStatus.ROUTINE_GROUP_NOT_FOUND)); + + assertThat(member.getOnboardingCompleted()).isFalse(); + } + + @Test + void rejectsMissingMember() { + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest())) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getBaseCode()) + .isEqualTo(ErrorStatus.MEMBER_NOT_FOUND)); + } + + private OnboardingRequestDTO.CompleteRequest createRequest() { + return new OnboardingRequestDTO.CompleteRequest(ROUTINE_GROUP_ID); + } + + private Member createMember(boolean onboardingCompleted) { + return Member.builder() + .id(MEMBER_ID) + .oauthId("google-member-id") + .nickname("모루") + .role(Role.MEMBER) + .loginType(LoginType.GOOGLE) + .onboardingCompleted(onboardingCompleted) + .build(); + } +} From 8abb0567ec2f8c92a8a320c2481c8338fd864f2d Mon Sep 17 00:00:00 2001 From: issuejong Date: Mon, 17 Aug 2026 21:29:58 +0900 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=EB=A3=A8=ED=8B=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/OnboardingCommandServiceImpl.java | 8 ++++---- .../OnboardingCommandServiceImplTest.java | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java index 19e527b..91cdebd 100644 --- a/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java +++ b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java @@ -27,16 +27,16 @@ public OnboardingResponseDTO.StatusResponse completeOnboarding( Member member = memberRepository.findByIdForUpdate(memberId) .orElseThrow(() -> new BusinessException(ErrorStatus.MEMBER_NOT_FOUND)); - if (Boolean.TRUE.equals(member.getOnboardingCompleted())) { - return new OnboardingResponseDTO.StatusResponse(true); - } - boolean ownsRoutineGroup = routineGroupRepository .existsByIdAndMember_Id(request.routineGroupId(), memberId); if (!ownsRoutineGroup) { throw new BusinessException(ErrorStatus.ROUTINE_GROUP_NOT_FOUND); } + if (Boolean.TRUE.equals(member.getOnboardingCompleted())) { + return new OnboardingResponseDTO.StatusResponse(true); + } + member.completeOnboarding(); return new OnboardingResponseDTO.StatusResponse(true); } diff --git a/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java index 56d7f64..3c363c4 100644 --- a/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java +++ b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -58,15 +57,31 @@ void completesOnboardingWhenMemberOwnsRoutineGroup() { void returnsSuccessWhenOnboardingIsAlreadyCompleted() { Member member = createMember(true); when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + when(routineGroupRepository.existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID)) + .thenReturn(true); OnboardingResponseDTO.StatusResponse response = onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest()); assertThat(response.onboardingCompleted()).isTrue(); - verify(routineGroupRepository, never()) + verify(routineGroupRepository) .existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID); } + @Test + void rejectsUnownedRoutineGroupEvenWhenOnboardingIsAlreadyCompleted() { + Member member = createMember(true); + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + when(routineGroupRepository.existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID)) + .thenReturn(false); + + assertThatThrownBy(() -> + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest())) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getBaseCode()) + .isEqualTo(ErrorStatus.ROUTINE_GROUP_NOT_FOUND)); + } + @Test void rejectsRoutineGroupNotOwnedByMember() { Member member = createMember(false); From 2eae1670680931b427e191573a02fa7773f6b442 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 18 Aug 2026 20:35:37 +0900 Subject: [PATCH 13/19] =?UTF-8?q?build:=20TTS=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=A3=EA=B8=B0=20=EC=9D=8C=EC=9B=90=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + build.gradle | 2 ++ docker-compose.prod.yml | 1 + src/main/resources/application.yml | 4 +++ .../V1__add_tts_preview_audio_key.sql | 30 +++++++++++++++++++ 5 files changed, 38 insertions(+) create mode 100644 src/main/resources/db/migration/V1__add_tts_preview_audio_key.sql diff --git a/.env.example b/.env.example index 6e977d4..21dfa11 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ DB_URL=jdbc:mysql://your-db-host:3306/moru?serverTimezone=Asia/Seoul&characterEn DB_USERNAME= DB_PASSWORD= JPA_DDL_AUTO=validate +FLYWAY_ENABLED=true JWT_SECRET= JWT_ACCESS_TOKEN_EXPIRATION=1h diff --git a/build.gradle b/build.gradle index 60bad8b..1ca479a 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-flyway' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-security' @@ -43,6 +44,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'org.flywaydb:flyway-mysql' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b96612b..4b46afe 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -26,6 +26,7 @@ services: SPRING_JPA_HIBERNATE_DDL_AUTO: ${JPA_DDL_AUTO:-validate} SPRING_JPA_OPEN_IN_VIEW: "false" SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL: "false" + SPRING_FLYWAY_ENABLED: ${FLYWAY_ENABLED:-true} LOGGING_LEVEL_ORG_HIBERNATE_SQL: info LOGGING_LEVEL_ORG_HIBERNATE_ORM_JDBC_BIND: off diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5a650d7..04a3e57 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,10 @@ spring: import: optional:file:.env[.properties] application: name: moru-server + flyway: + enabled: ${FLYWAY_ENABLED:false} + baseline-on-migrate: true + baseline-version: 0 jpa: properties: hibernate: diff --git a/src/main/resources/db/migration/V1__add_tts_preview_audio_key.sql b/src/main/resources/db/migration/V1__add_tts_preview_audio_key.sql new file mode 100644 index 0000000..08f609f --- /dev/null +++ b/src/main/resources/db/migration/V1__add_tts_preview_audio_key.sql @@ -0,0 +1,30 @@ +SET @preview_audio_key_exists = ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tts' + AND column_name = 'preview_audio_key' +); + +SET @add_preview_audio_key_sql = IF( + @preview_audio_key_exists = 0, + 'ALTER TABLE `tts` ADD COLUMN `preview_audio_key` VARCHAR(500) NULL', + 'SELECT 1' +); + +PREPARE add_preview_audio_key_statement FROM @add_preview_audio_key_sql; +EXECUTE add_preview_audio_key_statement; +DEALLOCATE PREPARE add_preview_audio_key_statement; + +UPDATE `tts` +SET `preview_audio_key` = CASE `name` + WHEN 'Leda' THEN 'tts/previews/v1/leda.mp3' + WHEN 'Kore' THEN 'tts/previews/v1/kore.mp3' + WHEN 'Despina' THEN 'tts/previews/v1/despina.mp3' + WHEN 'Charon' THEN 'tts/previews/v1/charon.mp3' + WHEN 'Orus' THEN 'tts/previews/v1/orus.mp3' + WHEN 'Alnilam' THEN 'tts/previews/v1/alnilam.mp3' + ELSE `preview_audio_key` +END +WHERE (`preview_audio_key` IS NULL OR `preview_audio_key` = '') + AND `name` IN ('Leda', 'Kore', 'Despina', 'Charon', 'Orus', 'Alnilam'); From 22608d22ddbd6d1180c122822d2f13f2c6d5adb9 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 18 Aug 2026 20:36:56 +0900 Subject: [PATCH 14/19] =?UTF-8?q?feat:=20TTS=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=8C=EC=9B=90=20=EC=9D=91=EB=8B=B5=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/tts/dto/TTSResponseDTO.java | 28 +++++++++++++++++++ .../moru/server/domain/tts/entity/TTS.java | 10 +++++++ .../tts/entity/enums/TtsAudioStatus.java | 6 ++++ 3 files changed, 44 insertions(+) create mode 100644 src/main/java/com/moru/server/domain/tts/entity/enums/TtsAudioStatus.java diff --git a/src/main/java/com/moru/server/domain/tts/dto/TTSResponseDTO.java b/src/main/java/com/moru/server/domain/tts/dto/TTSResponseDTO.java index 4203367..1ca7fdc 100644 --- a/src/main/java/com/moru/server/domain/tts/dto/TTSResponseDTO.java +++ b/src/main/java/com/moru/server/domain/tts/dto/TTSResponseDTO.java @@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; +import com.moru.server.domain.tts.entity.enums.TtsAudioStatus; + public record TTSResponseDTO() { @Builder @@ -36,6 +38,32 @@ public record VoiceResponse( ) String previewAudioUrl, + @Schema(description = "목소리 미리듣기 음원 생성 상태", example = "READY") + TtsAudioStatus previewAudioStatus, + + @Schema( + description = "루틴 완료 공통 음원 URL", + example = "https://moru-prod-preview-assets.s3.ap-northeast-2.amazonaws.com/tts/common/v1/leda/done.mp3", + nullable = true + ) + String doneAudioUrl, + + @Schema(description = "루틴 완료 공통 음원 생성 상태", example = "READY") + TtsAudioStatus doneAudioStatus, + + @Schema( + description = "루틴 리마인드 공통 음원 URL", + example = "https://moru-prod-preview-assets.s3.ap-northeast-2.amazonaws.com/tts/common/v1/leda/remind.mp3", + nullable = true + ) + String remindAudioUrl, + + @Schema(description = "루틴 리마인드 공통 음원 생성 상태", example = "READY") + TtsAudioStatus remindAudioStatus, + + @Schema(description = "음성 공통 음원 캐시 버전", example = "1") + Integer selectionVersion, + @Schema(description = "PRO 전용 여부", example = "false") Boolean proOnly ) { diff --git a/src/main/java/com/moru/server/domain/tts/entity/TTS.java b/src/main/java/com/moru/server/domain/tts/entity/TTS.java index b8d5e57..ac4899e 100644 --- a/src/main/java/com/moru/server/domain/tts/entity/TTS.java +++ b/src/main/java/com/moru/server/domain/tts/entity/TTS.java @@ -31,6 +31,16 @@ public class TTS extends BaseEntity { @Column(name = "preview_audio_key", length = 500) private String previewAudioKey; + @Column(name = "done_audio_key", length = 500) + private String doneAudioKey; + + @Column(name = "remind_audio_key", length = 500) + private String remindAudioKey; + + @Column(name = "selection_version", nullable = false) + @Builder.Default + private Integer selectionVersion = 1; + @Column(name = "is_pro_only", nullable = false) @Builder.Default private Boolean isProOnly = false; diff --git a/src/main/java/com/moru/server/domain/tts/entity/enums/TtsAudioStatus.java b/src/main/java/com/moru/server/domain/tts/entity/enums/TtsAudioStatus.java new file mode 100644 index 0000000..b7a6e19 --- /dev/null +++ b/src/main/java/com/moru/server/domain/tts/entity/enums/TtsAudioStatus.java @@ -0,0 +1,6 @@ +package com.moru.server.domain.tts.entity.enums; + +public enum TtsAudioStatus { + PENDING, + READY +} From 84ab81a212831861634fad01e841719dd8e37962 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 18 Aug 2026 20:38:15 +0900 Subject: [PATCH 15/19] =?UTF-8?q?feat:=20TTS=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=8C=EC=9B=90=20=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V2__add_tts_common_audio_keys.sql | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/main/resources/db/migration/V2__add_tts_common_audio_keys.sql diff --git a/src/main/resources/db/migration/V2__add_tts_common_audio_keys.sql b/src/main/resources/db/migration/V2__add_tts_common_audio_keys.sql new file mode 100644 index 0000000..f16d781 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_tts_common_audio_keys.sql @@ -0,0 +1,81 @@ +SET @done_audio_key_exists = ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tts' + AND column_name = 'done_audio_key' +); + +SET @add_done_audio_key_sql = IF( + @done_audio_key_exists = 0, + 'ALTER TABLE `tts` ADD COLUMN `done_audio_key` VARCHAR(500) NULL', + 'SELECT 1' +); + +PREPARE add_done_audio_key_statement FROM @add_done_audio_key_sql; +EXECUTE add_done_audio_key_statement; +DEALLOCATE PREPARE add_done_audio_key_statement; + +SET @remind_audio_key_exists = ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tts' + AND column_name = 'remind_audio_key' +); + +SET @add_remind_audio_key_sql = IF( + @remind_audio_key_exists = 0, + 'ALTER TABLE `tts` ADD COLUMN `remind_audio_key` VARCHAR(500) NULL', + 'SELECT 1' +); + +PREPARE add_remind_audio_key_statement FROM @add_remind_audio_key_sql; +EXECUTE add_remind_audio_key_statement; +DEALLOCATE PREPARE add_remind_audio_key_statement; + +SET @selection_version_exists = ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tts' + AND column_name = 'selection_version' +); + +SET @add_selection_version_sql = IF( + @selection_version_exists = 0, + 'ALTER TABLE `tts` ADD COLUMN `selection_version` INT NOT NULL DEFAULT 1', + 'SELECT 1' +); + +PREPARE add_selection_version_statement FROM @add_selection_version_sql; +EXECUTE add_selection_version_statement; +DEALLOCATE PREPARE add_selection_version_statement; + +UPDATE `tts` +SET + `done_audio_key` = CASE + WHEN `done_audio_key` IS NULL OR `done_audio_key` = '' THEN CASE `name` + WHEN 'Leda' THEN 'tts/common/v1/leda-done.mp3' + WHEN 'Kore' THEN 'tts/common/v1/kore-done.mp3' + WHEN 'Despina' THEN 'tts/common/v1/despina-done.mp3' + WHEN 'Charon' THEN 'tts/common/v1/charon-done.mp3' + WHEN 'Orus' THEN 'tts/common/v1/orus-done.mp3' + WHEN 'Alnilam' THEN 'tts/common/v1/alnilam-done.mp3' + ELSE `done_audio_key` + END + ELSE `done_audio_key` + END, + `remind_audio_key` = CASE + WHEN `remind_audio_key` IS NULL OR `remind_audio_key` = '' THEN CASE `name` + WHEN 'Leda' THEN 'tts/common/v1/leda-remind.mp3' + WHEN 'Kore' THEN 'tts/common/v1/kore-remind.mp3' + WHEN 'Despina' THEN 'tts/common/v1/despina-remind.mp3' + WHEN 'Charon' THEN 'tts/common/v1/charon-remind.mp3' + WHEN 'Orus' THEN 'tts/common/v1/orus-remind.mp3' + WHEN 'Alnilam' THEN 'tts/common/v1/alnilam-remind.mp3' + ELSE `remind_audio_key` + END + ELSE `remind_audio_key` + END +WHERE `name` IN ('Leda', 'Kore', 'Despina', 'Charon', 'Orus', 'Alnilam'); From f0857bdd7f45164be4448c233ae2d035a55c2b76 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 18 Aug 2026 20:38:57 +0900 Subject: [PATCH 16/19] =?UTF-8?q?feat:=20TTS=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=8C=EC=9B=90=20URL=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/tts/converter/TTSConverter.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/moru/server/domain/tts/converter/TTSConverter.java b/src/main/java/com/moru/server/domain/tts/converter/TTSConverter.java index 6f62a1c..9fc172b 100644 --- a/src/main/java/com/moru/server/domain/tts/converter/TTSConverter.java +++ b/src/main/java/com/moru/server/domain/tts/converter/TTSConverter.java @@ -6,6 +6,7 @@ import com.moru.server.domain.tts.dto.TTSResponseDTO; import com.moru.server.domain.tts.entity.TTS; +import com.moru.server.domain.tts.entity.enums.TtsAudioStatus; public class TTSConverter { @@ -30,10 +31,22 @@ public static TTSResponseDTO.VoiceResponse toVoiceResponse( .displayName(voice.getLabel()) .description(voice.getDescription()) .previewAudioUrl(resolvePublicAssetUrl(publicAssetBaseUrl, voice.getPreviewAudioKey())) + .previewAudioStatus(resolveAudioStatus(voice.getPreviewAudioKey())) + .doneAudioUrl(resolvePublicAssetUrl(publicAssetBaseUrl, voice.getDoneAudioKey())) + .doneAudioStatus(resolveAudioStatus(voice.getDoneAudioKey())) + .remindAudioUrl(resolvePublicAssetUrl(publicAssetBaseUrl, voice.getRemindAudioKey())) + .remindAudioStatus(resolveAudioStatus(voice.getRemindAudioKey())) + .selectionVersion(voice.getSelectionVersion()) .proOnly(voice.getIsProOnly()) .build(); } + private static TtsAudioStatus resolveAudioStatus(String objectKey) { + return StringUtils.hasText(objectKey) + ? TtsAudioStatus.READY + : TtsAudioStatus.PENDING; + } + private static String resolvePublicAssetUrl(String baseUrl, String objectKey) { if (!StringUtils.hasText(baseUrl) || !StringUtils.hasText(objectKey)) { return null; From 140d0b0858acfc63ab4ed51b01e8cf7757c52bf9 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 18 Aug 2026 20:40:26 +0900 Subject: [PATCH 17/19] =?UTF-8?q?test:=20TTS=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=8C=EC=9B=90=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tts/converter/TTSConverterTest.java | 69 +++++++++++++++---- .../query/TTSQueryServiceImplTest.java | 40 +++++++++-- 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/test/java/com/moru/server/domain/tts/converter/TTSConverterTest.java b/src/test/java/com/moru/server/domain/tts/converter/TTSConverterTest.java index f986f01..17853f7 100644 --- a/src/test/java/com/moru/server/domain/tts/converter/TTSConverterTest.java +++ b/src/test/java/com/moru/server/domain/tts/converter/TTSConverterTest.java @@ -8,6 +8,7 @@ import com.moru.server.domain.tts.dto.TTSResponseDTO; import com.moru.server.domain.tts.entity.TTS; +import com.moru.server.domain.tts.entity.enums.TtsAudioStatus; class TTSConverterTest { @@ -15,8 +16,15 @@ class TTSConverterTest { "https://moru-prod-preview-assets.s3.ap-northeast-2.amazonaws.com"; @Test - void convertsPreviewAudioKeyToPublicUrl() { - TTS voice = createVoice(1L, "Leda", "tts/previews/v1/leda.mp3"); + void convertsAudioKeysToPublicUrls() { + TTS voice = createVoice( + 1L, + "Leda", + "tts/previews/v1/leda.mp3", + "tts/common/v1/leda-done.mp3", + "tts/common/v1/leda-remind.mp3", + 2 + ); TTSResponseDTO.VoiceListResponse response = TTSConverter.toVoiceListResponse( List.of(voice), @@ -29,44 +37,81 @@ void convertsPreviewAudioKeyToPublicUrl() { assertThat(result.previewAudioUrl()).isEqualTo( PUBLIC_ASSET_BASE_URL + "/tts/previews/v1/leda.mp3" ); + assertThat(result.previewAudioStatus()).isEqualTo(TtsAudioStatus.READY); + assertThat(result.doneAudioUrl()).isEqualTo( + PUBLIC_ASSET_BASE_URL + "/tts/common/v1/leda-done.mp3" + ); + assertThat(result.doneAudioStatus()).isEqualTo(TtsAudioStatus.READY); + assertThat(result.remindAudioUrl()).isEqualTo( + PUBLIC_ASSET_BASE_URL + "/tts/common/v1/leda-remind.mp3" + ); + assertThat(result.remindAudioStatus()).isEqualTo(TtsAudioStatus.READY); + assertThat(result.selectionVersion()).isEqualTo(2); }); } @Test - void returnsNullPreviewUrlWhenObjectKeyIsMissing() { - TTS voice = createVoice(1L, "Leda", null); + void returnsPendingStatusAndNullUrlsWhenAudioKeysAreMissing() { + TTS voice = createVoice(1L, "Leda", null, null, null, 1); TTSResponseDTO.VoiceListResponse response = TTSConverter.toVoiceListResponse( List.of(voice), PUBLIC_ASSET_BASE_URL ); - assertThat(response.voices()).singleElement().satisfies(result -> - assertThat(result.previewAudioUrl()).isNull() - ); + assertThat(response.voices()).singleElement().satisfies(result -> { + assertThat(result.previewAudioUrl()).isNull(); + assertThat(result.previewAudioStatus()).isEqualTo(TtsAudioStatus.PENDING); + assertThat(result.doneAudioUrl()).isNull(); + assertThat(result.doneAudioStatus()).isEqualTo(TtsAudioStatus.PENDING); + assertThat(result.remindAudioUrl()).isNull(); + assertThat(result.remindAudioStatus()).isEqualTo(TtsAudioStatus.PENDING); + }); } @Test void returnsNullPreviewUrlWhenBaseUrlIsMissing() { - TTS voice = createVoice(1L, "Leda", "tts/previews/v1/leda.mp3"); + TTS voice = createVoice( + 1L, + "Leda", + "tts/previews/v1/leda.mp3", + "tts/common/v1/leda-done.mp3", + "tts/common/v1/leda-remind.mp3", + 1 + ); TTSResponseDTO.VoiceListResponse response = TTSConverter.toVoiceListResponse( List.of(voice), "" ); - assertThat(response.voices()).singleElement().satisfies(result -> - assertThat(result.previewAudioUrl()).isNull() - ); + assertThat(response.voices()).singleElement().satisfies(result -> { + assertThat(result.previewAudioUrl()).isNull(); + assertThat(result.doneAudioUrl()).isNull(); + assertThat(result.remindAudioUrl()).isNull(); + assertThat(result.previewAudioStatus()).isEqualTo(TtsAudioStatus.READY); + assertThat(result.doneAudioStatus()).isEqualTo(TtsAudioStatus.READY); + assertThat(result.remindAudioStatus()).isEqualTo(TtsAudioStatus.READY); + }); } - private TTS createVoice(Long id, String name, String previewAudioKey) { + private TTS createVoice( + Long id, + String name, + String previewAudioKey, + String doneAudioKey, + String remindAudioKey, + Integer selectionVersion + ) { return TTS.builder() .id(id) .name(name) .label(name) .description("미리듣기 테스트 음성") .previewAudioKey(previewAudioKey) + .doneAudioKey(doneAudioKey) + .remindAudioKey(remindAudioKey) + .selectionVersion(selectionVersion) .isProOnly(false) .build(); } diff --git a/src/test/java/com/moru/server/domain/tts/service/query/TTSQueryServiceImplTest.java b/src/test/java/com/moru/server/domain/tts/service/query/TTSQueryServiceImplTest.java index 58058a2..6ac1f2d 100644 --- a/src/test/java/com/moru/server/domain/tts/service/query/TTSQueryServiceImplTest.java +++ b/src/test/java/com/moru/server/domain/tts/service/query/TTSQueryServiceImplTest.java @@ -14,6 +14,7 @@ import com.moru.server.domain.tts.dto.TTSResponseDTO; import com.moru.server.domain.tts.entity.TTS; +import com.moru.server.domain.tts.entity.enums.TtsAudioStatus; import com.moru.server.domain.tts.repository.TTSRepository; import com.moru.server.global.config.AssetProperties; @@ -33,9 +34,23 @@ void setUp() { } @Test - void returnsVoicesInRepositoryOrderWithPreviewUrls() { - TTS leda = createVoice(1L, "Leda", "tts/previews/v1/leda.mp3"); - TTS kore = createVoice(2L, "Kore", "tts/previews/v1/kore.mp3"); + void returnsVoicesInRepositoryOrderWithAudioUrls() { + TTS leda = createVoice( + 1L, + "Leda", + "tts/previews/v1/leda.mp3", + "tts/common/v1/leda-done.mp3", + "tts/common/v1/leda-remind.mp3", + 1 + ); + TTS kore = createVoice( + 2L, + "Kore", + "tts/previews/v1/kore.mp3", + "tts/common/v1/kore-done.mp3", + "tts/common/v1/kore-remind.mp3", + 2 + ); when(ttsRepository.findAllByOrderByIdAsc()).thenReturn(List.of(leda, kore)); TTSResponseDTO.VoiceListResponse response = ttsQueryService.getVoices(); @@ -49,16 +64,33 @@ void returnsVoicesInRepositoryOrderWithPreviewUrls() { "https://assets.example.com/tts/previews/v1/leda.mp3", "https://assets.example.com/tts/previews/v1/kore.mp3" ); + assertThat(response.voices()).allSatisfy(voice -> { + assertThat(voice.doneAudioStatus()).isEqualTo(TtsAudioStatus.READY); + assertThat(voice.remindAudioStatus()).isEqualTo(TtsAudioStatus.READY); + }); + assertThat(response.voices()) + .extracting(TTSResponseDTO.VoiceResponse::selectionVersion) + .containsExactly(1, 2); verify(ttsRepository).findAllByOrderByIdAsc(); } - private TTS createVoice(Long id, String name, String previewAudioKey) { + private TTS createVoice( + Long id, + String name, + String previewAudioKey, + String doneAudioKey, + String remindAudioKey, + Integer selectionVersion + ) { return TTS.builder() .id(id) .name(name) .label(name) .description("미리듣기 테스트 음성") .previewAudioKey(previewAudioKey) + .doneAudioKey(doneAudioKey) + .remindAudioKey(remindAudioKey) + .selectionVersion(selectionVersion) .isProOnly(false) .build(); } From 05e8db25fa3c5c2d4e3ea4d094f77c721dda7dc1 Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 18 Aug 2026 21:44:06 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix:=20=EB=B9=88=20DB=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- .../db/migration/V1__initialize_schema.sql | 147 ++++++++++++++++++ ....sql => V2__add_tts_preview_audio_key.sql} | 0 ....sql => V3__add_tts_common_audio_keys.sql} | 0 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V1__initialize_schema.sql rename src/main/resources/db/migration/{V1__add_tts_preview_audio_key.sql => V2__add_tts_preview_audio_key.sql} (100%) rename src/main/resources/db/migration/{V2__add_tts_common_audio_keys.sql => V3__add_tts_common_audio_keys.sql} (100%) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 04a3e57..ce20f36 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,7 +6,7 @@ spring: flyway: enabled: ${FLYWAY_ENABLED:false} baseline-on-migrate: true - baseline-version: 0 + baseline-version: 1 jpa: properties: hibernate: diff --git a/src/main/resources/db/migration/V1__initialize_schema.sql b/src/main/resources/db/migration/V1__initialize_schema.sql new file mode 100644 index 0000000..e68de29 --- /dev/null +++ b/src/main/resources/db/migration/V1__initialize_schema.sql @@ -0,0 +1,147 @@ +CREATE TABLE IF NOT EXISTS `tts` ( + `is_pro_only` BIT(1) NOT NULL, + `selection_version` INT NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `updated_at` DATETIME(6) NOT NULL, + `name` VARCHAR(50) NOT NULL, + `description` VARCHAR(100) NULL, + `google_voice_name` VARCHAR(100) NULL, + `label` VARCHAR(100) NOT NULL, + `done_audio_key` VARCHAR(500) NULL, + `preview_audio_key` VARCHAR(500) NULL, + `remind_audio_key` VARCHAR(500) NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `members` ( + `onboarding_completed` BIT(1) NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `tts_id` BIGINT NULL, + `updated_at` DATETIME(6) NOT NULL, + `nickname` VARCHAR(50) NULL, + `profile_image_key` VARCHAR(500) NULL, + `oauth_id` VARCHAR(255) NOT NULL, + `login_type` ENUM('APPLE', 'GOOGLE', 'KAKAO', 'NAVER') NOT NULL, + `role` ENUM('ADMIN', 'MEMBER') NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_member_login_type_oauth_id` (`login_type`, `oauth_id`), + KEY `fk_members_tts` (`tts_id`), + CONSTRAINT `fk_members_tts` FOREIGN KEY (`tts_id`) REFERENCES `tts` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `terms` ( + `is_required` BIT(1) NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `updated_at` DATETIME(6) NOT NULL, + `title` VARCHAR(100) NOT NULL, + `content` TEXT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `routine_group` ( + `alarm_time` TIME NULL, + `is_active` BIT(1) NOT NULL, + `is_template` BIT(1) NOT NULL, + `weather_notification_enabled` BIT(1) NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `member_id` BIGINT NULL, + `updated_at` DATETIME(6) NOT NULL, + `alarm_days` VARCHAR(100) NULL, + `description` VARCHAR(100) NULL, + `title` VARCHAR(100) NOT NULL, + `goal_type` ENUM('HABIT', 'HEALTH', 'STABILITY', 'VITALITY') NULL, + PRIMARY KEY (`id`), + KEY `fk_routine_group_member` (`member_id`), + CONSTRAINT `fk_routine_group_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `routine` ( + `order_index` INT NOT NULL, + `timer` INT NULL, + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `routine_group_id` BIGINT NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + `title` VARCHAR(100) NOT NULL, + `type` ENUM('CHECK', 'INPUT', 'TIMER') NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_routine_routine_group` (`routine_group_id`), + CONSTRAINT `fk_routine_routine_group` FOREIGN KEY (`routine_group_id`) REFERENCES `routine_group` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `routine_execution` ( + `actual_wake_time` TIME NULL, + `duration_second` INT NULL, + `executed_date` DATE NOT NULL, + `is_completed` BIT(1) NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `routine_id` BIGINT NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + `ai_response` VARCHAR(500) NULL, + `member_input` VARCHAR(500) NULL, + PRIMARY KEY (`id`), + KEY `fk_routine_execution_routine` (`routine_id`), + CONSTRAINT `fk_routine_execution_routine` FOREIGN KEY (`routine_id`) REFERENCES `routine` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `routine_tts` ( + `order_index` INT NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `routine_id` BIGINT NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + `content` VARCHAR(255) NOT NULL, + `s3_url` VARCHAR(255) NULL, + `tts_done` VARCHAR(255) NULL, + `tts_intro` VARCHAR(255) NULL, + `tts_status` ENUM('COMPLETED', 'FAILED', 'PENDING') NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_routine_tts_routine` (`routine_id`), + CONSTRAINT `fk_routine_tts_routine` FOREIGN KEY (`routine_id`) REFERENCES `routine` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `member_term` ( + `is_agreed` BIT(1) NOT NULL, + `agreed_at` DATETIME(6) NOT NULL, + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `member_id` BIGINT NOT NULL, + `term_id` BIGINT NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_member_term` (`member_id`, `term_id`), + KEY `fk_member_term_term` (`term_id`), + CONSTRAINT `fk_member_term_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`), + CONSTRAINT `fk_member_term_term` FOREIGN KEY (`term_id`) REFERENCES `terms` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `subscriptions` ( + `created_at` DATETIME(6) NOT NULL, + `expires_at` DATETIME(6) NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `member_id` BIGINT NOT NULL, + `started_at` DATETIME(6) NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + `store_transaction_id` VARCHAR(255) NULL, + `plan` ENUM('FREE', 'PRO') NOT NULL, + `store` ENUM('APP_STORE', 'GOOGLE_PLAYSTORE') NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_subscriptions_member` (`member_id`), + CONSTRAINT `fk_subscriptions_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `apple_oauth_credentials` ( + `created_at` DATETIME(6) NOT NULL, + `id` BIGINT NOT NULL AUTO_INCREMENT, + `member_id` BIGINT NOT NULL, + `updated_at` DATETIME(6) NOT NULL, + `encrypted_refresh_token` TEXT NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_apple_oauth_credentials_member` (`member_id`), + CONSTRAINT `fk_apple_oauth_credentials_member` FOREIGN KEY (`member_id`) REFERENCES `members` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/main/resources/db/migration/V1__add_tts_preview_audio_key.sql b/src/main/resources/db/migration/V2__add_tts_preview_audio_key.sql similarity index 100% rename from src/main/resources/db/migration/V1__add_tts_preview_audio_key.sql rename to src/main/resources/db/migration/V2__add_tts_preview_audio_key.sql diff --git a/src/main/resources/db/migration/V2__add_tts_common_audio_keys.sql b/src/main/resources/db/migration/V3__add_tts_common_audio_keys.sql similarity index 100% rename from src/main/resources/db/migration/V2__add_tts_common_audio_keys.sql rename to src/main/resources/db/migration/V3__add_tts_common_audio_keys.sql From aae9fd7610578e03d50de34521cefad141cb9f8a Mon Sep 17 00:00:00 2001 From: issuejong Date: Tue, 18 Aug 2026 21:48:33 +0900 Subject: [PATCH 19/19] =?UTF-8?q?docs:=20TTS=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=8C=EC=9B=90=20Swagger=20=EC=98=88=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/moru/server/domain/tts/dto/TTSResponseDTO.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/moru/server/domain/tts/dto/TTSResponseDTO.java b/src/main/java/com/moru/server/domain/tts/dto/TTSResponseDTO.java index 1ca7fdc..50db3aa 100644 --- a/src/main/java/com/moru/server/domain/tts/dto/TTSResponseDTO.java +++ b/src/main/java/com/moru/server/domain/tts/dto/TTSResponseDTO.java @@ -43,7 +43,7 @@ public record VoiceResponse( @Schema( description = "루틴 완료 공통 음원 URL", - example = "https://moru-prod-preview-assets.s3.ap-northeast-2.amazonaws.com/tts/common/v1/leda/done.mp3", + example = "https://moru-prod-preview-assets.s3.ap-northeast-2.amazonaws.com/tts/common/v1/leda-done.mp3", nullable = true ) String doneAudioUrl, @@ -53,7 +53,7 @@ public record VoiceResponse( @Schema( description = "루틴 리마인드 공통 음원 URL", - example = "https://moru-prod-preview-assets.s3.ap-northeast-2.amazonaws.com/tts/common/v1/leda/remind.mp3", + example = "https://moru-prod-preview-assets.s3.ap-northeast-2.amazonaws.com/tts/common/v1/leda-remind.mp3", nullable = true ) String remindAudioUrl,