From f6186c248c9cb9b1076933500a85cb9b1b1df320 Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 24 May 2025 13:45:13 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[Feature]=20=EA=B8=B0=EC=A1=B4=20OAuth=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 - .../api/controller/AuthController.java | 19 -- .../api/dto/KakaoAuthRequest.java | 24 +++ .../api/dto/KakaoAuthResponse.java | 18 ++ .../config/SecurityConfig.java | 12 +- .../security/OAuth2SuccessHandler.java | 69 ------- src/main/resources/application.properties | 8 - .../config/SecurityConfigTest.java | 64 ------- .../security/OAuth2SuccessHandlerTest.java | 180 ------------------ 9 files changed, 44 insertions(+), 351 deletions(-) create mode 100644 src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthRequest.java create mode 100644 src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java delete mode 100644 src/main/java/com/_1/spring_rest_api/security/OAuth2SuccessHandler.java delete mode 100644 src/test/java/com/_1/spring_rest_api/config/SecurityConfigTest.java delete mode 100644 src/test/java/com/_1/spring_rest_api/security/OAuth2SuccessHandlerTest.java diff --git a/build.gradle b/build.gradle index 28f8d17..2b26363 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,6 @@ dependencies { implementation 'org.springframework.ai:spring-ai-starter-model-anthropic' // Spring Security OAuth2 Client - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' diff --git a/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java b/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java index 5bca57b..b895a19 100644 --- a/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java +++ b/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java @@ -1,31 +1,12 @@ package com._1.spring_rest_api.api.controller; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.view.RedirectView; @RestController @RequestMapping("/api/public/auth") @Tag(name = "인증 API", description = "사용자 인증 관련 API") public class AuthController { - @Value("${api.server.url}") - private String serverApiUrl; - - @GetMapping("/kakao") - @Operation( - summary = "카카오 OAuth2 로그인", - description = "카카오 OAuth2 인증 페이지로 리다이렉트합니다. 사용자가 카카오 계정으로 로그인하면 설정된 콜백 URL로 인증 코드와 함께 리다이렉트됩니다." - ) - @SecurityRequirements - public RedirectView kakaoLogin() { - RedirectView redirectView = new RedirectView(); - redirectView.setUrl(serverApiUrl + "/oauth2/authorization/kakao"); - return redirectView; - } } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthRequest.java b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthRequest.java new file mode 100644 index 0000000..cc15f78 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthRequest.java @@ -0,0 +1,24 @@ +package com._1.spring_rest_api.api.dto; + +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KakaoAuthRequest { + + @NotEmpty(message = "카카오 액세스 토큰은 필수입니다") + private String accessToken; + + private String refreshToken; + + @NotEmpty(message = "카카오 사용자 ID는 필수입니다") + private String kakaoId; + + private Long expiresIn; // 토큰 만료 시간(초) +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java new file mode 100644 index 0000000..32dba72 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java @@ -0,0 +1,18 @@ +package com._1.spring_rest_api.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class KakaoAuthResponse { + + private String accessToken; // 우리 서버에서 발급한 JWT 토큰 + private String tokenType; // "Bearer" + private Long expiresIn; // JWT 토큰 만료 시간(초) + private Long userId; // 사용자 ID + private String email; // 사용자 이메일 + private String name; // 사용자 이름 +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/config/SecurityConfig.java b/src/main/java/com/_1/spring_rest_api/config/SecurityConfig.java index c577bed..b788454 100644 --- a/src/main/java/com/_1/spring_rest_api/config/SecurityConfig.java +++ b/src/main/java/com/_1/spring_rest_api/config/SecurityConfig.java @@ -1,7 +1,7 @@ package com._1.spring_rest_api.config; import com._1.spring_rest_api.security.JwtAuthenticationFilter; -import com._1.spring_rest_api.security.OAuth2SuccessHandler; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -12,16 +12,11 @@ @EnableWebSecurity @Configuration +@RequiredArgsConstructor public class SecurityConfig { - private final OAuth2SuccessHandler oAuth2SuccessHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; - public SecurityConfig(OAuth2SuccessHandler oAuth2SuccessHandler, JwtAuthenticationFilter jwtAuthenticationFilter) { - this.oAuth2SuccessHandler = oAuth2SuccessHandler; - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - } - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -34,9 +29,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-resources/**", "/webjars/**", "/api/v1/**").permitAll() .anyRequest().authenticated() ) - .oauth2Login(oauth2 -> oauth2 - .successHandler(oAuth2SuccessHandler) - ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/java/com/_1/spring_rest_api/security/OAuth2SuccessHandler.java b/src/main/java/com/_1/spring_rest_api/security/OAuth2SuccessHandler.java deleted file mode 100644 index aeabd6b..0000000 --- a/src/main/java/com/_1/spring_rest_api/security/OAuth2SuccessHandler.java +++ /dev/null @@ -1,69 +0,0 @@ -package com._1.spring_rest_api.security; - -import com._1.spring_rest_api.entity.User; -import com._1.spring_rest_api.service.UserService; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - - private final UserService userService; - private final JwtService jwtService; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { - OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication; - OAuth2User oAuth2User = oauthToken.getPrincipal(); - - Map attributes = oAuth2User.getAttributes(); - String provider = oauthToken.getAuthorizedClientRegistrationId(); - - if ("kakao".equals(provider)) { - processKakaoUser(attributes, response); - } - } - - private void processKakaoUser(Map attributes, HttpServletResponse response) throws IOException { - String kakaoId = attributes.get("id").toString(); - - // Kakao account info is nested in 'kakao_account' - Map kakaoAccount = (Map) attributes.get("kakao_account"); - String email = (String) kakaoAccount.get("email"); - - // Profile info is nested in 'profile' - Map profile = (Map) kakaoAccount.get("profile"); - String nickname = (String) profile.get("nickname"); - - // Find or create the user - User user = userService.findByKakaoId(kakaoId); - - if (user == null) { - user = userService.createKakaoUser(email, nickname, kakaoId); - } - - // Update tokens - String accessToken = (String) attributes.get("access_token"); - String refreshToken = (String) attributes.get("refresh_token"); - LocalDateTime expiresAt = LocalDateTime.now().plusSeconds((Integer) attributes.get("expires_in")); - - userService.updateKakaoTokens(user, accessToken, refreshToken, expiresAt); - - // Generate JWT and send to client - String jwt = jwtService.generateToken(userService.createUserDetails(user)); - - response.sendRedirect("myapp://auth?token=" + jwt); - } -} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7d72cb0..9b41b8b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,14 +4,6 @@ spring.profiles.include=secret # \u00EC\u0084\u009C\u00EB\u00B2\u0084 \u00EC\u0084\u00A4\u00EC\u00A0\u0095 server.port=8080 -# Kakao provider \u00EC\u0084\u00A4\u00EC\u00A0\u0095 -spring.security.oauth2.client.provider.kakao.issuer-uri=https://kauth.kakao.com -spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize -spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token -spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me -spring.security.oauth2.client.provider.kakao.jwk-set-uri=https://kauth.kakao.com/.well-known/jwks.json -spring.security.oauth2.client.provider.kakao.user-name-attribute=id - # JPA \u00EC\u0084\u00A4\u00EC\u00A0\u0095 spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true diff --git a/src/test/java/com/_1/spring_rest_api/config/SecurityConfigTest.java b/src/test/java/com/_1/spring_rest_api/config/SecurityConfigTest.java deleted file mode 100644 index 22785c3..0000000 --- a/src/test/java/com/_1/spring_rest_api/config/SecurityConfigTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com._1.spring_rest_api.config; - -import com._1.spring_rest_api.security.JwtAuthenticationFilter; -import com._1.spring_rest_api.security.OAuth2SuccessHandler; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; -import org.springframework.test.web.servlet.MockMvc; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(SecurityConfig.class) -@Import(SecurityConfig.class) -@MockBean(JpaMetamodelMappingContext.class) -class SecurityConfigTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private OAuth2SuccessHandler oAuth2SuccessHandler; - - @MockBean - private JwtAuthenticationFilter jwtAuthenticationFilter; - - @Test - void accessHome_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/")) - .andExpect(status().isOk()); - } - - @Test - void accessLogin_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/login")) - .andExpect(status().isOk()); - } - - @Test - void accessOauth2Login_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/oauth2/authorization/kakao")) - .andExpect(status().is3xxRedirection()); - } - - @Test - void accessPublicAuth_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/api/public/auth")) - .andExpect(status().isOk()); - } - - @Test - void accessSwaggerUI_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/swagger-ui/index.html")) - .andExpect(status().isOk()); - } -} \ No newline at end of file diff --git a/src/test/java/com/_1/spring_rest_api/security/OAuth2SuccessHandlerTest.java b/src/test/java/com/_1/spring_rest_api/security/OAuth2SuccessHandlerTest.java deleted file mode 100644 index b9654b4..0000000 --- a/src/test/java/com/_1/spring_rest_api/security/OAuth2SuccessHandlerTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package com._1.spring_rest_api.security; - -import com._1.spring_rest_api.entity.User; -import com._1.spring_rest_api.service.UserService; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class OAuth2SuccessHandlerTest { - - @Mock - private UserService userService; - - @Mock - private JwtService jwtService; - - @Mock - private HttpServletRequest request; - - @Mock - private HttpServletResponse response; - - @Mock - private Authentication authentication; - - @InjectMocks - private OAuth2SuccessHandler oAuth2SuccessHandler; - - private final String KAKAO_ID = "12345"; - private final String EMAIL = "test@example.com"; - private final String NAME = "Test User"; - private final String ACCESS_TOKEN = "kakao_access_token"; - private final String REFRESH_TOKEN = "kakao_refresh_token"; - private final Integer EXPIRES_IN = 3600; - - @BeforeEach - void setUp() { - // 공통 준비 작업 - } - - @Test - void onAuthenticationSuccess_shouldProcessKakaoUser_whenExistingUser() throws IOException, ServletException { - // Given - User existingUser = mockExistingUser(); - mockKakaoAuthentication(); - - when(userService.findByKakaoId(KAKAO_ID)).thenReturn(existingUser); - - UserDetails userDetails = mock(UserDetails.class); - when(userService.createUserDetails(existingUser)).thenReturn(userDetails); - when(jwtService.generateToken(userDetails)).thenReturn("jwt_token"); - - // When - oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); - - // Then - verify(userService).updateKakaoTokens( - eq(existingUser), - eq(ACCESS_TOKEN), - eq(REFRESH_TOKEN), - any(LocalDateTime.class) - ); - verify(response).sendRedirect(contains("myapp://auth?token=")); - } - - @Test - void onAuthenticationSuccess_shouldProcessKakaoUser_whenNewUser() throws IOException, ServletException { - // Given - mockKakaoAuthentication(); - - when(userService.findByKakaoId(KAKAO_ID)).thenReturn(null); - - User newUser = User.builder() - .id(1L) - .email(EMAIL) - .build(); - when(userService.createKakaoUser(eq(EMAIL), eq(NAME), eq(KAKAO_ID))).thenReturn(newUser); - - UserDetails userDetails = mock(UserDetails.class); - when(userService.createUserDetails(newUser)).thenReturn(userDetails); - when(jwtService.generateToken(userDetails)).thenReturn("jwt_token"); - - // When - oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); - - // Then - verify(userService).createKakaoUser(EMAIL, NAME, KAKAO_ID); - verify(userService).updateKakaoTokens( - eq(newUser), - eq(ACCESS_TOKEN), - eq(REFRESH_TOKEN), - any(LocalDateTime.class) - ); - verify(response).sendRedirect(contains("myapp://auth?token=")); - } - - @Test - void onAuthenticationSuccess_shouldIgnore_whenNotKakaoProvider() throws IOException, ServletException { - // Given - OAuth2AuthenticationToken mockToken = mock(OAuth2AuthenticationToken.class); - OAuth2User mockOAuth2User = mock(OAuth2User.class); // OAuth2User 모의 객체 생성 - - when(mockToken.getAuthorizedClientRegistrationId()).thenReturn("google"); - when(mockToken.getPrincipal()).thenReturn(mockOAuth2User); // getPrincipal()이 모의 OAuth2User 반환하도록 설정 - - // When - oAuth2SuccessHandler.onAuthenticationSuccess(request, response, mockToken); - - // Then - verify(userService, never()).findByKakaoId(anyString()); - verify(userService, never()).createKakaoUser(anyString(), anyString(), anyString()); - verify(userService, never()).updateKakaoTokens(any(), anyString(), anyString(), any()); - } - - // 헬퍼 메서드 - private User mockExistingUser() { - return User.builder() - .id(1L) - .email(EMAIL) - .name(NAME) - .isActive(true) - .build(); - } - - private void mockKakaoAuthentication() { - // Kakao attributes 생성 - Map attributes = new HashMap<>(); - attributes.put("id", KAKAO_ID); - attributes.put("access_token", ACCESS_TOKEN); - attributes.put("refresh_token", REFRESH_TOKEN); - attributes.put("expires_in", EXPIRES_IN); - - // Kakao account 정보 - Map kakaoAccount = new HashMap<>(); - kakaoAccount.put("email", EMAIL); - - // Profile 정보 - Map profile = new HashMap<>(); - profile.put("nickname", NAME); - - kakaoAccount.put("profile", profile); - attributes.put("kakao_account", kakaoAccount); - - // OAuth2User 생성 - OAuth2User oAuth2User = new DefaultOAuth2User( - java.util.Collections.emptyList(), - attributes, - "id" - ); - - // OAuth2AuthenticationToken 생성 - OAuth2AuthenticationToken authToken = mock(OAuth2AuthenticationToken.class); - when(authToken.getPrincipal()).thenReturn(oAuth2User); - when(authToken.getAuthorizedClientRegistrationId()).thenReturn("kakao"); - - this.authentication = authToken; - } -} \ No newline at end of file From bfa20702b758cc10d55d40c3991869d3ae476efa Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 26 May 2025 12:18:40 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[Feat]=20ios=20SDK=EB=A5=BC=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/controller/AuthController.java | 36 ++++++ .../api/dto/KakaoAuthResponse.java | 10 ++ .../_1/spring_rest_api/config/WebConfig.java | 14 +++ .../spring_rest_api/security/JwtService.java | 2 +- .../service/KakaoApiClientService.java | 75 +++++++++++++ .../service/KakaoAuthService.java | 106 ++++++++++++++++++ 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/_1/spring_rest_api/config/WebConfig.java create mode 100644 src/main/java/com/_1/spring_rest_api/service/KakaoApiClientService.java create mode 100644 src/main/java/com/_1/spring_rest_api/service/KakaoAuthService.java diff --git a/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java b/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java index b895a19..8572db5 100644 --- a/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java +++ b/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java @@ -1,12 +1,48 @@ package com._1.spring_rest_api.api.controller; +import com._1.spring_rest_api.api.dto.KakaoAuthRequest; +import com._1.spring_rest_api.api.dto.KakaoAuthResponse; +import com._1.spring_rest_api.service.KakaoAuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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 @RequestMapping("/api/public/auth") +@RequiredArgsConstructor @Tag(name = "인증 API", description = "사용자 인증 관련 API") public class AuthController { + private final KakaoAuthService kakaoAuthService; + + @PostMapping("/kakao/token") + @Operation( + summary = "카카오 토큰 인증 (iOS/모바일용)", + description = "iOS SDK에서 받은 카카오 액세스 토큰을 검증하고 JWT 토큰을 발급합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증 성공 - JWT 토큰 발급됨"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 - 필수 필드 누락"), + @ApiResponse(responseCode = "401", description = "인증 실패 - 유효하지 않은 카카오 토큰"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @SecurityRequirements + public ResponseEntity authenticateWithKakaoToken( + @Valid @RequestBody KakaoAuthRequest request) { + + KakaoAuthResponse response = kakaoAuthService.authenticateWithKakao(request); + return ResponseEntity.ok(response); + } + } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java index 32dba72..2db98c1 100644 --- a/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java +++ b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java @@ -12,7 +12,17 @@ public class KakaoAuthResponse { private String accessToken; // 우리 서버에서 발급한 JWT 토큰 private String tokenType; // "Bearer" private Long expiresIn; // JWT 토큰 만료 시간(초) + private Long userId; // 사용자 ID private String email; // 사용자 이메일 private String name; // 사용자 이름 + + @Getter + @Builder + @AllArgsConstructor + public static class KakaoUserInfo { + private String kakaoId; + private String email; + private String nickname; + } } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/config/WebConfig.java b/src/main/java/com/_1/spring_rest_api/config/WebConfig.java new file mode 100644 index 0000000..458896f --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/config/WebConfig.java @@ -0,0 +1,14 @@ +package com._1.spring_rest_api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class WebConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/security/JwtService.java b/src/main/java/com/_1/spring_rest_api/security/JwtService.java index 6aabb8a..94c6e56 100644 --- a/src/main/java/com/_1/spring_rest_api/security/JwtService.java +++ b/src/main/java/com/_1/spring_rest_api/security/JwtService.java @@ -28,7 +28,7 @@ public class JwtService { @Value("${jwt.secret}") private String secretKey; - private static final long TOKEN_VALIDITY = 1000 * 60 * 60 * 24; // 24 hours + private static final long TOKEN_VALIDITY = 1000 * 60 * 60 * 24 * 7; // 1 week public String extractUsername(String token) { try { diff --git a/src/main/java/com/_1/spring_rest_api/service/KakaoApiClientService.java b/src/main/java/com/_1/spring_rest_api/service/KakaoApiClientService.java new file mode 100644 index 0000000..0cc7fc1 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/service/KakaoApiClientService.java @@ -0,0 +1,75 @@ +package com._1.spring_rest_api.service; + +import com._1.spring_rest_api.api.dto.KakaoAuthResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Slf4j +public class KakaoApiClientService { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + private static final String KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; + + public KakaoAuthResponse.KakaoUserInfo getUserInfo(String accessToken) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + KAKAO_USER_INFO_URL, + HttpMethod.GET, + entity, + String.class + ); + + if (response.getStatusCode() == HttpStatus.OK) { + return parseKakaoUserInfo(response.getBody()); + } else { + throw new RuntimeException("카카오 사용자 정보 조회 실패: HTTP " + response.getStatusCode()); + } + + } catch (HttpClientErrorException e) { + log.error("카카오 API 호출 실패: {}", e.getResponseBodyAsString()); + throw new RuntimeException("유효하지 않은 카카오 액세스 토큰입니다.", e); + } catch (Exception e) { + log.error("카카오 사용자 정보 조회 중 오류 발생", e); + throw new RuntimeException("카카오 사용자 정보 조회 실패", e); + } + } + + private KakaoAuthResponse.KakaoUserInfo parseKakaoUserInfo(String responseBody) { + try { + JsonNode rootNode = objectMapper.readTree(responseBody); + + String kakaoId = rootNode.path("id").asText(); + JsonNode kakaoAccount = rootNode.path("kakao_account"); + String email = kakaoAccount.path("email").asText(); + + JsonNode profile = kakaoAccount.path("profile"); + String nickname = profile.path("nickname").asText(); + + return KakaoAuthResponse.KakaoUserInfo.builder() + .kakaoId(kakaoId) + .email(email) + .nickname(nickname) + .build(); + + } catch (Exception e) { + log.error("카카오 사용자 정보 파싱 실패", e); + throw new RuntimeException("카카오 사용자 정보 파싱 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/service/KakaoAuthService.java b/src/main/java/com/_1/spring_rest_api/service/KakaoAuthService.java new file mode 100644 index 0000000..f179851 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/service/KakaoAuthService.java @@ -0,0 +1,106 @@ +package com._1.spring_rest_api.service; + +import com._1.spring_rest_api.api.dto.KakaoAuthRequest; +import com._1.spring_rest_api.api.dto.KakaoAuthResponse; +import com._1.spring_rest_api.api.dto.KakaoAuthResponse.KakaoUserInfo; +import com._1.spring_rest_api.entity.User; +import com._1.spring_rest_api.security.JwtService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class KakaoAuthService { + + private final KakaoApiClientService kakaoApiClientService; + private final UserService userService; + private final JwtService jwtService; + + public KakaoAuthResponse authenticateWithKakao(KakaoAuthRequest request) { + try { + // 1. 카카오 액세스 토큰으로 사용자 정보 조회 + KakaoUserInfo kakaoUserInfo = + kakaoApiClientService.getUserInfo(request.getAccessToken()); + + // 2. 카카오 ID가 일치하는지 검증 (보안 강화) + if (!kakaoUserInfo.getKakaoId().equals(request.getKakaoId())) { + throw new RuntimeException("카카오 사용자 ID가 일치하지 않습니다."); + } + + // 3. 기존 사용자 조회 또는 신규 사용자 생성 + User user = findOrCreateUser(kakaoUserInfo); + + // 4. 카카오 토큰 정보 업데이트 + updateKakaoTokens(user, request); + + // 5. JWT 토큰 생성 + UserDetails userDetails = userService.createUserDetails(user); + String jwtToken = jwtService.generateToken(userDetails); + + // 6. 응답 생성 + return buildAuthResponse(jwtToken, user); + + } catch (Exception e) { + log.error("카카오 인증 처리 중 오류 발생: {}", e.getMessage(), e); + throw new RuntimeException("카카오 인증 처리 실패: " + e.getMessage()); + } + } + + /** + * 카카오 사용자 정보로 기존 사용자를 찾거나 신규 사용자를 생성합니다. + */ + private User findOrCreateUser(KakaoUserInfo kakaoUserInfo) { + User existingUser = userService.findByKakaoId(kakaoUserInfo.getKakaoId()); + + if (existingUser != null) { + log.info("기존 사용자 로그인: kakaoId={}, email={}", + kakaoUserInfo.getKakaoId(), existingUser.getEmail()); + return existingUser; + } else { + log.info("신규 사용자 회원가입: kakaoId={}, email={}", + kakaoUserInfo.getKakaoId(), kakaoUserInfo.getEmail()); + return userService.createKakaoUser( + kakaoUserInfo.getEmail(), + kakaoUserInfo.getNickname(), + kakaoUserInfo.getKakaoId() + ); + } + } + + /** + * 카카오 토큰 정보를 업데이트합니다. + */ + private void updateKakaoTokens(User user, KakaoAuthRequest request) { + LocalDateTime expiresAt = request.getExpiresIn() != null + ? LocalDateTime.now().plusSeconds(request.getExpiresIn()) + : LocalDateTime.now().plusHours(24); // 기본 1시간 + + userService.updateKakaoTokens( + user, + request.getAccessToken(), + request.getRefreshToken(), + expiresAt + ); + } + + /** + * 인증 응답을 생성합니다. + */ + private KakaoAuthResponse buildAuthResponse(String jwtToken, User user) { + return KakaoAuthResponse.builder() + .accessToken(jwtToken) + .tokenType("Bearer") + .expiresIn(24 * 60 * 60L) // 24시간 (초 단위) + .userId(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .build(); + } +} \ No newline at end of file From f7ef23117ea482a021427e46717f3c837ca0765e Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 28 May 2025 00:45:11 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[Fix]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EA=B0=84=20=EC=96=91=EB=B0=A9=ED=96=A5=20=EC=97=B0=EA=B4=80?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EB=A7=A4=ED=95=91=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=95=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QuestionGenerationConverter.java | 30 ++++++-- .../com/_1/spring_rest_api/entity/Course.java | 8 -- .../_1/spring_rest_api/entity/CustomQuiz.java | 8 +- .../spring_rest_api/entity/QuizSession.java | 73 ++++++++++--------- .../com/_1/spring_rest_api/entity/User.java | 12 +-- .../_1/spring_rest_api/entity/UserKakao.java | 7 +- .../repository/CustomQuizRepository.java | 15 ++++ .../service/CourseService.java | 19 ++++- .../service/QuestionCommandServiceImpl.java | 9 +-- .../service/QuizCommandServiceImpl.java | 23 +++--- .../service/QuizQueryServiceImpl.java | 4 +- .../QuizSessionCommandServiceImpl.java | 6 -- .../spring_rest_api/service/UserService.java | 6 +- .../spring_rest_api/service/WeekService.java | 7 +- src/main/resources/application.properties | 2 +- .../service/UserServiceTest.java | 2 +- 16 files changed, 126 insertions(+), 105 deletions(-) diff --git a/src/main/java/com/_1/spring_rest_api/converter/QuestionGenerationConverter.java b/src/main/java/com/_1/spring_rest_api/converter/QuestionGenerationConverter.java index 91e709e..d668789 100644 --- a/src/main/java/com/_1/spring_rest_api/converter/QuestionGenerationConverter.java +++ b/src/main/java/com/_1/spring_rest_api/converter/QuestionGenerationConverter.java @@ -2,10 +2,16 @@ import com._1.spring_rest_api.api.dto.QuestionDto; import com._1.spring_rest_api.entity.Question; +import com._1.spring_rest_api.entity.Week; import com._1.spring_rest_api.repository.WeekRepository; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + /** * AI 생성 질문 DTO와 Question 엔티티 간의 변환을 담당하는 컨버터 */ @@ -39,13 +45,16 @@ public Question toEntity(QuestionDto dto) { .build(); } - /** - * AI가 생성한 질문 DTO를 특정 주차의 Question 엔티티로 변환 - * - * @param dto 질문 DTO - * @param weekId 주차 ID - * @return 주차에 연결된 Question 엔티티 - */ + public List createQuestionsForWeek(List dtos, Long weekId) { + if (dtos == null || dtos.isEmpty()) { + return new ArrayList<>(); + } + + return dtos.stream() + .map(dto -> createQuestionForWeek(dto, weekId)) + .collect(Collectors.toList()); + } + public Question createQuestionForWeek(QuestionDto dto, Long weekId) { if (dto == null) { return null; @@ -54,9 +63,14 @@ public Question createQuestionForWeek(QuestionDto dto, Long weekId) { Question question = toEntity(dto); if (weekId != null) { - weekRepository.findById(weekId).ifPresent(question::changeWeek); + Week week = weekRepository.findById(weekId) + .orElseThrow(() -> new EntityNotFoundException("Week not found with id: " + weekId)); + + week.addQuestion(question); } return question; } + + } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/entity/Course.java b/src/main/java/com/_1/spring_rest_api/entity/Course.java index 8d58d5f..1d52219 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/Course.java +++ b/src/main/java/com/_1/spring_rest_api/entity/Course.java @@ -62,14 +62,6 @@ public void changeCreator(User creator) { } } - - public Course(User creator, String title, String description) { - this.creator = creator; - this.title = title; - this.description = description; - this.weeks = new ArrayList<>(); - } - public Course(Long id, User creator, String title, String description) { this.id = id; this.creator = creator; diff --git a/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java b/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java index f8e1d5b..f048981 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java +++ b/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java @@ -93,24 +93,24 @@ public void addQuestion(Question question) { // 정적 팩토리 메서드를 통해 매핑 생성 및 양방향 연관관계 설정 QuizQuestionMapping mapping = QuizQuestionMapping.create(this, question); + this.addQuizQuestionMapping(mapping); + // 질문 수 증가 this.updateTotalQuestions(this.totalQuestions + 1); } - - // CustomQuiz와 QuizSession 간의 양방향 연관관계 메서드 public void addQuizSession(QuizSession session) { this.quizSessions.add(session); if (session.getQuiz() != this) { - session.changeQuiz(this); + session.addQuiz(this); } } public void removeQuizSession(QuizSession session) { this.quizSessions.remove(session); if (session.getQuiz() == this) { - session.changeQuiz(null); + session.addQuiz(null); } } diff --git a/src/main/java/com/_1/spring_rest_api/entity/QuizSession.java b/src/main/java/com/_1/spring_rest_api/entity/QuizSession.java index 81158bd..c5c22a4 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/QuizSession.java +++ b/src/main/java/com/_1/spring_rest_api/entity/QuizSession.java @@ -37,6 +37,22 @@ public class QuizSession extends BaseTimeEntity { private LocalDateTime completedAt; + // User와 QuizSession 간의 양방향 연관관계 메서드 + public void changUser(User user) { + this.user = user; + if (user != null && !user.getQuizSessions().contains(this)) { + user.getQuizSessions().add(this); + } + } + + // CustomQuiz와 QuizSession 간의 양방향 연관관계 메서드 + public void addQuiz(CustomQuiz quiz) { + this.quiz = quiz; + if (quiz != null && !quiz.getQuizSessions().contains(this)) { + quiz.getQuizSessions().add(this); + } + } + public static QuizSession create(User user, CustomQuiz quiz) { if (user == null) { throw new IllegalArgumentException("사용자는 null이 될 수 없습니다"); @@ -53,6 +69,29 @@ public static QuizSession create(User user, CustomQuiz quiz) { .build(); } + public UserAnswer createAnswer(String userAnswerText) { + Question currentQuestion = getCurrentQuestion(); + if (currentQuestion == null) { + throw new IllegalStateException("세션에 현재 질문이 없습니다"); + } + + boolean isCorrect = currentQuestion.isCorrectAnswer(userAnswerText); + + UserAnswer userAnswer = UserAnswer.builder() + .user(this.user) + .question(currentQuestion) + .userAnswer(userAnswerText) + .isCorrect(isCorrect) + .attemptCount(1) + .answeredAt(LocalDateTime.now()) + .build(); + + currentQuestion.addUserAnswer(userAnswer); + this.user.addUserAnswer(userAnswer); + + return userAnswer; + } + public void moveToNextQuestion() { this.currentQuestionIndex++; } @@ -86,38 +125,4 @@ public Question getNextQuestion() { public boolean isComplete() { return this.currentQuestionIndex >= this.quiz.getQuizQuestionMappings().size(); } - - public UserAnswer createAnswer(String userAnswerText) { - Question currentQuestion = getCurrentQuestion(); - if (currentQuestion == null) { - throw new IllegalStateException("세션에 현재 질문이 없습니다"); - } - - boolean isCorrect = currentQuestion.isCorrectAnswer(userAnswerText); - - return UserAnswer.builder() - .user(this.user) - .question(currentQuestion) - .userAnswer(userAnswerText) - .isCorrect(isCorrect) - .attemptCount(1) - .answeredAt(LocalDateTime.now()) - .build(); - } - - // User와 QuizSession 간의 양방향 연관관계 메서드 - public void changeUser(User user) { - this.user = user; - if (user != null && !user.getQuizSessions().contains(this)) { - user.getQuizSessions().add(this); - } - } - - // CustomQuiz와 QuizSession 간의 양방향 연관관계 메서드 - public void changeQuiz(CustomQuiz quiz) { - this.quiz = quiz; - if (quiz != null && !quiz.getQuizSessions().contains(this)) { - quiz.getQuizSessions().add(this); - } - } } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/entity/User.java b/src/main/java/com/_1/spring_rest_api/entity/User.java index a392a13..5e95afe 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/User.java +++ b/src/main/java/com/_1/spring_rest_api/entity/User.java @@ -45,11 +45,11 @@ public class User extends BaseTimeEntity { private List userAnswers = new ArrayList<>(); // User와 UserKakao 간의 양방향 연관관계 메서드 - public void linkWithKakao(UserKakao userKakao) { + public void changeUserKakao(UserKakao userKakao) { this.userKakao = userKakao; // userKakao의 user 필드가 this가 아닌 경우에만 설정 if (userKakao.getUser() != this) { - userKakao.linkWithUser(this); + userKakao.changeUser(this); } } @@ -87,14 +87,14 @@ public void removeCourse(Course course) { public void addQuizSession(QuizSession session) { this.quizSessions.add(session); if (session.getUser() != this) { - session.changeUser(this); + session.changUser(this); } } public void removeQuizSession(QuizSession session) { this.quizSessions.remove(session); if (session.getUser() == this) { - session.changeUser(null); + session.changUser(null); } } @@ -121,10 +121,6 @@ public void updateName(String name) { this.name = name; } - public void updateUserKakao(UserKakao userKakao) { - this.userKakao = userKakao; - } - public static User createKakaoUser(String email, String name) { if (email == null || email.isEmpty()) { throw new IllegalArgumentException("이메일은 필수 값입니다."); diff --git a/src/main/java/com/_1/spring_rest_api/entity/UserKakao.java b/src/main/java/com/_1/spring_rest_api/entity/UserKakao.java index 684a573..7e0c3c6 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/UserKakao.java +++ b/src/main/java/com/_1/spring_rest_api/entity/UserKakao.java @@ -39,11 +39,11 @@ public class UserKakao extends BaseTimeEntity { private LocalDateTime tokenExpiresAt; // User와 UserKakao 간의 양방향 연관관계 메서드 - public void linkWithUser(User user) { + public void changeUser(User user) { this.user = user; // user의 userKakao 필드가 this가 아닌 경우에만 설정 if (user.getUserKakao() != this) { - user.linkWithKakao(this); + user.changeUserKakao(this); } } @@ -65,12 +65,11 @@ public static UserKakao createKakaoAccountLink(User user, String kakaoId) { } UserKakao userKakao = UserKakao.builder() - .user(user) .kakaoAccountId(kakaoId) .build(); // 양방향 연관관계 설정 - user.updateUserKakao(userKakao); + userKakao.changeUser(user); return userKakao; } diff --git a/src/main/java/com/_1/spring_rest_api/repository/CustomQuizRepository.java b/src/main/java/com/_1/spring_rest_api/repository/CustomQuizRepository.java index e69830a..629de43 100644 --- a/src/main/java/com/_1/spring_rest_api/repository/CustomQuizRepository.java +++ b/src/main/java/com/_1/spring_rest_api/repository/CustomQuizRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface CustomQuizRepository extends JpaRepository { @@ -13,4 +14,18 @@ public interface CustomQuizRepository extends JpaRepository { @Query("SELECT q FROM CustomQuiz q WHERE q.creator.id = :creatorId ORDER BY q.createAt DESC") List findAllByCreatorId(@Param("creatorId") Long creatorId); + @Query("SELECT DISTINCT q FROM CustomQuiz q " + + "LEFT JOIN FETCH q.creator " + + "LEFT JOIN FETCH q.quizQuestionMappings qm " + + "LEFT JOIN FETCH qm.question " + + "WHERE q.id = :quizId") + Optional findByIdWithQuestions(@Param("quizId") Long quizId); + + @Query("SELECT DISTINCT q FROM CustomQuiz q " + + "LEFT JOIN FETCH q.quizWeekMappings wm " + + "LEFT JOIN FETCH wm.week w " + + "LEFT JOIN FETCH w.course " + + "WHERE q.id = :quizId") + Optional findByIdWithWeeks(@Param("quizId") Long quizId); + } diff --git a/src/main/java/com/_1/spring_rest_api/service/CourseService.java b/src/main/java/com/_1/spring_rest_api/service/CourseService.java index 382b9fc..c983715 100644 --- a/src/main/java/com/_1/spring_rest_api/service/CourseService.java +++ b/src/main/java/com/_1/spring_rest_api/service/CourseService.java @@ -4,6 +4,7 @@ import com._1.spring_rest_api.entity.User; import com._1.spring_rest_api.entity.Week; import com._1.spring_rest_api.repository.CourseRepository; +import com._1.spring_rest_api.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,11 +18,21 @@ public class CourseService { private final CourseRepository courseRepository; + private final UserRepository userRepository; - public Long createCourse( - Long userId, String title, String description) { - // User entity 조회 후 넣어야 함. - 임시로 User() 사용 - Course course = courseRepository.save(new Course(new User(userId), title, description)); + public Long createCourse(Long userId, String title, String description) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("User not found with id: " + userId)); + + Course course = Course.builder() + .creator(user) + .title(title) + .description(description) + .build(); + + user.addCourse(course); + + Course savedCourse = courseRepository.save(course); return course.getId(); } diff --git a/src/main/java/com/_1/spring_rest_api/service/QuestionCommandServiceImpl.java b/src/main/java/com/_1/spring_rest_api/service/QuestionCommandServiceImpl.java index 563cac6..02e57fc 100644 --- a/src/main/java/com/_1/spring_rest_api/service/QuestionCommandServiceImpl.java +++ b/src/main/java/com/_1/spring_rest_api/service/QuestionCommandServiceImpl.java @@ -41,14 +41,13 @@ public List generateAndSaveQuestions(Long weekId, int minQuestionCount) { List generatedQuestions = claudeService.generateQuestionsFromWeekTexts(weekId, minQuestionCount); - // 생성된 질문을 저장 (컨버터 활용) - List savedQuestionIds = generatedQuestions.stream() - .map(dto -> questionGenerationConverter.createQuestionForWeek(dto, weekId)) + List questions = questionGenerationConverter.createQuestionsForWeek( + generatedQuestions, weekId); + + return questions.stream() .map(questionRepository::save) .map(Question::getId) .collect(Collectors.toList()); - - return savedQuestionIds; } diff --git a/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java b/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java index f5618b9..fdd5069 100644 --- a/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java +++ b/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java @@ -65,15 +65,11 @@ public Long startQuizSession(Long quizId, Long userId) { .orElseThrow(() -> new EntityNotFoundException("User not found with id: " + userId)); // 새 퀴즈 세션 생성 - QuizSession session = QuizSession.builder() - .user(user) - .quiz(quiz) - .currentQuestionIndex(0) // 첫 번째 질문부터 시작 - .build(); + QuizSession session = QuizSession.create(user, quiz); // 양방향 연관관계 설정 - session.changeUser(user); - session.changeQuiz(quiz); + user.addQuizSession(session); + quiz.addQuizSession(session); // 저장 및 ID 반환 QuizSession savedSession = quizSessionRepository.save(session); @@ -97,10 +93,7 @@ private void connectWeeksToQuiz(List weekIds, CustomQuiz quiz) { .build(); // 양방향 연관관계 설정 - mapping.changeQuiz(quiz); - mapping.changeWeek(week); - - quizWeekMappingRepository.save(mapping); + quiz.addQuizWeekMapping(mapping); } } @@ -121,7 +114,13 @@ private void processQuestions(CreateQuizRequest request, CustomQuiz quiz) { // 질문 중복 제거 및 퀴즈에 추가 for (Question question : questions) { - quiz.addQuestion(question); + QuizQuestionMapping mapping = QuizQuestionMapping.builder() + .quiz(quiz) + .question(question) + .build(); + + question.addQuizQuestionMapping(mapping); + quiz.addQuizQuestionMapping(mapping); } // 총 질문 수 업데이트 및 저장 diff --git a/src/main/java/com/_1/spring_rest_api/service/QuizQueryServiceImpl.java b/src/main/java/com/_1/spring_rest_api/service/QuizQueryServiceImpl.java index b6f76a6..19f81bf 100644 --- a/src/main/java/com/_1/spring_rest_api/service/QuizQueryServiceImpl.java +++ b/src/main/java/com/_1/spring_rest_api/service/QuizQueryServiceImpl.java @@ -29,9 +29,11 @@ public List getQuizzesByUserId(Long userId) { @Override public QuizDetailResponse getQuizDetail(Long quizId) { - CustomQuiz quiz = customQuizRepository.findById(quizId) + CustomQuiz quiz = customQuizRepository.findByIdWithQuestions(quizId) .orElseThrow(() -> new EntityNotFoundException("Quiz not found with id: " + quizId)); + customQuizRepository.findByIdWithWeeks(quizId); + return convertToDetailResponse(quiz); } diff --git a/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandServiceImpl.java b/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandServiceImpl.java index c3e0ce0..a614a6e 100644 --- a/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandServiceImpl.java +++ b/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandServiceImpl.java @@ -4,7 +4,6 @@ import com._1.spring_rest_api.api.dto.AnswerResponse; import com._1.spring_rest_api.converter.AnswerResponseConverter; import com._1.spring_rest_api.converter.QuestionConverter; -import com._1.spring_rest_api.entity.CustomQuiz; import com._1.spring_rest_api.entity.Question; import com._1.spring_rest_api.entity.QuizSession; import com._1.spring_rest_api.entity.UserAnswer; @@ -15,8 +14,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - @Service @RequiredArgsConstructor @Transactional @@ -24,7 +21,6 @@ public class QuizSessionCommandServiceImpl implements QuizSessionCommandService private final QuizSessionRepository quizSessionRepository; private final UserAnswerRepository userAnswerRepository; - private final QuestionConverter questionConverter; private final AnswerResponseConverter answerResponseConverter; @Override @@ -40,8 +36,6 @@ public AnswerResponse answerQuestion(Long sessionId, AnswerRequest request) { // 3. 사용자 답변 생성 및 저장 UserAnswer userAnswer = session.createAnswer(request.getUserAnswer()); - userAnswer.changeUser(session.getUser()); - userAnswer.changeQuestion(currentQuestion); userAnswerRepository.save(userAnswer); // 4. 세션 상태 업데이트 diff --git a/src/main/java/com/_1/spring_rest_api/service/UserService.java b/src/main/java/com/_1/spring_rest_api/service/UserService.java index 1d3b425..e3d9472 100644 --- a/src/main/java/com/_1/spring_rest_api/service/UserService.java +++ b/src/main/java/com/_1/spring_rest_api/service/UserService.java @@ -18,6 +18,7 @@ @Service @RequiredArgsConstructor +@Transactional public class UserService { private final UserRepository userRepository; @@ -28,12 +29,8 @@ public User findByKakaoId(String kakaoId) { return userKakaoOpt.map(UserKakao::getUser).orElse(null); } - @Transactional public User createKakaoUser(String email, String name, String kakaoId) { - // Create new user User user = User.createKakaoUser(email, name); - - // Save user to get ID User savedUser = userRepository.save(user); // Create Kakao account link @@ -44,7 +41,6 @@ public User createKakaoUser(String email, String name, String kakaoId) { return savedUser; } - @Transactional public void updateKakaoTokens(User user, String accessToken, String refreshToken, LocalDateTime expiresAt) { UserKakao userKakao = user.getUserKakao(); diff --git a/src/main/java/com/_1/spring_rest_api/service/WeekService.java b/src/main/java/com/_1/spring_rest_api/service/WeekService.java index 67f4faf..886ec58 100644 --- a/src/main/java/com/_1/spring_rest_api/service/WeekService.java +++ b/src/main/java/com/_1/spring_rest_api/service/WeekService.java @@ -58,12 +58,11 @@ public WeekResponse createWeek(Long courseId, String title, Integer weekNumber) Week week = Week.builder() .title(title) .weekNumber(weekNumber) - .course(course) .build(); - week.changeCourse(course); - weekRepository.save(week); + course.addWeek(week); + weekRepository.save(week); return week.toWeekResponse(); } @@ -106,7 +105,7 @@ public void deleteWeek(Long weekId) { // 양방향 연관관계 정리 if (week.getCourse() != null) { - week.getCourse().getWeeks().remove(week); + week.getCourse().removeWeek(week); } weekRepository.delete(week); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9b41b8b..9349d1b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,7 +2,7 @@ spring.application.name=spring-rest-api spring.profiles.include=secret # \u00EC\u0084\u009C\u00EB\u00B2\u0084 \u00EC\u0084\u00A4\u00EC\u00A0\u0095 -server.port=8080 +server.port=8081 # JPA \u00EC\u0084\u00A4\u00EC\u00A0\u0095 spring.jpa.hibernate.ddl-auto=update diff --git a/src/test/java/com/_1/spring_rest_api/service/UserServiceTest.java b/src/test/java/com/_1/spring_rest_api/service/UserServiceTest.java index 13cbc09..c8646cc 100644 --- a/src/test/java/com/_1/spring_rest_api/service/UserServiceTest.java +++ b/src/test/java/com/_1/spring_rest_api/service/UserServiceTest.java @@ -173,7 +173,7 @@ private UserKakao createTestUserKakao(User user) { .build(); UserKakao saved = userKakaoRepository.save(userKakao); - userKakao.linkWithUser(user); + userKakao.changeUser(user); userRepository.save(user); From e370a59d41904a97d68374382e2f2f9111947a3c Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 28 May 2025 01:02:28 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[Fix]=20Course=20Service=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CourseServiceTest.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/_1/spring_rest_api/service/CourseServiceTest.java b/src/test/java/com/_1/spring_rest_api/service/CourseServiceTest.java index 27c1a5f..885269f 100644 --- a/src/test/java/com/_1/spring_rest_api/service/CourseServiceTest.java +++ b/src/test/java/com/_1/spring_rest_api/service/CourseServiceTest.java @@ -11,6 +11,7 @@ import com._1.spring_rest_api.entity.Week; import com._1.spring_rest_api.repository.CourseRepository; +import com._1.spring_rest_api.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -32,6 +33,9 @@ class CourseServiceTest { @Mock private CourseRepository courseRepository; + @Mock + private UserRepository userRepository; + @InjectMocks private CourseService courseService; @@ -41,7 +45,13 @@ class CourseServiceTest { @BeforeEach void setUp() { - testUser = new User(1L); + testUser = User.builder() + .id(1L) + .email("test@example.com") + .name("Test User") + .isActive(true) + .build(); + testCourse = new Course(1L, testUser, "Test Course", "Test Description"); testWeeks = new ArrayList<>(); @@ -59,9 +69,11 @@ void setUp() { @DisplayName("코스 생성 성공 테스트") void createCourse_Success() { // Given + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + when(courseRepository.save(any(Course.class))).thenAnswer(invocation -> { Course inputCourse = invocation.getArgument(0); - ReflectionTestUtils.setField(inputCourse, "id", 1L); // ID 설정 + ReflectionTestUtils.setField(inputCourse, "id", 1L); return inputCourse; }); From 0e56378b839948e4a15763ca8bfea5d4a86e72ca Mon Sep 17 00:00:00 2001 From: Dmori <83327857+Dimo-2562@users.noreply.github.com> Date: Wed, 28 May 2025 01:07:12 +0900 Subject: [PATCH 5/8] Update application.properties --- src/main/resources/application.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9349d1b..c23707d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,7 +2,7 @@ spring.application.name=spring-rest-api spring.profiles.include=secret # \u00EC\u0084\u009C\u00EB\u00B2\u0084 \u00EC\u0084\u00A4\u00EC\u00A0\u0095 -server.port=8081 +server.port=8080 # JPA \u00EC\u0084\u00A4\u00EC\u00A0\u0095 spring.jpa.hibernate.ddl-auto=update @@ -21,4 +21,4 @@ springdoc.swagger-ui.path=/swagger-ui.html springdoc.api-docs.path=/v3/api-docs springdoc.swagger-ui.enabled=true springdoc.api-docs.enabled=true -springdoc.packages-to-scan=com._1.spring_rest_api.api.controller \ No newline at end of file +springdoc.packages-to-scan=com._1.spring_rest_api.api.controller From 3481d79ba707335175bc9f2a14ffa6d8e038250c Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 28 May 2025 01:23:58 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[Fix]=20=EB=B0=B0=ED=8F=AC=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/QuizCommandServiceImpl.java | 10 +--------- src/main/resources/application.properties | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java b/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java index fdd5069..e7a37f0 100644 --- a/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java +++ b/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java @@ -114,17 +114,9 @@ private void processQuestions(CreateQuizRequest request, CustomQuiz quiz) { // 질문 중복 제거 및 퀴즈에 추가 for (Question question : questions) { - QuizQuestionMapping mapping = QuizQuestionMapping.builder() - .quiz(quiz) - .question(question) - .build(); - - question.addQuizQuestionMapping(mapping); - quiz.addQuizQuestionMapping(mapping); + quiz.addQuestion(question); } - // 총 질문 수 업데이트 및 저장 - quiz.updateTotalQuestions(questions.size()); customQuizRepository.save(quiz); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9349d1b..9b41b8b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,7 +2,7 @@ spring.application.name=spring-rest-api spring.profiles.include=secret # \u00EC\u0084\u009C\u00EB\u00B2\u0084 \u00EC\u0084\u00A4\u00EC\u00A0\u0095 -server.port=8081 +server.port=8080 # JPA \u00EC\u0084\u00A4\u00EC\u00A0\u0095 spring.jpa.hibernate.ddl-auto=update From 7d4b1dd9cbfb8e91075a04b5e07407ed4f53a02f Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 28 May 2025 13:43:55 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[Fix]=20CustomQuiz=EC=99=80=20QuizQuestionM?= =?UTF-8?q?apping=20=EA=B0=84=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=84=A4=EC=A0=95=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_1/spring_rest_api/entity/CustomQuiz.java | 16 +++------------- .../_1/spring_rest_api/entity/QuizSession.java | 2 +- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java b/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java index f048981..fab2d3f 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java +++ b/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java @@ -91,9 +91,7 @@ public void addQuestion(Question question) { } // 정적 팩토리 메서드를 통해 매핑 생성 및 양방향 연관관계 설정 - QuizQuestionMapping mapping = QuizQuestionMapping.create(this, question); - - this.addQuizQuestionMapping(mapping); + QuizQuestionMapping.create(this, question); // 질문 수 증가 this.updateTotalQuestions(this.totalQuestions + 1); @@ -103,22 +101,14 @@ public void addQuestion(Question question) { public void addQuizSession(QuizSession session) { this.quizSessions.add(session); if (session.getQuiz() != this) { - session.addQuiz(this); + session.changeQuiz(this); } } public void removeQuizSession(QuizSession session) { this.quizSessions.remove(session); if (session.getQuiz() == this) { - session.addQuiz(null); - } - } - - // CustomQuiz와 QuizQuestionMapping 간의 양방향 연관관계 메서드 - public void addQuizQuestionMapping(QuizQuestionMapping mapping) { - this.quizQuestionMappings.add(mapping); - if (mapping.getQuiz() != this) { - mapping.changeQuiz(this); + session.changeQuiz(null); } } diff --git a/src/main/java/com/_1/spring_rest_api/entity/QuizSession.java b/src/main/java/com/_1/spring_rest_api/entity/QuizSession.java index c5c22a4..1883e72 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/QuizSession.java +++ b/src/main/java/com/_1/spring_rest_api/entity/QuizSession.java @@ -46,7 +46,7 @@ public void changUser(User user) { } // CustomQuiz와 QuizSession 간의 양방향 연관관계 메서드 - public void addQuiz(CustomQuiz quiz) { + public void changeQuiz(CustomQuiz quiz) { this.quiz = quiz; if (quiz != null && !quiz.getQuizSessions().contains(this)) { quiz.getQuizSessions().add(this); From 28378b8dfcad6ae4684ee045f1c77e06a99f9657 Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 28 May 2025 13:48:34 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[Fix]=20=EB=B9=8C=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/_1/spring_rest_api/entity/CustomQuiz.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java b/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java index fab2d3f..5702dea 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java +++ b/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java @@ -112,13 +112,6 @@ public void removeQuizSession(QuizSession session) { } } - public void removeQuizQuestionMapping(QuizQuestionMapping mapping) { - this.quizQuestionMappings.remove(mapping); - if (mapping.getQuiz() == this) { - mapping.changeQuiz(null); - } - } - // CustomQuiz와 QuizWeekMapping 간의 양방향 연관관계 메서드 public void addQuizWeekMapping(QuizWeekMapping mapping) { this.quizWeekMappings.add(mapping);