diff --git a/Dockerfile b/Dockerfile index dbf4550..2340f1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN mvn clean package -DskipTests -B FROM eclipse-temurin:21-jre +ENV TZ=Asia/Seoul + RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* diff --git a/pom.xml b/pom.xml index 4c418f3..281a46c 100644 --- a/pom.xml +++ b/pom.xml @@ -126,12 +126,35 @@ spring-boot-starter-mail + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + + org.springframework.cloud + spring-cloud-starter-circuitbreaker-resilience4j + + io.micrometer micrometer-registry-prometheus + + + org.redisson + redisson-spring-boot-starter + 3.52.0 + + org.springframework.cloud spring-cloud-config-client diff --git a/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java b/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java index 8820c31..e9bc098 100644 --- a/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java +++ b/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java @@ -3,9 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableDiscoveryClient +@EnableFeignClients +@EnableScheduling public class InsightonAuthApplication { public static void main(String[] args) { diff --git a/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java b/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java new file mode 100644 index 0000000..b0d05ef --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java @@ -0,0 +1,13 @@ +package com.nhnacademy.insightonauth.client; + +import com.nhnacademy.insightonauth.dto.core.ManagerGroupExistsResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@FeignClient(name = "insighton-core") +public interface CoreClient { + + @GetMapping("/internal/v1/users/{userId}/manager-groups/exists") + ManagerGroupExistsResponse existsManagerGroup(@PathVariable Long userId); +} diff --git a/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java index 123875f..7ae7851 100644 --- a/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java +++ b/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java @@ -1,65 +1,8 @@ package com.nhnacademy.insightonauth.client; import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; -import java.util.Map; +public interface OauthClient { -@Component -@RequiredArgsConstructor -public class OauthClient { - - @Value("${oauth.google.client-id}") - private String clientId; - - @Value("${oauth.google.client-secret}") - private String clientSecret; - - @Value("${oauth.google.redirect-uri}") - private String redirectUri; - - private final RestClient restClient = RestClient.create(); - - public OauthUserInfo getUserInfo(String provider, String code) { - String accessToken = requestAccessToken(code); - return requestUserInfo(accessToken); - } - - private String requestAccessToken(String code) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("code", code); - body.add("client_id", clientId); - body.add("client_secret", clientSecret); - body.add("redirect_uri", redirectUri); - body.add("grant_type", "authorization_code"); - - Map response = restClient.post() - .uri("https://oauth2.googleapis.com/token") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(body) - .retrieve() - .body(Map.class); - - return (String) response.get("access_token"); - } - - private OauthUserInfo requestUserInfo(String accessToken) { - Map userInfo = restClient.get() - .uri("https://www.googleapis.com/oauth2/v3/userinfo") - .header("Authorization", "Bearer " + accessToken) - .retrieve() - .body(Map.class); - - return new OauthUserInfo( - (String) userInfo.get("email"), - (String) userInfo.get("name"), - (String) userInfo.get("sub") - ); - } + OauthUserInfo getUserInfo(String code); } diff --git a/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java b/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java new file mode 100644 index 0000000..8e880bc --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java @@ -0,0 +1,22 @@ +package com.nhnacademy.insightonauth.client; + +import com.nhnacademy.insightonauth.exception.UnsupportedOAuthProviderException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class OauthClientResolver { + // 스프링이 bean 객체를 가져다줌 + private final Map clients; + + public OauthClient resolve(String provider) { + OauthClient client = clients.get(provider + "OauthClient"); + if (client == null) { + throw new UnsupportedOAuthProviderException(provider); + } + return client; + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java new file mode 100644 index 0000000..882b302 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java @@ -0,0 +1,103 @@ +package com.nhnacademy.insightonauth.client.impl; + +import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; +import com.nhnacademy.insightonauth.exception.EmailNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +@Component("githubOauthClient") +@RequiredArgsConstructor +public class GithubOauthClient implements OauthClient { + + @Value("${oauth.github.client-id}") + private String clientId; + + @Value("${oauth.github.client-secret}") + private String clientSecret; + + @Value("${oauth.redirect-uri}") + private String redirectUri; + + private final RestClient restClient = RestClient.builder() + .requestFactory(ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults() + .withConnectTimeout(Duration.ofSeconds(3)) + .withReadTimeout(Duration.ofSeconds(5)))) + .build(); + + @Override + public OauthUserInfo getUserInfo(String code) { + String accessToken = requestAccessToken(code); + return requestUserInfo(accessToken); + } + + private String requestAccessToken(String code) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + + Map response = restClient.post() + .uri("https://github.com/login/oauth/access_token") // GitHub URL로 수정 + .header("Accept", "application/json") // JSON 응답 요청 (필수) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(Map.class); + + return (String) response.get("access_token"); + } + + private OauthUserInfo requestUserInfo(String accessToken) { + Map userInfo = restClient.get() + .uri("https://api.github.com/user") // GitHub URL로 수정 + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(Map.class); + + String email = requestPrimaryEmail(accessToken); + + String name = (String) userInfo.get("name"); + if (name == null || name.isBlank()) { + name = (String) userInfo.get("login"); // name이 없으면 login(아이디)으로 대체 + } + + Object id = userInfo.get("id"); // GitHub은 sub가 아니라 id (숫자) + + return new OauthUserInfo( + email, + name, + String.valueOf(id) + ); + } + + // 이메일 열람 불가의 경우 api를 통해서 가져와야함 + private String requestPrimaryEmail(String accessToken) { + List> emails = restClient.get() + .uri("https://api.github.com/user/emails") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(List.class); + + // primary git에서 대표로 지정된 이메일 가져옴, verified 그 중에 인증된거 가져옴 + return emails.stream() + .filter(e -> + Boolean.TRUE.equals(e.get("primary")) && Boolean.TRUE.equals(e.get("verified"))) + .map(e -> (String) e.get("email")) + .findFirst() + .orElseThrow(() -> new EmailNotFoundException("GitHub 계정에서 이메일을 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java new file mode 100644 index 0000000..ac5e2c0 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java @@ -0,0 +1,76 @@ +package com.nhnacademy.insightonauth.client.impl; + +import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.Map; + +@Component("googleOauthClient") +@RequiredArgsConstructor +public class GoogleOauthClient implements OauthClient { + + @Value("${oauth.google.client-id}") + private String clientId; + + @Value("${oauth.google.client-secret}") + private String clientSecret; + + @Value("${oauth.redirect-uri}") + private String redirectUri; + + private final RestClient restClient = RestClient.builder() + .requestFactory(ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults() + .withConnectTimeout(Duration.ofSeconds(3)) + .withReadTimeout(Duration.ofSeconds(5)))) + .build(); + + @Override + public OauthUserInfo getUserInfo(String code) { + String accessToken = requestAccessToken(code); + return requestUserInfo(accessToken); + } + + private String requestAccessToken(String code) { +// FormHttpMessageConverter + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + body.add("grant_type", "authorization_code"); + + Map response = restClient.post() + .uri("https://oauth2.googleapis.com/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(Map.class); + + return (String) response.get("access_token"); + } + + private OauthUserInfo requestUserInfo(String accessToken) { + Map userInfo = restClient.get() + .uri("https://www.googleapis.com/oauth2/v3/userinfo") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(Map.class); + + return new OauthUserInfo( + (String) userInfo.get("email"), + (String) userInfo.get("name"), + (String) userInfo.get("sub") + ); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java b/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java index b31fc66..e744613 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java @@ -1,7 +1,6 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.ApiResponse; import com.nhnacademy.insightonauth.dto.admin.AdminFindUsersResponse; import com.nhnacademy.insightonauth.dto.admin.AdminUserDetailResponse; import com.nhnacademy.insightonauth.dto.admin.RoleChangeRequest; @@ -24,41 +23,41 @@ public class AdminController { // 회원 목록 조회 (검색·페이징) @GetMapping("/users") - public ResponseEntity>> findUsers( + public ResponseEntity> findUsers( @RequestParam(required = false) String email, @RequestParam(required = false) String userName, @RequestParam(required = false) Status status, Pageable pageable) { Page response = adminUserService.findUsers(email, userName, status, pageable); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 회원 상세 조회 @GetMapping("/users/{userId}") - public ResponseEntity> findUserDetail(@PathVariable Long userId) { + public ResponseEntity findUserDetail(@PathVariable Long userId) { AdminUserDetailResponse response = adminUserService.findUserDetail(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 회원 상태 변경 @PutMapping("/users/{userId}/status") - public ResponseEntity> changeStatus( + public ResponseEntity changeStatus( @PathVariable Long userId, @RequestBody @Valid StatusChangeRequest request) { adminUserService.changeStatus(userId, request.status()); - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.ok().build(); } // 회원 권한 변경 @PutMapping("/users/{userId}/roles") - public ResponseEntity> changeRole( + public ResponseEntity changeRole( @PathVariable Long userId, @RequestBody @Valid RoleChangeRequest request) { adminUserService.addUserRole(userId, request.role()); - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.ok().build(); } // 회원 삭제 (실제로는 상태 변경 처리) diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java b/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java new file mode 100644 index 0000000..3138435 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java @@ -0,0 +1,27 @@ +package com.nhnacademy.insightonauth.controller; + +import com.nhnacademy.insightonauth.dto.core.AuthUserResponse; +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/users") +@RequiredArgsConstructor +public class CoreController { + + private final UserService userService; + + @GetMapping("{userId}") + public ResponseEntity getUserById(@PathVariable("userId") Long userId) { + User user = userService.findById(userId); + + return ResponseEntity.ok( + new AuthUserResponse(user.getUserId(), user.getUserName(), user.getPhoneNumber(), user.getStatus())); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java b/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java index dea0265..1a47404 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java @@ -1,6 +1,5 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.ApiResponse; import com.nhnacademy.insightonauth.dto.mypage.MyInfoResponse; import com.nhnacademy.insightonauth.dto.mypage.PasswordChangeRequest; import com.nhnacademy.insightonauth.dto.mypage.RoleResponse; @@ -32,16 +31,16 @@ public class MypageController { // 내 정보 조회 @GetMapping("/me") - public ResponseEntity> findMyInfo( + public ResponseEntity findMyInfo( @RequestHeader(name = X_USER_ID) @Valid Long userId) { MyInfoResponse response = myPageService.findMyInfo(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 내 정보 수정 @PutMapping("/me") - public ResponseEntity> updateMyInfo( + public ResponseEntity updateMyInfo( @RequestHeader(name = X_USER_ID) @Valid Long userId, @RequestBody @Valid MyInfoUpdateRequest request) { @@ -59,7 +58,7 @@ public ResponseEntity withdraw(@RequestHeader(name = X_USER_ID) @Valid Lon // 비밀번호 변경 @PutMapping("/me/password") - public ResponseEntity> changePassword( + public ResponseEntity changePassword( @RequestHeader(name = X_USER_ID) @Valid Long userId, @RequestBody @Valid PasswordChangeRequest request) { @@ -69,25 +68,25 @@ public ResponseEntity> changePassword( // 내 권한 목록 조회 @GetMapping("/me/roles") - public ResponseEntity>> findMyRoles( + public ResponseEntity> findMyRoles( @RequestHeader(name = X_USER_ID) @Valid Long userId) { List response = myPageService.findMyRoles(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 연동 소셜 계정 목록 @GetMapping("/me/oauths") - public ResponseEntity>> findMyOauths( + public ResponseEntity> findMyOauths( @RequestHeader(name = X_USER_ID) @Valid Long userId) { List response = myPageService.findMyOauths(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 소셜 계정 신규 연동 @PostMapping("/me/oauths/{provider}") - public ResponseEntity> linkOauth( + public ResponseEntity linkOauth( @RequestHeader(name = X_USER_ID) @Valid Long userId, @PathVariable String provider, @RequestBody @Valid OauthLoginRequest request) { @@ -106,5 +105,4 @@ public ResponseEntity unlinkOauth( oauthService.delete(user, oauthId); return ResponseEntity.noContent().build(); } - } diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java b/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java index 3b1b2e0..803696a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java @@ -1,6 +1,5 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.*; import com.nhnacademy.insightonauth.dto.auth.*; import com.nhnacademy.insightonauth.dto.oauth.OauthLoginRequest; import com.nhnacademy.insightonauth.entity.Role; @@ -23,114 +22,113 @@ public class UserController { @PostMapping("/email/verify-request") public ResponseEntity sendEmailVerify(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { userService.emailVerifyRequest(emailVerifyRequest.email()); - return ResponseEntity.noContent().build(); } @PostMapping("/email/verify-confirm") - public ResponseEntity> emailCodeConfirm( + public ResponseEntity emailCodeConfirm( @RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { String verificationToken = userService.emailVerifyConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); - return ResponseEntity.ok(new ApiResponse<>(new EmailVerifyConfirmResponse(verificationToken))); + return ResponseEntity.ok(new EmailVerifyConfirmResponse(verificationToken)); } @PostMapping("/check-email") - public ResponseEntity> checkEmailAvailable( + public ResponseEntity checkEmailAvailable( @RequestBody @Valid EmailAvailableRequest emailAvailableRequest) { boolean available = userService.checkEmailAvailable(emailAvailableRequest.email()); - return ResponseEntity.ok(new ApiResponse<>(new EmailAvailableResponse(available))); + return ResponseEntity.ok(new EmailAvailableResponse(available)); } @PostMapping("/signup") - public ResponseEntity> doSignup( + public ResponseEntity doSignup( @RequestBody @Valid UserSignupRequest userSignupRequest) { UserSignupResponse userSignupResponse = userService.createUser(userSignupRequest.email(), - userSignupRequest.password(), - userSignupRequest.userName(), - userSignupRequest.phoneNumber(), - Role.MEMBER, - userSignupRequest.token()); - - return ResponseEntity.status(HttpStatus.CREATED) - .body(new ApiResponse<>(userSignupResponse)); + userSignupRequest.password(), + userSignupRequest.userName(), + userSignupRequest.phoneNumber(), + Role.MEMBER, + userSignupRequest.token()); + + return ResponseEntity.status(HttpStatus.CREATED).body(userSignupResponse); } @PostMapping("/login") - public ResponseEntity> doLogin( + public ResponseEntity doLogin( @RequestBody @Valid UserLoginRequest userLoginRequest) { UserLoginResponse userLoginResponse = userService.login(userLoginRequest.email(), userLoginRequest.password()); - return ResponseEntity.ok(new ApiResponse<>(userLoginResponse)); + return ResponseEntity.ok(userLoginResponse); } @PostMapping("/logout") public ResponseEntity doLogout(@RequestHeader(name = X_USER_ID) @Valid Long userId) { userService.logout(userId); - return ResponseEntity.noContent().build(); } + @PostMapping("/reactive") - public ResponseEntity> userReactive( + public ResponseEntity userReactive( @RequestBody @Valid ReactiveRequest request) { UserLoginResponse response = userService.reactive(request.reactiveToken()); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } @PostMapping("/reactivate/email-verify-request") - public ResponseEntity userReactive(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { + public ResponseEntity userReactivateRequest(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { userService.reactivateRequest(emailVerifyRequest.email()); - return ResponseEntity.noContent().build(); } @PostMapping("/reactivate/email-verify-confirm") - public ResponseEntity> userReactiveConfirm(@RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { - UserLoginResponse userLoginResponse = userService.reactivateConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); + public ResponseEntity userReactiveConfirm( + @RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { + UserLoginResponse userLoginResponse = + userService.reactivateConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); - return ResponseEntity.ok(new ApiResponse<>(userLoginResponse)); + return ResponseEntity.ok(userLoginResponse); } @PostMapping("/find-email") - public ResponseEntity> findEmail(@RequestBody @Valid FindEmailRequest findEmailRequest) { + public ResponseEntity findEmail(@RequestBody @Valid FindEmailRequest findEmailRequest) { String email = userService.findMaskedEmail(findEmailRequest.userName(), findEmailRequest.phoneNumber()); - return ResponseEntity.ok(new ApiResponse<>(email)); + return ResponseEntity.ok(email); } @PostMapping("/password/reset-request") - public ResponseEntity> passwordReset(@RequestBody @Valid PasswordResetRequest passwordResetRequest) { + public ResponseEntity passwordReset(@RequestBody @Valid PasswordResetRequest passwordResetRequest) { userService.passwordResetRequest(passwordResetRequest.email()); - - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.noContent().build(); } @PostMapping("/password/reset-confirm") - public ResponseEntity passwordResetConfirm(@RequestBody @Valid PasswordResetConfirmRequest passwordResetConfirmRequest) { + public ResponseEntity passwordResetConfirm( + @RequestBody @Valid PasswordResetConfirmRequest passwordResetConfirmRequest) { userService.passwordResetConfirm(passwordResetConfirmRequest.token(), passwordResetConfirmRequest.password()); return ResponseEntity.ok().build(); } @PostMapping("/oauth/{provider}") - public ResponseEntity> oauthLogin( + public ResponseEntity oauthLogin( @PathVariable String provider, @RequestBody @Valid OauthLoginRequest request) { UserLoginResponse response = userService.oauthLogin(provider, request.code()); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } @PostMapping("/refresh") - public ResponseEntity> refresh( + public ResponseEntity refresh( @RequestHeader(name = X_USER_ID) @Valid Long userId, @CookieValue("refreshToken") String refreshToken) { TokenRefreshResponse tokenRefreshResponse = userService.refresh(userId, refreshToken); - return ResponseEntity.ok(new ApiResponse<>(tokenRefreshResponse)); + return ResponseEntity.ok(tokenRefreshResponse); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java deleted file mode 100644 index 5db731b..0000000 --- a/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.nhnacademy.insightonauth.dto; - -import lombok.Getter; - -@Getter -public class ApiResponse { - private final boolean success; - private final T data; - private final ErrorResponse error; - - // 성공 응답용 생성자 - public ApiResponse(T data) { - this.success = true; - this.data = data; - this.error = null; - } - - public ApiResponse(String code, String message) { - this.success = false; - this.data = null; - this.error = new ErrorResponse(code, message); - } -} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java deleted file mode 100644 index f44eba7..0000000 --- a/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.nhnacademy.insightonauth.dto; - -public class ErrorResponse { - private final String code; - private final String message; - - public ErrorResponse(String code, String message) { - this.code = code; - this.message = message; - } - - public String getCode() { - return code; - } - - public String getMessage() { - return message; - } -} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java new file mode 100644 index 0000000..2760afb --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java @@ -0,0 +1,11 @@ +package com.nhnacademy.insightonauth.dto.core; + +import com.nhnacademy.insightonauth.entity.Status; + +public record AuthUserResponse( + Long userId, + String userName, + String userPhoneNumber, + Status userStatus +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java new file mode 100644 index 0000000..66715b1 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java @@ -0,0 +1,6 @@ +package com.nhnacademy.insightonauth.dto.core; + +public record ManagerGroupExistsResponse( + boolean exists +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java b/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java index 9f25f91..23247c3 100644 --- a/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java +++ b/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java @@ -75,7 +75,6 @@ private void send(String to, String subject, String text) { javaMailSender.send(message); } catch (MessagingException | UnsupportedEncodingException e) { throw new EmailSendException("이메일 발송에 실패했습니다.", e); - } catch (MailException e) { log.error("이메일 전송 실패(타임아웃 등) - to: {}, error: {}", to, e.getMessage()); throw new EmailSendException("이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.", e); diff --git a/src/main/java/com/nhnacademy/insightonauth/entity/User.java b/src/main/java/com/nhnacademy/insightonauth/entity/User.java index 040661c..8a89dd0 100644 --- a/src/main/java/com/nhnacademy/insightonauth/entity/User.java +++ b/src/main/java/com/nhnacademy/insightonauth/entity/User.java @@ -40,7 +40,6 @@ public class User { @Column(name = "status", nullable = false, length = 20) private Status status; - @Setter @Column(name = "last_login_at", nullable = true) private OffsetDateTime lastLoginAt; @@ -66,6 +65,14 @@ public User(String email, String userName, String phoneNumber) { this.createdAt = now; } + public void updateLastLoginAt() { + this.lastLoginAt = OffsetDateTime.now(ZoneOffset.UTC); + } + + public void updateLastLoginAt(OffsetDateTime lastLoginAt) { // 테스트/특정 시각 지정용 + this.lastLoginAt = lastLoginAt; + } + public void reactivate() { if (this.status != Status.SLEEP && this.status != Status.WITHDRAW) { throw new InvalidUserStatusException("휴면 또는 탈퇴 상태가 아닙니다."); diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java b/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java new file mode 100644 index 0000000..1a704af --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java @@ -0,0 +1,17 @@ +package com.nhnacademy.insightonauth.exception; + +import org.springframework.http.HttpStatus; + +public class BusinessException extends RuntimeException { + private final HttpStatus status; + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + this.status = errorCode.getStatus(); + } + + public HttpStatus getStatus() { return status; } + public ErrorCode getErrorCode() { return errorCode; } +} \ No newline at end of file diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java b/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java new file mode 100644 index 0000000..c5b05d1 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class CoreServiceUnavailableException extends BusinessException { + public CoreServiceUnavailableException(String message) { + super(ErrorCode.CORE_SERVICE_UNAVAILABLE, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java index 4e2d5e8..e741874 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class DuplicateEmailException extends RuntimeException { +public class DuplicateEmailException extends BusinessException { public DuplicateEmailException(String message) { - super(message); + super(ErrorCode.DUPLICATE_EMAIL, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java index 04cac4e..98868c6 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class DuplicatePhoneNumberException extends RuntimeException { +public class DuplicatePhoneNumberException extends BusinessException { public DuplicatePhoneNumberException(String message) { - super(message); + super(ErrorCode.DUPLICATE_PHONE_NUMBER, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java new file mode 100644 index 0000000..7bfb50a --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class EmailAlreadyRegisteredException extends BusinessException { + public EmailAlreadyRegisteredException(String message) { + super(ErrorCode.EMAIL_ALREADY_REGISTERED, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java new file mode 100644 index 0000000..6973962 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class EmailNotFoundException extends BusinessException { + public EmailNotFoundException(String message) { + super(ErrorCode.EMAIL_NOT_FOUND, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java index 3de9e2f..10a7f7e 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java @@ -1,7 +1,9 @@ package com.nhnacademy.insightonauth.exception; -public class EmailSendException extends RuntimeException { +public class EmailSendException extends BusinessException { public EmailSendException(String message, Exception e) { - super(message); + super(ErrorCode.EMAIL_SEND_FAILED, message); + // 왜 이메일이 실패했는지 정확인 exception이 로그이 보이기 위한 코드 + initCause(e); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java new file mode 100644 index 0000000..723bf5f --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java @@ -0,0 +1,41 @@ +package com.nhnacademy.insightonauth.exception; + +import org.springframework.http.HttpStatus; + +public enum ErrorCode { + CORE_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE), + DUPLICATE_EMAIL(HttpStatus.CONFLICT), + DUPLICATE_PHONE_NUMBER(HttpStatus.CONFLICT), + EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND), + EMAIL_SEND_FAILED(HttpStatus.SERVICE_UNAVAILABLE), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED), + INVALID_REACTIVE_TOKEN(HttpStatus.BAD_REQUEST), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED), + INVALID_USER(HttpStatus.FORBIDDEN), + INVALID_USER_STATUS(HttpStatus.CONFLICT), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST), + INVALID_VERIFICATION_TOKEN(HttpStatus.BAD_REQUEST), + LAST_LOGIN_METHOD(HttpStatus.CONFLICT), + LOGIN_TEMPORARILY_LOCKED(HttpStatus.LOCKED), + MANAGER_GROUP_EXISTS(HttpStatus.CONFLICT), + OAUTH_ALREADY_LINKED(HttpStatus.CONFLICT), + OAUTH_NOT_FOUND(HttpStatus.NOT_FOUND), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND), + RESTORE_PERIOD_EXPIRED(HttpStatus.GONE), + UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST), + USER_CREDENTIALS_NOT_FOUND(HttpStatus.NOT_FOUND), + USER_NOT_FOUND(HttpStatus.NOT_FOUND), + USER_ROLE_NOT_FOUND(HttpStatus.NOT_FOUND), + VERIFICATION_TEMPORARILY_LOCKED(HttpStatus.LOCKED), + EMAIL_ALREADY_REGISTERED(HttpStatus.CONFLICT); + + private final HttpStatus status; + + ErrorCode(HttpStatus status) { + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java new file mode 100644 index 0000000..bca0216 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public record ErrorResponse( + int status, + String message +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java index 1af1f55..98c00e8 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidCredentialsException extends RuntimeException { +public class InvalidCredentialsException extends BusinessException { public InvalidCredentialsException(String message) { - super(message); + super(ErrorCode.INVALID_CREDENTIALS, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java index 074a82b..f168d04 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidReactiveTokenException extends RuntimeException { +public class InvalidReactiveTokenException extends BusinessException { public InvalidReactiveTokenException(String message) { - super(message); + super(ErrorCode.INVALID_REACTIVE_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java index bf1e007..73a606a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidRefreshTokenException extends RuntimeException { +public class InvalidRefreshTokenException extends BusinessException { public InvalidRefreshTokenException(String message) { - super(message); + super(ErrorCode.INVALID_REFRESH_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java index 3262d98..fc50ed7 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidUserException extends RuntimeException { +public class InvalidUserException extends BusinessException { public InvalidUserException(String message) { - super(message); + super(ErrorCode.INVALID_USER, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java index d4a8bd5..be026ad 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidUserStatusException extends RuntimeException { +public class InvalidUserStatusException extends BusinessException { public InvalidUserStatusException(String message) { - super(message); + super(ErrorCode.INVALID_USER_STATUS, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java index e06567f..91e10f0 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidVerificationCodeException extends RuntimeException { +public class InvalidVerificationCodeException extends BusinessException { public InvalidVerificationCodeException(String message) { - super(message); + super(ErrorCode.INVALID_VERIFICATION_CODE, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java index 2b58f9c..ac51b62 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidVerificationTokenException extends RuntimeException { +public class InvalidVerificationTokenException extends BusinessException { public InvalidVerificationTokenException(String message) { - super(message); + super(ErrorCode.INVALID_VERIFICATION_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java b/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java index e4c71e8..a7537fa 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class LastLoginMethodException extends RuntimeException { +public class LastLoginMethodException extends BusinessException { public LastLoginMethodException(String message) { - super(message); + super(ErrorCode.LAST_LOGIN_METHOD, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java index 60d6100..c8dd2ce 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class LoginTemporarilyLockedException extends RuntimeException { +public class LoginTemporarilyLockedException extends BusinessException { public LoginTemporarilyLockedException(String message) { - super(message); + super(ErrorCode.LOGIN_TEMPORARILY_LOCKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java b/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java new file mode 100644 index 0000000..e5ad93a --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class ManagerGroupExistsException extends BusinessException { + public ManagerGroupExistsException(String message) { + super(ErrorCode.MANAGER_GROUP_EXISTS, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java index 3bf4f8f..8350e51 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class OauthAlreadyLinkedException extends RuntimeException { +public class OauthAlreadyLinkedException extends BusinessException { public OauthAlreadyLinkedException(String message) { - super(message); + super(ErrorCode.OAUTH_ALREADY_LINKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java index ceba420..02c3ad2 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class OauthNotFoundException extends RuntimeException { +public class OauthNotFoundException extends BusinessException { public OauthNotFoundException(String message) { - super(message); + super(ErrorCode.OAUTH_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java index 7dfd0c8..5df5727 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class RefreshTokenNotFoundException extends RuntimeException { +public class RefreshTokenNotFoundException extends BusinessException { public RefreshTokenNotFoundException(String message) { - super(message); + super(ErrorCode.REFRESH_TOKEN_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java b/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java index 75684d2..9b18984 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class RestorePeriodExpiredException extends RuntimeException { +public class RestorePeriodExpiredException extends BusinessException { public RestorePeriodExpiredException(String message) { - super(message); + super(ErrorCode.RESTORE_PERIOD_EXPIRED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java new file mode 100644 index 0000000..b5ff99c --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class UnsupportedOAuthProviderException extends BusinessException { + public UnsupportedOAuthProviderException(String message) { + super(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER, "지원하지 않는 OAuth 제공자입니다: " + message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java index 6b4d2f7..0d0cef9 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserCredentialsNotFoundException extends RuntimeException { +public class UserCredentialsNotFoundException extends BusinessException { public UserCredentialsNotFoundException(String message) { - super(message); + super(ErrorCode.USER_CREDENTIALS_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java index 7b2266a..74473c4 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserNotFoundException extends RuntimeException { +public class UserNotFoundException extends BusinessException { public UserNotFoundException(String message) { - super(message); + super(ErrorCode.USER_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java index 9e8856c..0256109 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserRoleNotFoundException extends RuntimeException { +public class UserRoleNotFoundException extends BusinessException { public UserRoleNotFoundException(String message) { - super(message); + super(ErrorCode.USER_ROLE_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java index d989fc3..6712f8c 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class VerificationTemporarilyLockedException extends RuntimeException { +public class VerificationTemporarilyLockedException extends BusinessException { public VerificationTemporarilyLockedException(String message) { - super(message); + super(ErrorCode.VERIFICATION_TEMPORARILY_LOCKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java b/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java index c6a7c08..05721b8 100644 --- a/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java +++ b/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java @@ -1,7 +1,5 @@ package com.nhnacademy.insightonauth.filter; -import com.nhnacademy.insightonauth.entity.User; -import com.nhnacademy.insightonauth.entity.UserRole; import com.nhnacademy.insightonauth.service.UserRoleService; import com.nhnacademy.insightonauth.service.UserService; import jakarta.servlet.FilterChain; @@ -16,10 +14,10 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.ArrayList; import java.util.List; @Slf4j @@ -33,18 +31,24 @@ public class HeaderAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String userIdHeader = request.getHeader("X-User-Id"); + String rolesHeader = request.getHeader("X-User-Role"); if (userIdHeader != null) { - Long userId = Long.valueOf(userIdHeader); - - User user = userService.findById(userId); - List userRoles = userRoleService.findByUser(user); - List authorities = userRoles.stream() - .map(userRole -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + userRole.getRole().name())) - .toList(); - - Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); + try { + Long userId = Long.valueOf(userIdHeader); + + List authorities = new ArrayList<>(); + if (rolesHeader != null && !rolesHeader.isBlank()) { + for (String role : rolesHeader.split(",")) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role.trim())); + } + } + + Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception e) { + log.debug("유효하지 않은 인증 헤더 - X-User-Id: {}, X-User-Role: {}", userIdHeader, rolesHeader); + } } filterChain.doFilter(request, response); diff --git a/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java b/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..a4c24cc --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java @@ -0,0 +1,44 @@ +package com.nhnacademy.insightonauth.handler; + +import com.nhnacademy.insightonauth.exception.BusinessException; +import com.nhnacademy.insightonauth.exception.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // 비즈니스 로직 실패 + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e) { + return ResponseEntity.status(e.getStatus()) + .body(new ErrorResponse(e.getStatus().value(), e.getMessage())); + } + + // Validate 실패 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(FieldError::getDefaultMessage) + .orElse("잘못된 요청입니다."); + return ResponseEntity.badRequest() + .body(new ErrorResponse(HttpStatus.BAD_REQUEST.value(), message)); + } + + // 그 외 + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception e) { + log.error("예상치 못한 예외 발생", e); + return ResponseEntity.internalServerError() + .body(new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "서버 오류가 발생했습니다.")); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java b/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java index c43c880..e086c8d 100644 --- a/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java +++ b/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java @@ -10,7 +10,9 @@ public enum RedisKey { LOGIN_LOCK("login-lock:"), PASSWORD_RESET("password-reset:"), REACTIVE("reactive:"), - BLACKLIST("blacklist:"); + BLACKLIST("blacklist:"), + HARD_DELETE_SCHEDULER_LOCK("scheduler-lock:hard-delete-users"), + SLEEP_CONVERSION_SCHEDULER_LOCK("scheduler-lock:sleep-conversion");; private final String prefix; diff --git a/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java b/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java index 86ce5ee..616dead 100644 --- a/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java +++ b/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.OffsetDateTime; +import java.util.List; import java.util.Optional; public interface UserRepository extends JpaRepository { @@ -25,4 +27,8 @@ Page findByEmailContainingAndUserNameContaining( boolean existsByEmail(String email); boolean existsByPhoneNumber(String phoneNumber); + + List findByStatusAndWithdrawnAtBefore(Status status, OffsetDateTime withdrawnAtBefore); + + List findByStatusAndLastLoginAtBefore(Status status, OffsetDateTime dateTime);; } diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java new file mode 100644 index 0000000..1fba30b --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java @@ -0,0 +1,69 @@ +package com.nhnacademy.insightonauth.scheduler; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.redis.RedisKey; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserHardDeleteScheduler { + + private final UserService userService; + private final RedissonClient redissonClient; + + @Scheduled(cron = "0 0 1 * * *") // 매일 새벽 1시 + public void hardDeleteExpiredUsers() { + RLock lock = redissonClient.getLock(RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + + boolean acquired; + try { + // waitTime=0 : 이미 남이 잡았으면 기다리지 않고 즉시 스킵 + // leaseTime=-1 : 워치독 활성화 (작업이 끝날 때까지 TTL 자동 갱신) + acquired = lock.tryLock(0, -1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("하드 삭제 락 획득 중 인터럽트 발생"); + return; + } + + if (!acquired) { + log.info("다른 인스턴스가 이미 이 작업을 실행 중입니다. 건너뜁니다."); + return; + } + + try { + List targets = userService.findExpiredWithdrawnUsers(); + + for (User user : targets) { + try { + userService.deleteUser(user.getUserId()); + } catch (Exception e) { + log.warn("탈퇴 계정 삭제 실패 - userId: {}, error: {}", user.getUserId(), e.getMessage()); + } + } + + log.info("탈퇴 계정 물리 삭제 완료 - 대상 {}건", targets.size()); + } finally { + // 내가 쥔 락일 때만 해제 (남의 락/이미 만료된 락은 건드리지 않음) + if (lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + // 확인~해제 사이 워치독 갱신 실패로 락이 만료돼 소유권을 잃은 경우 + log.warn("락 해제 실패 - 이미 소유권을 상실함 (TTL 만료 추정). lockKey={}", + RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + } + } + } + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java new file mode 100644 index 0000000..1b1cda2 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java @@ -0,0 +1,74 @@ +package com.nhnacademy.insightonauth.scheduler; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.redis.RedisKey; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserSleepConversionScheduler { + + private final UserService userService; + private final RedissonClient redissonClient; + + @Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시 + public void convertInactiveUsersToSleep() { + // 이중화된 여러 인스턴스가 동시에 이 배치를 실행하지 않도록 Redisson 분산 락 사용. + // 워치독이 작업 시간에 맞춰 락 TTL을 자동 갱신하므로, 배치가 오래 걸려도 + // 락이 만료돼 다른 인스턴스가 끼어드는 일이 없고, 소유권 확인 해제로 남의 락 삭제도 방지됨. + // 락 객체(핸들) 획득 — 실제 잠금은 tryLock에서 수행 + RLock lock = redissonClient.getLock(RedisKey.SLEEP_CONVERSION_SCHEDULER_LOCK.getPrefix()); + + boolean acquired; + try { + // waitTime=0 : 이미 다른 인스턴스가 잡았으면 대기 없이 즉시 실패(false) → 스킵 + // leaseTime=-1: 워치독 활성화. 고정 만료 대신, 인스턴스가 살아있는 동안 TTL을 자동 갱신 + acquired = lock.tryLock(0, -1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("휴면 전환 락 획득 중 인터럽트 발생"); + return; + } + + if (!acquired) { + log.info("다른 인스턴스가 이미 휴면 전환 작업을 실행 중입니다. 건너뜁니다."); + return; + } + + try { + List targets = userService.findInactiveUsers(); + + for (User user : targets) { + try { + userService.sleep(user.getUserId()); + } catch (Exception e) { + // 하나가 실패해도 나머지는 진행되게 + log.warn("휴면 전환 실패 - userId: {}, error: {}", user.getUserId(), e.getMessage()); + } + } + + log.info("휴면 전환 완료 - 대상 {}건", targets.size()); + } finally { + // 내가 쥔 락일 때만 해제 (혹시 만료로 소유권이 넘어갔으면 건드리지 않음 → 예외/남의 락 삭제 방지) + if (lock.isHeldByCurrentThread()) { + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + // 확인~해제 사이 워치독 갱신 실패로 락이 만료돼 소유권을 잃은 경우 + log.warn("락 해제 실패 - 이미 소유권을 상실함 (TTL 만료 추정). lockKey={}", + RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + } + } + } + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/service/UserService.java b/src/main/java/com/nhnacademy/insightonauth/service/UserService.java index c010b69..df64a93 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/UserService.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/UserService.java @@ -47,8 +47,6 @@ public interface UserService { String findMaskedEmail(String userName, String phoneNumber); - void updateLastLoginAt(Long userId); - void activate(Long userId); void withdraw(Long userId); @@ -62,4 +60,8 @@ public interface UserService { UserLoginResponse oauthLogin(String provider, String code); TokenRefreshResponse refresh(Long userId, String refreshToken); + + List findExpiredWithdrawnUsers(); + + List findInactiveUsers(); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java index 9d6031f..ac5ea1a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java @@ -1,6 +1,7 @@ package com.nhnacademy.insightonauth.service.impl; import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.client.OauthClientResolver; import com.nhnacademy.insightonauth.dto.mypage.MyInfoResponse; import com.nhnacademy.insightonauth.dto.mypage.RoleResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthResponse; @@ -28,10 +29,11 @@ public class MyPageServiceImpl implements MyPageService { private final UserCredentialService userCredentialService; private final UserRoleService userRoleService; private final OauthService oauthService; - private final OauthClient oauthClient; + private final OauthClientResolver oauthClientResolver; private final PasswordEncoder passwordEncoder; @Override + @Transactional(readOnly = true) public MyInfoResponse findMyInfo(Long userId) { User user = userService.findById(userId); @@ -52,6 +54,7 @@ public void updatePassword(Long userId, String currentPassword, String newPasswo } @Override + @Transactional(readOnly = true) public List findMyRoles(Long userId) { User user = userService.findById(userId); @@ -61,6 +64,7 @@ public List findMyRoles(Long userId) { } @Override + @Transactional(readOnly = true) public List findMyOauths(Long userId) { User user = userService.findById(userId); @@ -73,7 +77,8 @@ public List findMyOauths(Long userId) { public void linkOauth(Long userId, String provider, String code) { User user = userService.findById(userId); // 이미 로그인된 그 사람 - OauthUserInfo userInfo = oauthClient.getUserInfo(provider, code); // Google 검증 + OauthClient oauthClient = oauthClientResolver.resolve(provider); + OauthUserInfo userInfo = oauthClient.getUserInfo(code); // Google 검증 // 이 소셜 계정이 이미 다른 사람 것인지 확인 (중요!) oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()) diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java index 0fc9acc..0626830 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java @@ -55,6 +55,7 @@ public void deleteAllByUser(User user) { } @Override + @Transactional(readOnly = true) public Oauth findOauth(User user, String provider) { return oauthRepository.findByUserAndProvider(user, provider) .orElseThrow(() -> new OauthNotFoundException("연동된 소셜 계정을 찾을 수 없습니다.")); @@ -62,11 +63,13 @@ public Oauth findOauth(User user, String provider) { // 전체 삭제 @Override + @Transactional(readOnly = true) public List findAllByUser(User user) { return oauthRepository.findByUser(user); } @Override + @Transactional(readOnly = true) public Optional findByProviderAndProviderUserId(String provider, String providerUserId) { return oauthRepository.findByProviderAndProviderUserId(provider, providerUserId); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java index 978e0e0..0dc8306 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java @@ -28,6 +28,7 @@ public void create(User user, String password) { } @Override + @Transactional(readOnly = true) public UserCredential findByUser(User user) { UserCredential userCredential = userCredentialRepository.findByUser(user) .orElseThrow(() -> new UserCredentialsNotFoundException("유저 인증 정보가 없습니다.")); @@ -49,6 +50,7 @@ public void updatePassword(OffsetDateTime now, User user, String password) { } @Override + @Transactional(readOnly = true) public boolean exists(User user) { return userCredentialRepository.existsByUser(user); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java index 822a041..b9d4855 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java @@ -46,6 +46,7 @@ public void removeRole(User user, Role role) { } @Override + @Transactional(readOnly = true) public List findByUser(User user) { List userRoleList = userRoleRepository.findByUser(user); diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java index 903b8d4..3c0aa71 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java @@ -1,9 +1,12 @@ package com.nhnacademy.insightonauth.service.impl; +import com.nhnacademy.insightonauth.client.CoreClient; import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.client.OauthClientResolver; import com.nhnacademy.insightonauth.dto.auth.TokenRefreshResponse; import com.nhnacademy.insightonauth.dto.auth.UserLoginResponse; import com.nhnacademy.insightonauth.dto.auth.UserSignupResponse; +import com.nhnacademy.insightonauth.dto.core.ManagerGroupExistsResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; import com.nhnacademy.insightonauth.email.EmailService; import com.nhnacademy.insightonauth.entity.*; @@ -16,6 +19,7 @@ import com.nhnacademy.insightonauth.util.PhoneNumberUtil; import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,15 +27,19 @@ import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; +@Slf4j @Service @Transactional @RequiredArgsConstructor public class UserServiceImpl implements UserService { + // Transactional 전파 필요없으면 빼기 어노테이션 붙이기 + // private 메소드의 Transactional의 붙는 경우 proxy가 적용안 될수 있음 private final UserRepository userRepository; private final UserCredentialService userCredentialService; private final UserRoleService userRoleService; @@ -39,8 +47,9 @@ public class UserServiceImpl implements UserService { private final JwtProvider jwtProvider; private final RedisService redisService; private final EmailService emailService; - private final OauthClient oauthClient; + private final OauthClientResolver oauthClientResolver; private final OauthService oauthService; + private final CoreClient coreClient; @Override public UserSignupResponse createUser(String email, String password, String userName, String phoneNumber, Role role, String verificationToken) { @@ -99,6 +108,7 @@ public UserLoginResponse login(String email, String password) { } // 유저 계정 존재 여부 숨기기 + // 기존대로 UserNotFound로 하는건 어떤가 User user = userRepository.findByEmail(email) .orElseThrow(() -> new InvalidCredentialsException("유저를 찾을 수 없습니다.")); UserCredential credential = userCredentialService.findByUser(user); @@ -120,7 +130,7 @@ public UserLoginResponse login(String email, String password) { throw new InvalidUserException(user.getStatus().getMessage()); } - user.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + user.updateLastLoginAt(); return issueTokens(user, email); } @@ -213,6 +223,7 @@ public void updatePhoneNumber(Long userId, String phoneNumber) { user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); } + // 전화번호 찾기시 인증이 방법시 생각해보기 @Override public String findMaskedEmail(String userName, String phoneNumber) { String normalized = PhoneNumberUtil.normalize(phoneNumber); @@ -236,12 +247,6 @@ public String findMaskedEmail(String userName, String phoneNumber) { return visible + masked + domain; } - @Override - public void updateLastLoginAt(Long userId) { - User user = findById(userId); - user.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); - } - @Override public void activate(Long userId) { User user = findById(userId); @@ -254,6 +259,7 @@ public void activate(Long userId) { user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); } + //탈톼시 비밀번호 확인 @Override public void withdraw(Long userId) { User user = findById(userId); @@ -262,7 +268,21 @@ public void withdraw(Long userId) { throw new InvalidUserStatusException("이미 탈퇴한 계정입니다."); } + ManagerGroupExistsResponse response; + try { + response = coreClient.existsManagerGroup(userId); + } catch (Exception e) { + log.warn("Core 서비스 호출 실패로 탈퇴를 차단합니다 - userId: {}, 원인: {}", userId, e.getMessage()); + throw new CoreServiceUnavailableException( + "일시적으로 그룹 정보를 확인할 수 없어 탈퇴가 제한됩니다. 잠시 후 다시 시도해주세요."); + } + + if (response.exists()) { + throw new ManagerGroupExistsException("그룹 관리자 역할이 있어 탈퇴할 수 없습니다."); + } + user.withdraw(); + redisService.delete(RedisKey.REFRESH.getPrefix() + userId); } @Override @@ -306,7 +326,8 @@ public void deleteUser(Long userId) { @Override public UserLoginResponse oauthLogin(String provider, String code) { - OauthUserInfo userInfo = oauthClient.getUserInfo(provider, code); + OauthClient oauthClient = oauthClientResolver.resolve(provider); + OauthUserInfo userInfo = oauthClient.getUserInfo(code); Optional existingOauth = oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()); @@ -324,19 +345,29 @@ public UserLoginResponse oauthLogin(String provider, String code) { throw new InvalidUserException(user.getStatus().getMessage()); } + user.updateLastLoginAt(); return issueTokens(user, user.getEmail()); } + // 새 User 만들기 전에, 이 이메일이 이미 가입돼 있는지 확인 + if (userRepository.existsByEmail(userInfo.email())) { + // 이미 이 이메일로 가입된 계정이 있음 → 자동 생성/연결하지 않고 차단 + throw new EmailAlreadyRegisteredException( + "이미 가입된 이메일입니다. 로그인 후 마이페이지에서 소셜 계정을 연동해 주세요."); + } + User newUser = new User(userInfo.email(), userInfo.name(), null); userRepository.save(newUser); userRoleService.create(newUser, Role.MEMBER); oauthService.create(newUser, provider, userInfo.providerId()); + newUser.updateLastLoginAt(); return issueTokens(newUser, userInfo.email()); } @Override public TokenRefreshResponse refresh(Long userId, String refreshToken) { + // base64 왜쓰는지 try { jwtProvider.validateRefreshToken(userId, refreshToken); // Redis의 jti와 대조 검증 } catch (JwtException e) { @@ -357,6 +388,20 @@ public TokenRefreshResponse refresh(Long userId, String refreshToken) { return new TokenRefreshResponse(accessToken); } + @Override + @Transactional(readOnly = true) + public List findExpiredWithdrawnUsers() { + return userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + } + + @Override + @Transactional(readOnly = true) + public List findInactiveUsers() { + return userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + } + private User findActiveUser(Long userId) { User user = findById(userId); if (user.getStatus() != Status.ACTIVE) { diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 7163775..b00428e 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -74,8 +74,25 @@ mail.from-address=insighton@insighton.store mail.from-name=InsightOn # =============================== -# OAuth +# OAuth - Google # =============================== oauth.google.client-id=${GOOGLE_CLIENT_ID} oauth.google.client-secret=${GOOGLE_CLIENT_SECRET} -oauth.google.redirect-uri=https://insighton.store/oauth/callback \ No newline at end of file +oauth.redirect-uri=https://insighton.store/oauth/callback + +# =============================== +# OAuth - GitHub +# =============================== +oauth.github.client-id=${GITHUB_CLIENT_ID} +oauth.github.client-secret=${GITHUB_CLIENT_SECRET} + +# =============================== +# Resilience4j +# =============================== +spring.cloud.openfeign.circuitbreaker.enabled=true + +# Feign이 생성하는 CB 이름(CoreClient#메서드(파라미터))과 인스턴스 이름 매칭이 +# 까다로워, 이름 무관하게 적용되는 configs.default로 공통 정책 설정 +resilience4j.circuitbreaker.configs.default.sliding-window-size=10 +resilience4j.circuitbreaker.configs.default.failure-rate-threshold=50 +resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state=10s \ No newline at end of file diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java new file mode 100644 index 0000000..2c3f97d --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java @@ -0,0 +1,27 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class OauthTest { + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("oauth 생성자 생성 성공") + void createOauth() { + Oauth oauth = new Oauth(user, "google", "provider-user-id-123"); + + assertThat(oauth.getUser()).isEqualTo(user); + assertThat(oauth.getProvider()).isEqualTo("google"); + assertThat(oauth.getProviderUserId()).isEqualTo("provider-user-id-123"); + assertThat(oauth.getCreatedAt()).isNotNull(); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java new file mode 100644 index 0000000..e9d2282 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java @@ -0,0 +1,45 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class UserCredentialTest { + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("userCredential 생성자 생성 성공") + void createUserCredential() { + User user = new User("test@test.com", "test", "01012345678"); + UserCredential credential = new UserCredential(user, "hashed-password"); + + assertThat(credential.getUser()).isEqualTo(user); + assertThat(credential.getPasswordHash()).isEqualTo("hashed-password"); + assertThat(credential.getCreatedAt()).isNotNull(); + assertThat(credential.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("changePassword 비밀번호, 수정시각 변경") + void changePassword_updatesPasswordAndUpdatedAt() { + UserCredential credential = new UserCredential(user, "old-hashed-password"); + OffsetDateTime newTime = OffsetDateTime.now(ZoneOffset.UTC).plusDays(1); + + credential.changePassword(newTime, "new-hashed-password"); + + assertThat(credential.getPasswordHash()).isEqualTo("new-hashed-password"); + assertThat(credential.getUpdatedAt()).isEqualTo(newTime); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java new file mode 100644 index 0000000..cec8b11 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java @@ -0,0 +1,28 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class UserRoleTest { + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("userRole 생성자 생성 성공") + void createUserRole() { + UserRole userRole = new UserRole(user, Role.MEMBER); + + assertThat(userRole.getUser()).isEqualTo(user); + assertThat(userRole.getRole()).isEqualTo(Role.MEMBER); + assertThat(userRole.getCreatedAt()).isNotNull(); + } + +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java new file mode 100644 index 0000000..a7b143f --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java @@ -0,0 +1,93 @@ +package com.nhnacademy.insightonauth.entity; + +import com.nhnacademy.insightonauth.exception.InvalidUserStatusException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class UserTest { + + @Test + @DisplayName("user 생성자 생성 성공") + void createUser() { + User user = new User("test@test.com", "test", "01012345678"); + + assertThat(user.getEmail()).isEqualTo("test@test.com"); + assertThat(user.getUserName()).isEqualTo("test"); + assertThat(user.getPhoneNumber()).isEqualTo("01012345678"); + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + } + + @Test + @DisplayName("withdraw시 접미사 확인") + void withdraw_suffix() { + User user = new User("test@test.com", "test", "01012345678"); + + user.withdraw(); + + assertThat(user.getEmail()).startsWith("test@test.com;"); + assertThat(user.getPhoneNumber()).startsWith("01012345678;"); + assertThat(user.getStatus()).isEqualTo(Status.WITHDRAW); + assertThat(user.getWithdrawnAt()).isNotNull(); + } + + @Test + @DisplayName("withdraw후 reactivate시 원복") + void withdraw_then_reactivate_restoresEmail() { + User user = new User("test@test.com", "test", "01012345678"); + user.withdraw(); + + user.reactivate(); + + assertThat(user.getEmail()).isEqualTo("test@test.com"); + assertThat(user.getPhoneNumber()).isEqualTo("01012345678"); + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + assertThat(user.getWithdrawnAt()).isNull(); + } + + @Test + @DisplayName("ACTIVE 상태에서 reactivate 호출 시 예외 발생") + void reactivate_whenActive_throwsException() { + User user = new User("test@test.com", "test", "01012345678"); + + assertThatThrownBy(user::reactivate) + .isInstanceOf(InvalidUserStatusException.class) + .hasMessage("휴면 또는 탈퇴 상태가 아닙니다."); + } + + @Test + @DisplayName("전화번호가 null이어도 withdraw 정상 동작") + void withdraw_withNullPhoneNumber_worksCorrectly() { + User user = new User("test@test.com", "test", null); + user.withdraw(); + + assertThat(user.getPhoneNumber()).isNull(); + assertThat(user.getStatus()).isEqualTo(Status.WITHDRAW); + } + + @Test + @DisplayName("SLEEP 상태에서 reactivate 호출 시 ACTIVE로 전환") + void reactivate_whenSleep_becomesActive() { + User user = new User("test@test.com", "test", "01012345678"); + user.setStatus(Status.SLEEP); + + user.reactivate(); + + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + assertThat(user.getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("BLOCK 상태에서 reactivate 호출 시 예외 발생") + void reactivate_whenBlocked_throwsException() { + User user = new User("test@test.com", "test", "01012345678"); + user.setStatus(Status.BLOCK); + + assertThatThrownBy(user::reactivate) + .isInstanceOf(InvalidUserStatusException.class) + .hasMessage("휴면 또는 탈퇴 상태가 아닙니다."); + } + +} diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java new file mode 100644 index 0000000..7b69b7f --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java @@ -0,0 +1,104 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Oauth; +import com.nhnacademy.insightonauth.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + + +@DataJpaTest +class OauthRepositoryTest { + + @Autowired + private OauthRepository oauthRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + userRepository.save(user); + oauthRepository.save(new Oauth(user, "google", "google-provider-id-123")); + } + + @Test + @DisplayName("user, provider로 연동 정보 조회") + void findByUserAndProvider_returnsOauth() { + Optional found = oauthRepository.findByUserAndProvider(user, "google"); + + assertThat(found) + .isPresent() + .get() + .extracting(Oauth::getProviderUserId) + .isEqualTo("google-provider-id-123"); + } + + @Test + @DisplayName("연동 없는 provider 조회 시 빈 값 반환") + void findByUserAndProvider_whenNotExists_returnsEmpty() { + Optional found = oauthRepository.findByUserAndProvider(user, "github"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("provider, providerUserId로 연동 정보 조회") + void findByProviderAndProviderUserId_returnsOauth() { + Optional found = oauthRepository.findByProviderAndProviderUserId("google", "google-provider-id-123"); + + assertThat(found) + .isPresent() + .get() + .extracting(oauth -> oauth.getUser().getUserId()) + .isEqualTo(user.getUserId()); + } + + @Test + @DisplayName("없는 providerUserId 조회 시 빈 값 반환") + void findByProviderAndProviderUserId_whenNotExists_returnsEmpty() { + Optional found = oauthRepository.findByProviderAndProviderUserId("google", "not-exist-id"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("user로 연동 목록 조회") + void findByUser_returnsOauthList() { + List result = oauthRepository.findByUser(user); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getProvider()).isEqualTo("google"); + } + + @Test + @DisplayName("user로 연동 개수 조회") + void countByUser_returnsCount() { + oauthRepository.save(new Oauth(user, "github", "github-provider-id-456")); + + Long count = oauthRepository.countByUser(user); + + assertThat(count).isEqualTo(2L); + } + + @Test + @DisplayName("user로 연동 정보 전체 삭제") + void deleteByUser_removesAllOauths() { + oauthRepository.save(new Oauth(user, "github", "github-provider-id-456")); + + oauthRepository.deleteByUser(user); + + List result = oauthRepository.findByUser(user); + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java new file mode 100644 index 0000000..11e3820 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java @@ -0,0 +1,73 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.entity.UserCredential; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@DataJpaTest +class UserCredentialRepositoryTest { + @Autowired + private UserCredentialRepository userCredentialRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + userRepository.save(user); + userCredentialRepository.save(new UserCredential(user, "hashed-password")); + } + + @Test + @DisplayName("user로 존재 여부 확인 - true") + void existsByUser_returnsTrueWhenExists() { + boolean exists = userCredentialRepository.existsByUser(user); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("user로 존재 여부 확인 - false") + void existsByUser_returnsFalseWhenNotExists() { + User otherUser = new User("other@test.com", "other", "01099998888"); + userRepository.save(otherUser); + + boolean exists = userCredentialRepository.existsByUser(otherUser); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("user로 인증 정보 조회") + void findByUser_returnsUserCredential() { + Optional found = userCredentialRepository.findByUser(user); + + assertThat(found) + .isPresent() + .get() + .extracting(UserCredential::getPasswordHash) + .isEqualTo("hashed-password"); + } + + @Test + @DisplayName("인증 정보 없는 user 조회 시 빈 값 반환") + void findByUser_whenNotExists_returnsEmpty() { + User otherUser = new User("other@test.com", "other", "01099998888"); + userRepository.save(otherUser); + + Optional found = userCredentialRepository.findByUser(otherUser); + + assertThat(found).isEmpty(); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java new file mode 100644 index 0000000..ab92e16 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java @@ -0,0 +1,169 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Status; +import com.nhnacademy.insightonauth.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@DataJpaTest +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + private User activeUser; + + @BeforeEach + void setUp() { + activeUser = new User("test@test.com", "test", "01012345678"); + userRepository.save(activeUser); + } + + @Test + @DisplayName("email로 유저 조회") + void findByEmail_returnsUser() { + Optional found = userRepository.findByEmail("test@test.com"); + + assertThat(found).isPresent(); + assertThat(found.get().getUserName()).isEqualTo("test"); + } + + @Test + @DisplayName("없는 email 조회 시 빈 값 반환") + void findByEmail_whenNotExists_returnsEmpty() { + Optional found = userRepository.findByEmail("notfound@test.com"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("userName, phoneNumber로 유저 조회") + void findByUserNameAndPhoneNumber_returnsUser() { + Optional found = userRepository.findByUserNameAndPhoneNumber("test", "01012345678"); + + assertThat(found).isPresent(); + assertThat(found.get().getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("탈퇴 email 접두어, status로 유저 조회") + void findByEmailStartingWithAndStatus_returnsUser() { + activeUser.withdraw(); + userRepository.save(activeUser); + String originalEmailPrefix = "test@test.com;"; + + Optional found = userRepository.findByEmailStartingWithAndStatus(originalEmailPrefix, Status.WITHDRAW); + + assertThat(found).isPresent(); + } + + @Test + @DisplayName("email, userName, status로 페이징 조회") + void findByEmailContainingAndUserNameContainingAndStatus_returnsPage() { + Pageable pageable = PageRequest.of(0, 10); + + Page result = userRepository.findByEmailContainingAndUserNameContainingAndStatus( + "test", "test", Status.ACTIVE, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("email, userName으로 페이징 조회") + void findByEmailContainingAndUserNameContaining_returnsPage() { + Pageable pageable = PageRequest.of(0, 10); + + Page result = userRepository.findByEmailContainingAndUserNameContaining( + "test", "test", pageable); + + assertThat(result.getContent()).hasSize(1); + } + + @Test + @DisplayName("email 존재 여부 확인 - true") + void existsByEmail_returnsTrueWhenExists() { + boolean exists = userRepository.existsByEmail("test@test.com"); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("email 존재 여부 확인 - false") + void existsByEmail_returnsFalseWhenNotExists() { + boolean exists = userRepository.existsByEmail("notfound@test.com"); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("phoneNumber 존재 여부 확인") + void existsByPhoneNumber_returnsTrueWhenExists() { + boolean exists = userRepository.existsByPhoneNumber("01012345678"); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("탈퇴 90일 경과 유저 조회") + void findByStatusAndWithdrawnAtBefore_returnsExpiredWithdrawnUsers() { + activeUser.withdraw(); + activeUser.setWithdrawnAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(100)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("탈퇴 90일 미경과 유저 제외") + void findByStatusAndWithdrawnAtBefore_excludesRecentWithdrawnUsers() { + activeUser.withdraw(); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("30일 미접속 유저 조회") + void findByStatusAndLastLoginAtBefore_returnsInactiveUsers() { + activeUser.updateLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(40)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("최근 접속 유저 제외") + void findByStatusAndLastLoginAtBefore_excludesRecentlyActiveUsers() { + activeUser.updateLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + + assertThat(result).isEmpty(); + } +} + diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java new file mode 100644 index 0000000..c5ed64b --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java @@ -0,0 +1,76 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Role; +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.entity.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@DataJpaTest +class UserRoleRepositoryTest { + + @Autowired + private UserRoleRepository userRoleRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + userRepository.save(user); + userRoleRepository.save(new UserRole(user, Role.MEMBER)); + } + + @Test + @DisplayName("user로 권한 목록 조회") + void findByUser_returnsRoles() { + List result = userRoleRepository.findByUser(user); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getRole()).isEqualTo(Role.MEMBER); + } + + @Test + @DisplayName("user, role로 존재 여부 확인 - true") + void existsByUserAndRole_returnsTrueWhenExists() { + boolean exists = userRoleRepository.existsByUserAndRole(user, Role.MEMBER); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("user, role로 존재 여부 확인 - false") + void existsByUserAndRole_returnsFalseWhenNotExists() { + boolean exists = userRoleRepository.existsByUserAndRole(user, Role.ADMIN); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("user, role로 권한 조회") + void findByUserAndRole_returnsUserRole() { + Optional found = userRoleRepository.findByUserAndRole(user, Role.MEMBER); + + assertThat(found).isPresent(); + assertThat(found.get().getUser()).isEqualTo(user); + } + + @Test + @DisplayName("없는 role로 조회 시 빈 값 반환") + void findByUserAndRole_whenNotExists_returnsEmpty() { + Optional found = userRoleRepository.findByUserAndRole(user, Role.ADMIN); + + assertThat(found).isEmpty(); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 7631639..84e56da 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -31,7 +31,6 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect # =============================== spring.data.redis.host=localhost spring.data.redis.port=6379 -spring.data.redis.password= spring.data.redis.database=0 # =============================== @@ -72,7 +71,24 @@ mail.from-name=InsightOn # =============================== oauth.google.client-id=GOOGLE_CLIENT_ID_TEST oauth.google.client-secret=GOOGLE_CLIENT_SECRET_TEST -oauth.google.redirect-uri=https://insighton.store/oauth/callback # test의 경우 config가 필요 없음 -spring.cloud.config.enabled=false \ No newline at end of file +spring.cloud.config.enabled=false + +oauth.redirect-uri=https://insighton.store/oauth/callback + +# =============================== +# OAuth - GitHub +# =============================== +oauth.github.client-id=GITHUB_CLIENT_ID_TEST +oauth.github.client-secret=GITHUB_CLIENT_SECRET_TEST + + +# =============================== +# Resilience4j +# =============================== +spring.cloud.openfeign.circuitbreaker.enabled=true + +resilience4j.circuitbreaker.configs.default.sliding-window-size=10 +resilience4j.circuitbreaker.configs.default.failure-rate-threshold=50 +resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state=10s