From 83602b36683d65dc63fb67b3f460d6b95604ca65 Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 21:09:21 +0900 Subject: [PATCH 01/25] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20Rep?= =?UTF-8?q?ository=206=EA=B0=9C=20=EC=B6=94=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/UserStatisticsRepository.java | 10 ++++++++++ .../repository/SubscriptionRepository.java | 11 +++++++++++ .../domain/user/repository/InstrumentRepository.java | 10 ++++++++++ .../user/repository/StudentInstrumentRepository.java | 11 +++++++++++ .../mr/domain/user/repository/StudentRepository.java | 11 +++++++++++ .../com/mr/domain/user/repository/UserRepository.java | 11 +++++++++++ 6 files changed, 64 insertions(+) create mode 100644 src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java create mode 100644 src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java create mode 100644 src/main/java/com/mr/domain/user/repository/InstrumentRepository.java create mode 100644 src/main/java/com/mr/domain/user/repository/StudentInstrumentRepository.java create mode 100644 src/main/java/com/mr/domain/user/repository/StudentRepository.java create mode 100644 src/main/java/com/mr/domain/user/repository/UserRepository.java diff --git a/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java b/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java new file mode 100644 index 00000000..4b03e947 --- /dev/null +++ b/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java @@ -0,0 +1,10 @@ +package com.mr.domain.statistics.repository; + +import com.mr.domain.statistics.entity.UserStatistics; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserStatisticsRepository extends JpaRepository { + + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java b/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java new file mode 100644 index 00000000..219dae6a --- /dev/null +++ b/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java @@ -0,0 +1,11 @@ +package com.mr.domain.subscriptions.repository; + +import com.mr.domain.subscriptions.entity.Subscription; +import com.mr.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SubscriptionRepository extends JpaRepository { + + Optional findFirstByUserOrderByStartDateDesc(User user); +} diff --git a/src/main/java/com/mr/domain/user/repository/InstrumentRepository.java b/src/main/java/com/mr/domain/user/repository/InstrumentRepository.java new file mode 100644 index 00000000..5c111658 --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/InstrumentRepository.java @@ -0,0 +1,10 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.Instrument; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InstrumentRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/src/main/java/com/mr/domain/user/repository/StudentInstrumentRepository.java b/src/main/java/com/mr/domain/user/repository/StudentInstrumentRepository.java new file mode 100644 index 00000000..3efd3dfa --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/StudentInstrumentRepository.java @@ -0,0 +1,11 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.StudentInstrument; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudentInstrumentRepository extends JpaRepository { + + Optional findByStudentAndPrimaryTrue(Student student); +} diff --git a/src/main/java/com/mr/domain/user/repository/StudentRepository.java b/src/main/java/com/mr/domain/user/repository/StudentRepository.java new file mode 100644 index 00000000..e6d662b7 --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/StudentRepository.java @@ -0,0 +1,11 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudentRepository extends JpaRepository { + + Optional findByUser(User user); +} diff --git a/src/main/java/com/mr/domain/user/repository/UserRepository.java b/src/main/java/com/mr/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..16b40138 --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +// NOTE: feat/#32-learning-status-api(PR #46, 동균 강)에도 동일한 이름의 빈 UserRepository가 있음. +// 그쪽이 먼저 머지되면 병합 시 이 파일(메서드 포함된 상위호환 버전)로 유지하면 됨. +public interface UserRepository extends JpaRepository { + + boolean existsByNicknameAndUserIdNot(String nickname, Long userId); +} From a2c3f0de3c2c67a273c8d7f1b09facf3c0b09e21 Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 21:14:50 +0900 Subject: [PATCH 02/25] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=204=EA=B0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/user/exception/StudentErrorStatus.java | 3 ++- .../java/com/mr/domain/user/exception/UserErrorStatus.java | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java b/src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java index 55a879ac..4d31a34d 100644 --- a/src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java @@ -10,7 +10,8 @@ public enum StudentErrorStatus implements BaseCode { USER_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_400_01", "user는 필수입니다."), - THEORY_LEVEL_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_400_02", "theoryLevel은 필수입니다."); + THEORY_LEVEL_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_400_02", "theoryLevel은 필수입니다."), + STUDENT_NOT_FOUND(HttpStatus.NOT_FOUND, "STUDENT_404_01", "온보딩 정보가 없는 사용자입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java index 4ac855ef..55cc638d 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -11,7 +11,10 @@ public enum UserErrorStatus implements BaseCode { NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."), NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."), - PROFILE_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_03", "프로필 이미지 URL은 필수입니다."); + PROFILE_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_03", "프로필 이미지 URL은 필수입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다."), + NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_409_01", "이미 사용 중인 닉네임입니다."), + ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "USER_409_02", "이미 온보딩을 완료한 사용자입니다."); private final HttpStatus status; private final String code; From 04ffe98754f342a5dcde46488dd2266b147b359a Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 21:16:03 +0900 Subject: [PATCH 03/25] =?UTF-8?q?feat:=20PIANO=20=EC=95=85=EA=B8=B0=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/config/InstrumentSeeder.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/com/mr/domain/user/config/InstrumentSeeder.java diff --git a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java new file mode 100644 index 00000000..6c0cc633 --- /dev/null +++ b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java @@ -0,0 +1,26 @@ +package com.mr.domain.user.config; + +import com.mr.domain.user.entity.Instrument; +import com.mr.domain.user.repository.InstrumentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class InstrumentSeeder implements ApplicationRunner { + + private static final String PIANO_CODE = "PIANO"; + private static final String PIANO_NAME = "피아노"; + + private final InstrumentRepository instrumentRepository; + + @Override + @Transactional + public void run(ApplicationArguments args) { + instrumentRepository.findByCode(PIANO_CODE) + .orElseGet(() -> instrumentRepository.save(Instrument.create(PIANO_CODE, PIANO_NAME))); + } +} From e99d1f174489356cf038f7589f5ecb8d7886401a Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 21:20:41 +0900 Subject: [PATCH 04/25] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserProfileController.java | 22 +++++ .../user/dto/UserProfileResponseDTO.java | 24 ++++++ .../user/service/UserProfileService.java | 84 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/main/java/com/mr/domain/user/controller/UserProfileController.java create mode 100644 src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java create mode 100644 src/main/java/com/mr/domain/user/service/UserProfileService.java diff --git a/src/main/java/com/mr/domain/user/controller/UserProfileController.java b/src/main/java/com/mr/domain/user/controller/UserProfileController.java new file mode 100644 index 00000000..25a47b21 --- /dev/null +++ b/src/main/java/com/mr/domain/user/controller/UserProfileController.java @@ -0,0 +1,22 @@ +package com.mr.domain.user.controller; + +import com.mr.domain.user.dto.UserProfileResponseDTO; +import com.mr.domain.user.service.UserProfileService; +import com.mr.global.apipayload.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/users/me/profile") +public class UserProfileController { + + private final UserProfileService userProfileService; + + @GetMapping + public ApiResponse getMyProfile() { + return ApiResponse.onSuccess(userProfileService.getMyProfile()); + } +} diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java b/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java new file mode 100644 index 00000000..fff4b96d --- /dev/null +++ b/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java @@ -0,0 +1,24 @@ +package com.mr.domain.user.dto; + +import com.mr.domain.user.entity.enums.TheoryLevel; +import lombok.Builder; + +public class UserProfileResponseDTO { + + @Builder + public record ProfileResponse( + String nickname, + String profileImgUrl, + String instrumentType, + TheoryLevel skillLevel, + String subscriptionTier, + StatisticsResponse statistics + ) {} + + @Builder + public record StatisticsResponse( + Long practiceSessionCount, + Long totalPracticeMinutes, + Long completedLearningCount + ) {} +} diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java new file mode 100644 index 00000000..dbbe0c09 --- /dev/null +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -0,0 +1,84 @@ +package com.mr.domain.user.service; + +import com.mr.domain.subscriptions.entity.Subscription; +import com.mr.domain.subscriptions.repository.SubscriptionRepository; +import com.mr.domain.statistics.entity.UserStatistics; +import com.mr.domain.statistics.repository.UserStatisticsRepository; +import com.mr.domain.user.dto.UserProfileResponseDTO; +import com.mr.domain.user.entity.Instrument; +import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.StudentInstrument; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.exception.StudentErrorStatus; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.StudentInstrumentRepository; +import com.mr.domain.user.repository.StudentRepository; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.security.SecurityUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserProfileService { + + private final UserRepository userRepository; + private final StudentRepository studentRepository; + private final StudentInstrumentRepository studentInstrumentRepository; + private final SubscriptionRepository subscriptionRepository; + private final UserStatisticsRepository userStatisticsRepository; + + public UserProfileResponseDTO.ProfileResponse getMyProfile() { + Long userId = SecurityUtil.getCurrentUserId(); + User user = getUser(userId); + Student student = getStudent(user); + + String instrumentType = studentInstrumentRepository.findByStudentAndPrimaryTrue(student) + .map(StudentInstrument::getInstrument) + .map(Instrument::getCode) + .orElseThrow(() -> new IllegalStateException("대표 악기 정보가 존재하지 않습니다.")); + + String subscriptionTier = subscriptionRepository.findFirstByUserOrderByStartDateDesc(user) + .map(Subscription::getTier) + .orElseThrow(() -> new IllegalStateException("구독 정보가 존재하지 않습니다.")); + + return UserProfileResponseDTO.ProfileResponse.builder() + .nickname(user.getNickname()) + .profileImgUrl(user.getProfileImgUrl()) + .instrumentType(instrumentType) + .skillLevel(student.getTheoryLevel()) + .subscriptionTier(subscriptionTier) + .statistics(buildStatistics(userId)) + .build(); + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + } + + private Student getStudent(User user) { + return studentRepository.findByUser(user) + .orElseThrow(() -> new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); + } + + private UserProfileResponseDTO.StatisticsResponse buildStatistics(Long userId) { + UserStatistics stats = userStatisticsRepository.findByUserId(userId).orElse(null); + + return UserProfileResponseDTO.StatisticsResponse.builder() + .practiceSessionCount(stats == null ? 0L : stats.getTotalPracticeCount().longValue()) + .totalPracticeMinutes(stats == null ? 0L : stats.getTotalPracticeMinutes().longValue()) + .completedLearningCount(countCompletedLearnings(userId)) + .build(); + } + + // TODO: feat/#32-learning-status-api(동균 강) 병합 후 UserLearningProgressRepository로 교체 + // SELECT COUNT(DISTINCT ulp.learning_id) FROM user_learning_progress ulp + // WHERE ulp.user_id = :userId AND ulp.score >= 90 + private Long countCompletedLearnings(Long userId) { + return 0L; + } +} From fe988648c667089c7b3345e14b588f9d0e4a9f0c Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 21:33:36 +0900 Subject: [PATCH 05/25] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=B5=9C=EC=B4=88=20=EB=93=B1=EB=A1=9D=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/enums/SubscriptionTier.java | 7 +++ .../controller/UserProfileController.java | 13 ++++++ .../user/dto/UserProfileRequestDTO.java | 13 ++++++ .../user/dto/UserProfileResponseDTO.java | 10 ++++ .../user/service/UserProfileService.java | 46 +++++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 src/main/java/com/mr/domain/subscriptions/entity/enums/SubscriptionTier.java create mode 100644 src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java diff --git a/src/main/java/com/mr/domain/subscriptions/entity/enums/SubscriptionTier.java b/src/main/java/com/mr/domain/subscriptions/entity/enums/SubscriptionTier.java new file mode 100644 index 00000000..b7f365a6 --- /dev/null +++ b/src/main/java/com/mr/domain/subscriptions/entity/enums/SubscriptionTier.java @@ -0,0 +1,7 @@ +package com.mr.domain.subscriptions.entity.enums; + +public enum SubscriptionTier { + FREE, + STANDARD, + PRO +} diff --git a/src/main/java/com/mr/domain/user/controller/UserProfileController.java b/src/main/java/com/mr/domain/user/controller/UserProfileController.java index 25a47b21..ecbd6789 100644 --- a/src/main/java/com/mr/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/mr/domain/user/controller/UserProfileController.java @@ -1,11 +1,16 @@ package com.mr.domain.user.controller; +import com.mr.domain.user.dto.UserProfileRequestDTO; import com.mr.domain.user.dto.UserProfileResponseDTO; import com.mr.domain.user.service.UserProfileService; import com.mr.global.apipayload.ApiResponse; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; 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.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController @@ -19,4 +24,12 @@ public class UserProfileController { public ApiResponse getMyProfile() { return ApiResponse.onSuccess(userProfileService.getMyProfile()); } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse registerProfile( + @RequestBody UserProfileRequestDTO.OnboardingRequest request + ) { + return ApiResponse.onSuccess(userProfileService.registerProfile(request)); + } } diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java b/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java new file mode 100644 index 00000000..8ef6b5e6 --- /dev/null +++ b/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java @@ -0,0 +1,13 @@ +package com.mr.domain.user.dto; + +import com.mr.domain.subscriptions.entity.enums.SubscriptionTier; +import com.mr.domain.user.entity.enums.TheoryLevel; + +public class UserProfileRequestDTO { + + public record OnboardingRequest( + String nickname, + TheoryLevel skillLevel, + SubscriptionTier subscriptionTier + ) {} +} diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java b/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java index fff4b96d..a4cb9b59 100644 --- a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java +++ b/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java @@ -21,4 +21,14 @@ public record StatisticsResponse( Long totalPracticeMinutes, Long completedLearningCount ) {} + + @Builder + public record OnboardingResponse( + Long userId, + String nickname, + TheoryLevel skillLevel, + String instrumentType, + String subscriptionTier, + String profileImgUrl + ) {} } diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index dbbe0c09..44768eab 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -4,6 +4,7 @@ import com.mr.domain.subscriptions.repository.SubscriptionRepository; import com.mr.domain.statistics.entity.UserStatistics; import com.mr.domain.statistics.repository.UserStatisticsRepository; +import com.mr.domain.user.dto.UserProfileRequestDTO; import com.mr.domain.user.dto.UserProfileResponseDTO; import com.mr.domain.user.entity.Instrument; import com.mr.domain.user.entity.Student; @@ -11,11 +12,13 @@ import com.mr.domain.user.entity.User; import com.mr.domain.user.exception.StudentErrorStatus; import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.InstrumentRepository; import com.mr.domain.user.repository.StudentInstrumentRepository; import com.mr.domain.user.repository.StudentRepository; import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.SecurityUtil; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,9 +28,13 @@ @Transactional(readOnly = true) public class UserProfileService { + private static final String PIANO_CODE = "PIANO"; + private static final int SUBSCRIPTION_PERIOD_DAYS = 30; + private final UserRepository userRepository; private final StudentRepository studentRepository; private final StudentInstrumentRepository studentInstrumentRepository; + private final InstrumentRepository instrumentRepository; private final SubscriptionRepository subscriptionRepository; private final UserStatisticsRepository userStatisticsRepository; @@ -55,6 +62,39 @@ public UserProfileResponseDTO.ProfileResponse getMyProfile() { .build(); } + @Transactional + public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequestDTO.OnboardingRequest request) { + Long userId = SecurityUtil.getCurrentUserId(); + User user = getUser(userId); + + if (user.isOnboardingCompleted()) { + throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + } + + user.updateNickname(request.nickname()); + ensureNicknameNotTaken(user); + + Student student = studentRepository.save(Student.create(user, request.skillLevel())); + + Instrument piano = instrumentRepository.findByCode(PIANO_CODE) + .orElseThrow(() -> new IllegalStateException("PIANO 악기 시드 데이터가 존재하지 않습니다.")); + studentInstrumentRepository.save(StudentInstrument.createPrimary(student, piano)); + + String tier = request.subscriptionTier() == null ? null : request.subscriptionTier().name(); + LocalDateTime now = LocalDateTime.now(); + Subscription subscription = subscriptionRepository.save( + Subscription.create(user, tier, now, now.plusDays(SUBSCRIPTION_PERIOD_DAYS))); + + return UserProfileResponseDTO.OnboardingResponse.builder() + .userId(user.getUserId()) + .nickname(user.getNickname()) + .skillLevel(student.getTheoryLevel()) + .instrumentType(piano.getCode()) + .subscriptionTier(subscription.getTier()) + .profileImgUrl(user.getProfileImgUrl()) + .build(); + } + private User getUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); @@ -65,6 +105,12 @@ private Student getStudent(User user) { .orElseThrow(() -> new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); } + private void ensureNicknameNotTaken(User user) { + if (userRepository.existsByNicknameAndUserIdNot(user.getNickname(), user.getUserId())) { + throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); + } + } + private UserProfileResponseDTO.StatisticsResponse buildStatistics(Long userId) { UserStatistics stats = userStatisticsRepository.findByUserId(userId).orElse(null); From 5d3a2c110f58973504edf8dcccd814d557bbd21b Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 21:35:20 +0900 Subject: [PATCH 06/25] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserProfileController.java | 8 ++++++++ .../domain/user/dto/UserProfileRequestDTO.java | 5 +++++ .../user/dto/UserProfileResponseDTO.java | 9 +++++++++ .../user/service/UserProfileService.java | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/src/main/java/com/mr/domain/user/controller/UserProfileController.java b/src/main/java/com/mr/domain/user/controller/UserProfileController.java index ecbd6789..304f21f3 100644 --- a/src/main/java/com/mr/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/mr/domain/user/controller/UserProfileController.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -32,4 +33,11 @@ public ApiResponse registerProfile( ) { return ApiResponse.onSuccess(userProfileService.registerProfile(request)); } + + @PatchMapping + public ApiResponse updateProfile( + @RequestBody UserProfileRequestDTO.UpdateRequest request + ) { + return ApiResponse.onSuccess(userProfileService.updateProfile(request)); + } } diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java b/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java index 8ef6b5e6..70309697 100644 --- a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java +++ b/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java @@ -10,4 +10,9 @@ public record OnboardingRequest( TheoryLevel skillLevel, SubscriptionTier subscriptionTier ) {} + + public record UpdateRequest( + String nickname, + TheoryLevel skillLevel + ) {} } diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java b/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java index a4cb9b59..901c78dc 100644 --- a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java +++ b/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java @@ -1,6 +1,7 @@ package com.mr.domain.user.dto; import com.mr.domain.user.entity.enums.TheoryLevel; +import java.time.LocalDateTime; import lombok.Builder; public class UserProfileResponseDTO { @@ -31,4 +32,12 @@ public record OnboardingResponse( String subscriptionTier, String profileImgUrl ) {} + + @Builder + public record UpdateResponse( + Long userId, + String nickname, + TheoryLevel skillLevel, + LocalDateTime updatedAt + ) {} } diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index 44768eab..d8958eb8 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -95,6 +95,24 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ .build(); } + @Transactional + public UserProfileResponseDTO.UpdateResponse updateProfile(UserProfileRequestDTO.UpdateRequest request) { + Long userId = SecurityUtil.getCurrentUserId(); + User user = getUser(userId); + Student student = getStudent(user); + + user.updateNickname(request.nickname()); + ensureNicknameNotTaken(user); + student.updateTheoryLevel(request.skillLevel()); + + return UserProfileResponseDTO.UpdateResponse.builder() + .userId(user.getUserId()) + .nickname(user.getNickname()) + .skillLevel(student.getTheoryLevel()) + .updatedAt(LocalDateTime.now()) + .build(); + } + private User getUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); From e8c44c1f55696287b6998e518de2fe6e757d00a7 Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 22:16:32 +0900 Subject: [PATCH 07/25] =?UTF-8?q?test:=20=ED=94=84=EB=A1=9C=ED=95=84=20API?= =?UTF-8?q?=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserProfileControllerTest.java | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java diff --git a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java new file mode 100644 index 00000000..9bfcdfef --- /dev/null +++ b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java @@ -0,0 +1,233 @@ +package com.mr.domain.user.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +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 com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.domain.subscriptions.entity.enums.SubscriptionTier; +import com.mr.domain.user.dto.UserProfileRequestDTO; +import com.mr.domain.user.dto.UserProfileResponseDTO; +import com.mr.domain.user.entity.enums.TheoryLevel; +import com.mr.domain.user.exception.StudentErrorStatus; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.service.UserProfileService; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.apipayload.handler.GlobalExceptionHandler; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +class UserProfileControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @Mock + private UserProfileService userProfileService; + + private MockMvc setUp() { + objectMapper = Jackson2ObjectMapperBuilder.json() + .findModulesViaServiceLoader(true) + .build(); + + return MockMvcBuilders.standaloneSetup(new UserProfileController(userProfileService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .build(); + } + + @Test + @DisplayName("GET /api/users/me/profile - 조회 성공") + void getMyProfile_success() throws Exception { + mockMvc = setUp(); + + UserProfileResponseDTO.ProfileResponse response = UserProfileResponseDTO.ProfileResponse.builder() + .nickname("김뮤즈") + .profileImgUrl("https://cdn.example.com/profile/default-profile.png") + .instrumentType("PIANO") + .skillLevel(TheoryLevel.INTERMEDIATE) + .subscriptionTier("PRO") + .statistics(UserProfileResponseDTO.StatisticsResponse.builder() + .practiceSessionCount(24L) + .totalPracticeMinutes(350L) + .completedLearningCount(0L) + .build()) + .build(); + given(userProfileService.getMyProfile()).willReturn(response); + + mockMvc.perform(get("/api/users/me/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.nickname").value("김뮤즈")) + .andExpect(jsonPath("$.data.instrumentType").value("PIANO")) + .andExpect(jsonPath("$.data.subscriptionTier").value("PRO")) + .andExpect(jsonPath("$.data.statistics.practiceSessionCount").value(24)); + } + + @Test + @DisplayName("GET /api/users/me/profile - user 없으면 404") + void getMyProfile_userNotFound() throws Exception { + mockMvc = setUp(); + + given(userProfileService.getMyProfile()) + .willThrow(new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + mockMvc.perform(get("/api/users/me/profile")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("USER_404_01")); + } + + @Test + @DisplayName("GET /api/users/me/profile - 온보딩 안 된 유저면 404") + void getMyProfile_studentNotFound() throws Exception { + mockMvc = setUp(); + + given(userProfileService.getMyProfile()) + .willThrow(new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); + + mockMvc.perform(get("/api/users/me/profile")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STUDENT_404_01")); + } + + @Test + @DisplayName("POST /api/users/me/profile - 최초 등록 성공") + void registerProfile_success() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( + "김뮤즈", TheoryLevel.INTERMEDIATE, SubscriptionTier.PRO + ); + UserProfileResponseDTO.OnboardingResponse response = UserProfileResponseDTO.OnboardingResponse.builder() + .userId(1L) + .nickname("김뮤즈") + .skillLevel(TheoryLevel.INTERMEDIATE) + .instrumentType("PIANO") + .subscriptionTier("PRO") + .profileImgUrl("https://cdn.example.com/profile/default-profile.png") + .build(); + given(userProfileService.registerProfile(any())).willReturn(response); + + mockMvc.perform(post("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.userId").value(1L)) + .andExpect(jsonPath("$.data.nickname").value("김뮤즈")) + .andExpect(jsonPath("$.data.instrumentType").value("PIANO")); + } + + @Test + @DisplayName("POST /api/users/me/profile - 이미 온보딩 완료했으면 409") + void registerProfile_alreadyCompleted() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( + "김뮤즈", TheoryLevel.INTERMEDIATE, SubscriptionTier.PRO + ); + willThrow(new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED)) + .given(userProfileService).registerProfile(any()); + + mockMvc.perform(post("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_409_02")); + } + + @Test + @DisplayName("POST /api/users/me/profile - 닉네임 중복이면 409") + void registerProfile_nicknameDuplicated() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( + "김뮤즈", TheoryLevel.INTERMEDIATE, SubscriptionTier.PRO + ); + willThrow(new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED)) + .given(userProfileService).registerProfile(any()); + + mockMvc.perform(post("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_409_01")); + } + + @Test + @DisplayName("PATCH /api/users/me/profile - 수정 성공") + void updateProfile_success() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.UpdateRequest request = new UserProfileRequestDTO.UpdateRequest( + "새로운뮤즈", TheoryLevel.ADVANCED + ); + UserProfileResponseDTO.UpdateResponse response = UserProfileResponseDTO.UpdateResponse.builder() + .userId(1L) + .nickname("새로운뮤즈") + .skillLevel(TheoryLevel.ADVANCED) + .updatedAt(LocalDateTime.of(2026, 7, 24, 1, 30)) + .build(); + given(userProfileService.updateProfile(any())).willReturn(response); + + mockMvc.perform(patch("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.nickname").value("새로운뮤즈")) + .andExpect(jsonPath("$.data.skillLevel").value("ADVANCED")); + } + + @Test + @DisplayName("PATCH /api/users/me/profile - 온보딩 안 된 유저면 404") + void updateProfile_studentNotFound() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.UpdateRequest request = new UserProfileRequestDTO.UpdateRequest( + "새로운뮤즈", TheoryLevel.ADVANCED + ); + willThrow(new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)) + .given(userProfileService).updateProfile(any()); + + mockMvc.perform(patch("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STUDENT_404_01")); + } + + @Test + @DisplayName("PATCH /api/users/me/profile - 닉네임 중복이면 409") + void updateProfile_nicknameDuplicated() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.UpdateRequest request = new UserProfileRequestDTO.UpdateRequest( + "새로운뮤즈", TheoryLevel.ADVANCED + ); + willThrow(new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED)) + .given(userProfileService).updateProfile(any()); + + mockMvc.perform(patch("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_409_01")); + } +} From 4a8642a0ee0da808ef5a2aa9dfa5f76cfc2fb161 Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 23:01:24 +0900 Subject: [PATCH 08/25] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=ED=99=9C=EC=84=B1=20=EA=B5=AC?= =?UTF-8?q?=EB=8F=85=EB=A7=8C=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subscriptions/repository/SubscriptionRepository.java | 4 +++- .../java/com/mr/domain/user/service/UserProfileService.java | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java b/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java index 219dae6a..62112388 100644 --- a/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java +++ b/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java @@ -2,10 +2,12 @@ import com.mr.domain.subscriptions.entity.Subscription; import com.mr.domain.user.entity.User; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface SubscriptionRepository extends JpaRepository { - Optional findFirstByUserOrderByStartDateDesc(User user); + Optional findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc( + User user, LocalDateTime startDateInclusive, LocalDateTime endDateInclusive); } diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index d8958eb8..e3e8d7c9 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -48,9 +48,11 @@ public UserProfileResponseDTO.ProfileResponse getMyProfile() { .map(Instrument::getCode) .orElseThrow(() -> new IllegalStateException("대표 악기 정보가 존재하지 않습니다.")); - String subscriptionTier = subscriptionRepository.findFirstByUserOrderByStartDateDesc(user) + LocalDateTime now = LocalDateTime.now(); + String subscriptionTier = subscriptionRepository + .findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(user, now, now) .map(Subscription::getTier) - .orElseThrow(() -> new IllegalStateException("구독 정보가 존재하지 않습니다.")); + .orElseThrow(() -> new IllegalStateException("유효한 구독 정보가 존재하지 않습니다.")); return UserProfileResponseDTO.ProfileResponse.builder() .nickname(user.getNickname()) From 73b868449970ba10cd154ca6603d290dbdb3d77a Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 23:09:16 +0900 Subject: [PATCH 09/25] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=8B=A4=ED=96=89=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=95=88=EC=A0=84=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/user/config/InstrumentSeeder.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java index 6c0cc633..849b89e0 100644 --- a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java +++ b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java @@ -3,11 +3,14 @@ import com.mr.domain.user.entity.Instrument; import com.mr.domain.user.repository.InstrumentRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Component @RequiredArgsConstructor public class InstrumentSeeder implements ApplicationRunner { @@ -20,7 +23,16 @@ public class InstrumentSeeder implements ApplicationRunner { @Override @Transactional public void run(ApplicationArguments args) { - instrumentRepository.findByCode(PIANO_CODE) - .orElseGet(() -> instrumentRepository.save(Instrument.create(PIANO_CODE, PIANO_NAME))); + if (instrumentRepository.findByCode(PIANO_CODE).isPresent()) { + return; + } + + try { + instrumentRepository.save(Instrument.create(PIANO_CODE, PIANO_NAME)); + } catch (DataIntegrityViolationException e) { + // 여러 인스턴스가 동시에 기동해 경쟁 상태로 중복 저장을 시도한 경우 + // code unique 제약으로 한쪽만 성공하므로, 나머지는 무시하고 넘어감 + log.info("PIANO 악기 시드 데이터가 다른 인스턴스에 의해 이미 생성됨"); + } } } From da3f6d27b59202cb04046bff9929b685b35d5536 Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 23:18:20 +0900 Subject: [PATCH 10/25] =?UTF-8?q?fix:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=82=AC=EB=A5=BC=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B3=80=EA=B2=BD=20=EC=A0=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/user/service/UserProfileService.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index e3e8d7c9..d5f75e60 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -73,8 +73,8 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); } + ensureNicknameNotTaken(user.getUserId(), request.nickname()); user.updateNickname(request.nickname()); - ensureNicknameNotTaken(user); Student student = studentRepository.save(Student.create(user, request.skillLevel())); @@ -103,8 +103,8 @@ public UserProfileResponseDTO.UpdateResponse updateProfile(UserProfileRequestDTO User user = getUser(userId); Student student = getStudent(user); + ensureNicknameNotTaken(user.getUserId(), request.nickname()); user.updateNickname(request.nickname()); - ensureNicknameNotTaken(user); student.updateTheoryLevel(request.skillLevel()); return UserProfileResponseDTO.UpdateResponse.builder() @@ -125,8 +125,9 @@ private Student getStudent(User user) { .orElseThrow(() -> new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); } - private void ensureNicknameNotTaken(User user) { - if (userRepository.existsByNicknameAndUserIdNot(user.getNickname(), user.getUserId())) { + private void ensureNicknameNotTaken(Long userId, String requestedNickname) { + String trimmedNickname = requestedNickname == null ? null : requestedNickname.trim(); + if (userRepository.existsByNicknameAndUserIdNot(trimmedNickname, userId)) { throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); } } From 8403213e172002b0d94f4b60931fd776ff0badb9 Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 23:29:37 +0900 Subject: [PATCH 11/25] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=A7=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/user/service/UserProfileService.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index d5f75e60..59456643 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -20,6 +20,7 @@ import com.mr.global.security.SecurityUtil; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -76,7 +77,13 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ ensureNicknameNotTaken(user.getUserId(), request.nickname()); user.updateNickname(request.nickname()); - Student student = studentRepository.save(Student.create(user, request.skillLevel())); + Student student; + try { + student = studentRepository.save(Student.create(user, request.skillLevel())); + } catch (DataIntegrityViolationException e) { + // 동시에 들어온 다른 요청이 먼저 온보딩을 완료한 경우(Student.user_id unique 제약 위반) + throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + } Instrument piano = instrumentRepository.findByCode(PIANO_CODE) .orElseThrow(() -> new IllegalStateException("PIANO 악기 시드 데이터가 존재하지 않습니다.")); From 8fdd0e3cd80301f8e5b18f63a9861d07e94528ab Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 23:50:06 +0900 Subject: [PATCH 12/25] =?UTF-8?q?fix:=20=EA=B5=AC=EB=8F=85=EB=93=B1?= =?UTF-8?q?=EA=B8=89=20=ED=83=80=EC=9E=85=EC=9D=84=20String=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=98=EA=B3=A0=20=EA=B0=92=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/dto/UserProfileRequestDTO.java | 3 +-- .../domain/user/service/UserProfileService.java | 17 ++++++++++++++++- .../controller/UserProfileControllerTest.java | 7 +++---- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java b/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java index 70309697..90eb7d61 100644 --- a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java +++ b/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java @@ -1,6 +1,5 @@ package com.mr.domain.user.dto; -import com.mr.domain.subscriptions.entity.enums.SubscriptionTier; import com.mr.domain.user.entity.enums.TheoryLevel; public class UserProfileRequestDTO { @@ -8,7 +7,7 @@ public class UserProfileRequestDTO { public record OnboardingRequest( String nickname, TheoryLevel skillLevel, - SubscriptionTier subscriptionTier + String subscriptionTier ) {} public record UpdateRequest( diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index 59456643..f2f5c37d 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -1,6 +1,7 @@ package com.mr.domain.user.service; import com.mr.domain.subscriptions.entity.Subscription; +import com.mr.domain.subscriptions.entity.enums.SubscriptionTier; import com.mr.domain.subscriptions.repository.SubscriptionRepository; import com.mr.domain.statistics.entity.UserStatistics; import com.mr.domain.statistics.repository.UserStatisticsRepository; @@ -16,9 +17,11 @@ import com.mr.domain.user.repository.StudentInstrumentRepository; import com.mr.domain.user.repository.StudentRepository; import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.code.CommonStatus; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.SecurityUtil; import java.time.LocalDateTime; +import java.util.Arrays; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -89,7 +92,7 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ .orElseThrow(() -> new IllegalStateException("PIANO 악기 시드 데이터가 존재하지 않습니다.")); studentInstrumentRepository.save(StudentInstrument.createPrimary(student, piano)); - String tier = request.subscriptionTier() == null ? null : request.subscriptionTier().name(); + String tier = validateSubscriptionTier(request.subscriptionTier()); LocalDateTime now = LocalDateTime.now(); Subscription subscription = subscriptionRepository.save( Subscription.create(user, tier, now, now.plusDays(SUBSCRIPTION_PERIOD_DAYS))); @@ -132,6 +135,18 @@ private Student getStudent(User user) { .orElseThrow(() -> new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); } + private String validateSubscriptionTier(String subscriptionTier) { + if (subscriptionTier == null) { + return null; + } + boolean isValidTier = Arrays.stream(SubscriptionTier.values()) + .anyMatch(tier -> tier.name().equals(subscriptionTier)); + if (!isValidTier) { + throw new GeneralException(CommonStatus.HTTP_MESSAGE_NOT_READABLE); + } + return subscriptionTier; + } + private void ensureNicknameNotTaken(Long userId, String requestedNickname) { String trimmedNickname = requestedNickname == null ? null : requestedNickname.trim(); if (userRepository.existsByNicknameAndUserIdNot(trimmedNickname, userId)) { diff --git a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java index 9bfcdfef..b31aba91 100644 --- a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java @@ -10,7 +10,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import com.mr.domain.subscriptions.entity.enums.SubscriptionTier; import com.mr.domain.user.dto.UserProfileRequestDTO; import com.mr.domain.user.dto.UserProfileResponseDTO; import com.mr.domain.user.entity.enums.TheoryLevel; @@ -112,7 +111,7 @@ void registerProfile_success() throws Exception { mockMvc = setUp(); UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( - "김뮤즈", TheoryLevel.INTERMEDIATE, SubscriptionTier.PRO + "김뮤즈", TheoryLevel.INTERMEDIATE, "PRO" ); UserProfileResponseDTO.OnboardingResponse response = UserProfileResponseDTO.OnboardingResponse.builder() .userId(1L) @@ -140,7 +139,7 @@ void registerProfile_alreadyCompleted() throws Exception { mockMvc = setUp(); UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( - "김뮤즈", TheoryLevel.INTERMEDIATE, SubscriptionTier.PRO + "김뮤즈", TheoryLevel.INTERMEDIATE, "PRO" ); willThrow(new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED)) .given(userProfileService).registerProfile(any()); @@ -158,7 +157,7 @@ void registerProfile_nicknameDuplicated() throws Exception { mockMvc = setUp(); UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( - "김뮤즈", TheoryLevel.INTERMEDIATE, SubscriptionTier.PRO + "김뮤즈", TheoryLevel.INTERMEDIATE, "PRO" ); willThrow(new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED)) .given(userProfileService).registerProfile(any()); From 3eca142113a4d1a3c41f441611f1633cfb98fd1d Mon Sep 17 00:00:00 2001 From: p1001q Date: Fri, 24 Jul 2026 23:56:13 +0900 Subject: [PATCH 13/25] =?UTF-8?q?fix:=20IllegalStateException=EC=9D=84=20G?= =?UTF-8?q?eneralException=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/SubscriptionErrorStatus.java | 3 ++- .../user/exception/InstrumentErrorStatus.java | 3 ++- .../exception/StudentInstrumentErrorStatus.java | 3 ++- .../mr/domain/user/service/UserProfileService.java | 13 ++++++++----- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java b/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java index 4b9fb959..567de8af 100644 --- a/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java +++ b/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java @@ -12,7 +12,8 @@ public enum SubscriptionErrorStatus implements BaseCode { INVALID_SUBSCRIPTION_DATE(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_01", "구독 종료일은 시작일보다 이전일 수 없습니다."), INVALID_SUBSCRIPTION_EXTENSION_DATE(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_02", "연장할 종료일은 기존 종료일보다 이후여야 합니다."), USER_REQUIRED(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_03", "user는 필수입니다."), - TIER_REQUIRED(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_04", "구독 등급은 필수입니다."); + TIER_REQUIRED(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_04", "구독 등급은 필수입니다."), + ACTIVE_SUBSCRIPTION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "SUBSCRIPTION_500_01", "유효한 구독 정보가 존재하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/exception/InstrumentErrorStatus.java b/src/main/java/com/mr/domain/user/exception/InstrumentErrorStatus.java index b0257eb5..4324b7ba 100644 --- a/src/main/java/com/mr/domain/user/exception/InstrumentErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/InstrumentErrorStatus.java @@ -10,7 +10,8 @@ public enum InstrumentErrorStatus implements BaseCode { CODE_REQUIRED(HttpStatus.BAD_REQUEST, "INSTRUMENT_400_01", "code는 필수입니다."), - NAME_REQUIRED(HttpStatus.BAD_REQUEST, "INSTRUMENT_400_02", "name은 필수입니다."); + NAME_REQUIRED(HttpStatus.BAD_REQUEST, "INSTRUMENT_400_02", "name은 필수입니다."), + INSTRUMENT_NOT_SEEDED(HttpStatus.INTERNAL_SERVER_ERROR, "INSTRUMENT_500_01", "PIANO 악기 시드 데이터가 존재하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/exception/StudentInstrumentErrorStatus.java b/src/main/java/com/mr/domain/user/exception/StudentInstrumentErrorStatus.java index 4814213c..070ccd70 100644 --- a/src/main/java/com/mr/domain/user/exception/StudentInstrumentErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/StudentInstrumentErrorStatus.java @@ -10,7 +10,8 @@ public enum StudentInstrumentErrorStatus implements BaseCode { STUDENT_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_INSTRUMENT_400_01", "student는 필수입니다."), - INSTRUMENT_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_INSTRUMENT_400_02", "instrument는 필수입니다."); + INSTRUMENT_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_INSTRUMENT_400_02", "instrument는 필수입니다."), + PRIMARY_INSTRUMENT_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "STUDENT_INSTRUMENT_500_01", "대표 악기 정보가 존재하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index f2f5c37d..7c6c8097 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -1,17 +1,20 @@ package com.mr.domain.user.service; +import com.mr.domain.statistics.entity.UserStatistics; +import com.mr.domain.statistics.repository.UserStatisticsRepository; import com.mr.domain.subscriptions.entity.Subscription; import com.mr.domain.subscriptions.entity.enums.SubscriptionTier; +import com.mr.domain.subscriptions.exception.SubscriptionErrorStatus; import com.mr.domain.subscriptions.repository.SubscriptionRepository; -import com.mr.domain.statistics.entity.UserStatistics; -import com.mr.domain.statistics.repository.UserStatisticsRepository; import com.mr.domain.user.dto.UserProfileRequestDTO; import com.mr.domain.user.dto.UserProfileResponseDTO; import com.mr.domain.user.entity.Instrument; import com.mr.domain.user.entity.Student; import com.mr.domain.user.entity.StudentInstrument; import com.mr.domain.user.entity.User; +import com.mr.domain.user.exception.InstrumentErrorStatus; import com.mr.domain.user.exception.StudentErrorStatus; +import com.mr.domain.user.exception.StudentInstrumentErrorStatus; import com.mr.domain.user.exception.UserErrorStatus; import com.mr.domain.user.repository.InstrumentRepository; import com.mr.domain.user.repository.StudentInstrumentRepository; @@ -50,13 +53,13 @@ public UserProfileResponseDTO.ProfileResponse getMyProfile() { String instrumentType = studentInstrumentRepository.findByStudentAndPrimaryTrue(student) .map(StudentInstrument::getInstrument) .map(Instrument::getCode) - .orElseThrow(() -> new IllegalStateException("대표 악기 정보가 존재하지 않습니다.")); + .orElseThrow(() -> new GeneralException(StudentInstrumentErrorStatus.PRIMARY_INSTRUMENT_NOT_FOUND)); LocalDateTime now = LocalDateTime.now(); String subscriptionTier = subscriptionRepository .findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(user, now, now) .map(Subscription::getTier) - .orElseThrow(() -> new IllegalStateException("유효한 구독 정보가 존재하지 않습니다.")); + .orElseThrow(() -> new GeneralException(SubscriptionErrorStatus.ACTIVE_SUBSCRIPTION_NOT_FOUND)); return UserProfileResponseDTO.ProfileResponse.builder() .nickname(user.getNickname()) @@ -89,7 +92,7 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ } Instrument piano = instrumentRepository.findByCode(PIANO_CODE) - .orElseThrow(() -> new IllegalStateException("PIANO 악기 시드 데이터가 존재하지 않습니다.")); + .orElseThrow(() -> new GeneralException(InstrumentErrorStatus.INSTRUMENT_NOT_SEEDED)); studentInstrumentRepository.save(StudentInstrument.createPrimary(student, piano)); String tier = validateSubscriptionTier(request.subscriptionTier()); From e445c834d34e1b2c1ba38e81d204ffb19f259608 Mon Sep 17 00:00:00 2001 From: p1001q Date: Sat, 25 Jul 2026 00:26:38 +0900 Subject: [PATCH 14/25] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EC=97=90=EC=84=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=98=A4=EC=97=BC=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/user/config/InstrumentSeeder.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java index 849b89e0..51a0e4aa 100644 --- a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java +++ b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java @@ -8,7 +8,6 @@ import org.springframework.boot.ApplicationRunner; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; @Slf4j @Component @@ -20,8 +19,11 @@ public class InstrumentSeeder implements ApplicationRunner { private final InstrumentRepository instrumentRepository; + // NOTE: run() 자체엔 @Transactional을 걸지 않음 — PostgreSQL은 트랜잭션 내 한 문장이라도 + // 제약 위반으로 실패하면 그 트랜잭션 전체가 abort 상태가 되어, 이후 catch로 예외를 소비해도 + // 커밋 시점에 다시 실패함. save() 각각이 Spring Data JPA의 자체 트랜잭션으로 독립 처리되도록 + // run() 레벨 트랜잭션을 없애서, 중복 저장 실패가 그 save() 호출 안에서 깔끔하게 끝나게 함. @Override - @Transactional public void run(ApplicationArguments args) { if (instrumentRepository.findByCode(PIANO_CODE).isPresent()) { return; From 825a92c7e0f14bbe2edc39b7f21c5776cab6bf28 Mon Sep 17 00:00:00 2001 From: p1001q Date: Sat, 25 Jul 2026 00:39:44 +0900 Subject: [PATCH 15/25] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EC=97=90=EC=84=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=98=A4=EC=97=BC=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/user/config/InstrumentSeeder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java index 51a0e4aa..c3bcc720 100644 --- a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java +++ b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java @@ -30,7 +30,7 @@ public void run(ApplicationArguments args) { } try { - instrumentRepository.save(Instrument.create(PIANO_CODE, PIANO_NAME)); + instrumentRepository.saveAndFlush(Instrument.create(PIANO_CODE, PIANO_NAME)); } catch (DataIntegrityViolationException e) { // 여러 인스턴스가 동시에 기동해 경쟁 상태로 중복 저장을 시도한 경우 // code unique 제약으로 한쪽만 성공하므로, 나머지는 무시하고 넘어감 From 32daad5bcb1b3cb4db11a39c7217ae33d2be65a5 Mon Sep 17 00:00:00 2001 From: p1001q Date: Sat, 25 Jul 2026 01:32:47 +0900 Subject: [PATCH 16/25] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=ED=82=A4=20=EC=99=B8=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=8A=94=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/user/config/InstrumentSeeder.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java index c3bcc720..ce4d85e8 100644 --- a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java +++ b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java @@ -4,6 +4,7 @@ import com.mr.domain.user.repository.InstrumentRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.dao.DataIntegrityViolationException; @@ -16,13 +17,14 @@ public class InstrumentSeeder implements ApplicationRunner { private static final String PIANO_CODE = "PIANO"; private static final String PIANO_NAME = "피아노"; + private static final String POSTGRES_UNIQUE_VIOLATION_SQL_STATE = "23505"; private final InstrumentRepository instrumentRepository; // NOTE: run() 자체엔 @Transactional을 걸지 않음 — PostgreSQL은 트랜잭션 내 한 문장이라도 // 제약 위반으로 실패하면 그 트랜잭션 전체가 abort 상태가 되어, 이후 catch로 예외를 소비해도 - // 커밋 시점에 다시 실패함. save() 각각이 Spring Data JPA의 자체 트랜잭션으로 독립 처리되도록 - // run() 레벨 트랜잭션을 없애서, 중복 저장 실패가 그 save() 호출 안에서 깔끔하게 끝나게 함. + // 커밋 시점에 다시 실패함. saveAndFlush() 각각이 Spring Data JPA의 자체 트랜잭션으로 독립 처리되도록 + // run() 레벨 트랜잭션을 없애서, 중복 저장 실패가 그 saveAndFlush() 호출 안에서 깔끔하게 끝나게 함. @Override public void run(ApplicationArguments args) { if (instrumentRepository.findByCode(PIANO_CODE).isPresent()) { @@ -32,9 +34,17 @@ public void run(ApplicationArguments args) { try { instrumentRepository.saveAndFlush(Instrument.create(PIANO_CODE, PIANO_NAME)); } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyViolation(e)) { + throw e; + } // 여러 인스턴스가 동시에 기동해 경쟁 상태로 중복 저장을 시도한 경우 // code unique 제약으로 한쪽만 성공하므로, 나머지는 무시하고 넘어감 log.info("PIANO 악기 시드 데이터가 다른 인스턴스에 의해 이미 생성됨"); } } + + private boolean isDuplicateKeyViolation(DataIntegrityViolationException e) { + return e.getCause() instanceof ConstraintViolationException constraintViolationException + && POSTGRES_UNIQUE_VIOLATION_SQL_STATE.equals(constraintViolationException.getSQLState()); + } } From da5c09205ea1c528a27ebd78fe81b6d63c757149 Mon Sep 17 00:00:00 2001 From: p1001q Date: Sat, 25 Jul 2026 01:33:32 +0900 Subject: [PATCH 17/25] =?UTF-8?q?fix:=20=EA=B5=AC=EB=8F=85=EB=93=B1?= =?UTF-8?q?=EA=B8=89=20=EA=B2=80=EC=A6=9D=20=EC=88=9C=EC=84=9C=20=EB=B0=8F?= =?UTF-8?q?=20Student=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/user/service/UserProfileService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index 7c6c8097..8ee041c8 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -80,12 +80,14 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); } + String tier = validateSubscriptionTier(request.subscriptionTier()); + ensureNicknameNotTaken(user.getUserId(), request.nickname()); user.updateNickname(request.nickname()); Student student; try { - student = studentRepository.save(Student.create(user, request.skillLevel())); + student = studentRepository.saveAndFlush(Student.create(user, request.skillLevel())); } catch (DataIntegrityViolationException e) { // 동시에 들어온 다른 요청이 먼저 온보딩을 완료한 경우(Student.user_id unique 제약 위반) throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); @@ -95,7 +97,6 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ .orElseThrow(() -> new GeneralException(InstrumentErrorStatus.INSTRUMENT_NOT_SEEDED)); studentInstrumentRepository.save(StudentInstrument.createPrimary(student, piano)); - String tier = validateSubscriptionTier(request.subscriptionTier()); LocalDateTime now = LocalDateTime.now(); Subscription subscription = subscriptionRepository.save( Subscription.create(user, tier, now, now.plusDays(SUBSCRIPTION_PERIOD_DAYS))); From a61f6379d47b7f3e0a989a704c3c22642a890cae Mon Sep 17 00:00:00 2001 From: p1001q Date: Sat, 25 Jul 2026 05:41:50 +0900 Subject: [PATCH 18/25] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=ED=95=84=20API?= =?UTF-8?q?=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=84=A4=EB=AA=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserProfileController.java | 16 ++++++++ .../user/dto/UserProfileRequestDTO.java | 11 ++++++ .../user/dto/UserProfileResponseDTO.java | 38 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/src/main/java/com/mr/domain/user/controller/UserProfileController.java b/src/main/java/com/mr/domain/user/controller/UserProfileController.java index 304f21f3..a0074bb3 100644 --- a/src/main/java/com/mr/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/mr/domain/user/controller/UserProfileController.java @@ -4,6 +4,8 @@ import com.mr.domain.user.dto.UserProfileResponseDTO; import com.mr.domain.user.service.UserProfileService; import com.mr.global.apipayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; @@ -17,15 +19,25 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/users/me/profile") +@Tag(name = "사용자 프로필", description = "프로필 조회/최초 등록(온보딩)/수정 API") public class UserProfileController { private final UserProfileService userProfileService; + @Operation( + summary = "프로필 조회", + description = "현재 로그인한 사용자의 닉네임, 프로필 이미지, 대표 악기, 화성학 숙련도, 구독 등급, 누적 학습 통계를 조회합니다." + ) @GetMapping public ApiResponse getMyProfile() { return ApiResponse.onSuccess(userProfileService.getMyProfile()); } + @Operation( + summary = "프로필 최초 등록 (온보딩)", + description = "닉네임, 화성학 숙련도, 구독 등급을 입력받아 Student/StudentInstrument(대표 악기 PIANO)/Subscription을 하나의 트랜잭션으로 생성합니다. " + + "이미 온보딩을 완료한 사용자가 다시 호출하면 409(USER_409_02)가 반환됩니다." + ) @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse registerProfile( @@ -34,6 +46,10 @@ public ApiResponse registerProfile( return ApiResponse.onSuccess(userProfileService.registerProfile(request)); } + @Operation( + summary = "프로필 수정", + description = "닉네임과 화성학 숙련도만 수정합니다. 대표 악기, 프로필 이미지, 구독 등급은 이 API에서 변경되지 않습니다." + ) @PatchMapping public ApiResponse updateProfile( @RequestBody UserProfileRequestDTO.UpdateRequest request diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java b/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java index 90eb7d61..64b2353f 100644 --- a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java +++ b/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java @@ -1,17 +1,28 @@ package com.mr.domain.user.dto; import com.mr.domain.user.entity.enums.TheoryLevel; +import io.swagger.v3.oas.annotations.media.Schema; public class UserProfileRequestDTO { + @Schema(description = "프로필 최초 등록(온보딩) 요청") public record OnboardingRequest( + @Schema(description = "닉네임 (한글/영문/숫자 2~10자)", example = "김뮤즈") String nickname, + + @Schema(description = "화성학 숙련도", example = "INTERMEDIATE") TheoryLevel skillLevel, + + @Schema(description = "구독 등급 (버튼 선택과 무관하게 항상 PRO 전송)", example = "PRO") String subscriptionTier ) {} + @Schema(description = "프로필 수정 요청") public record UpdateRequest( + @Schema(description = "변경할 닉네임 (한글/영문/숫자 2~10자)", example = "새로운뮤즈") String nickname, + + @Schema(description = "변경할 화성학 숙련도", example = "ADVANCED") TheoryLevel skillLevel ) {} } diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java b/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java index 901c78dc..689973d4 100644 --- a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java +++ b/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java @@ -1,43 +1,81 @@ package com.mr.domain.user.dto; import com.mr.domain.user.entity.enums.TheoryLevel; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import lombok.Builder; public class UserProfileResponseDTO { @Builder + @Schema(description = "프로필 조회 응답") public record ProfileResponse( + @Schema(description = "닉네임", example = "김뮤즈") String nickname, + + @Schema(description = "프로필 이미지 URL", example = "https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png") String profileImgUrl, + + @Schema(description = "대표 악기 (MVP는 PIANO 고정)", example = "PIANO") String instrumentType, + + @Schema(description = "화성학 숙련도", example = "INTERMEDIATE") TheoryLevel skillLevel, + + @Schema(description = "구독 등급", example = "PRO") String subscriptionTier, + StatisticsResponse statistics ) {} @Builder + @Schema(description = "누적 학습 통계") public record StatisticsResponse( + @Schema(description = "누적 연주 세션 수", example = "24") Long practiceSessionCount, + + @Schema(description = "누적 연습 시간(분)", example = "350") Long totalPracticeMinutes, + + @Schema(description = "완료한 학습 개수 (score 90점 이상)", example = "8") Long completedLearningCount ) {} @Builder + @Schema(description = "프로필 최초 등록(온보딩) 응답") public record OnboardingResponse( + @Schema(description = "사용자 ID", example = "1") Long userId, + + @Schema(description = "닉네임", example = "김뮤즈") String nickname, + + @Schema(description = "화성학 숙련도", example = "INTERMEDIATE") TheoryLevel skillLevel, + + @Schema(description = "대표 악기", example = "PIANO") String instrumentType, + + @Schema(description = "구독 등급", example = "PRO") String subscriptionTier, + + @Schema(description = "프로필 이미지 URL", example = "https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png") String profileImgUrl ) {} @Builder + @Schema(description = "프로필 수정 응답") public record UpdateResponse( + @Schema(description = "사용자 ID", example = "1") Long userId, + + @Schema(description = "수정된 닉네임", example = "새로운뮤즈") String nickname, + + @Schema(description = "수정된 화성학 숙련도", example = "ADVANCED") TheoryLevel skillLevel, + + @Schema(description = "처리 완료 시각", example = "2026-07-24T01:30:00") LocalDateTime updatedAt ) {} } From ef9704406be8d1bf5104d8b91e318d05f15e6a8d Mon Sep 17 00:00:00 2001 From: p1001q Date: Sun, 26 Jul 2026 01:03:41 +0900 Subject: [PATCH 19/25] =?UTF-8?q?fix:=20=EB=8B=89=EB=84=A4=EC=9E=84=20trim?= =?UTF-8?q?=EC=9D=84=20=ED=95=9C=20=EA=B3=B3=EC=97=90=EC=84=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=A0=95=EB=A6=AC=20(#3?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/UserProfileService.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index 8ee041c8..adc32e92 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -6,8 +6,8 @@ import com.mr.domain.subscriptions.entity.enums.SubscriptionTier; import com.mr.domain.subscriptions.exception.SubscriptionErrorStatus; import com.mr.domain.subscriptions.repository.SubscriptionRepository; -import com.mr.domain.user.dto.UserProfileRequestDTO; -import com.mr.domain.user.dto.UserProfileResponseDTO; +import com.mr.domain.user.dto.req.UserProfileRequestDTO; +import com.mr.domain.user.dto.res.UserProfileResponseDTO; import com.mr.domain.user.entity.Instrument; import com.mr.domain.user.entity.Student; import com.mr.domain.user.entity.StudentInstrument; @@ -82,8 +82,9 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ String tier = validateSubscriptionTier(request.subscriptionTier()); - ensureNicknameNotTaken(user.getUserId(), request.nickname()); - user.updateNickname(request.nickname()); + String trimmedNickname = trimNickname(request.nickname()); + ensureNicknameNotTaken(user.getUserId(), trimmedNickname); + user.updateNickname(trimmedNickname); Student student; try { @@ -117,8 +118,9 @@ public UserProfileResponseDTO.UpdateResponse updateProfile(UserProfileRequestDTO User user = getUser(userId); Student student = getStudent(user); - ensureNicknameNotTaken(user.getUserId(), request.nickname()); - user.updateNickname(request.nickname()); + String trimmedNickname = trimNickname(request.nickname()); + ensureNicknameNotTaken(user.getUserId(), trimmedNickname); + user.updateNickname(trimmedNickname); student.updateTheoryLevel(request.skillLevel()); return UserProfileResponseDTO.UpdateResponse.builder() @@ -151,8 +153,11 @@ private String validateSubscriptionTier(String subscriptionTier) { return subscriptionTier; } - private void ensureNicknameNotTaken(Long userId, String requestedNickname) { - String trimmedNickname = requestedNickname == null ? null : requestedNickname.trim(); + private String trimNickname(String nickname) { + return nickname == null ? null : nickname.trim(); + } + + private void ensureNicknameNotTaken(Long userId, String trimmedNickname) { if (userRepository.existsByNicknameAndUserIdNot(trimmedNickname, userId)) { throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); } From 67ba5298131784777028a3e679e2effa95fa727c Mon Sep 17 00:00:00 2001 From: p1001q Date: Sun, 26 Jul 2026 01:04:34 +0900 Subject: [PATCH 20/25] =?UTF-8?q?refactor:=20DTO=EB=A5=BC=20req/res=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#3?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/user/controller/UserProfileController.java | 4 ++-- .../mr/domain/user/dto/{ => req}/UserProfileRequestDTO.java | 2 +- .../mr/domain/user/dto/{ => res}/UserProfileResponseDTO.java | 2 +- .../mr/domain/user/controller/UserProfileControllerTest.java | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/main/java/com/mr/domain/user/dto/{ => req}/UserProfileRequestDTO.java (96%) rename src/main/java/com/mr/domain/user/dto/{ => res}/UserProfileResponseDTO.java (98%) diff --git a/src/main/java/com/mr/domain/user/controller/UserProfileController.java b/src/main/java/com/mr/domain/user/controller/UserProfileController.java index a0074bb3..764f6860 100644 --- a/src/main/java/com/mr/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/mr/domain/user/controller/UserProfileController.java @@ -1,7 +1,7 @@ package com.mr.domain.user.controller; -import com.mr.domain.user.dto.UserProfileRequestDTO; -import com.mr.domain.user.dto.UserProfileResponseDTO; +import com.mr.domain.user.dto.req.UserProfileRequestDTO; +import com.mr.domain.user.dto.res.UserProfileResponseDTO; import com.mr.domain.user.service.UserProfileService; import com.mr.global.apipayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java b/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java similarity index 96% rename from src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java rename to src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java index 64b2353f..18b9694b 100644 --- a/src/main/java/com/mr/domain/user/dto/UserProfileRequestDTO.java +++ b/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java @@ -1,4 +1,4 @@ -package com.mr.domain.user.dto; +package com.mr.domain.user.dto.req; import com.mr.domain.user.entity.enums.TheoryLevel; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java b/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java similarity index 98% rename from src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java rename to src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java index 689973d4..f09040b5 100644 --- a/src/main/java/com/mr/domain/user/dto/UserProfileResponseDTO.java +++ b/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java @@ -1,4 +1,4 @@ -package com.mr.domain.user.dto; +package com.mr.domain.user.dto.res; import com.mr.domain.user.entity.enums.TheoryLevel; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java index b31aba91..387e04ea 100644 --- a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java @@ -10,8 +10,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import com.mr.domain.user.dto.UserProfileRequestDTO; -import com.mr.domain.user.dto.UserProfileResponseDTO; +import com.mr.domain.user.dto.req.UserProfileRequestDTO; +import com.mr.domain.user.dto.res.UserProfileResponseDTO; import com.mr.domain.user.entity.enums.TheoryLevel; import com.mr.domain.user.exception.StudentErrorStatus; import com.mr.domain.user.exception.UserErrorStatus; From 600940b310025896ff4d2e63b80971631254fc7e Mon Sep 17 00:00:00 2001 From: p1001q Date: Sun, 26 Jul 2026 23:15:11 +0900 Subject: [PATCH 21/25] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EB=93=B1=EA=B8=89=EC=9D=84=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EA=B0=92=20=EB=8C=80=EC=8B=A0=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20PRO=EB=A1=9C=20=EA=B3=A0=EC=A0=95=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserProfileController.java | 3 +- .../user/dto/req/UserProfileRequestDTO.java | 5 +- .../user/service/UserProfileService.java | 18 +-- .../controller/UserProfileControllerTest.java | 6 +- .../user/service/UserProfileServiceTest.java | 151 ++++++++++++++++++ 5 files changed, 158 insertions(+), 25 deletions(-) create mode 100644 src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java diff --git a/src/main/java/com/mr/domain/user/controller/UserProfileController.java b/src/main/java/com/mr/domain/user/controller/UserProfileController.java index 764f6860..3102e582 100644 --- a/src/main/java/com/mr/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/mr/domain/user/controller/UserProfileController.java @@ -35,7 +35,8 @@ public ApiResponse getMyProfile() { @Operation( summary = "프로필 최초 등록 (온보딩)", - description = "닉네임, 화성학 숙련도, 구독 등급을 입력받아 Student/StudentInstrument(대표 악기 PIANO)/Subscription을 하나의 트랜잭션으로 생성합니다. " + description = "닉네임, 화성학 숙련도를 입력받아 Student/StudentInstrument(대표 악기 PIANO)/Subscription을 하나의 트랜잭션으로 생성합니다. " + + "구독 등급은 MVP 기간 동안 결제 없이 항상 PRO로 서버에서 고정됩니다. " + "이미 온보딩을 완료한 사용자가 다시 호출하면 409(USER_409_02)가 반환됩니다." ) @PostMapping diff --git a/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java b/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java index 18b9694b..06ac04ad 100644 --- a/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java +++ b/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java @@ -11,10 +11,7 @@ public record OnboardingRequest( String nickname, @Schema(description = "화성학 숙련도", example = "INTERMEDIATE") - TheoryLevel skillLevel, - - @Schema(description = "구독 등급 (버튼 선택과 무관하게 항상 PRO 전송)", example = "PRO") - String subscriptionTier + TheoryLevel skillLevel ) {} @Schema(description = "프로필 수정 요청") diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index adc32e92..f8065092 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -20,11 +20,9 @@ import com.mr.domain.user.repository.StudentInstrumentRepository; import com.mr.domain.user.repository.StudentRepository; import com.mr.domain.user.repository.UserRepository; -import com.mr.global.apipayload.code.CommonStatus; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.SecurityUtil; import java.time.LocalDateTime; -import java.util.Arrays; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -80,8 +78,6 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); } - String tier = validateSubscriptionTier(request.subscriptionTier()); - String trimmedNickname = trimNickname(request.nickname()); ensureNicknameNotTaken(user.getUserId(), trimmedNickname); user.updateNickname(trimmedNickname); @@ -100,7 +96,7 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ LocalDateTime now = LocalDateTime.now(); Subscription subscription = subscriptionRepository.save( - Subscription.create(user, tier, now, now.plusDays(SUBSCRIPTION_PERIOD_DAYS))); + Subscription.create(user, SubscriptionTier.PRO.name(), now, now.plusDays(SUBSCRIPTION_PERIOD_DAYS))); return UserProfileResponseDTO.OnboardingResponse.builder() .userId(user.getUserId()) @@ -141,18 +137,6 @@ private Student getStudent(User user) { .orElseThrow(() -> new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); } - private String validateSubscriptionTier(String subscriptionTier) { - if (subscriptionTier == null) { - return null; - } - boolean isValidTier = Arrays.stream(SubscriptionTier.values()) - .anyMatch(tier -> tier.name().equals(subscriptionTier)); - if (!isValidTier) { - throw new GeneralException(CommonStatus.HTTP_MESSAGE_NOT_READABLE); - } - return subscriptionTier; - } - private String trimNickname(String nickname) { return nickname == null ? null : nickname.trim(); } diff --git a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java index 387e04ea..3e7203c3 100644 --- a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java @@ -111,7 +111,7 @@ void registerProfile_success() throws Exception { mockMvc = setUp(); UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( - "김뮤즈", TheoryLevel.INTERMEDIATE, "PRO" + "김뮤즈", TheoryLevel.INTERMEDIATE ); UserProfileResponseDTO.OnboardingResponse response = UserProfileResponseDTO.OnboardingResponse.builder() .userId(1L) @@ -139,7 +139,7 @@ void registerProfile_alreadyCompleted() throws Exception { mockMvc = setUp(); UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( - "김뮤즈", TheoryLevel.INTERMEDIATE, "PRO" + "김뮤즈", TheoryLevel.INTERMEDIATE ); willThrow(new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED)) .given(userProfileService).registerProfile(any()); @@ -157,7 +157,7 @@ void registerProfile_nicknameDuplicated() throws Exception { mockMvc = setUp(); UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( - "김뮤즈", TheoryLevel.INTERMEDIATE, "PRO" + "김뮤즈", TheoryLevel.INTERMEDIATE ); willThrow(new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED)) .given(userProfileService).registerProfile(any()); diff --git a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java new file mode 100644 index 00000000..ea253ce8 --- /dev/null +++ b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java @@ -0,0 +1,151 @@ +package com.mr.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.mr.domain.statistics.repository.UserStatisticsRepository; +import com.mr.domain.subscriptions.entity.Subscription; +import com.mr.domain.subscriptions.repository.SubscriptionRepository; +import com.mr.domain.user.dto.req.UserProfileRequestDTO; +import com.mr.domain.user.dto.res.UserProfileResponseDTO; +import com.mr.domain.user.entity.Instrument; +import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.entity.enums.TheoryLevel; +import com.mr.domain.user.entity.enums.UserRole; +import com.mr.domain.user.exception.InstrumentErrorStatus; +import com.mr.domain.user.exception.StudentErrorStatus; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.InstrumentRepository; +import com.mr.domain.user.repository.StudentInstrumentRepository; +import com.mr.domain.user.repository.StudentRepository; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.security.principal.CustomUserDetails; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +class UserProfileServiceTest { + + private static final Long USER_ID = 1L; + + @Mock + private UserRepository userRepository; + @Mock + private StudentRepository studentRepository; + @Mock + private StudentInstrumentRepository studentInstrumentRepository; + @Mock + private InstrumentRepository instrumentRepository; + @Mock + private SubscriptionRepository subscriptionRepository; + @Mock + private UserStatisticsRepository userStatisticsRepository; + + @InjectMocks + private UserProfileService userProfileService; + + private User user; + + @BeforeEach + void setUp() { + user = User.createFromOAuth("https://cdn.example.com/profile/default-profile.png"); + + CustomUserDetails userDetails = new CustomUserDetails(USER_ID, UserRole.ROLE_STUDENT); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities())); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("registerProfile - 정상 온보딩 시 구독 등급은 요청값과 무관하게 항상 PRO로 저장된다") + void registerProfile_alwaysUsesProTier() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + + Student student = Student.create(user, TheoryLevel.INTERMEDIATE); + given(studentRepository.saveAndFlush(any(Student.class))).willReturn(student); + + Instrument piano = Instrument.create("PIANO", "피아노"); + given(instrumentRepository.findByCode("PIANO")).willReturn(Optional.of(piano)); + + given(subscriptionRepository.save(any(Subscription.class))).willAnswer(invocation -> invocation.getArgument(0)); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + UserProfileResponseDTO.OnboardingResponse response = userProfileService.registerProfile(request); + + assertThat(response.subscriptionTier()).isEqualTo("PRO"); + assertThat(user.getNickname()).isEqualTo("김뮤즈"); + } + + @Test + @DisplayName("registerProfile - 이미 온보딩을 완료한 유저면 USER_409_02") + void registerProfile_alreadyOnboarded() { + user.updateNickname("이미가입함"); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + + verify(studentRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("registerProfile - PIANO 시드 데이터가 없으면 INSTRUMENT_500_01") + void registerProfile_instrumentNotSeeded() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + + Student student = Student.create(user, TheoryLevel.INTERMEDIATE); + given(studentRepository.saveAndFlush(any(Student.class))).willReturn(student); + given(instrumentRepository.findByCode("PIANO")).willReturn(Optional.empty()); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(InstrumentErrorStatus.INSTRUMENT_NOT_SEEDED); + } + + @Test + @DisplayName("updateProfile - 온보딩 정보(student)가 없으면 STUDENT_404_01") + void updateProfile_studentNotFound() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.empty()); + + UserProfileRequestDTO.UpdateRequest request = + new UserProfileRequestDTO.UpdateRequest("새로운뮤즈", TheoryLevel.ADVANCED); + + assertThatThrownBy(() -> userProfileService.updateProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(StudentErrorStatus.STUDENT_NOT_FOUND); + } +} From ad9a83375a6389e19d1585958e6e6a8011eb720a Mon Sep 17 00:00:00 2001 From: p1001q Date: Mon, 27 Jul 2026 00:26:30 +0900 Subject: [PATCH 22/25] =?UTF-8?q?fix:=20=EB=8B=89=EB=84=A4=EC=9E=84/?= =?UTF-8?q?=EC=98=A8=EB=B3=B4=EB=94=A9=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EB=A5=BC=20SQLState=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=ED=95=B4=20=EC=B2=98=EB=A6=AC=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/UserProfileService.java | 30 +++++++ .../user/service/UserProfileServiceTest.java | 83 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index f8065092..46b314d9 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -24,6 +24,7 @@ import com.mr.global.security.SecurityUtil; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +36,7 @@ public class UserProfileService { private static final String PIANO_CODE = "PIANO"; private static final int SUBSCRIPTION_PERIOD_DAYS = 30; + private static final String POSTGRES_UNIQUE_VIOLATION_SQL_STATE = "23505"; private final UserRepository userRepository; private final StudentRepository studentRepository; @@ -82,10 +84,23 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ ensureNicknameNotTaken(user.getUserId(), trimmedNickname); user.updateNickname(trimmedNickname); + try { + userRepository.saveAndFlush(user); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyViolation(e)) { + throw e; + } + // 동시에 들어온 다른 요청이 먼저 같은 닉네임을 선점한 경우(User.nickname unique 제약 위반) + throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); + } + Student student; try { student = studentRepository.saveAndFlush(Student.create(user, request.skillLevel())); } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyViolation(e)) { + throw e; + } // 동시에 들어온 다른 요청이 먼저 온보딩을 완료한 경우(Student.user_id unique 제약 위반) throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); } @@ -119,6 +134,16 @@ public UserProfileResponseDTO.UpdateResponse updateProfile(UserProfileRequestDTO user.updateNickname(trimmedNickname); student.updateTheoryLevel(request.skillLevel()); + try { + userRepository.saveAndFlush(user); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyViolation(e)) { + throw e; + } + // 동시에 들어온 다른 요청이 먼저 같은 닉네임을 선점한 경우(User.nickname unique 제약 위반) + throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); + } + return UserProfileResponseDTO.UpdateResponse.builder() .userId(user.getUserId()) .nickname(user.getNickname()) @@ -137,6 +162,11 @@ private Student getStudent(User user) { .orElseThrow(() -> new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); } + private boolean isDuplicateKeyViolation(DataIntegrityViolationException e) { + return e.getCause() instanceof ConstraintViolationException constraintViolationException + && POSTGRES_UNIQUE_VIOLATION_SQL_STATE.equals(constraintViolationException.getSQLState()); + } + private String trimNickname(String nickname) { return nickname == null ? null : nickname.trim(); } diff --git a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java index ea253ce8..de6358d0 100644 --- a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java +++ b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java @@ -26,7 +26,9 @@ import com.mr.domain.user.repository.UserRepository; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.principal.CustomUserDetails; +import java.sql.SQLException; import java.util.Optional; +import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -35,6 +37,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -115,6 +118,58 @@ void registerProfile_alreadyOnboarded() { verify(studentRepository, never()).saveAndFlush(any()); } + @Test + @DisplayName("registerProfile - 동시 요청으로 닉네임이 먼저 선점되면(UNIQUE 위반) USER_409_01(닉네임 중복)로 변환된다") + void registerProfile_nicknameRaceCondition_convertedToNicknameDuplicated() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + given(userRepository.saveAndFlush(any(User.class))) + .willThrow(uniqueViolation("users_nickname_key")); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.NICKNAME_DUPLICATED); + + verify(studentRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("registerProfile - 동시 요청으로 온보딩이 먼저 완료되면(Student UNIQUE 위반) USER_409_02로 변환된다") + void registerProfile_studentRaceCondition_convertedToAlreadyCompleted() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + given(studentRepository.saveAndFlush(any(Student.class))) + .willThrow(uniqueViolation("student_user_id_key")); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + } + + @Test + @DisplayName("registerProfile - 닉네임 위반이 아닌 다른 무결성 오류는 그대로 전파된다") + void registerProfile_nonDuplicateIntegrityViolation_propagates() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + DataIntegrityViolationException notNullViolation = + new DataIntegrityViolationException("not null violation"); + given(userRepository.saveAndFlush(any(User.class))).willThrow(notNullViolation); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isSameAs(notNullViolation); + } + @Test @DisplayName("registerProfile - PIANO 시드 데이터가 없으면 INSTRUMENT_500_01") void registerProfile_instrumentNotSeeded() { @@ -148,4 +203,32 @@ void updateProfile_studentNotFound() { .extracting(e -> ((GeneralException) e).getCode()) .isEqualTo(StudentErrorStatus.STUDENT_NOT_FOUND); } + + @Test + @DisplayName("updateProfile - 동시 요청으로 닉네임이 먼저 선점되면(UNIQUE 위반) USER_409_01로 변환된다") + void updateProfile_nicknameRaceCondition_convertedToNicknameDuplicated() { + user.updateNickname("기존닉네임"); + Student student = Student.create(user, TheoryLevel.INTERMEDIATE); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + given(userRepository.saveAndFlush(any(User.class))) + .willThrow(uniqueViolation("users_nickname_key")); + + UserProfileRequestDTO.UpdateRequest request = + new UserProfileRequestDTO.UpdateRequest("새로운뮤즈", TheoryLevel.ADVANCED); + + assertThatThrownBy(() -> userProfileService.updateProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.NICKNAME_DUPLICATED); + } + + private static DataIntegrityViolationException uniqueViolation(String constraintName) { + SQLException sqlException = new SQLException("duplicate key value violates unique constraint", "23505"); + ConstraintViolationException constraintViolationException = + new ConstraintViolationException("duplicate key", sqlException, constraintName); + return new DataIntegrityViolationException("duplicate key", constraintViolationException); + } } From f824f11a14069be192cf635ce79e668915efa99a Mon Sep 17 00:00:00 2001 From: p1001q Date: Mon, 27 Jul 2026 00:40:06 +0900 Subject: [PATCH 23/25] =?UTF-8?q?refactor:=20=EB=8F=99=EC=8B=9C=EC=84=B1?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=ED=8C=90=EB=B3=84=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=8D=EC=A0=95=EB=AC=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/service/UserProfileService.java | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index 46b314d9..ee6406b9 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -87,22 +87,22 @@ public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequ try { userRepository.saveAndFlush(user); } catch (DataIntegrityViolationException e) { - if (!isDuplicateKeyViolation(e)) { - throw e; + if (isDuplicateKeyViolation(e)) { + // 동시에 들어온 다른 요청이 먼저 같은 닉네임을 선점한 경우(User.nickname unique 제약 위반) + throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); } - // 동시에 들어온 다른 요청이 먼저 같은 닉네임을 선점한 경우(User.nickname unique 제약 위반) - throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); + throw e; } Student student; try { student = studentRepository.saveAndFlush(Student.create(user, request.skillLevel())); } catch (DataIntegrityViolationException e) { - if (!isDuplicateKeyViolation(e)) { - throw e; + if (isDuplicateKeyViolation(e)) { + // 동시에 들어온 다른 요청이 먼저 온보딩을 완료한 경우(Student.user_id unique 제약 위반) + throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); } - // 동시에 들어온 다른 요청이 먼저 온보딩을 완료한 경우(Student.user_id unique 제약 위반) - throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + throw e; } Instrument piano = instrumentRepository.findByCode(PIANO_CODE) @@ -137,11 +137,11 @@ public UserProfileResponseDTO.UpdateResponse updateProfile(UserProfileRequestDTO try { userRepository.saveAndFlush(user); } catch (DataIntegrityViolationException e) { - if (!isDuplicateKeyViolation(e)) { - throw e; + if (isDuplicateKeyViolation(e)) { + // 동시에 들어온 다른 요청이 먼저 같은 닉네임을 선점한 경우(User.nickname unique 제약 위반) + throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); } - // 동시에 들어온 다른 요청이 먼저 같은 닉네임을 선점한 경우(User.nickname unique 제약 위반) - throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); + throw e; } return UserProfileResponseDTO.UpdateResponse.builder() From 7c17409cd002e02f449681e749d860a214846687 Mon Sep 17 00:00:00 2001 From: p1001q Date: Mon, 27 Jul 2026 00:58:15 +0900 Subject: [PATCH 24/25] =?UTF-8?q?feat:=20completedLearningCount=EB=A5=BC?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=EC=A7=91=EA=B3=84=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EB=8F=99=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserLearningProgressRepository.java | 5 +++ .../user/service/UserProfileService.java | 7 ++-- .../user/service/UserProfileServiceTest.java | 32 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java index 21d68f35..d5720169 100644 --- a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -14,4 +14,9 @@ public interface UserLearningProgressRepository extends JpaRepository= 90") long countCompletedStepsByUserIdAndLearningId(@Param("userId") Long userId, @Param("learningId") Long learningId); + + // 유저가 완료(점수 90점 이상)한 학습(learning) distinct 개수 조회 (프로필 통계의 completedLearningCount) + @Query("SELECT COUNT(DISTINCT ulp.learning.id) FROM UserLearningProgress ulp " + + "WHERE ulp.user.userId = :userId AND ulp.score >= 90") + long countDistinctCompletedLearningsByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index ee6406b9..c7902f98 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -1,5 +1,6 @@ package com.mr.domain.user.service; +import com.mr.domain.learning.repository.UserLearningProgressRepository; import com.mr.domain.statistics.entity.UserStatistics; import com.mr.domain.statistics.repository.UserStatisticsRepository; import com.mr.domain.subscriptions.entity.Subscription; @@ -44,6 +45,7 @@ public class UserProfileService { private final InstrumentRepository instrumentRepository; private final SubscriptionRepository subscriptionRepository; private final UserStatisticsRepository userStatisticsRepository; + private final UserLearningProgressRepository userLearningProgressRepository; public UserProfileResponseDTO.ProfileResponse getMyProfile() { Long userId = SecurityUtil.getCurrentUserId(); @@ -187,10 +189,7 @@ private UserProfileResponseDTO.StatisticsResponse buildStatistics(Long userId) { .build(); } - // TODO: feat/#32-learning-status-api(동균 강) 병합 후 UserLearningProgressRepository로 교체 - // SELECT COUNT(DISTINCT ulp.learning_id) FROM user_learning_progress ulp - // WHERE ulp.user_id = :userId AND ulp.score >= 90 private Long countCompletedLearnings(Long userId) { - return 0L; + return userLearningProgressRepository.countDistinctCompletedLearningsByUserId(userId); } } diff --git a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java index de6358d0..92ef346e 100644 --- a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java +++ b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import com.mr.domain.learning.repository.UserLearningProgressRepository; import com.mr.domain.statistics.repository.UserStatisticsRepository; import com.mr.domain.subscriptions.entity.Subscription; import com.mr.domain.subscriptions.repository.SubscriptionRepository; @@ -14,6 +15,7 @@ import com.mr.domain.user.dto.res.UserProfileResponseDTO; import com.mr.domain.user.entity.Instrument; import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.StudentInstrument; import com.mr.domain.user.entity.User; import com.mr.domain.user.entity.enums.TheoryLevel; import com.mr.domain.user.entity.enums.UserRole; @@ -27,6 +29,7 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.principal.CustomUserDetails; import java.sql.SQLException; +import java.time.LocalDateTime; import java.util.Optional; import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.AfterEach; @@ -58,6 +61,8 @@ class UserProfileServiceTest { private SubscriptionRepository subscriptionRepository; @Mock private UserStatisticsRepository userStatisticsRepository; + @Mock + private UserLearningProgressRepository userLearningProgressRepository; @InjectMocks private UserProfileService userProfileService; @@ -189,6 +194,33 @@ void registerProfile_instrumentNotSeeded() { .isEqualTo(InstrumentErrorStatus.INSTRUMENT_NOT_SEEDED); } + @Test + @DisplayName("getMyProfile - 통계의 completedLearningCount는 UserLearningProgressRepository 집계 결과를 그대로 사용한다") + void getMyProfile_usesLearningProgressAggregationForCompletedLearningCount() { + user.updateNickname("김뮤즈"); + Student student = Student.create(user, TheoryLevel.INTERMEDIATE); + Instrument piano = Instrument.create("PIANO", "피아노"); + StudentInstrument primaryInstrument = StudentInstrument.createPrimary(student, piano); + Subscription subscription = Subscription.create( + user, "PRO", LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(29)); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); + given(studentInstrumentRepository.findByStudentAndPrimaryTrue(student)) + .willReturn(Optional.of(primaryInstrument)); + given(subscriptionRepository + .findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc( + any(), any(), any())) + .willReturn(Optional.of(subscription)); + given(userStatisticsRepository.findByUserId(USER_ID)).willReturn(Optional.empty()); + given(userLearningProgressRepository.countDistinctCompletedLearningsByUserId(USER_ID)).willReturn(8L); + + UserProfileResponseDTO.ProfileResponse response = userProfileService.getMyProfile(); + + assertThat(response.statistics().completedLearningCount()).isEqualTo(8L); + verify(userLearningProgressRepository).countDistinctCompletedLearningsByUserId(USER_ID); + } + @Test @DisplayName("updateProfile - 온보딩 정보(student)가 없으면 STUDENT_404_01") void updateProfile_studentNotFound() { From 9a53f66b062c0d7e7d7fed9511ba47e14054670a Mon Sep 17 00:00:00 2001 From: p1001q Date: Mon, 27 Jul 2026 01:37:24 +0900 Subject: [PATCH 25/25] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=EB=A5=BC=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserProfileController.java | 3 +- .../user/dto/req/UserProfileRequestDTO.java | 6 +-- .../user/service/UserProfileService.java | 12 ++++-- .../user/service/UserProfileServiceTest.java | 38 +++++++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/mr/domain/user/controller/UserProfileController.java b/src/main/java/com/mr/domain/user/controller/UserProfileController.java index 3102e582..baa33be8 100644 --- a/src/main/java/com/mr/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/mr/domain/user/controller/UserProfileController.java @@ -49,7 +49,8 @@ public ApiResponse registerProfile( @Operation( summary = "프로필 수정", - description = "닉네임과 화성학 숙련도만 수정합니다. 대표 악기, 프로필 이미지, 구독 등급은 이 API에서 변경되지 않습니다." + description = "닉네임과 화성학 숙련도를 부분 수정합니다. 요청에 생략하거나 null로 보낸 필드는 기존 값을 유지합니다. " + + "대표 악기, 프로필 이미지, 구독 등급은 이 API에서 변경되지 않습니다." ) @PatchMapping public ApiResponse updateProfile( diff --git a/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java b/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java index 06ac04ad..07327203 100644 --- a/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java +++ b/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java @@ -14,12 +14,12 @@ public record OnboardingRequest( TheoryLevel skillLevel ) {} - @Schema(description = "프로필 수정 요청") + @Schema(description = "프로필 수정 요청 (부분 수정 — 생략하거나 null인 필드는 기존 값 유지)") public record UpdateRequest( - @Schema(description = "변경할 닉네임 (한글/영문/숫자 2~10자)", example = "새로운뮤즈") + @Schema(description = "변경할 닉네임 (한글/영문/숫자 2~10자). 생략 시 기존 닉네임 유지", example = "새로운뮤즈") String nickname, - @Schema(description = "변경할 화성학 숙련도", example = "ADVANCED") + @Schema(description = "변경할 화성학 숙련도. 생략 시 기존 숙련도 유지", example = "ADVANCED") TheoryLevel skillLevel ) {} } diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index c7902f98..186a04ac 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -131,10 +131,14 @@ public UserProfileResponseDTO.UpdateResponse updateProfile(UserProfileRequestDTO User user = getUser(userId); Student student = getStudent(user); - String trimmedNickname = trimNickname(request.nickname()); - ensureNicknameNotTaken(user.getUserId(), trimmedNickname); - user.updateNickname(trimmedNickname); - student.updateTheoryLevel(request.skillLevel()); + if (request.nickname() != null) { + String trimmedNickname = trimNickname(request.nickname()); + ensureNicknameNotTaken(user.getUserId(), trimmedNickname); + user.updateNickname(trimmedNickname); + } + if (request.skillLevel() != null) { + student.updateTheoryLevel(request.skillLevel()); + } try { userRepository.saveAndFlush(user); diff --git a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java index 92ef346e..b6c5f4b5 100644 --- a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java +++ b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java @@ -257,6 +257,44 @@ void updateProfile_nicknameRaceCondition_convertedToNicknameDuplicated() { .isEqualTo(UserErrorStatus.NICKNAME_DUPLICATED); } + @Test + @DisplayName("updateProfile - nickname을 생략(null)하면 기존 닉네임을 유지하고 skillLevel만 변경한다") + void updateProfile_partialUpdate_skillLevelOnly() { + user.updateNickname("기존닉네임"); + Student student = Student.create(user, TheoryLevel.BEGINNER); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); + + UserProfileRequestDTO.UpdateRequest request = + new UserProfileRequestDTO.UpdateRequest(null, TheoryLevel.ADVANCED); + + UserProfileResponseDTO.UpdateResponse response = userProfileService.updateProfile(request); + + assertThat(response.nickname()).isEqualTo("기존닉네임"); + assertThat(response.skillLevel()).isEqualTo(TheoryLevel.ADVANCED); + verify(userRepository, never()).existsByNicknameAndUserIdNot(any(), any()); + } + + @Test + @DisplayName("updateProfile - skillLevel을 생략(null)하면 기존 숙련도를 유지하고 nickname만 변경한다") + void updateProfile_partialUpdate_nicknameOnly() { + user.updateNickname("기존닉네임"); + Student student = Student.create(user, TheoryLevel.BEGINNER); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + + UserProfileRequestDTO.UpdateRequest request = + new UserProfileRequestDTO.UpdateRequest("새로운뮤즈", null); + + UserProfileResponseDTO.UpdateResponse response = userProfileService.updateProfile(request); + + assertThat(response.nickname()).isEqualTo("새로운뮤즈"); + assertThat(response.skillLevel()).isEqualTo(TheoryLevel.BEGINNER); + } + private static DataIntegrityViolationException uniqueViolation(String constraintName) { SQLException sqlException = new SQLException("duplicate key value violates unique constraint", "23505"); ConstraintViolationException constraintViolationException =