Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
17387e4
feat: λͺ©μ†Œλ¦¬ λ³€κ²½ μ‹œ κΈ°μ‘΄ 루틴 TTS μž¬μƒμ„±
youngsun0331 Aug 10, 2026
283d577
feat: TTS selectionVersion 응닡 λ…ΈμΆœ
youngsun0331 Aug 10, 2026
a6315ac
chore: TTS μŠ€λ ˆλ“œν’€ 뢄리 및 μ’…λ£Œ μ‹œ μž‘μ—… λŒ€κΈ°
youngsun0331 Aug 10, 2026
9b2acce
fix : 같은 λͺ©μ†Œλ¦¬ μ„ νƒμ‹œ μž¬ν•©μ„± μ’…λ£Œ
youngsun0331 Aug 10, 2026
4b536d8
fix: λͺ©μ†Œλ¦¬ λ³€κ²½ μž¬ν•©μ„±μ˜ λͺ©μ†Œλ¦¬ 이름·버전 처리 μˆ˜μ •
youngsun0331 Aug 15, 2026
1710891
Merge branch 'dev' into feat/107-TTS-change
youngsun0331 Aug 15, 2026
a8dbb7b
fix : μ½”λ“œ λ ˆλΉ— 리뷰 반영
youngsun0331 Aug 15, 2026
6e9db94
fix : CI 버그 μˆ˜μ •
youngsun0331 Aug 15, 2026
18ea504
fix : μˆ˜μ • 원상 볡ꡬ
youngsun0331 Aug 15, 2026
438de36
fix : 리뷰 μˆ˜μ •
youngsun0331 Aug 16, 2026
adf99b7
fix: Apple 둜그인 identityToken μš”μ²­ ν˜Έν™˜ 처리
issuejong Aug 17, 2026
7ac8b66
feat: μ˜¨λ³΄λ”© μ™„λ£Œ API κ΅¬ν˜„
issuejong Aug 17, 2026
480058f
Merge pull request #127 from Team-Moru/fix/126-apple-login-identity-t…
issuejong Aug 17, 2026
8abb056
fix: μ˜¨λ³΄λ”© μ™„λ£Œ μ‹œ 루틴 κ·Έλ£Ή μ†Œμœ κΆŒ 검증
issuejong Aug 17, 2026
e1a9356
Merge pull request #129 from Team-Moru/feat/128-onboarding-complete
issuejong Aug 17, 2026
2eae167
build: TTS 미리듣기 μŒμ› λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μΆ”κ°€
issuejong Aug 18, 2026
22608d2
feat: TTS 곡톡 μŒμ› 응닡 λͺ¨λΈ κ΅¬ν˜„
issuejong Aug 18, 2026
84ab81a
feat: TTS 곡톡 μŒμ› 메타데이터 λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μΆ”κ°€
issuejong Aug 18, 2026
f0857bd
feat: TTS 곡톡 μŒμ› URL 쑰회 κ΅¬ν˜„
issuejong Aug 18, 2026
140d0b0
test: TTS 곡톡 μŒμ› 쑰회 ν…ŒμŠ€νŠΈ μΆ”κ°€
issuejong Aug 18, 2026
05e8db2
fix: 빈 DB 초기 μŠ€ν‚€λ§ˆ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μΆ”κ°€
issuejong Aug 18, 2026
aae9fd7
docs: TTS 곡톡 μŒμ› Swagger μ˜ˆμ‹œ μˆ˜μ •
issuejong Aug 18, 2026
9ff8d17
Merge pull request #131 from Team-Moru/feat/130-tts-common-audio
issuejong Aug 18, 2026
98bac69
Merge pull request #125 from Team-Moru/feat/107-TTS-change
youngsun0331 Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down
3 changes: 3 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,6 +52,8 @@ services:

restart: unless-stopped

stop_grace_period: 90s

logging: *awslogs

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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,15 @@ public record TtsUpdateResponse(
String voiceCode,

@Schema(description = "TTS λͺ©μ†Œλ¦¬ ν‘œμ‹œ 이름", example = "λ―Όμ„œ")
String displayName
String displayName,

@Schema(description = """
이번 λ³€κ²½μœΌλ‘œ μ˜¬λΌκ°„ μ΅œμ‹  λͺ©μ†Œλ¦¬ 선택 버전. λͺ©μ†Œλ¦¬λ₯Ό λ°”κΏ€ λ•Œλ§ˆλ‹€ 1μ”© μ¦κ°€ν•œλ‹€.
이 응닡 μ‹œμ μ— νšŒμ›μ˜ λͺ¨λ“  루틴 TTS λŠ” PENDING 으둜 μ΄ˆκΈ°ν™”λ˜λ©°,
μž¬ν•©μ„±μ΄ λλ‚œ μŠ€ν…μ€ TTS 쑰회 μ‘λ‹΅μ˜ selectionVersion 이 이 κ°’κ³Ό κ°™μ•„μ§„λ‹€.
두 값이 κ°™κ³  ttsStatus κ°€ COMPLETED 인 μŠ€ν…λ§Œ μƒˆ λͺ©μ†Œλ¦¬λ‘œ λ§Œλ“€μ–΄μ§„ μŒμ›μ΄λ‹€.""",
example = "3")
Long selectionVersion
) {
}

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

@Column(name = "voice_selection_version", nullable = false)
@Builder.Default
private Long voiceSelectionVersion = 0L;

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

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

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

import java.util.List;

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

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

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

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

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

if (isSameVoice(member, ttsId)) {
log.info("[TTS] λ™μΌν•œ λͺ©μ†Œλ¦¬λΌ μž¬ν•©μ„±ν•˜μ§€ μ•ŠμŒ. memberId={}, ttsId={}", memberId, ttsId);
return MemberConverter.toTtsUpdateResponse(member);
}

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

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

requestRegeneration(memberId, resolveVoiceName(tts), member.getVoiceSelectionVersion());

return MemberConverter.toTtsUpdateResponse(member);
}

/**
* μž¬ν•©μ„±μ„ μˆ˜ν–‰ν•  μ›Œμ»€κ°€ μ—†λ‹€. λ²„μ „λ§Œ 올리고 두면 κΈ°μ‘΄ μŒμ›μ΄ μƒˆ 버전인 κ²ƒμ²˜λŸΌ λ³΄μ΄λ―€λ‘œ
* 생성 경둜(RoutineGroupCommandServiceImpl.publishTtsEvent)와 λ™μΌν•˜κ²Œ FAILED 둜 μ’…κ²°ν•œλ‹€.
*/
private void markAllFailed(Long memberId) {
List<RoutineTTS> targets = routineTTSRepository.findAllByMemberId(memberId);
for (RoutineTTS target : targets) {
target.markFailed();
}
log.info("[TTS] κΈ°λŠ₯이 λΉ„ν™œμ„±ν™”λ˜μ–΄ μž¬ν•©μ„±μ„ κ±΄λ„ˆλ›°κ³  FAILED 둜 μ’…κ²°. memberId={}, 건수={}",
memberId, targets.size());
}

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

private String resolveVoiceName(TTS tts) {
String voiceName = tts.getGoogleVoiceName();
if (voiceName == null || voiceName.isBlank()) {
log.error("[TTS] λͺ©μ†Œλ¦¬ 프리셋에 googleVoiceName 이 μ—†μ–΄ μž¬ν•©μ„±μ„ 쀑단. ttsId={}, name={}",
tts.getId(), tts.getName());
throw new BusinessException(ErrorStatus.TTS_VOICE_NAME_NOT_CONFIGURED);
}
return voiceName;
}

private void requestRegeneration(Long memberId, String voiceName, Long voiceVersion) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"μž¬ν•©μ„± μ΄λ²€νŠΈλŠ” νŠΈλžœμž­μ…˜ μ•ˆμ—μ„œ λ°œν–‰ν•΄μ•Ό ν•œλ‹€. memberId=" + memberId);
}

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

log.info("[TTS] λͺ©μ†Œλ¦¬ λ³€κ²½μœΌλ‘œ μž¬ν•©μ„± μš”μ²­. memberId={}, 건수={}, voiceVersion={}",
memberId, targets.size(), voiceVersion);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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<OnboardingResponseDTO.StatusResponse> completeOnboarding(
@AuthenticationPrincipal AuthenticatedMember member,
@Valid @RequestBody OnboardingRequestDTO.CompleteRequest request
) {
return ApiResponse.onSuccess(
onboardingCommandService.completeOnboarding(member.memberId(), request));
}

@Operation(summary = "μ˜¨λ³΄λ”© μ™„λ£Œ μ—¬λΆ€ 쑰회", description = "ν˜„μž¬ λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ μ˜¨λ³΄λ”© μ™„λ£Œ μ—¬λΆ€λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€.")
@GetMapping("/status")
public ApiResponse<OnboardingResponseDTO.StatusResponse> getOnboardingStatus(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

public record OnboardingResponseDTO() {

@Schema(description = "μ˜¨λ³΄λ”© μ™„λ£Œ μ—¬λΆ€ 쑰회 응닡")
@Schema(description = "μ˜¨λ³΄λ”© μ™„λ£Œ μƒνƒœ 응닡")
public record StatusResponse(
@Schema(description = "μ˜¨λ³΄λ”© μ™„λ£Œ μ—¬λΆ€", example = "false")
Boolean onboardingCompleted
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@ public record RoutineTTSStep(
String ttsStatus,

@Schema(description = "μŒμ› 파일 S3 URL", example = "https://.../101.mp3", nullable = true)
String s3Url
String s3Url,

@Schema(description = """
이 μŒμ›μ΄ λ§Œλ“€μ–΄μ§„ λͺ©μ†Œλ¦¬ 선택 버전. 합성에 μ„±κ³΅ν•œ μ‹œμ μ—λ§Œ κΈ°λ‘λœλ‹€.
PATCH /members/me/tts μ‘λ‹΅μ˜ selectionVersion κ³Ό 이 값이 κ°™κ³ 
ttsStatus κ°€ COMPLETED 일 λ•Œλ§Œ μ΅œμ‹  λͺ©μ†Œλ¦¬λ‘œ λ§Œλ“€μ–΄μ§„ μŒμ›μ΄λ‹€.
값이 더 μž‘μœΌλ©΄ 아직 μž¬ν•©μ„±μ΄ λλ‚˜μ§€ μ•Šμ•„ 이전 λͺ©μ†Œλ¦¬μ˜ μŒμ›μ΄ λ‚΄λ €κ°€λŠ” 쀑이닀.""",
example = "3", nullable = true)
Long selectionVersion
) {}
}
}
Loading
Loading