Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
83602b3
feat: 프로필 Repository 6개 추가 (#39)
p1001q Jul 24, 2026
a2c3f0d
feat: 프로필 에러 코드 4개 추가 (#39)
p1001q Jul 24, 2026
04ffe98
feat: PIANO 악기 시드 데이터 추가 (#39)
p1001q Jul 24, 2026
e99d1f1
feat: 프로필 조회 API 구현 (#39)
p1001q Jul 24, 2026
fe98864
feat: 프로필 최초 등록 API 구현 (#39)
p1001q Jul 24, 2026
5d3a2c1
feat: 프로필 수정 API 구현 (#39)
p1001q Jul 24, 2026
e8c44c1
test: 프로필 API 컨트롤러 테스트 추가 (#39)
p1001q Jul 24, 2026
4a8642a
fix: 프로필 조회 시 활성 구독만 조회하도록 수정 (#39)
p1001q Jul 24, 2026
73b8684
fix: 시드 동시 실행 시 중복 저장 안전 처리 (#39)
p1001q Jul 24, 2026
da3f6d2
fix: 닉네임 중복 검사를 엔티티 변경 전으로 이동 (#39)
p1001q Jul 24, 2026
8403213
fix: 온보딩 동시 요청 시 중복 생성 방지 (#39)
p1001q Jul 24, 2026
8fdd0e3
fix: 구독등급 타입을 String으로 통일하고 값 검증 추가 (#39)
p1001q Jul 24, 2026
3eca142
fix: IllegalStateException을 GeneralException으로 교체 (#39)
p1001q Jul 24, 2026
e445c83
fix: 시드 실행에서 트랜잭션 오염 문제 해결 (#39)
p1001q Jul 24, 2026
825a92c
fix: 시드 실행에서 트랜잭션 오염 문제 해결 (#39)
p1001q Jul 24, 2026
32daad5
fix: 시드 중복키 외 무결성 오류는 그대로 전파 (#39)
p1001q Jul 24, 2026
da5c092
fix: 구독등급 검증 순서 및 Student 저장 방식 개선 (#39)
p1001q Jul 24, 2026
a61f637
docs: 프로필 API 스웨거 설명 추가 (#39)
p1001q Jul 24, 2026
47c6fbf
Merge remote-tracking branch 'origin/develop' into feat/#39-user-prof…
p1001q Jul 25, 2026
ef97044
fix: 닉네임 trim을 한 곳에서 처리하도록 정리 (#39)
p1001q Jul 25, 2026
67ba529
refactor: DTO를 req/res 패키지로 분리 (#39)
p1001q Jul 25, 2026
600940b
fix: 온보딩 구독 등급을 요청값 대신 서버에서 PRO로 고정 (#39)
p1001q Jul 26, 2026
ad9a833
fix: 닉네임/온보딩 동시성 예외를 SQLState로 구분해 처리 (#39)
p1001q Jul 26, 2026
f824f11
refactor: 동시성 예외 판별 분기를 긍정문으로 정리 (#39)
p1001q Jul 26, 2026
7c17409
feat: completedLearningCount를 실제 집계값으로 연동 (#39)
p1001q Jul 26, 2026
9a53f66
fix: 프로필 수정 API를 부분 수정 방식으로 변경 (#39)
p1001q Jul 26, 2026
7d8ab2f
Merge branch 'develop' into feat/#39-user-profile-api
p1001q Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@ public interface UserLearningProgressRepository extends JpaRepository<UserLearni
@Query("SELECT COUNT(ulp) FROM UserLearningProgress ulp " +
"WHERE ulp.user.userId = :userId AND ulp.learning.id = :learningId AND ulp.score >= 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);
}
Original file line number Diff line number Diff line change
@@ -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<UserStatistics, Long> {

Optional<UserStatistics> findByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.mr.domain.subscriptions.entity.enums;

public enum SubscriptionTier {
FREE,
STANDARD,
PRO
}
Comment thread
p1001q marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.mr.domain.subscriptions.repository;

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<Subscription, Long> {

Optional<Subscription> findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(
User user, LocalDateTime startDateInclusive, LocalDateTime endDateInclusive);
}
50 changes: 50 additions & 0 deletions src/main/java/com/mr/domain/user/config/InstrumentSeeder.java
Comment thread
p1001q marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.mr.domain.user.config;

import com.mr.domain.user.entity.Instrument;
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;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
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로 예외를 소비해도
// 커밋 시점에 다시 실패함. saveAndFlush() 각각이 Spring Data JPA의 자체 트랜잭션으로 독립 처리되도록
// run() 레벨 트랜잭션을 없애서, 중복 저장 실패가 그 saveAndFlush() 호출 안에서 깔끔하게 끝나게 함.
@Override
public void run(ApplicationArguments args) {
if (instrumentRepository.findByCode(PIANO_CODE).isPresent()) {
return;
}

try {
instrumentRepository.saveAndFlush(Instrument.create(PIANO_CODE, PIANO_NAME));
} catch (DataIntegrityViolationException e) {
if (!isDuplicateKeyViolation(e)) {
throw e;
}
// 여러 인스턴스가 동시에 기동해 경쟁 상태로 중복 저장을 시도한 경우
// code unique 제약으로 한쪽만 성공하므로, 나머지는 무시하고 넘어감
log.info("PIANO 악기 시드 데이터가 다른 인스턴스에 의해 이미 생성됨");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private boolean isDuplicateKeyViolation(DataIntegrityViolationException e) {
return e.getCause() instanceof ConstraintViolationException constraintViolationException
&& POSTGRES_UNIQUE_VIOLATION_SQL_STATE.equals(constraintViolationException.getSQLState());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.mr.domain.user.controller;

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;
import io.swagger.v3.oas.annotations.tags.Tag;
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;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users/me/profile")
@Tag(name = "사용자 프로필", description = "프로필 조회/최초 등록(온보딩)/수정 API")
public class UserProfileController {

private final UserProfileService userProfileService;

@Operation(
summary = "프로필 조회",
description = "현재 로그인한 사용자의 닉네임, 프로필 이미지, 대표 악기, 화성학 숙련도, 구독 등급, 누적 학습 통계를 조회합니다."
)
@GetMapping
public ApiResponse<UserProfileResponseDTO.ProfileResponse> getMyProfile() {
return ApiResponse.onSuccess(userProfileService.getMyProfile());
}

@Operation(
summary = "프로필 최초 등록 (온보딩)",
description = "닉네임, 화성학 숙련도를 입력받아 Student/StudentInstrument(대표 악기 PIANO)/Subscription을 하나의 트랜잭션으로 생성합니다. "
+ "구독 등급은 MVP 기간 동안 결제 없이 항상 PRO로 서버에서 고정됩니다. "
+ "이미 온보딩을 완료한 사용자가 다시 호출하면 409(USER_409_02)가 반환됩니다."
)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<UserProfileResponseDTO.OnboardingResponse> registerProfile(
@RequestBody UserProfileRequestDTO.OnboardingRequest request
Comment thread
p1001q marked this conversation as resolved.
) {
return ApiResponse.onSuccess(userProfileService.registerProfile(request));
}

@Operation(
summary = "프로필 수정",
description = "닉네임과 화성학 숙련도를 부분 수정합니다. 요청에 생략하거나 null로 보낸 필드는 기존 값을 유지합니다. "
+ "대표 악기, 프로필 이미지, 구독 등급은 이 API에서 변경되지 않습니다."
)
@PatchMapping
public ApiResponse<UserProfileResponseDTO.UpdateResponse> updateProfile(
@RequestBody UserProfileRequestDTO.UpdateRequest request
) {
return ApiResponse.onSuccess(userProfileService.updateProfile(request));
}
}
Comment thread
p1001q marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.mr.domain.user.dto.req;

import com.mr.domain.user.entity.enums.TheoryLevel;
import io.swagger.v3.oas.annotations.media.Schema;

public class UserProfileRequestDTO {
Comment thread
p1001q marked this conversation as resolved.

@Schema(description = "프로필 최초 등록(온보딩) 요청")
public record OnboardingRequest(
@Schema(description = "닉네임 (한글/영문/숫자 2~10자)", example = "김뮤즈")
String nickname,

@Schema(description = "화성학 숙련도", example = "INTERMEDIATE")
TheoryLevel skillLevel
) {}

@Schema(description = "프로필 수정 요청 (부분 수정 — 생략하거나 null인 필드는 기존 값 유지)")
public record UpdateRequest(
@Schema(description = "변경할 닉네임 (한글/영문/숫자 2~10자). 생략 시 기존 닉네임 유지", example = "새로운뮤즈")
String nickname,

@Schema(description = "변경할 화성학 숙련도. 생략 시 기존 숙련도 유지", example = "ADVANCED")
TheoryLevel skillLevel
) {}
}
Comment thread
p1001q marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.mr.domain.user.dto.res;

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
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ 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은 필수입니다."),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다.");

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;
private final String message;
}
}
Original file line number Diff line number Diff line change
@@ -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<Instrument, Long> {

Optional<Instrument> findByCode(String code);
}
Original file line number Diff line number Diff line change
@@ -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<StudentInstrument, Long> {

Optional<StudentInstrument> findByStudentAndPrimaryTrue(Student student);
}
11 changes: 11 additions & 0 deletions src/main/java/com/mr/domain/user/repository/StudentRepository.java
Original file line number Diff line number Diff line change
@@ -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<Student, Long> {

Optional<Student> findByUser(User user);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}

boolean existsByNicknameAndUserIdNot(String nickname, Long userId);
}
Loading
Loading