From 7cedf614f7bdde9e7f39cd1937cd63227043635e Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Wed, 22 Jul 2026 19:41:58 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=ED=95=99=EC=8A=B5=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20API=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/LearningController.java | 33 ++++++++++++ .../dto/req/LearningResultSaveRequestDTO.java | 10 ++++ .../dto/res/LearningResultResponseDTO.java | 27 ++++++++++ .../learning/entity/UserLearningProgress.java | 22 +++++--- .../exception/LearningErrorStatus.java | 3 +- .../repository/LearningRepository.java | 7 +++ .../UserLearningProgressRepository.java | 11 ++++ .../learning/service/LearningService.java | 54 +++++++++++++++++++ .../user/exception/UserErrorStatus.java | 4 +- .../user/repository/UserRepository.java | 7 +++ 10 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/mr/domain/learning/controller/LearningController.java create mode 100644 src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java create mode 100644 src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java create mode 100644 src/main/java/com/mr/domain/learning/repository/LearningRepository.java create mode 100644 src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java create mode 100644 src/main/java/com/mr/domain/learning/service/LearningService.java create mode 100644 src/main/java/com/mr/domain/user/repository/UserRepository.java diff --git a/src/main/java/com/mr/domain/learning/controller/LearningController.java b/src/main/java/com/mr/domain/learning/controller/LearningController.java new file mode 100644 index 00000000..333f852c --- /dev/null +++ b/src/main/java/com/mr/domain/learning/controller/LearningController.java @@ -0,0 +1,33 @@ +package com.mr.domain.learning.controller; + +import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO; +import com.mr.domain.learning.dto.res.LearningResultResponseDTO; +import com.mr.domain.learning.service.LearningService; +import com.mr.global.apipayload.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/learnings") +public class LearningController { + + private final LearningService learningService; + + @PostMapping("/{learningId}/result") + public ApiResponse saveLearningResult( + @PathVariable Long learningId, // 임시 + @Valid @RequestBody LearningResultSaveRequestDTO.SaveResultDTO request + ){ + Long userId = 1L; // 임시 + LearningResultResponseDTO.SaveResultResultDTO response = + learningService.saveResult(userId, learningId, request); + + return ApiResponse.onSuccess(response); + } +} diff --git a/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java new file mode 100644 index 00000000..ecbf0108 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java @@ -0,0 +1,10 @@ +package com.mr.domain.learning.dto.req; + +import jakarta.validation.constraints.NotNull; + +public class LearningResultSaveRequestDTO { + public record SaveResultDTO( + @NotNull(message = "점수는 필수 입력값입니다.") + Integer score + ) {} +} diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java new file mode 100644 index 00000000..8cec4a2b --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java @@ -0,0 +1,27 @@ +package com.mr.domain.learning.dto.res; + +import com.mr.domain.learning.entity.UserLearningProgress; + +import java.time.LocalDateTime; + +public class LearningResultResponseDTO { + public record SaveResultResultDTO( + Long userLearningProgressId, + Long userId, + Long learningId, + String status, + Integer score, + LocalDateTime completedAt + ) { + public static SaveResultResultDTO from(UserLearningProgress progress) { + return new SaveResultResultDTO( + progress.getId(), + progress.getUser().getUserId(), + progress.getLearning().getId(), + progress.getLearningStatus(), + progress.getScore(), + progress.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java index 590a671a..689b4249 100644 --- a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java +++ b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java @@ -1,6 +1,7 @@ package com.mr.domain.learning.entity; import com.mr.domain.learning.exception.LearningErrorStatus; +import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -41,8 +42,9 @@ public class UserLearningProgress extends BaseTimeEntity { private Long id; // 유저 아이디 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // 학습 아이디 @ManyToOne(fetch = FetchType.LAZY) @@ -63,20 +65,20 @@ public class UserLearningProgress extends BaseTimeEntity { private LocalDateTime lastStudiedAt; @Builder(access = AccessLevel.PRIVATE) - private UserLearningProgress(Long userId, Learning learning, LearningStep learningStep, + private UserLearningProgress(User user, Learning learning, LearningStep learningStep, Integer score, LocalDateTime lastStudiedAt) { - this.userId = userId; + this.user = user; this.learning = learning; this.learningStep = learningStep; this.score = score; this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now(); } - public static UserLearningProgress create(Long userId, Learning learning, LearningStep learningStep) { + public static UserLearningProgress create(User user, Learning learning, LearningStep learningStep) { validateLearningAndStep(learning, learningStep); return UserLearningProgress.builder() - .userId(userId) + .user(user) .learning(learning) .learningStep(learningStep) .lastStudiedAt(LocalDateTime.now()) @@ -98,4 +100,12 @@ public void updateProgress(Integer score, LocalDateTime lastStudiedAt) { this.score = score; this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now(); } + + // 점수 기준 학습 진행 상태 파악/ 점수는 임시 값 + public String getLearningStatus() { + if (this.score == null) { + return "BEFORE_START"; + } + return this.score >= 80 ? "COMPLETED" : "IN_PROGRESS"; + } } diff --git a/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java b/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java index 7c793b62..b728bdbf 100644 --- a/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java +++ b/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java @@ -9,7 +9,8 @@ @AllArgsConstructor public enum LearningErrorStatus implements BaseCode { - INVALID_LEARNING_STEP(HttpStatus.BAD_REQUEST, "LEARNING_400_01", "해당 학습 단계는 지칭한 학습(Learning)에 속하지 않습니다."); + INVALID_LEARNING_STEP(HttpStatus.BAD_REQUEST, "LEARNING_400_01", "해당 학습 단계는 지칭한 학습(Learning)에 속하지 않습니다."), + LEARNING_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_01", "해당 학습(Learning)을 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/learning/repository/LearningRepository.java b/src/main/java/com/mr/domain/learning/repository/LearningRepository.java new file mode 100644 index 00000000..d26d6217 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/repository/LearningRepository.java @@ -0,0 +1,7 @@ +package com.mr.domain.learning.repository; + +import com.mr.domain.learning.entity.Learning; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LearningRepository extends JpaRepository { +} diff --git a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java new file mode 100644 index 00000000..b732e42b --- /dev/null +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -0,0 +1,11 @@ +package com.mr.domain.learning.repository; + +import com.mr.domain.learning.entity.UserLearningProgress; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserLearningProgressRepository extends JpaRepository { + + Optional findByUserIdAndLearningId(Long userId, Long learningId); +} diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java new file mode 100644 index 00000000..9efca249 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -0,0 +1,54 @@ +package com.mr.domain.learning.service; + +import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO; +import com.mr.domain.learning.dto.res.LearningResultResponseDTO; +import com.mr.domain.learning.entity.Learning; +import com.mr.domain.learning.entity.UserLearningProgress; +import com.mr.domain.learning.exception.LearningErrorStatus; +import com.mr.domain.learning.repository.LearningRepository; +import com.mr.domain.learning.repository.UserLearningProgressRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LearningService { + + private final UserLearningProgressRepository userLearningProgressRepository; + private final LearningRepository learningRepository; + // 임시 작명 + private final UserRepository userRepository; + + public LearningResultResponseDTO.SaveResultResultDTO saveResult( + Long userId, + Long learningId, + LearningResultSaveRequestDTO.SaveResultDTO request + ){ + User user = userRepository.findById(userId) + .orElseThrow(()-> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + Learning learning = learningRepository.findById(learningId) + .orElseThrow(() -> new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND)); + + UserLearningProgress progress = userLearningProgressRepository + .findByUserIdAndLearningId(userId, learning.getId()) + .map(p -> { + p.updateProgress(request.score(), LocalDateTime.now()); + return p; + }) + .orElseGet(() -> { + UserLearningProgress newProgress = UserLearningProgress.create(user, learning, null); + newProgress.updateProgress(request.score(), LocalDateTime.now()); + return userLearningProgressRepository.save(newProgress); + }); + return LearningResultResponseDTO.SaveResultResultDTO.from(progress); + } +} 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 abd27d52..9bf9c7cc 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -10,7 +10,9 @@ public enum UserErrorStatus implements BaseCode { NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."), - NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."); + NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."), + // 임의로 추가 + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다."); private final HttpStatus status; private final String code; 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..5826f092 --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/UserRepository.java @@ -0,0 +1,7 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} From 647cb2671ee9184421d4446ca6a3006667a4e963 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 15:02:07 +0900 Subject: [PATCH 02/12] Merge remote-tracking branch 'origin/develop' into feat/#32-learning-status-api --- .gitignore | 5 + MR_config/local/application.example.yml | 16 ++- .../auth/controller/AuthController.java | 33 +++++ .../mr/domain/auth/dto/AuthRequestDTO.java | 16 +++ .../mr/domain/auth/dto/AuthResponseDTO.java | 21 +++ .../com/mr/domain/auth/entity/SocialAuth.java | 7 +- .../domain/auth/entity/enums/SocialType.java | 4 +- .../mr/domain/auth/service/AuthService.java | 47 ++++++ .../subscriptions/entity/Subscription.java | 33 ++++- .../exception/SubscriptionErrorStatus.java | 4 +- .../domain/user/entity/StudentInstrument.java | 4 + .../java/com/mr/domain/user/entity/User.java | 7 + .../mr/domain/user/entity/enums/UserRole.java | 15 ++ .../user/exception/UserErrorStatus.java | 2 - .../global/apipayload/code/CommonStatus.java | 4 +- .../domain/AiServerErrorStatus.java | 20 +++ .../global/client/ai/AiAnalysisRequest.java | 41 ++++++ .../mr/global/client/ai/AiServerClient.java | 62 ++++++++ .../mr/global/config/AiServerProperties.java | 18 +++ .../config/AiServerRestClientConfig.java | 26 ++++ .../com/mr/global/config/SecurityConfig.java | 30 ---- .../com/mr/global/config/SwaggerConfig.java | 4 + .../mr/global/security/SecurityConfig.java | 82 +++++++++++ .../com/mr/global/security/SecurityUtil.java | 30 ++++ .../security/jwt/JwtAccessDeniedHandler.java | 39 +++++ .../jwt/JwtAuthenticationEntryPoint.java | 39 +++++ .../security/jwt/JwtAuthenticationFilter.java | 63 ++++++++ .../mr/global/security/jwt/JwtProperties.java | 20 +++ .../global/security/jwt/JwtTokenProvider.java | 107 ++++++++++++++ .../security/principal/CustomUserDetails.java | 54 +++++++ .../principal/CustomUserDetailsService.java | 28 ++++ src/main/resources/application.example.yml | 11 +- src/main/resources/application.yml | 19 ++- .../AiAnalysisRequestSerializationTest.java | 39 +++++ .../global/client/ai/AiServerClientTest.java | 135 ++++++++++++++++++ .../config/AiServerPropertiesBindingTest.java | 47 ++++++ 36 files changed, 1075 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/mr/domain/auth/controller/AuthController.java create mode 100644 src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java create mode 100644 src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java create mode 100644 src/main/java/com/mr/domain/auth/service/AuthService.java create mode 100644 src/main/java/com/mr/domain/user/entity/enums/UserRole.java create mode 100644 src/main/java/com/mr/global/apipayload/domain/AiServerErrorStatus.java create mode 100644 src/main/java/com/mr/global/client/ai/AiAnalysisRequest.java create mode 100644 src/main/java/com/mr/global/client/ai/AiServerClient.java create mode 100644 src/main/java/com/mr/global/config/AiServerProperties.java create mode 100644 src/main/java/com/mr/global/config/AiServerRestClientConfig.java delete mode 100644 src/main/java/com/mr/global/config/SecurityConfig.java create mode 100644 src/main/java/com/mr/global/config/SwaggerConfig.java create mode 100644 src/main/java/com/mr/global/security/SecurityConfig.java create mode 100644 src/main/java/com/mr/global/security/SecurityUtil.java create mode 100644 src/main/java/com/mr/global/security/jwt/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/mr/global/security/jwt/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/mr/global/security/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/mr/global/security/jwt/JwtProperties.java create mode 100644 src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java create mode 100644 src/main/java/com/mr/global/security/principal/CustomUserDetails.java create mode 100644 src/main/java/com/mr/global/security/principal/CustomUserDetailsService.java create mode 100644 src/test/java/com/mr/global/client/ai/AiAnalysisRequestSerializationTest.java create mode 100644 src/test/java/com/mr/global/client/ai/AiServerClientTest.java create mode 100644 src/test/java/com/mr/global/config/AiServerPropertiesBindingTest.java diff --git a/.gitignore b/.gitignore index 37c88c4f..7064c441 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,8 @@ awscliv2.zip # Spring Boot local config src/main/resources/application-local.yml + +# docs +docs/* +.claude/* +CLAUDE.md diff --git a/MR_config/local/application.example.yml b/MR_config/local/application.example.yml index 3dd38451..71dfa30d 100644 --- a/MR_config/local/application.example.yml +++ b/MR_config/local/application.example.yml @@ -24,8 +24,9 @@ spring: # 3. Spring Security 및 JWT 인증 설정 jwt: - # [입력] HS256 알고리즘을 충족하는 256비트(32바이트) 이상의 임의의 비밀키를 채워주세요. - # 기입 예시: "your-local-custom-secret-key-must-be-very-long-and-secure-32bytes" + # [입력] Base64로 인코딩된 256비트(32바이트) 이상의 Secret Key를 채워주세요. + # (주의: Decoders.BASE64.decode를 사용하므로 Plain Text가 아닌 Base64 인코딩 문자열이어야 합니다.) + # 기입 예시: "c29tZS1zZWNyZXQta2V5LW11c3QtYmUtYXQtbGVhc3QtMzItYmF5dGVzLWxvbmc=" secret: "" access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) @@ -39,4 +40,13 @@ oauth: google: client-id: "" # [입력] 구글 클라우드 콘솔 OAuth 클라이언트 ID client-secret: "" # [입력] 구글 클라우드 콘솔 보안 비밀번호 - redirect-uri: http://localhost:8080/login/oauth2/code/google \ No newline at end of file + redirect-uri: http://localhost:8080/login/oauth2/code/google + +# 5. AI 서버 연동 설정 +ai: + internal: + base-url: http://localhost:8000 # [입력] 로컬에서 띄운 AI 서버 주소 + connect-timeout: 3s + read-timeout: 30s + endpoints: + analyze: /analyze \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..ebbc8639 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -0,0 +1,33 @@ +package com.mr.domain.auth.controller; + +import com.mr.domain.auth.dto.AuthRequestDTO; +import com.mr.domain.auth.dto.AuthResponseDTO; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.service.AuthService; +import com.mr.global.apipayload.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth") +@Profile({"local", "dev"}) +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login/{socialType}") + public ApiResponse socialLogin( + @PathVariable(name = "socialType") SocialType socialType, + @RequestBody @Valid AuthRequestDTO.SocialLoginRequest request + ) { + AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken()); + return ApiResponse.onSuccess(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java b/src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java new file mode 100644 index 00000000..57a5ad49 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java @@ -0,0 +1,16 @@ +package com.mr.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public class AuthRequestDTO { + + public record SocialLoginRequest( + @NotBlank(message = "소셜 액세스 토큰은 필수 입력값입니다.") + String accessToken + ) {} + + public record TokenRefreshRequest( + @NotBlank(message = "Refresh Token은 필수 입력값입니다.") + String refreshToken + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java b/src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java new file mode 100644 index 00000000..dc542d7d --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java @@ -0,0 +1,21 @@ +package com.mr.domain.auth.dto; + +import lombok.Builder; + +public class AuthResponseDTO { + + @Builder + public record TokenResponse( + String accessToken, + String refreshToken, + Long accessTokenExpiresInSeconds + ) {} + + @Builder + public record LoginResponse( + Long userId, + String nickname, + boolean isNewUser, + TokenResponse tokenInfo + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index ba8b38a6..db7a8d65 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -14,7 +14,7 @@ @Getter @Entity -// TODO: 추후 User 도메인 완성 시 단방향/양방향 인덱스 추가 +// TODO: 추후 User 도메인 완성 시 인덱스 추가 @Table( name = "social_auth", uniqueConstraints = { @@ -29,7 +29,7 @@ public class SocialAuth extends BaseCreatedEntity { @Column(name = "social_auth_id") private Long id; - // TODO: User 엔티티 연관관계 연결 예정 + // TODO: User연결 예정 @Column(name = "user_id", nullable = false) private Long userId; @@ -55,7 +55,7 @@ public class SocialAuth extends BaseCreatedEntity { @Builder(access = AccessLevel.PRIVATE) private SocialAuth(Long userId, SocialType socialType, String socialId, String refreshToken, String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) { - // 컴파일 에러 수정: 실제 정의된 validateUserAccount 메서드로 매핑 + validateUserAccount(userId); validateUserAccount(socialType); validateUserAccount(socialId); @@ -116,7 +116,6 @@ public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDat this.deviceInfo = deviceInfo; } - // 최신 토큰 만료 및 폐기 처리 public void expireToken() { this.refreshToken = null; this.refreshTokenHash = null; diff --git a/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java b/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java index bb236510..668ecdd2 100644 --- a/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java +++ b/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java @@ -1,6 +1,6 @@ package com.mr.domain.auth.entity.enums; public enum SocialType { - KAKAO, - GOOGLE + kakao, + google } diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java new file mode 100644 index 00000000..4ccac698 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -0,0 +1,47 @@ +package com.mr.domain.auth.service; + +import com.mr.domain.auth.dto.AuthResponseDTO; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.global.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + // private final KakaoOAuthService kakaoOAuthService; (외부 API 파싱용 서비스) + // private final GoogleOAuthService googleOAuthService; + + @Transactional + public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken) { + // 1. 외부 소셜 API (카카오/구글) 통신하여 유저 프로필(email, socialId) 파싱 + // SocialUserInfo userInfo = getSocialUserInfo(socialType, accessToken); + + // 2. TODO: User 엔티티 연동 및 가입여부 검증 (Stub 구조) + // 만약 가입 안 되어있으면 DB User 생성 -> 저장 + Long mockUserId = 1L; + String mockEmail = "user@example.com"; + String mockNickname = "뮤즈유저"; + boolean isNewUser = false; + + String appAccessToken = jwtTokenProvider.createAccessToken(mockUserId); + String appRefreshToken = jwtTokenProvider.createRefreshToken(mockUserId); + + + AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() + .accessToken(appAccessToken) + .refreshToken(appRefreshToken) + .accessTokenExpiresInSeconds(3600L) + .build(); + return AuthResponseDTO.LoginResponse.builder() + .userId(mockUserId) + .nickname(mockNickname) + .isNewUser(isNewUser) + .tokenInfo(tokenResponse) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/subscriptions/entity/Subscription.java b/src/main/java/com/mr/domain/subscriptions/entity/Subscription.java index e655fe74..6ccd7064 100644 --- a/src/main/java/com/mr/domain/subscriptions/entity/Subscription.java +++ b/src/main/java/com/mr/domain/subscriptions/entity/Subscription.java @@ -1,12 +1,16 @@ package com.mr.domain.subscriptions.entity; import com.mr.domain.subscriptions.exception.SubscriptionErrorStatus; +import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -28,9 +32,10 @@ public class Subscription { @Column(name = "subscription_id") private Long id; - // 유저 아이디 - @Column(name = "user_id", nullable = false) - private Long userId; + // 유저 (유저 1 : 구독 1..N, UNIQUE 제약 없음) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // 구독 등급 @Column(name = "tier", nullable = false, length = 20) @@ -45,24 +50,38 @@ public class Subscription { private LocalDateTime endDate; @Builder(access = AccessLevel.PRIVATE) - private Subscription(Long userId, String tier, LocalDateTime startDate, LocalDateTime endDate) { - this.userId = userId; + private Subscription(User user, String tier, LocalDateTime startDate, LocalDateTime endDate) { + this.user = user; this.tier = tier; this.startDate = startDate; this.endDate = endDate; } // 정적 팩토리 메서드 (구독 생성) - public static Subscription create(Long userId, String tier, LocalDateTime startDate, LocalDateTime endDate) { + public static Subscription create(User user, String tier, LocalDateTime startDate, LocalDateTime endDate) { + validateUser(user); + validateTier(tier); validateDates(startDate, endDate); return Subscription.builder() - .userId(userId) + .user(user) .tier(tier) .startDate(startDate) .endDate(endDate) .build(); } + private static void validateUser(User user) { + if (user == null) { + throw new GeneralException(SubscriptionErrorStatus.USER_REQUIRED); + } + } + + private static void validateTier(String tier) { + if (tier == null || tier.isBlank()) { + throw new GeneralException(SubscriptionErrorStatus.TIER_REQUIRED); + } + } + private static void validateDates(LocalDateTime startDate, LocalDateTime endDate) { if (startDate != null && endDate != null && endDate.isBefore(startDate)) { throw new GeneralException(SubscriptionErrorStatus.INVALID_SUBSCRIPTION_DATE); 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 f3a6b9e8..4b9fb959 100644 --- a/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java +++ b/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java @@ -10,7 +10,9 @@ public enum SubscriptionErrorStatus implements BaseCode { INVALID_SUBSCRIPTION_DATE(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_01", "구독 종료일은 시작일보다 이전일 수 없습니다."), - INVALID_SUBSCRIPTION_EXTENSION_DATE(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_02", "연장할 종료일은 기존 종료일보다 이후여야 합니다."); + 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", "구독 등급은 필수입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/entity/StudentInstrument.java b/src/main/java/com/mr/domain/user/entity/StudentInstrument.java index 8cedd60c..ad636469 100644 --- a/src/main/java/com/mr/domain/user/entity/StudentInstrument.java +++ b/src/main/java/com/mr/domain/user/entity/StudentInstrument.java @@ -9,6 +9,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -22,6 +23,9 @@ @Entity @Table( name = "student_instrument", + indexes = { + @Index(name = "idx_student_instrument_instrument_id", columnList = "instrument_id") + }, uniqueConstraints = { @UniqueConstraint(name = "uk_student_instrument_student_id_instrument_id", columnNames = {"student_id", "instrument_id"}) diff --git a/src/main/java/com/mr/domain/user/entity/User.java b/src/main/java/com/mr/domain/user/entity/User.java index 8716d0f5..354202cd 100644 --- a/src/main/java/com/mr/domain/user/entity/User.java +++ b/src/main/java/com/mr/domain/user/entity/User.java @@ -45,6 +45,7 @@ public class User extends BaseTimeEntity { @Builder(access = AccessLevel.PRIVATE) private User(String profileImgUrl) { + validateProfileImgUrl(profileImgUrl); this.profileImgUrl = profileImgUrl; } @@ -72,4 +73,10 @@ private static void validateNickname(String nickname) { throw new GeneralException(UserErrorStatus.NICKNAME_INVALID_FORMAT); } } + + private static void validateProfileImgUrl(String profileImgUrl) { + if (profileImgUrl == null || profileImgUrl.isBlank()) { + throw new GeneralException(UserErrorStatus.PROFILE_IMAGE_REQUIRED); + } + } } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/user/entity/enums/UserRole.java b/src/main/java/com/mr/domain/user/entity/enums/UserRole.java new file mode 100644 index 00000000..4d7b9c4c --- /dev/null +++ b/src/main/java/com/mr/domain/user/entity/enums/UserRole.java @@ -0,0 +1,15 @@ +package com.mr.domain.user.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserRole { + ROLE_STUDENT("ROLE_STUDENT", "학생"), + ROLE_TEACHER("ROLE_TEACHER", "강사"), + ROLE_ADMIN("ROLE_ADMIN", "관리자"); + + private final String key; + private final String title; +} \ No newline at end of file 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 9bf9c7cc..e12994fb 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -11,8 +11,6 @@ public enum UserErrorStatus implements BaseCode { NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."), NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."), - // 임의로 추가 - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/global/apipayload/code/CommonStatus.java b/src/main/java/com/mr/global/apipayload/code/CommonStatus.java index 25d78bdc..a7869f84 100644 --- a/src/main/java/com/mr/global/apipayload/code/CommonStatus.java +++ b/src/main/java/com/mr/global/apipayload/code/CommonStatus.java @@ -12,7 +12,9 @@ public enum CommonStatus implements BaseCode { SUCCESS(HttpStatus.OK, "COMMON_200", "요청에 성공하였습니다."), INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "COMMON_400_01", "입력값이 올바르지 않습니다."), HTTP_MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST, "COMMON_400_02", "요청 본문(JSON) 파싱에 실패했습니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500_01", "서버 에러가 발생했습니다."); + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500_01", "서버 에러가 발생했습니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401_01", "인증이 필요합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403_01", "금지된 접근입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/global/apipayload/domain/AiServerErrorStatus.java b/src/main/java/com/mr/global/apipayload/domain/AiServerErrorStatus.java new file mode 100644 index 00000000..bee1fa8d --- /dev/null +++ b/src/main/java/com/mr/global/apipayload/domain/AiServerErrorStatus.java @@ -0,0 +1,20 @@ +package com.mr.global.apipayload.domain; + +import com.mr.global.apipayload.code.BaseCode; +import org.springframework.http.HttpStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AiServerErrorStatus implements BaseCode { + + INVALID_RESPONSE(HttpStatus.INTERNAL_SERVER_ERROR, "AI_SERVER_500_01", "AI 서버 응답을 처리하지 못했습니다."), + RESPONSE_ERROR(HttpStatus.BAD_GATEWAY, "AI_SERVER_502_01", "AI 서버가 오류 응답을 반환했습니다."), + CONNECTION_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "AI_SERVER_503_01", "AI 서버에 연결할 수 없습니다."), + TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "AI_SERVER_504_01", "AI 서버 응답이 지연되고 있습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/mr/global/client/ai/AiAnalysisRequest.java b/src/main/java/com/mr/global/client/ai/AiAnalysisRequest.java new file mode 100644 index 00000000..8388f3c3 --- /dev/null +++ b/src/main/java/com/mr/global/client/ai/AiAnalysisRequest.java @@ -0,0 +1,41 @@ +package com.mr.global.client.ai; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record AiAnalysisRequest( + Meta meta, + List chords, + List notes +) { + public record Meta( + Double bpm, + @JsonProperty("time_signature") List timeSignature, + Key key, + String genre + ) { + } + + public record Key( + String tonic, + String mode + ) { + } + + public record Chord( + Integer bar, + Double beat, + String symbol + ) { + } + + public record Note( + Integer index, + Integer pitch, + @JsonProperty("onset_beats") Double onsetBeats, + @JsonProperty("duration_beats") Double durationBeats, + Integer velocity + ) { + } +} diff --git a/src/main/java/com/mr/global/client/ai/AiServerClient.java b/src/main/java/com/mr/global/client/ai/AiServerClient.java new file mode 100644 index 00000000..6bef6e7b --- /dev/null +++ b/src/main/java/com/mr/global/client/ai/AiServerClient.java @@ -0,0 +1,62 @@ +package com.mr.global.client.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.mr.global.apipayload.domain.AiServerErrorStatus; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.config.AiServerProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.net.SocketTimeoutException; +import java.net.http.HttpTimeoutException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AiServerClient { + + private final RestClient aiServerRestClient; + private final AiServerProperties properties; + + public JsonNode requestAnalysis(AiAnalysisRequest request) { + try { + JsonNode response = aiServerRestClient.post() + .uri(properties.endpoints().analyze()) + .body(request) + .retrieve() + .body(JsonNode.class); + + if (response == null) { + throw new GeneralException(AiServerErrorStatus.INVALID_RESPONSE); + } + return response; + } catch (ResourceAccessException e) { + throw new GeneralException(hasTimeoutCause(e) ? AiServerErrorStatus.TIMEOUT : AiServerErrorStatus.CONNECTION_FAILED); + } catch (HttpStatusCodeException e) { + log.warn("AI 서버가 오류 응답을 반환했습니다. status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(AiServerErrorStatus.RESPONSE_ERROR); + } catch (RestClientException e) { + throw new GeneralException(AiServerErrorStatus.INVALID_RESPONSE); + } + } + + private boolean hasTimeoutCause(Throwable throwable) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Throwable current = throwable; + while (current != null && visited.add(current)) { + if (current instanceof SocketTimeoutException || current instanceof HttpTimeoutException) { + return true; + } + current = current.getCause(); + } + return false; + } +} diff --git a/src/main/java/com/mr/global/config/AiServerProperties.java b/src/main/java/com/mr/global/config/AiServerProperties.java new file mode 100644 index 00000000..9a3b00d2 --- /dev/null +++ b/src/main/java/com/mr/global/config/AiServerProperties.java @@ -0,0 +1,18 @@ +package com.mr.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "ai.internal") +public record AiServerProperties( + String baseUrl, + Duration connectTimeout, + Duration readTimeout, + Endpoints endpoints +) { + public record Endpoints( + String analyze + ) { + } +} diff --git a/src/main/java/com/mr/global/config/AiServerRestClientConfig.java b/src/main/java/com/mr/global/config/AiServerRestClientConfig.java new file mode 100644 index 00000000..1fd70c75 --- /dev/null +++ b/src/main/java/com/mr/global/config/AiServerRestClientConfig.java @@ -0,0 +1,26 @@ +package com.mr.global.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +@EnableConfigurationProperties(AiServerProperties.class) +public class AiServerRestClientConfig { + + @Bean + public RestClient aiServerRestClient(AiServerProperties properties) { + ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS + .withConnectTimeout(properties.connectTimeout()) + .withReadTimeout(properties.readTimeout()); + + return RestClient.builder() + .baseUrl(properties.baseUrl()) + .requestFactory(ClientHttpRequestFactories.get(settings)) + .defaultHeader("Content-Type", "application/json") + .build(); + } +} diff --git a/src/main/java/com/mr/global/config/SecurityConfig.java b/src/main/java/com/mr/global/config/SecurityConfig.java deleted file mode 100644 index 7993a7ae..00000000 --- a/src/main/java/com/mr/global/config/SecurityConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.mr.global.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> { - try { - http.csrf(csrfSpec -> csrfSpec.disable()); - } catch (Exception e) { - throw new RuntimeException(e); - } - }) // CSRF 해제 - .authorizeHttpRequests(auth -> auth - // Swagger - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .anyRequest().authenticated() - ); - return http.build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/mr/global/config/SwaggerConfig.java b/src/main/java/com/mr/global/config/SwaggerConfig.java new file mode 100644 index 00000000..4febd52b --- /dev/null +++ b/src/main/java/com/mr/global/config/SwaggerConfig.java @@ -0,0 +1,4 @@ +package com.mr.global.config; + +public class SwaggerConfig { +} diff --git a/src/main/java/com/mr/global/security/SecurityConfig.java b/src/main/java/com/mr/global/security/SecurityConfig.java new file mode 100644 index 00000000..b28f5968 --- /dev/null +++ b/src/main/java/com/mr/global/security/SecurityConfig.java @@ -0,0 +1,82 @@ +package com.mr.global.security; + +import com.mr.global.security.jwt.JwtAccessDeniedHandler; +import com.mr.global.security.jwt.JwtAuthenticationEntryPoint; +import com.mr.global.security.jwt.JwtAuthenticationFilter; +import com.mr.global.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + private static final String[] PUBLIC_URLS = { + "/swagger-ui/**", + "/v3/api-docs/**", + "/api/auth/login/**", + "/api/auth/refactor" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers(PUBLIC_URLS).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + // CORS 설정 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOriginPatterns(List.of( + "http://localhost:3000", + "http://localhost:5173", + "https://*.musereview.site" + )); + + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + + configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With")); + + configuration.setExposedHeaders(List.of("Authorization")); + + configuration.setAllowCredentials(true); + + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/SecurityUtil.java b/src/main/java/com/mr/global/security/SecurityUtil.java new file mode 100644 index 00000000..e19f6ba5 --- /dev/null +++ b/src/main/java/com/mr/global/security/SecurityUtil.java @@ -0,0 +1,30 @@ +package com.mr.global.security; + +import com.mr.global.apipayload.code.CommonStatus; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.security.principal.CustomUserDetails; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + + private SecurityUtil() { + } + + public static Long getCurrentUserId() { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw new GeneralException(CommonStatus.UNAUTHORIZED); + } + + if (authentication.getPrincipal() instanceof CustomUserDetails userDetails) { + return userDetails.getUserId(); + } + + throw new GeneralException(CommonStatus.UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/mr/global/security/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..26555777 --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package com.mr.global.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.global.apipayload.ApiResponse; +import com.mr.global.apipayload.code.CommonStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + + // 403 Forbidden 공통 JSON 응답 반환 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + + ApiResponse apiResponse = ApiResponse.onFailure( + CommonStatus.FORBIDDEN.getCode(), + CommonStatus.FORBIDDEN.getMessage(), + null + ); + + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/mr/global/security/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..3967a019 --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package com.mr.global.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.global.apipayload.ApiResponse; +import com.mr.global.apipayload.code.CommonStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + + // 401 Unauthorized 공통 JSON 응답 반환 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + ApiResponse apiResponse = ApiResponse.onFailure( + CommonStatus.UNAUTHORIZED.getCode(), + CommonStatus.UNAUTHORIZED.getMessage(), + null + ); + + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/mr/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..1375ddcd --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,63 @@ +package com.mr.global.security.jwt; + +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider tokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String jwt = resolveToken(request); + + if (StringUtils.hasText(jwt)) { + if (!tokenProvider.validateAccessToken(jwt)) { + log.warn("유효하지 않은 JWT 토큰입니다. Token: {}", jwt); + SecurityContextHolder.clearContext(); + request.setAttribute("exception", new JwtException("유효하지 않거나 만료된 토큰입니다.")); + } else { + try { + Authentication authentication = tokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (UsernameNotFoundException | NumberFormatException e) { + log.error("Security Context에 인증 정보를 저장할 수 없습니다. Token: {}, Error: {}", jwt, e.getMessage()); + SecurityContextHolder.clearContext(); + request.setAttribute("exception", e); + } catch (Exception e) { + log.error("JWT 인증 처리 중 알 수 없는 에러 발생: {}", e.getMessage()); + SecurityContextHolder.clearContext(); + request.setAttribute("exception", e); + } + } + } + + filterChain.doFilter(request, response); + } + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtProperties.java b/src/main/java/com/mr/global/security/jwt/JwtProperties.java new file mode 100644 index 00000000..f4e3b0e8 --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtProperties.java @@ -0,0 +1,20 @@ +package com.mr.global.security.jwt; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + @NotBlank(message = "JWT Secret Key는 필수 입력값입니다.") + String secret, + + @Positive(message = "Access Token 만료 시간은 양수여야 합니다.") + long accessTokenValidityInSeconds, + + @Positive(message = "Refresh Token 만료 시간은 양수여야 합니다.") + long refreshTokenValidityInSeconds +) { +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..67423040 --- /dev/null +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,107 @@ +package com.mr.global.security.jwt; + +import com.mr.global.security.principal.CustomUserDetailsService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties(JwtProperties.class) +public class JwtTokenProvider { + + private static final String TOKEN_TYPE_CLAIM = "type"; + private static final String ACCESS_TYPE = "access"; + private static final String REFRESH_TYPE = "refresh"; + + private final CustomUserDetailsService userDetailsService; + private final JwtProperties jwtProperties; + + private Key key; + + @PostConstruct + protected void init() { + try { + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret()); + this.key = Keys.hmacShaKeyFor(keyBytes); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("[ERROR] JWT Secret Key는 올바른 Base64 인코딩 포맷이어야 합니다.", e); + } + } + + public String createAccessToken(Long userId) { + Claims claims = Jwts.claims().setSubject(String.valueOf(userId)); + claims.put(TOKEN_TYPE_CLAIM, ACCESS_TYPE); + Date now = new Date(); + Date validity = new Date(now.getTime() + jwtProperties.accessTokenValidityInSeconds() * 1000); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken(Long userId) { + Claims claims = Jwts.claims().setSubject(String.valueOf(userId)); + claims.put(TOKEN_TYPE_CLAIM, REFRESH_TYPE); + Date now = new Date(); + Date validity = new Date(now.getTime() + jwtProperties.refreshTokenValidityInSeconds() * 1000); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + String userId = claims.getSubject(); + + UserDetails userDetails = userDetailsService.loadUserByUsername(userId); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + public boolean validateAccessToken(String token) { + return validateTokenWithType(token, ACCESS_TYPE); + } + + public boolean validateRefreshToken(String token) { + return validateTokenWithType(token, REFRESH_TYPE); + } + + private boolean validateTokenWithType(String token, String expectedType) { + try { + Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + String tokenType = claims.get(TOKEN_TYPE_CLAIM, String.class); + return expectedType.equals(tokenType); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + private Claims parseClaims(String token) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + } catch (ExpiredJwtException e) { + throw new JwtException("만료된 토큰입니다.", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/principal/CustomUserDetails.java b/src/main/java/com/mr/global/security/principal/CustomUserDetails.java new file mode 100644 index 00000000..66cc3ea6 --- /dev/null +++ b/src/main/java/com/mr/global/security/principal/CustomUserDetails.java @@ -0,0 +1,54 @@ +package com.mr.global.security.principal; + +import com.mr.domain.user.entity.enums.UserRole; +import com.mr.global.apipayload.code.CommonStatus; +import com.mr.global.apipayload.exception.GeneralException; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +public class CustomUserDetails implements UserDetails { + + private final Long userId; + private final UserRole role; + + public CustomUserDetails(Long userId, UserRole role) { + if (userId == null || role == null) { + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + } + this.userId = userId; + this.role = role; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(role.getKey())); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return String.valueOf(userId); + } + + @Override + public boolean isAccountNonExpired() { return true; } + + @Override + public boolean isAccountNonLocked() { return true; } + + @Override + public boolean isCredentialsNonExpired() { return true; } + + @Override + public boolean isEnabled() { return true; } +} \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/principal/CustomUserDetailsService.java b/src/main/java/com/mr/global/security/principal/CustomUserDetailsService.java new file mode 100644 index 00000000..5919b5fc --- /dev/null +++ b/src/main/java/com/mr/global/security/principal/CustomUserDetailsService.java @@ -0,0 +1,28 @@ +package com.mr.global.security.principal; + +import com.mr.domain.user.entity.enums.UserRole; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + // TODO: 추후 User 엔티티 및 UserRepository 완성 시 주입 + // private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + try { + Long userId = Long.parseLong(username); + + // TODO: User user = userRepository.findById(userId) + // .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 사용자입니다. ID: " + userId)); + + return new CustomUserDetails(userId, UserRole.ROLE_STUDENT); + } catch (NumberFormatException e) { + throw new UsernameNotFoundException("올바르지 않은 사용자 ID 포맷입니다: " + username, e); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.example.yml b/src/main/resources/application.example.yml index 3dd38451..18d2b032 100644 --- a/src/main/resources/application.example.yml +++ b/src/main/resources/application.example.yml @@ -39,4 +39,13 @@ oauth: google: client-id: "" # [입력] 구글 클라우드 콘솔 OAuth 클라이언트 ID client-secret: "" # [입력] 구글 클라우드 콘솔 보안 비밀번호 - redirect-uri: http://localhost:8080/login/oauth2/code/google \ No newline at end of file + redirect-uri: http://localhost:8080/login/oauth2/code/google + +# 5. AI 서버 연동 설정 +ai: + internal: + base-url: http://localhost:8000 # [입력] 로컬에서 띄운 AI 서버 주소 + connect-timeout: 3s + read-timeout: 30s + endpoints: + analyze: /analyze \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d916e688..1ffa7a7e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -47,14 +47,21 @@ oauth: client-secret: ${GOOGLE_CLIENT_SECRET:} redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080/login/oauth2/code/google} -# AI 도입 및 AWS S3 공통 구조 -external: - ai: - base-url: ${AI_BASE_URL:} - api-key: ${AI_API_KEY:} - model: ${AI_MODEL:} +ai: + internal: + base-url: ${AI_INTERNAL_BASE_URL:http://localhost:8000} + connect-timeout: ${AI_INTERNAL_CONNECT_TIMEOUT:3s} + read-timeout: ${AI_INTERNAL_READ_TIMEOUT:30s} + endpoints: + analyze: ${AI_INTERNAL_ANALYZE_ENDPOINT:/analyze} + # TODO: 외부 LLM(Gemini) 연동 설정 - 리포트 생성용, 별도 이슈에서 ai.external.* 로 base-url/api-key/model 등 확정 예정 aws: s3: bucket: ${AWS_S3_BUCKET:} region: ${AWS_REGION:ap-northeast-2} + +# 프로필 관련 공통 설정 +app: + profile: + default-image-url: ${DEFAULT_PROFILE_IMAGE_URL:} diff --git a/src/test/java/com/mr/global/client/ai/AiAnalysisRequestSerializationTest.java b/src/test/java/com/mr/global/client/ai/AiAnalysisRequestSerializationTest.java new file mode 100644 index 00000000..a6dbe55d --- /dev/null +++ b/src/test/java/com/mr/global/client/ai/AiAnalysisRequestSerializationTest.java @@ -0,0 +1,39 @@ +package com.mr.global.client.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AiAnalysisRequestSerializationTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void 요청_직렬화시_AI_서버_스키마와_동일한_snake_case_필드명을_사용한다() throws Exception { + AiAnalysisRequest request = new AiAnalysisRequest( + new AiAnalysisRequest.Meta(120.0, List.of(4, 4), + new AiAnalysisRequest.Key("C", "major"), "jazz"), + List.of(new AiAnalysisRequest.Chord(1, 1.0, "Dm7")), + List.of(new AiAnalysisRequest.Note(0, 62, 0.0, 1.0, 90)) + ); + + JsonNode json = objectMapper.valueToTree(request); + + assertThat(json.path("meta").has("time_signature")).isTrue(); + assertThat(json.path("meta").has("timeSignature")).isFalse(); + assertThat(json.path("notes").get(0).has("onset_beats")).isTrue(); + assertThat(json.path("notes").get(0).has("duration_beats")).isTrue(); + + assertThat(json.path("meta").path("bpm").asDouble()).isEqualTo(120.0); + assertThat(json.path("meta").path("key").path("tonic").asText()).isEqualTo("C"); + assertThat(json.path("meta").path("key").path("mode").asText()).isEqualTo("major"); + assertThat(json.path("chords").get(0).path("bar").asInt()).isEqualTo(1); + assertThat(json.path("chords").get(0).path("symbol").asText()).isEqualTo("Dm7"); + assertThat(json.path("notes").get(0).path("pitch").asInt()).isEqualTo(62); + assertThat(json.path("notes").get(0).path("velocity").asInt()).isEqualTo(90); + } +} diff --git a/src/test/java/com/mr/global/client/ai/AiServerClientTest.java b/src/test/java/com/mr/global/client/ai/AiServerClientTest.java new file mode 100644 index 00000000..ecd98299 --- /dev/null +++ b/src/test/java/com/mr/global/client/ai/AiServerClientTest.java @@ -0,0 +1,135 @@ +package com.mr.global.client.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.mr.global.apipayload.domain.AiServerErrorStatus; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.config.AiServerProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class AiServerClientTest { + + private static final String BASE_URL = "http://localhost:8000"; + + private MockRestServiceServer mockServer; + private AiServerClient client; + + @BeforeEach + void setUp() { + AiServerProperties properties = new AiServerProperties( + BASE_URL, Duration.ofSeconds(3), Duration.ofSeconds(30), + new AiServerProperties.Endpoints("/analyze")); + + RestClient.Builder builder = RestClient.builder().baseUrl(properties.baseUrl()); + mockServer = MockRestServiceServer.bindTo(builder).build(); + client = new AiServerClient(builder.build(), properties); + } + + private AiAnalysisRequest sampleRequest() { + return new AiAnalysisRequest( + new AiAnalysisRequest.Meta(120.0, List.of(4, 4), + new AiAnalysisRequest.Key("C", "major"), "jazz"), + List.of(new AiAnalysisRequest.Chord(1, 1.0, "Dm7")), + List.of(new AiAnalysisRequest.Note(0, 62, 0.0, 1.0, 90)) + ); + } + + @Test + void 정상_응답이면_AI_서버가_반환한_JSON을_그대로_돌려준다() { + mockServer.expect(requestTo(BASE_URL + "/analyze")) + .andRespond(withSuccess("{\"scores\":{\"final_score\":80.5}}", MediaType.APPLICATION_JSON)); + + JsonNode result = client.requestAnalysis(sampleRequest()); + + assertThat(result.path("scores").path("final_score").asDouble()).isEqualTo(80.5); + } + + @Test + void AI_서버가_400을_반환하면_RESPONSE_ERROR로_변환된다() { + mockServer.expect(requestTo(BASE_URL + "/analyze")) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .body("{\"detail\":\"코드 파싱 실패\"}") + .contentType(MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.requestAnalysis(sampleRequest())) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AiServerErrorStatus.RESPONSE_ERROR)); + } + + @Test + void AI_서버가_500을_반환하면_RESPONSE_ERROR로_변환된다() { + mockServer.expect(requestTo(BASE_URL + "/analyze")) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR) + .body("{\"detail\":\"알 수 없는 오류\"}") + .contentType(MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.requestAnalysis(sampleRequest())) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AiServerErrorStatus.RESPONSE_ERROR)); + } + + @Test + void 연결이_거부되면_CONNECTION_FAILED로_변환된다() { + mockServer.expect(requestTo(BASE_URL + "/analyze")) + .andRespond(request -> { + throw new ConnectException("Connection refused"); + }); + + assertThatThrownBy(() -> client.requestAnalysis(sampleRequest())) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AiServerErrorStatus.CONNECTION_FAILED)); + } + + @Test + void 응답이_지연되어_타임아웃되면_TIMEOUT으로_변환된다() { + mockServer.expect(requestTo(BASE_URL + "/analyze")) + .andRespond(request -> { + throw new SocketTimeoutException("Read timed out"); + }); + + assertThatThrownBy(() -> client.requestAnalysis(sampleRequest())) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AiServerErrorStatus.TIMEOUT)); + } + + @Test + void JDK_HttpClient_기반_타임아웃_예외도_TIMEOUT으로_변환된다() { + mockServer.expect(requestTo(BASE_URL + "/analyze")) + .andRespond(request -> { + throw new HttpTimeoutException("Request timed out"); + }); + + assertThatThrownBy(() -> client.requestAnalysis(sampleRequest())) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AiServerErrorStatus.TIMEOUT)); + } + + @Test + void 원인_체인_안쪽에_타임아웃_예외가_있어도_TIMEOUT으로_변환된다() { + mockServer.expect(requestTo(BASE_URL + "/analyze")) + .andRespond(request -> { + throw new IOException("wrapped", new SocketTimeoutException("connect timed out")); + }); + + assertThatThrownBy(() -> client.requestAnalysis(sampleRequest())) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AiServerErrorStatus.TIMEOUT)); + } +} diff --git a/src/test/java/com/mr/global/config/AiServerPropertiesBindingTest.java b/src/test/java/com/mr/global/config/AiServerPropertiesBindingTest.java new file mode 100644 index 00000000..77bf876e --- /dev/null +++ b/src/test/java/com/mr/global/config/AiServerPropertiesBindingTest.java @@ -0,0 +1,47 @@ +package com.mr.global.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class AiServerPropertiesBindingTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfig.class); + + @EnableConfigurationProperties(AiServerProperties.class) + static class TestConfig { + } + + @Test + void ai_internal_프리픽스로_설정값이_바인딩된다() { + contextRunner + .withPropertyValues( + "ai.internal.base-url=http://localhost:8000", + "ai.internal.connect-timeout=3s", + "ai.internal.read-timeout=30s", + "ai.internal.endpoints.analyze=/analyze" + ) + .run(context -> { + AiServerProperties properties = context.getBean(AiServerProperties.class); + assertThat(properties.baseUrl()).isEqualTo("http://localhost:8000"); + assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(properties.readTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.endpoints().analyze()).isEqualTo("/analyze"); + }); + } + + @Test + void 이전_ai_server_프리픽스만_설정되면_바인딩되지_않는다() { + contextRunner + .withPropertyValues("ai.server.base-url=http://old-prefix-should-be-ignored:9999") + .run(context -> { + AiServerProperties properties = context.getBean(AiServerProperties.class); + assertThat(properties.baseUrl()).isNull(); + }); + } +} From e8dda6555c6e36e766def596227185f29f10f730 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 15:17:48 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=ED=95=99=EC=8A=B5=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=A5=A0=20=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/LearningController.java | 12 ++++++++ .../dto/res/LearningProgressResponseDTO.java | 13 +++++++++ .../learning/entity/UserLearningProgress.java | 4 +-- .../repository/LearningStepRepository.java | 9 ++++++ .../UserLearningProgressRepository.java | 7 +++++ .../learning/service/LearningService.java | 28 +++++++++++++++++++ .../user/exception/UserErrorStatus.java | 4 +++ 7 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/mr/domain/learning/dto/res/LearningProgressResponseDTO.java create mode 100644 src/main/java/com/mr/domain/learning/repository/LearningStepRepository.java diff --git a/src/main/java/com/mr/domain/learning/controller/LearningController.java b/src/main/java/com/mr/domain/learning/controller/LearningController.java index 333f852c..17cbd0d7 100644 --- a/src/main/java/com/mr/domain/learning/controller/LearningController.java +++ b/src/main/java/com/mr/domain/learning/controller/LearningController.java @@ -1,11 +1,13 @@ package com.mr.domain.learning.controller; import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO; +import com.mr.domain.learning.dto.res.LearningProgressResponseDTO; import com.mr.domain.learning.dto.res.LearningResultResponseDTO; import com.mr.domain.learning.service.LearningService; import com.mr.global.apipayload.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -19,6 +21,7 @@ public class LearningController { private final LearningService learningService; + // 학습 결과 저장 @PostMapping("/{learningId}/result") public ApiResponse saveLearningResult( @PathVariable Long learningId, // 임시 @@ -30,4 +33,13 @@ public ApiResponse saveLearningRe return ApiResponse.onSuccess(response); } + + // 학습 진행률 조회 + @GetMapping("/{learningId}/progress") + public ApiResponse getLearningProgress( + @PathVariable Long learningId + ) { + LearningProgressResponseDTO.ProgressResultDTO result = learningService.getLearningProgress(learningId); + return ApiResponse.onSuccess(result); + } } diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningProgressResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningProgressResponseDTO.java new file mode 100644 index 00000000..24682e0f --- /dev/null +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningProgressResponseDTO.java @@ -0,0 +1,13 @@ +package com.mr.domain.learning.dto.res; + +public class LearningProgressResponseDTO { + + public record ProgressResultDTO( + Long learningId, + Integer progressRate + ){ + public static ProgressResultDTO of(Long learningId, Integer progressRate){ + return new ProgressResultDTO(learningId, progressRate); + } + } +} diff --git a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java index 689b4249..1edefce8 100644 --- a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java +++ b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java @@ -101,11 +101,11 @@ public void updateProgress(Integer score, LocalDateTime lastStudiedAt) { this.lastStudiedAt = lastStudiedAt != null ? lastStudiedAt : LocalDateTime.now(); } - // 점수 기준 학습 진행 상태 파악/ 점수는 임시 값 + // 점수 기준 학습 진행 상태 파악 public String getLearningStatus() { if (this.score == null) { return "BEFORE_START"; } - return this.score >= 80 ? "COMPLETED" : "IN_PROGRESS"; + return this.score >= 90 ? "COMPLETED" : "IN_PROGRESS"; } } diff --git a/src/main/java/com/mr/domain/learning/repository/LearningStepRepository.java b/src/main/java/com/mr/domain/learning/repository/LearningStepRepository.java new file mode 100644 index 00000000..91782968 --- /dev/null +++ b/src/main/java/com/mr/domain/learning/repository/LearningStepRepository.java @@ -0,0 +1,9 @@ +package com.mr.domain.learning.repository; + +import com.mr.domain.learning.entity.LearningStep; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LearningStepRepository extends JpaRepository { + // 특정 학습에 속한 전체 단계 수 조회 + long countByLearningId(Long learningId); +} 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 b732e42b..8e8f5e63 100644 --- a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -2,10 +2,17 @@ import com.mr.domain.learning.entity.UserLearningProgress; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface UserLearningProgressRepository extends JpaRepository { Optional findByUserIdAndLearningId(Long userId, Long learningId); + // 유저가 완료한(점수 80점 이상) 학습 단계 수 조회 + @Query("SELECT COUNT(ulp) FROM UserLearningProgress ulp " + + "JOIN ulp.learningStep ls " + + "WHERE ulp.user.id = :userId AND ls.learning.id = :learningId AND ulp.score >= 90") + long countCompletedStepsByUserIdAndLearningId(@Param("userId") Long userId, @Param("learningId") Long learningId); } diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index 9efca249..3333049c 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -1,16 +1,19 @@ package com.mr.domain.learning.service; import com.mr.domain.learning.dto.req.LearningResultSaveRequestDTO; +import com.mr.domain.learning.dto.res.LearningProgressResponseDTO; import com.mr.domain.learning.dto.res.LearningResultResponseDTO; import com.mr.domain.learning.entity.Learning; import com.mr.domain.learning.entity.UserLearningProgress; import com.mr.domain.learning.exception.LearningErrorStatus; import com.mr.domain.learning.repository.LearningRepository; +import com.mr.domain.learning.repository.LearningStepRepository; import com.mr.domain.learning.repository.UserLearningProgressRepository; import com.mr.domain.user.entity.User; import com.mr.domain.user.exception.UserErrorStatus; 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; @@ -23,10 +26,13 @@ public class LearningService { private final UserLearningProgressRepository userLearningProgressRepository; + private final LearningStepRepository learningStepRepository; + private final UserLearningProgressRepository progressRepository; private final LearningRepository learningRepository; // 임시 작명 private final UserRepository userRepository; + // 학습 결과 저장 public LearningResultResponseDTO.SaveResultResultDTO saveResult( Long userId, Long learningId, @@ -51,4 +57,26 @@ public LearningResultResponseDTO.SaveResultResultDTO saveResult( }); return LearningResultResponseDTO.SaveResultResultDTO.from(progress); } + + // 학습 진행률 조회 로직 + public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress(Long learningId) { + // 학습 존재 여부 확인 + if (!learningRepository.existsById(learningId)) { + throw new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND); + } + + // 현재 로그인한 유저 ID 획득 (SecurityUtil 활용) + Long userId = SecurityUtil.getCurrentUserId(); + + // 전체 학습 단계 수 조회 + long totalStepCount = learningStepRepository.countByLearningId(learningId); + + // 완료한 학습 단계 수 조회 + long completedStepCount = progressRepository.countCompletedStepsByUserIdAndLearningId(userId, learningId); + + // 진행률 계산 (0으로 나누기 예외 방지) + int progressRate = totalStepCount == 0 ? 0 : (int) Math.round((double) completedStepCount / totalStepCount * 100); + + return LearningProgressResponseDTO.ProgressResultDTO.of(learningId, progressRate); + } } 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 e12994fb..5abffd09 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -12,6 +12,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자로 입력해야 합니다."), + // 임의로 추가 + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다."), + PROFILE_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_03", "프로필 이미지 URL은 필수입니다."); + private final HttpStatus status; private final String code; private final String message; From 74f710332412123d21f02549f891984ca5bae7da Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 15:29:49 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=20=EC=82=AC=ED=95=AD=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/learning/controller/LearningController.java | 3 +-- .../com/mr/domain/learning/service/LearningService.java | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/mr/domain/learning/controller/LearningController.java b/src/main/java/com/mr/domain/learning/controller/LearningController.java index 17cbd0d7..3074861d 100644 --- a/src/main/java/com/mr/domain/learning/controller/LearningController.java +++ b/src/main/java/com/mr/domain/learning/controller/LearningController.java @@ -27,9 +27,8 @@ public ApiResponse saveLearningRe @PathVariable Long learningId, // 임시 @Valid @RequestBody LearningResultSaveRequestDTO.SaveResultDTO request ){ - Long userId = 1L; // 임시 LearningResultResponseDTO.SaveResultResultDTO response = - learningService.saveResult(userId, learningId, request); + learningService.saveResult(learningId, request); return ApiResponse.onSuccess(response); } diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index 3333049c..dd2d374a 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -27,17 +27,17 @@ public class LearningService { private final UserLearningProgressRepository userLearningProgressRepository; private final LearningStepRepository learningStepRepository; - private final UserLearningProgressRepository progressRepository; private final LearningRepository learningRepository; // 임시 작명 private final UserRepository userRepository; // 학습 결과 저장 public LearningResultResponseDTO.SaveResultResultDTO saveResult( - Long userId, Long learningId, LearningResultSaveRequestDTO.SaveResultDTO request ){ + Long userId = SecurityUtil.getCurrentUserId(); + User user = userRepository.findById(userId) .orElseThrow(()-> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); @@ -72,7 +72,7 @@ public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress(Long le long totalStepCount = learningStepRepository.countByLearningId(learningId); // 완료한 학습 단계 수 조회 - long completedStepCount = progressRepository.countCompletedStepsByUserIdAndLearningId(userId, learningId); + long completedStepCount = userLearningProgressRepository.countCompletedStepsByUserIdAndLearningId(userId, learningId); // 진행률 계산 (0으로 나누기 예외 방지) int progressRate = totalStepCount == 0 ? 0 : (int) Math.round((double) completedStepCount / totalStepCount * 100); From 7fc950df09a129562980362ff3aa851a84781e17 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 18:00:49 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=9E=98=EB=B9=97=20=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/learning/entity/UserLearningProgress.java | 4 ++-- .../java/com/mr/domain/user/exception/UserErrorStatus.java | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java index 1edefce8..b0cdac51 100644 --- a/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java +++ b/src/main/java/com/mr/domain/learning/entity/UserLearningProgress.java @@ -104,8 +104,8 @@ public void updateProgress(Integer score, LocalDateTime lastStudiedAt) { // 점수 기준 학습 진행 상태 파악 public String getLearningStatus() { if (this.score == null) { - return "BEFORE_START"; + return "NOT_STARTED"; } - return this.score >= 90 ? "COMPLETED" : "IN_PROGRESS"; + return this.score >= 90 ? "COMPLETED" : "RETRY"; } } 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 5abffd09..13dc7057 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -11,10 +11,7 @@ public enum UserErrorStatus implements BaseCode { NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."), NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."), - - // 임의로 추가 - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다."), - PROFILE_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_03", "프로필 이미지 URL은 필수입니다."); + private final HttpStatus status; private final String code; From 3bf01a7951caf5a553e33df01f5b11bcce5f4051 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 19:32:57 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=5F2=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/user/exception/UserErrorStatus.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1c752e35..1196da59 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -10,7 +10,7 @@ public enum UserErrorStatus implements BaseCode { NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."), - NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."), + NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."); private final HttpStatus status; From 6865798d6aa4756d87b9f046f712cd574bf31fd8 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 19:40:37 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=5F3=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/user/exception/UserErrorStatus.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 1196da59..73bff3a9 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -10,10 +10,12 @@ public enum UserErrorStatus implements BaseCode { NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."), - NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."); + 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", "존재하지 않는 사용자입니다."); private final HttpStatus status; private final String code; private final String message; -} +} \ No newline at end of file From 15b0fb4da1eb38cb577842f61b50ea517b2f3b35 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 21:30:19 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor:=20=ED=8C=80=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=88=98=EC=A0=95=5F1=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../learning/dto/req/LearningResultSaveRequestDTO.java | 5 ++++- .../mr/domain/learning/exception/LearningErrorStatus.java | 3 ++- .../repository/UserLearningProgressRepository.java | 6 +++--- .../com/mr/domain/learning/service/LearningService.java | 8 ++++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java index ecbf0108..a77b332f 100644 --- a/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java +++ b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java @@ -5,6 +5,9 @@ public class LearningResultSaveRequestDTO { public record SaveResultDTO( @NotNull(message = "점수는 필수 입력값입니다.") - Integer score + Integer score, + + @NotNull(message = "학습 스텝 ID는 필수 입력값입니다.") + Long learningStepId ) {} } diff --git a/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java b/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java index b728bdbf..f024e2e7 100644 --- a/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java +++ b/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java @@ -10,7 +10,8 @@ public enum LearningErrorStatus implements BaseCode { INVALID_LEARNING_STEP(HttpStatus.BAD_REQUEST, "LEARNING_400_01", "해당 학습 단계는 지칭한 학습(Learning)에 속하지 않습니다."), - LEARNING_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_01", "해당 학습(Learning)을 찾을 수 없습니다."); + LEARNING_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_01", "해당 학습(Learning)을 찾을 수 없습니다."), + LEARNING_STEP_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_02", "해당 학습 단계(Step)를 찾을 수 없습니다.");; private final HttpStatus status; private final String code; 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 8e8f5e63..c5f1607b 100644 --- a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -9,10 +9,10 @@ public interface UserLearningProgressRepository extends JpaRepository { - Optional findByUserIdAndLearningId(Long userId, Long learningId); - // 유저가 완료한(점수 80점 이상) 학습 단계 수 조회 + Optional findByUser_UserIdAndLearningId(Long userId, Long learningId); + // 유저가 완료한(점수 90점 이상) 학습 단계 수 조회 @Query("SELECT COUNT(ulp) FROM UserLearningProgress ulp " + "JOIN ulp.learningStep ls " + - "WHERE ulp.user.id = :userId AND ls.learning.id = :learningId AND ulp.score >= 90") + "WHERE ulp.user.userId = :userId AND ls.learning.id = :learningId AND ulp.score >= 90") long countCompletedStepsByUserIdAndLearningId(@Param("userId") Long userId, @Param("learningId") Long learningId); } diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index dd2d374a..3b981ab4 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -4,6 +4,7 @@ import com.mr.domain.learning.dto.res.LearningProgressResponseDTO; import com.mr.domain.learning.dto.res.LearningResultResponseDTO; import com.mr.domain.learning.entity.Learning; +import com.mr.domain.learning.entity.LearningStep; import com.mr.domain.learning.entity.UserLearningProgress; import com.mr.domain.learning.exception.LearningErrorStatus; import com.mr.domain.learning.repository.LearningRepository; @@ -44,14 +45,17 @@ public LearningResultResponseDTO.SaveResultResultDTO saveResult( Learning learning = learningRepository.findById(learningId) .orElseThrow(() -> new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND)); + LearningStep learningStep = learningStepRepository.findById(request.learningStepId()) + .orElseThrow(() -> new GeneralException(LearningErrorStatus.LEARNING_STEP_NOT_FOUND)); + UserLearningProgress progress = userLearningProgressRepository - .findByUserIdAndLearningId(userId, learning.getId()) + .findByUser_UserIdAndLearningId(userId, learning.getId()) .map(p -> { p.updateProgress(request.score(), LocalDateTime.now()); return p; }) .orElseGet(() -> { - UserLearningProgress newProgress = UserLearningProgress.create(user, learning, null); + UserLearningProgress newProgress = UserLearningProgress.create(user, learning, learningStep); newProgress.updateProgress(request.score(), LocalDateTime.now()); return userLearningProgressRepository.save(newProgress); }); From bbe2d76b86132bdc019ad9d2b28b5f3f296ee0a3 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 22:00:21 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20=ED=8C=80=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=88=98=EC=A0=95=5F2=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/learning/dto/req/LearningResultSaveRequestDTO.java | 4 ++++ .../mr/domain/learning/dto/res/LearningResultResponseDTO.java | 2 +- .../java/com/mr/domain/learning/service/LearningService.java | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java index a77b332f..42bbd129 100644 --- a/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java +++ b/src/main/java/com/mr/domain/learning/dto/req/LearningResultSaveRequestDTO.java @@ -1,10 +1,14 @@ package com.mr.domain.learning.dto.req; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; public class LearningResultSaveRequestDTO { public record SaveResultDTO( @NotNull(message = "점수는 필수 입력값입니다.") + @Min(value = 0, message = "점수는 0점 이상이어야 합니다.") + @Max(value = 100, message = "점수는 100점 이하여야 합니다.") Integer score, @NotNull(message = "학습 스텝 ID는 필수 입력값입니다.") diff --git a/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java b/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java index 8cec4a2b..4c42e00c 100644 --- a/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java +++ b/src/main/java/com/mr/domain/learning/dto/res/LearningResultResponseDTO.java @@ -20,7 +20,7 @@ public static SaveResultResultDTO from(UserLearningProgress progress) { progress.getLearning().getId(), progress.getLearningStatus(), progress.getScore(), - progress.getUpdatedAt() + progress.getLastStudiedAt() ); } } diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index 3b981ab4..66fc5c4e 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -33,6 +33,7 @@ public class LearningService { private final UserRepository userRepository; // 학습 결과 저장 + @Transactional public LearningResultResponseDTO.SaveResultResultDTO saveResult( Long learningId, LearningResultSaveRequestDTO.SaveResultDTO request From 4a2cbd4d026271b19c7532728a6b47f5a0f88385 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 22:43:07 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EC=88=98=EC=A0=95=5F?= =?UTF-8?q?1=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/learning/entity/LearningStep.java | 8 ++++++++ .../repository/UserLearningProgressRepository.java | 2 +- .../com/mr/domain/learning/service/LearningService.java | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/learning/entity/LearningStep.java b/src/main/java/com/mr/domain/learning/entity/LearningStep.java index 150b3ec6..8a2d6680 100644 --- a/src/main/java/com/mr/domain/learning/entity/LearningStep.java +++ b/src/main/java/com/mr/domain/learning/entity/LearningStep.java @@ -1,5 +1,7 @@ package com.mr.domain.learning.entity; +import com.mr.domain.learning.exception.LearningErrorStatus; +import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -101,4 +103,10 @@ public void updateStepInfo(Integer stepNo, String title, String summary, this.estimatedMinutes = estimatedMinutes; } } + + public void validateBelongsTo(Learning targetLearning) { + if (this.learning == null || !this.learning.getId().equals(targetLearning.getId())) { + throw new GeneralException(LearningErrorStatus.INVALID_LEARNING_STEP); + } + } } 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 c5f1607b..0c52cc24 100644 --- a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -9,7 +9,7 @@ public interface UserLearningProgressRepository extends JpaRepository { - Optional findByUser_UserIdAndLearningId(Long userId, Long learningId); + Optional findByUser_UserIdAndLearningStep_Id(Long userId, Long learningStepId); // 유저가 완료한(점수 90점 이상) 학습 단계 수 조회 @Query("SELECT COUNT(ulp) FROM UserLearningProgress ulp " + "JOIN ulp.learningStep ls " + diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index 66fc5c4e..a9c1d29b 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -49,8 +49,10 @@ public LearningResultResponseDTO.SaveResultResultDTO saveResult( LearningStep learningStep = learningStepRepository.findById(request.learningStepId()) .orElseThrow(() -> new GeneralException(LearningErrorStatus.LEARNING_STEP_NOT_FOUND)); + learningStep.validateBelongsTo(learning); + UserLearningProgress progress = userLearningProgressRepository - .findByUser_UserIdAndLearningId(userId, learning.getId()) + .findByUser_UserIdAndLearningStep_Id(userId, request.learningStepId()) .map(p -> { p.updateProgress(request.score(), LocalDateTime.now()); return p; From ed2975d6178eb737abde59c1eb9537b638b82010 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Fri, 24 Jul 2026 23:44:33 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20join=20=EC=A0=9C=EA=B1=B0=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../learning/repository/UserLearningProgressRepository.java | 3 +-- 1 file changed, 1 insertion(+), 2 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 0c52cc24..21d68f35 100644 --- a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -12,7 +12,6 @@ public interface UserLearningProgressRepository extends JpaRepository findByUser_UserIdAndLearningStep_Id(Long userId, Long learningStepId); // 유저가 완료한(점수 90점 이상) 학습 단계 수 조회 @Query("SELECT COUNT(ulp) FROM UserLearningProgress ulp " + - "JOIN ulp.learningStep ls " + - "WHERE ulp.user.userId = :userId AND ls.learning.id = :learningId AND ulp.score >= 90") + "WHERE ulp.user.userId = :userId AND ulp.learning.id = :learningId AND ulp.score >= 90") long countCompletedStepsByUserIdAndLearningId(@Param("userId") Long userId, @Param("learningId") Long learningId); } From 47f7de24157806c75f1cd0903bc3012337c4f7e3 Mon Sep 17 00:00:00 2001 From: rkdehdrbs7885-oss Date: Sat, 25 Jul 2026 01:31:54 +0900 Subject: [PATCH 12/12] =?UTF-8?q?refactor:=20=ED=8C=80=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=88=98=EC=A0=95=5F3=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../learning/controller/LearningController.java | 11 +++++++---- .../learning/exception/LearningErrorStatus.java | 2 +- .../mr/domain/learning/service/LearningService.java | 13 +++++-------- .../mr/domain/user/exception/UserErrorStatus.java | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/mr/domain/learning/controller/LearningController.java b/src/main/java/com/mr/domain/learning/controller/LearningController.java index 3074861d..55746cdd 100644 --- a/src/main/java/com/mr/domain/learning/controller/LearningController.java +++ b/src/main/java/com/mr/domain/learning/controller/LearningController.java @@ -5,8 +5,10 @@ import com.mr.domain.learning.dto.res.LearningResultResponseDTO; import com.mr.domain.learning.service.LearningService; import com.mr.global.apipayload.ApiResponse; +import com.mr.global.security.principal.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -24,11 +26,11 @@ public class LearningController { // 학습 결과 저장 @PostMapping("/{learningId}/result") public ApiResponse saveLearningResult( - @PathVariable Long learningId, // 임시 + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long learningId, @Valid @RequestBody LearningResultSaveRequestDTO.SaveResultDTO request ){ - LearningResultResponseDTO.SaveResultResultDTO response = - learningService.saveResult(learningId, request); + LearningResultResponseDTO.SaveResultResultDTO response = learningService.saveResult(userDetails.getUserId(), learningId, request); return ApiResponse.onSuccess(response); } @@ -36,9 +38,10 @@ public ApiResponse saveLearningRe // 학습 진행률 조회 @GetMapping("/{learningId}/progress") public ApiResponse getLearningProgress( + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long learningId ) { - LearningProgressResponseDTO.ProgressResultDTO result = learningService.getLearningProgress(learningId); + LearningProgressResponseDTO.ProgressResultDTO result = learningService.getLearningProgress(userDetails.getUserId(), learningId); return ApiResponse.onSuccess(result); } } diff --git a/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java b/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java index f024e2e7..3dd23a11 100644 --- a/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java +++ b/src/main/java/com/mr/domain/learning/exception/LearningErrorStatus.java @@ -11,7 +11,7 @@ public enum LearningErrorStatus implements BaseCode { INVALID_LEARNING_STEP(HttpStatus.BAD_REQUEST, "LEARNING_400_01", "해당 학습 단계는 지칭한 학습(Learning)에 속하지 않습니다."), LEARNING_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_01", "해당 학습(Learning)을 찾을 수 없습니다."), - LEARNING_STEP_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_02", "해당 학습 단계(Step)를 찾을 수 없습니다.");; + LEARNING_STEP_NOT_FOUND(HttpStatus.NOT_FOUND, "LEARNING_404_02", "해당 학습 단계(Step)를 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/learning/service/LearningService.java b/src/main/java/com/mr/domain/learning/service/LearningService.java index a9c1d29b..a203164a 100644 --- a/src/main/java/com/mr/domain/learning/service/LearningService.java +++ b/src/main/java/com/mr/domain/learning/service/LearningService.java @@ -14,7 +14,6 @@ import com.mr.domain.user.exception.UserErrorStatus; 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; @@ -35,11 +34,10 @@ public class LearningService { // 학습 결과 저장 @Transactional public LearningResultResponseDTO.SaveResultResultDTO saveResult( + Long userId, Long learningId, LearningResultSaveRequestDTO.SaveResultDTO request ){ - Long userId = SecurityUtil.getCurrentUserId(); - User user = userRepository.findById(userId) .orElseThrow(()-> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); @@ -66,15 +64,14 @@ public LearningResultResponseDTO.SaveResultResultDTO saveResult( } // 학습 진행률 조회 로직 - public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress(Long learningId) { + public LearningProgressResponseDTO.ProgressResultDTO getLearningProgress( + Long userId, + Long learningId + ) { // 학습 존재 여부 확인 if (!learningRepository.existsById(learningId)) { throw new GeneralException(LearningErrorStatus.LEARNING_NOT_FOUND); } - - // 현재 로그인한 유저 ID 획득 (SecurityUtil 활용) - Long userId = SecurityUtil.getCurrentUserId(); - // 전체 학습 단계 수 조회 long totalStepCount = learningStepRepository.countByLearningId(learningId); 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 73bff3a9..3cca4c33 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -18,4 +18,4 @@ public enum UserErrorStatus implements BaseCode { private final HttpStatus status; private final String code; private final String message; -} \ No newline at end of file +}