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..9607ce8 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 @@ -51,6 +52,8 @@ services: restart: unless-stopped + stop_grace_period: 90s + logging: *awslogs healthcheck: 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/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/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/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/member/entity/Member.java b/src/main/java/com/moru/server/domain/member/entity/Member.java index 789b2ff..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 @@ -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; @@ -67,4 +71,8 @@ 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 dad36ca..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 @@ -4,32 +4,105 @@ import com.moru.server.domain.member.dto.MemberResponseDTO; import com.moru.server.domain.member.entity.Member; import com.moru.server.domain.member.repository.MemberRepository; +import com.moru.server.domain.routine.entity.RoutineTTS; +import com.moru.server.domain.routine.event.RoutineTtsVoiceChangedEvent; +import com.moru.server.domain.routine.repository.RoutineTTSRepository; import com.moru.server.domain.tts.entity.TTS; import com.moru.server.domain.tts.repository.TTSRepository; import com.moru.server.global.exception.BusinessException; import com.moru.server.global.response.code.status.ErrorStatus; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; @Service +@Slf4j @RequiredArgsConstructor @Transactional public class MemberCommandServiceImpl implements MemberCommandService { private final MemberRepository memberRepository; private final TTSRepository ttsRepository; + private final RoutineTTSRepository routineTTSRepository; + private final ApplicationEventPublisher eventPublisher; + + @Value("${google.tts.enabled:false}") + private boolean ttsEnabled; - @Override + @Override public MemberResponseDTO.TtsUpdateResponse updateTts(Long memberId, Long ttsId) { - Member member = memberRepository.findById(memberId) + Member member = memberRepository.findByIdForUpdate(memberId) .orElseThrow(() -> new BusinessException(ErrorStatus.MEMBER_NOT_FOUND)); TTS tts = ttsRepository.findById(ttsId) .orElseThrow(() -> new BusinessException(ErrorStatus.TTS_NOT_FOUND)); + if (isSameVoice(member, ttsId)) { + log.info("[TTS] 동일한 목소리라 재합성하지 않음. memberId={}, ttsId={}", memberId, ttsId); + return MemberConverter.toTtsUpdateResponse(member); + } + member.updateVoiceType(tts); + member.bumpVoiceSelectionVersion(); + + if (!ttsEnabled) { + markAllFailed(memberId); + return MemberConverter.toTtsUpdateResponse(member); + } + + requestRegeneration(memberId, resolveVoiceName(tts), member.getVoiceSelectionVersion()); 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); + } + + private String resolveVoiceName(TTS tts) { + String voiceName = tts.getGoogleVoiceName(); + if (voiceName == null || voiceName.isBlank()) { + log.error("[TTS] 목소리 프리셋에 googleVoiceName 이 없어 재합성을 중단. ttsId={}, name={}", + tts.getId(), tts.getName()); + throw new BusinessException(ErrorStatus.TTS_VOICE_NAME_NOT_CONFIGURED); + } + return voiceName; + } + + private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException( + "재합성 이벤트는 트랜잭션 안에서 발행해야 한다. memberId=" + memberId); + } + + List 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/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..91cdebd --- /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)); + + 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/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/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/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/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java b/src/main/java/com/moru/server/domain/routine/repository/RoutineTTSRepository.java index cce7654..9e39585 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 { @@ -29,6 +30,23 @@ public interface RoutineTTSRepository extends JpaRepository { """) 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); + @Query(""" select t.s3Url from RoutineTTS t where t.routine.routineGroup.member.id = :memberId 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 f812c9e..474e6a0 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 @@ -105,7 +105,7 @@ private RoutineGroupResponseDTO.DetailResponse doCreateRoutineGroup( for (Routine routine : saved.getRoutines()) { for (RoutineTTS tts : routine.getTtsList()) { - publishTtsEvent(tts, voiceName); + publishTtsEvent(tts, voiceName, member.getVoiceSelectionVersion()); } } @@ -199,7 +199,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 이벤트는 트랜잭션 안에서 발행해야 한다."); } @@ -208,7 +208,7 @@ private void publishTtsEvent(RoutineTTS tts,String voiceName) { tts.markFailed(); return; } - eventPublisher.publishEvent(new RoutineTtsCreatedEvent(tts.getId(),voiceName)); + eventPublisher.publishEvent(new RoutineTtsCreatedEvent(tts.getId(),voiceName,voiceVersion)); } @@ -355,7 +355,7 @@ private RoutineGroupResponseDTO.RoutineResponse doAddRoutine( Routine saved = routineRepository.save(routine); for (RoutineTTS tts : saved.getTtsList()) { - publishTtsEvent(tts, voiceName); + publishTtsEvent(tts, voiceName, member.getVoiceSelectionVersion()); } return saved; 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 6226a42..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 @@ -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.domain.member.service.MemberWithdrawalLock; import com.moru.server.global.ai.AiClient; @@ -11,9 +12,13 @@ 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; import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; @@ -28,7 +33,9 @@ public class TTSAsyncService { private final RoutineTTSRepository routineTTSRepository; private final AiClient aiClient; 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( @@ -37,7 +44,9 @@ public TTSAsyncService( RoutineTTSRepository routineTTSRepository, AiClient aiClient, MemberWithdrawalLock memberWithdrawalLock, - @Qualifier("ttsExecutor") Executor ttsExecutor + PlatformTransactionManager transactionManager, + @Qualifier("ttsExecutor") Executor ttsExecutor, + @Qualifier("ttsRegenerateExecutor") Executor ttsRegenerateExecutor ) { this.googleTtsClient = googleTtsClient; this.s3Uploader = s3Uploader; @@ -45,14 +54,19 @@ public TTSAsyncService( this.aiClient = aiClient; 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) 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={}, exceptionType={}", routineTtsId, @@ -62,7 +76,23 @@ public void onRoutineTtsCreated(RoutineTtsCreatedEvent event) { } } - 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={}, exceptionType={}", + routineTtsId, + e.getClass().getSimpleName(), + SanitizedLogException.from(e)); + markFailedQuietly(routineTtsId); + } + } + + private void synthesizeAndUpload(Long routineTtsId, String voiceName, Long voiceVersion) { log.info("[TTS] 백그라운드 작업 시작. routineTtsId={}", routineTtsId); @@ -106,7 +136,7 @@ private void synthesizeAndUpload(Long routineTtsId, String voiceName) { RoutineTTS entity = routineTTSRepository.findById(routineTtsId) .orElseThrow(() -> new IllegalStateException("RoutineTTS 행이 없습니다.")); - entity.markCompleted(key, ttsScript.ttsIntro(), ttsScript.ttsDone()); + entity.markCompleted(key, ttsScript.ttsIntro(), ttsScript.ttsDone(), voiceVersion); routineTTSRepository.save(entity); log.info("[TTS] 생성 및 업로드 완료. routineTtsId={}", routineTtsId); @@ -122,15 +152,97 @@ private void synthesizeAndUpload(Long routineTtsId, String voiceName) { } } + private void regenerate(Long routineTtsId, String voiceName, Long voiceVersion) { + + log.info("[TTS] 재합성 시작. routineTtsId={}, voiceVersion={}", routineTtsId, voiceVersion); + + 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(); + + // 최초 합성이 끝난 적 없는 행은 재사용할 멘트가 없다. 이때만 생성 경로와 동일하게 만든다. + if (intro == null || intro.isBlank()) { + GeminiResponseDTO.AiTtsResult ttsScript = aiClient.generateTtsScript(entity.getContent()); + intro = ttsScript.ttsIntro(); + done = ttsScript.ttsDone(); + } + + byte[] audio = googleTtsClient.synthesize(intro, voiceName); + + if (memberWithdrawalLock.isLocked(memberId)) { + return; + } + + String key = "tts/members/%d/%d-%s.mp3" + .formatted(memberId, routineTtsId, UUID.randomUUID()); + s3Uploader.upload(key, audio, CONTENT_TYPE); + uploadedKey = key; + + if (memberWithdrawalLock.isLocked(memberId)) { + deleteQuietly(uploadedKey, routineTtsId); + return; + } + + Long currentVersion = routineTTSRepository.findCurrentVoiceVersion(routineTtsId).orElse(null); + if (!Objects.equals(currentVersion, voiceVersion)) { + log.info("[TTS] 재합성 중 목소리가 다시 바뀌어 결과를 폐기. routineTtsId={}, 작업버전={}, 현재버전={}", + routineTtsId, voiceVersion, currentVersion); + deleteQuietly(uploadedKey, routineTtsId); + return; + } + + RoutineTTS target = routineTTSRepository.findById(routineTtsId) + .orElseThrow(() -> new IllegalStateException("RoutineTTS 행이 없습니다.")); + + target.markCompleted(key, intro, done, voiceVersion); + routineTTSRepository.save(target); + + // 교체가 확정된 뒤에야 옛 음원을 지운다. 먼저 지우면 실패 시 들려줄 음원이 사라진다. + if (previousKey != null && !previousKey.equals(key)) { + deleteQuietly(previousKey, routineTtsId); + } + + log.info("[TTS] 재합성 완료. routineTtsId={}", routineTtsId); + + } catch (Exception e) { + + log.error("[TTS] 재합성 실패. 기존 음원은 유지한다. routineTtsId={}, exceptionType={}", + routineTtsId, + e.getClass().getSimpleName(), + SanitizedLogException.from(e)); + deleteQuietly(uploadedKey, routineTtsId); + markFailedQuietly(routineTtsId); + } + } + private void deleteQuietly(String key, Long routineTtsId) { if (key == null) { return; } try { s3Uploader.delete(key); - log.info("[TTS] 실패로 업로드된 객체를 삭제함. routineTtsId={}", routineTtsId); + log.info("[TTS] 사용되지 않는 객체를 삭제함. routineTtsId={}", routineTtsId); } catch (Exception e) { - log.error("[TTS] 업로드된 객체 삭제 실패. routineTtsId={}, exceptionType={}", + log.error("[TTS] 객체 삭제 실패. routineTtsId={}, exceptionType={}", routineTtsId, e.getClass().getSimpleName(), SanitizedLogException.from(e)); @@ -139,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/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(); } 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; 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..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 @@ -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 +} 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; + } } 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 606eb18..ec3a32d 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 @@ -67,7 +67,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; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5a650d7..b1bae94 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: 1 jpa: properties: hibernate: @@ -47,3 +51,4 @@ member: aws: s3: enabled: ${AWS_S3_ENABLED:false} + 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/V2__add_tts_preview_audio_key.sql b/src/main/resources/db/migration/V2__add_tts_preview_audio_key.sql new file mode 100644 index 0000000..08f609f --- /dev/null +++ b/src/main/resources/db/migration/V2__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'); diff --git a/src/main/resources/db/migration/V3__add_tts_common_audio_keys.sql b/src/main/resources/db/migration/V3__add_tts_common_audio_keys.sql new file mode 100644 index 0000000..f16d781 --- /dev/null +++ b/src/main/resources/db/migration/V3__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'); 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(); + } +} 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..3c363c4 --- /dev/null +++ b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java @@ -0,0 +1,126 @@ +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.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)); + 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) + .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); + 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(); + } +} 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 5f15c3f..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,8 @@ void setUp() { routineTTSRepository, aiClient, memberWithdrawalLock, + transactionManager, + directExecutor, directExecutor ); } @@ -65,7 +71,7 @@ void marksTtsFailedWhenWithdrawalLockLookupFails() { when(memberWithdrawalLock.isLocked(10L)) .thenThrow(new IllegalStateException("Redis unavailable")); - ttsAsyncService.onRoutineTtsCreated(new RoutineTtsCreatedEvent(1L, "voice-name")); + ttsAsyncService.onRoutineTtsCreated(new RoutineTtsCreatedEvent(1L, "voice-name", 0L)); assertThat(routineTTS.getTtsStatus()).isEqualTo(TtsStatus.FAILED); verify(routineTTSRepository).save(routineTTS); 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(); }