From 326044f44b9e9f910ba7051a1533224008549dce Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 18 Feb 2026 16:48:06 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9B=90,=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 35 +++ .../java/flipnote/user/UserApplication.java | 6 + .../user/controller/UserController.java | 44 ---- .../domain/user/application/AuthService.java | 213 ++++++++++++++++++ .../domain/user/application/OAuthService.java | 118 ++++++++++ .../domain/user/application/UserService.java | 48 ++++ .../user/domain/user/domain/OAuthLink.java | 46 ++++ .../user/domain/OAuthLinkRepository.java | 27 +++ .../user/domain/PasswordResetConstants.java | 10 + .../user/domain/user/domain/User.java | 92 ++++++++ .../domain/user/domain/UserErrorCode.java | 37 +++ .../domain/user/domain/UserException.java | 15 ++ .../domain/user/domain/UserRepository.java | 19 ++ .../user/domain/VerificationConstants.java | 10 + .../event/EmailVerificationSendEvent.java | 7 + .../event/PasswordResetCreateEvent.java | 7 + .../user/grpc/GrpcUserQueryService.java | 72 ++++++ .../EmailVerificationRepository.java | 57 +++++ .../user/infrastructure/GoogleUserInfo.java | 32 +++ .../user/infrastructure/JwtProvider.java | 106 +++++++++ .../user/infrastructure/MailService.java | 8 + .../user/infrastructure/OAuth2UserInfo.java | 12 + .../user/infrastructure/OAuthApiClient.java | 95 ++++++++ .../PasswordResetRepository.java | 46 ++++ .../PasswordResetTokenGenerator.java | 13 ++ .../domain/user/infrastructure/PkceUtil.java | 33 +++ .../infrastructure/ResendMailService.java | 68 ++++++ .../SocialLinkTokenRepository.java | 31 +++ .../TokenBlacklistRepository.java | 29 +++ .../user/infrastructure/TokenClaims.java | 8 + .../domain/user/infrastructure/TokenPair.java | 4 + .../VerificationCodeGenerator.java | 16 ++ .../EmailVerificationEventListener.java | 38 ++++ .../listener/PasswordResetEventListener.java | 38 ++++ .../user/presentation/AuthController.java | 127 +++++++++++ .../user/presentation/OAuthController.java | 101 +++++++++ .../user/presentation/UserController.java | 48 ++++ .../dto/request/ChangePasswordRequest.java | 18 ++ .../dto/request/EmailVerificationRequest.java | 15 ++ .../dto/request/EmailVerifyRequest.java | 20 ++ .../dto/request}/LoginRequest.java | 2 +- .../request/PasswordResetCreateRequest.java | 15 ++ .../dto/request/PasswordResetRequest.java | 18 ++ .../dto/request}/SignupRequest.java | 13 +- .../dto/request/TokenValidateRequest.java | 13 ++ .../dto/request/UpdateProfileRequest.java | 23 ++ .../dto/response/MyInfoResponse.java | 43 ++++ .../dto/response/SocialLinkResponse.java | 23 ++ .../dto/response/SocialLinksResponse.java | 21 ++ .../dto/response/TokenValidateResponse.java | 13 ++ .../dto/response/UserInfoResponse.java | 19 ++ .../dto/response/UserResponse.java | 16 ++ .../dto/response/UserUpdateResponse.java | 28 +++ .../java/flipnote/user/dto/LoginResponse.java | 17 -- .../java/flipnote/user/dto/UserResponse.java | 28 --- src/main/java/flipnote/user/entity/User.java | 56 ----- .../exception/GlobalExceptionHandler.java | 32 --- .../user/exception/UserException.java | 27 --- .../config/AppConfig.java} | 10 +- .../user/global/config/ClientProperties.java | 25 ++ .../user/global/config/JpaAuditingConfig.java | 9 + .../user/global/config/JwtProperties.java | 15 ++ .../user/global/config/OAuthProperties.java | 29 +++ .../user/global/config/ResendConfig.java | 18 ++ .../user/global/config/ResendProperties.java | 20 ++ .../user/global/constants/HttpConstants.java | 12 + .../user/global/entity/BaseEntity.java | 24 ++ .../flipnote/user/global/error/ErrorCode.java | 10 + .../user/global/error/ErrorResponse.java | 20 ++ .../global/error/GlobalExceptionHandler.java | 49 ++++ .../global/exception/EmailSendException.java | 8 + .../flipnote/user/global/util/CookieUtil.java | 32 +++ .../user/repository/UserRepository.java | 13 -- .../flipnote/user/security/JwtProvider.java | 44 ---- .../flipnote/user/service/UserService.java | 59 ----- src/main/proto/user_query.proto | 31 +++ src/main/resources/application.yml | 40 +++- .../templates/email/email-verification.html | 42 ++++ .../templates/email/password-reset.html | 44 ++++ .../java/flipnote/user/TestRedisConfig.java | 21 ++ .../flipnote/user/UserApplicationTests.java | 2 + src/test/resources/application.yml | 36 ++- 82 files changed, 2462 insertions(+), 327 deletions(-) delete mode 100644 src/main/java/flipnote/user/controller/UserController.java create mode 100644 src/main/java/flipnote/user/domain/user/application/AuthService.java create mode 100644 src/main/java/flipnote/user/domain/user/application/OAuthService.java create mode 100644 src/main/java/flipnote/user/domain/user/application/UserService.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/OAuthLink.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/OAuthLinkRepository.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/PasswordResetConstants.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/User.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/UserException.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/UserRepository.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/VerificationConstants.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/event/EmailVerificationSendEvent.java create mode 100644 src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java create mode 100644 src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/EmailVerificationRepository.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/GoogleUserInfo.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/MailService.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/AuthController.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/OAuthController.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/UserController.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/request/ChangePasswordRequest.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerificationRequest.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerifyRequest.java rename src/main/java/flipnote/user/{dto => domain/user/presentation/dto/request}/LoginRequest.java (88%) create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetCreateRequest.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java rename src/main/java/flipnote/user/{dto => domain/user/presentation/dto/request}/SignupRequest.java (62%) create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/request/TokenValidateRequest.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/response/MyInfoResponse.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinkResponse.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinksResponse.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/response/TokenValidateResponse.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/response/UserResponse.java create mode 100644 src/main/java/flipnote/user/domain/user/presentation/dto/response/UserUpdateResponse.java delete mode 100644 src/main/java/flipnote/user/dto/LoginResponse.java delete mode 100644 src/main/java/flipnote/user/dto/UserResponse.java delete mode 100644 src/main/java/flipnote/user/entity/User.java delete mode 100644 src/main/java/flipnote/user/exception/GlobalExceptionHandler.java delete mode 100644 src/main/java/flipnote/user/exception/UserException.java rename src/main/java/flipnote/user/{config/SecurityConfig.java => global/config/AppConfig.java} (65%) create mode 100644 src/main/java/flipnote/user/global/config/ClientProperties.java create mode 100644 src/main/java/flipnote/user/global/config/JpaAuditingConfig.java create mode 100644 src/main/java/flipnote/user/global/config/JwtProperties.java create mode 100644 src/main/java/flipnote/user/global/config/OAuthProperties.java create mode 100644 src/main/java/flipnote/user/global/config/ResendConfig.java create mode 100644 src/main/java/flipnote/user/global/config/ResendProperties.java create mode 100644 src/main/java/flipnote/user/global/constants/HttpConstants.java create mode 100644 src/main/java/flipnote/user/global/entity/BaseEntity.java create mode 100644 src/main/java/flipnote/user/global/error/ErrorCode.java create mode 100644 src/main/java/flipnote/user/global/error/ErrorResponse.java create mode 100644 src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java create mode 100644 src/main/java/flipnote/user/global/exception/EmailSendException.java create mode 100644 src/main/java/flipnote/user/global/util/CookieUtil.java delete mode 100644 src/main/java/flipnote/user/repository/UserRepository.java delete mode 100644 src/main/java/flipnote/user/security/JwtProvider.java delete mode 100644 src/main/java/flipnote/user/service/UserService.java create mode 100644 src/main/proto/user_query.proto create mode 100644 src/main/resources/templates/email/email-verification.html create mode 100644 src/main/resources/templates/email/password-reset.html create mode 100644 src/test/java/flipnote/user/TestRedisConfig.java diff --git a/build.gradle.kts b/build.gradle.kts index d12a94d..4375fe8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { java id("org.springframework.boot") version "3.5.9" id("io.spring.dependency-management") version "1.1.7" + id("com.google.protobuf") version "0.9.4" } group = "flipnote" @@ -30,9 +31,25 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.security:spring-security-crypto") + implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("io.jsonwebtoken:jjwt-api:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + + // gRPC + implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE") + implementation("io.grpc:grpc-stub:1.63.0") + implementation("io.grpc:grpc-protobuf:1.63.0") + compileOnly("javax.annotation:javax.annotation-api:1.3.2") + + // Email + implementation("com.resend:resend-java:3.1.0") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + + // Async & Retry + implementation("org.springframework.retry:spring-retry") + implementation("org.springframework.boot:spring-boot-starter-aop") + compileOnly("org.projectlombok:lombok") runtimeOnly("com.mysql:mysql-connector-j") annotationProcessor("org.projectlombok:lombok") @@ -41,6 +58,24 @@ dependencies { testRuntimeOnly("com.h2database:h2") } +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.25.3" + } + plugins { + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:1.63.0" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("grpc") + } + } + } +} + tasks.withType { useJUnitPlatform() } diff --git a/src/main/java/flipnote/user/UserApplication.java b/src/main/java/flipnote/user/UserApplication.java index c752932..04ce926 100644 --- a/src/main/java/flipnote/user/UserApplication.java +++ b/src/main/java/flipnote/user/UserApplication.java @@ -2,8 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@ConfigurationPropertiesScan +@EnableAsync +@EnableRetry public class UserApplication { public static void main(String[] args) { diff --git a/src/main/java/flipnote/user/controller/UserController.java b/src/main/java/flipnote/user/controller/UserController.java deleted file mode 100644 index 49d75ac..0000000 --- a/src/main/java/flipnote/user/controller/UserController.java +++ /dev/null @@ -1,44 +0,0 @@ -package flipnote.user.controller; - -import flipnote.user.dto.LoginRequest; -import flipnote.user.dto.LoginResponse; -import flipnote.user.dto.SignupRequest; -import flipnote.user.dto.UserResponse; -import flipnote.user.service.UserService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/v1/users") -@RequiredArgsConstructor -public class UserController { - - private final UserService userService; - - @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { - UserResponse response = userService.signup(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LoginRequest request) { - LoginResponse response = userService.login(request); - return ResponseEntity.ok(response); - } - - @GetMapping("/me") - public ResponseEntity getMyProfile(@RequestHeader("X-User-Id") Long userId) { - UserResponse response = userService.getProfile(userId); - return ResponseEntity.ok(response); - } - - @GetMapping("/{id}") - public ResponseEntity getProfile(@PathVariable Long id) { - UserResponse response = userService.getProfile(id); - return ResponseEntity.ok(response); - } -} diff --git a/src/main/java/flipnote/user/domain/user/application/AuthService.java b/src/main/java/flipnote/user/domain/user/application/AuthService.java new file mode 100644 index 0000000..dbd42c4 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/application/AuthService.java @@ -0,0 +1,213 @@ +package flipnote.user.domain.user.application; + +import flipnote.user.domain.user.domain.*; +import flipnote.user.domain.user.domain.event.EmailVerificationSendEvent; +import flipnote.user.domain.user.domain.event.PasswordResetCreateEvent; +import flipnote.user.domain.user.infrastructure.*; +import flipnote.user.domain.user.presentation.dto.request.*; +import flipnote.user.domain.user.presentation.dto.response.SocialLinksResponse; +import flipnote.user.domain.user.presentation.dto.response.TokenValidateResponse; +import flipnote.user.domain.user.presentation.dto.response.UserResponse; +import flipnote.user.global.config.ClientProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZoneId; +import java.util.Date; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + private final TokenBlacklistRepository tokenBlacklistRepository; + private final EmailVerificationRepository emailVerificationRepository; + private final PasswordResetRepository passwordResetRepository; + private final OAuthLinkRepository oAuthLinkRepository; + private final VerificationCodeGenerator verificationCodeGenerator; + private final PasswordResetTokenGenerator passwordResetTokenGenerator; + private final ClientProperties clientProperties; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public UserResponse register(SignupRequest request) { + if (userRepository.existsByEmail(request.getEmail())) { + throw new UserException(UserErrorCode.EMAIL_ALREADY_EXISTS); + } + + User user = User.builder() + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .name(request.getName()) + .nickname(request.getNickname()) + .phone(request.getPhone()) + .smsAgree(Boolean.TRUE.equals(request.getSmsAgree())) + .build(); + + User savedUser = userRepository.save(user); + return UserResponse.from(savedUser); + } + + public TokenPair login(LoginRequest request) { + User user = userRepository.findByEmailAndStatus(request.getEmail(), User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.INVALID_CREDENTIALS)); + + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new UserException(UserErrorCode.INVALID_CREDENTIALS); + } + + return jwtProvider.generateTokenPair(user); + } + + public void logout(String refreshToken) { + if (refreshToken != null && jwtProvider.isTokenValid(refreshToken)) { + long remaining = jwtProvider.getRemainingExpiration(refreshToken); + if (remaining > 0) { + tokenBlacklistRepository.add(refreshToken, remaining); + } + } + } + + public TokenPair refreshToken(String refreshToken) { + if (refreshToken == null || !jwtProvider.isTokenValid(refreshToken)) { + throw new UserException(UserErrorCode.INVALID_TOKEN); + } + + if (tokenBlacklistRepository.isBlacklisted(refreshToken)) { + throw new UserException(UserErrorCode.BLACKLISTED_TOKEN); + } + + TokenClaims claims = jwtProvider.extractClaims(refreshToken); + User user = findActiveUser(claims.userId()); + + if (user.getInvalidatedAt() != null) { + Date issuedAt = jwtProvider.getIssuedAt(refreshToken); + if (issuedAt.before(Date.from(user.getInvalidatedAt() + .atZone(ZoneId.systemDefault()).toInstant()))) { + throw new UserException(UserErrorCode.INVALIDATED_SESSION); + } + } + + long remaining = jwtProvider.getRemainingExpiration(refreshToken); + if (remaining > 0) { + tokenBlacklistRepository.add(refreshToken, remaining); + } + + return jwtProvider.generateTokenPair(user); + } + + @Transactional + public void changePassword(Long userId, ChangePasswordRequest request) { + User user = findActiveUser(userId); + + if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) { + throw new UserException(UserErrorCode.PASSWORD_MISMATCH); + } + + user.changePassword(passwordEncoder.encode(request.getNewPassword())); + } + + public TokenValidateResponse validateToken(String token) { + if (!jwtProvider.isTokenValid(token)) { + throw new UserException(UserErrorCode.INVALID_TOKEN); + } + + if (tokenBlacklistRepository.isBlacklisted(token)) { + throw new UserException(UserErrorCode.BLACKLISTED_TOKEN); + } + + TokenClaims claims = jwtProvider.extractClaims(token); + User user = findActiveUser(claims.userId()); + + if (user.getInvalidatedAt() != null) { + Date issuedAt = jwtProvider.getIssuedAt(token); + if (issuedAt.before(Date.from(user.getInvalidatedAt() + .atZone(ZoneId.systemDefault()).toInstant()))) { + throw new UserException(UserErrorCode.INVALIDATED_SESSION); + } + } + + return new TokenValidateResponse(claims.userId(), claims.email(), claims.role()); + } + + public void sendEmailVerificationCode(String email) { + if (emailVerificationRepository.hasCode(email)) { + throw new UserException(UserErrorCode.ALREADY_ISSUED_VERIFICATION_CODE); + } + + String code = verificationCodeGenerator.generate(); + emailVerificationRepository.saveCode(email, code); + eventPublisher.publishEvent(new EmailVerificationSendEvent(email, code)); + } + + public void verifyEmail(String email, String code) { + if (!emailVerificationRepository.hasCode(email)) { + throw new UserException(UserErrorCode.NOT_ISSUED_VERIFICATION_CODE); + } + + String savedCode = emailVerificationRepository.getCode(email); + if (!code.equals(savedCode)) { + throw new UserException(UserErrorCode.INVALID_VERIFICATION_CODE); + } + + emailVerificationRepository.deleteCode(email); + emailVerificationRepository.markVerified(email); + } + + public void requestPasswordReset(String email) { + if (passwordResetRepository.hasToken(email)) { + throw new UserException(UserErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK); + } + + // 사용자가 없어도 정상 반환 (이메일 존재 여부 노출 방지) + if (!userRepository.existsByEmail(email)) { + return; + } + + String token = passwordResetTokenGenerator.generate(); + passwordResetRepository.save(token, email); + + String link = clientProperties.getUrl() + clientProperties.getPaths().getPasswordReset() + + "?token=" + token; + eventPublisher.publishEvent(new PasswordResetCreateEvent(email, link)); + } + + @Transactional + public void resetPassword(String token, String newPassword) { + String email = passwordResetRepository.findEmailByToken(token); + if (email == null) { + throw new UserException(UserErrorCode.INVALID_PASSWORD_RESET_TOKEN); + } + + User user = userRepository.findByEmailAndStatus(email, User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + user.changePassword(passwordEncoder.encode(newPassword)); + passwordResetRepository.delete(token, email); + } + + public SocialLinksResponse getSocialLinks(Long userId) { + List links = oAuthLinkRepository.findByUser_Id(userId); + return SocialLinksResponse.from(links); + } + + @Transactional + public void deleteSocialLink(Long userId, Long socialLinkId) { + if (!oAuthLinkRepository.existsByIdAndUser_Id(socialLinkId, userId)) { + throw new UserException(UserErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT); + } + oAuthLinkRepository.deleteById(socialLinkId); + } + + private User findActiveUser(Long userId) { + return userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/flipnote/user/domain/user/application/OAuthService.java b/src/main/java/flipnote/user/domain/user/application/OAuthService.java new file mode 100644 index 0000000..b4b5c16 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/application/OAuthService.java @@ -0,0 +1,118 @@ +package flipnote.user.domain.user.application; + +import flipnote.user.domain.user.domain.*; +import flipnote.user.domain.user.infrastructure.*; +import flipnote.user.global.config.OAuthProperties; +import flipnote.user.global.constants.HttpConstants; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OAuthService { + + private final PkceUtil pkceUtil; + private final OAuthApiClient oAuthApiClient; + private final OAuthLinkRepository oAuthLinkRepository; + private final UserRepository userRepository; + private final SocialLinkTokenRepository socialLinkTokenRepository; + private final JwtProvider jwtProvider; + private final OAuthProperties oAuthProperties; + + public record AuthorizationRedirect(String authorizeUri, ResponseCookie verifierCookie) {} + + private static final int VERIFIER_COOKIE_MAX_AGE = 180; + + public AuthorizationRedirect getAuthorizationUri(String providerName, HttpServletRequest request, + Long userId) { + OAuthProperties.Provider provider = resolveProvider(providerName); + + String codeVerifier = pkceUtil.generateCodeVerifier(); + String codeChallenge = pkceUtil.generateCodeChallenge(codeVerifier); + + String state = null; + if (userId != null) { + state = UUID.randomUUID().toString(); + socialLinkTokenRepository.save(userId, state); + } + + String authorizeUri = oAuthApiClient.buildAuthorizeUri(request, provider, codeChallenge, state); + + ResponseCookie verifierCookie = ResponseCookie.from(HttpConstants.OAUTH_VERIFIER_COOKIE, codeVerifier) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(VERIFIER_COOKIE_MAX_AGE) + .sameSite("Lax") + .build(); + + return new AuthorizationRedirect(authorizeUri, verifierCookie); + } + + public TokenPair socialLogin(String providerName, String code, String codeVerifier, + HttpServletRequest request) { + OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); + + OAuthLink oAuthLink = oAuthLinkRepository + .findByProviderAndProviderIdWithUser(userInfo.getProvider(), userInfo.getProviderId()) + .orElseThrow(() -> new UserException(UserErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT)); + + return jwtProvider.generateTokenPair(oAuthLink.getUser()); + } + + @Transactional + public void linkSocialAccount(String providerName, String code, String state, + String codeVerifier, HttpServletRequest request) { + Long userId = socialLinkTokenRepository.findUserIdByState(state) + .orElseThrow(() -> new UserException(UserErrorCode.INVALID_SOCIAL_LINK_TOKEN)); + + socialLinkTokenRepository.delete(state); + + OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); + + if (oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId( + userId, userInfo.getProvider(), userInfo.getProviderId())) { + throw new UserException(UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); + } + + User user = userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + OAuthLink link = OAuthLink.builder() + .provider(userInfo.getProvider()) + .providerId(userInfo.getProviderId()) + .user(user) + .build(); + oAuthLinkRepository.save(link); + } + + private OAuth2UserInfo getOAuth2UserInfo(String providerName, String code, + String codeVerifier, HttpServletRequest request) { + OAuthProperties.Provider provider = resolveProvider(providerName); + String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request); + Map attributes = oAuthApiClient.requestUserInfo(provider, accessToken); + return oAuthApiClient.createUserInfo(providerName, attributes); + } + + private OAuthProperties.Provider resolveProvider(String providerName) { + Map providers = oAuthProperties.getProviders(); + if (providers == null) { + throw new UserException(UserErrorCode.INVALID_OAUTH_PROVIDER); + } + OAuthProperties.Provider provider = providers.get(providerName.toLowerCase()); + if (provider == null) { + log.warn("지원하지 않는 OAuth Provider: {}", providerName); + throw new UserException(UserErrorCode.INVALID_OAUTH_PROVIDER); + } + return provider; + } +} diff --git a/src/main/java/flipnote/user/domain/user/application/UserService.java b/src/main/java/flipnote/user/domain/user/application/UserService.java new file mode 100644 index 0000000..a8f5f6b --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/application/UserService.java @@ -0,0 +1,48 @@ +package flipnote.user.domain.user.application; + +import flipnote.user.domain.user.domain.*; +import flipnote.user.domain.user.presentation.dto.request.UpdateProfileRequest; +import flipnote.user.domain.user.presentation.dto.response.MyInfoResponse; +import flipnote.user.domain.user.presentation.dto.response.UserInfoResponse; +import flipnote.user.domain.user.presentation.dto.response.UserUpdateResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + + public MyInfoResponse getMyInfo(Long userId) { + User user = findActiveUser(userId); + return MyInfoResponse.from(user); + } + + public UserInfoResponse getUserInfo(Long userId) { + User user = userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + return UserInfoResponse.from(user); + } + + @Transactional + public UserUpdateResponse updateProfile(Long userId, UpdateProfileRequest request) { + User user = findActiveUser(userId); + user.updateProfile(request.getNickname(), request.getPhone(), + Boolean.TRUE.equals(request.getSmsAgree()), null); + return UserUpdateResponse.from(user); + } + + @Transactional + public void withdraw(Long userId) { + User user = findActiveUser(userId); + user.withdraw(); + } + + private User findActiveUser(Long userId) { + return userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/flipnote/user/domain/user/domain/OAuthLink.java b/src/main/java/flipnote/user/domain/user/domain/OAuthLink.java new file mode 100644 index 0000000..c59ddc6 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/OAuthLink.java @@ -0,0 +1,46 @@ +package flipnote.user.domain.user.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "oauth_link", + indexes = { + @Index(name = "idx_oauth_provider_provider_id", columnList = "provider, providerId") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OAuthLink { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String provider; + + @Column(nullable = false) + private String providerId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(updatable = false) + private LocalDateTime linkedAt; + + @Builder + public OAuthLink(String provider, String providerId, User user) { + this.provider = provider; + this.providerId = providerId; + this.user = user; + this.linkedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/flipnote/user/domain/user/domain/OAuthLinkRepository.java b/src/main/java/flipnote/user/domain/user/domain/OAuthLinkRepository.java new file mode 100644 index 0000000..4655b9e --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/OAuthLinkRepository.java @@ -0,0 +1,27 @@ +package flipnote.user.domain.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface OAuthLinkRepository extends JpaRepository { + + @Query(""" + SELECT ol FROM OAuthLink ol + JOIN FETCH ol.user + WHERE ol.provider = :provider AND ol.providerId = :providerId + """) + Optional findByProviderAndProviderIdWithUser( + @Param("provider") String provider, + @Param("providerId") String providerId + ); + + boolean existsByUser_IdAndProviderAndProviderId(Long userId, String provider, String providerId); + + boolean existsByIdAndUser_Id(Long id, Long userId); + + List findByUser_Id(Long userId); +} diff --git a/src/main/java/flipnote/user/domain/user/domain/PasswordResetConstants.java b/src/main/java/flipnote/user/domain/user/domain/PasswordResetConstants.java new file mode 100644 index 0000000..ef5fa9c --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/PasswordResetConstants.java @@ -0,0 +1,10 @@ +package flipnote.user.domain.user.domain; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PasswordResetConstants { + + public static final int TOKEN_TTL_MINUTES = 30; +} diff --git a/src/main/java/flipnote/user/domain/user/domain/User.java b/src/main/java/flipnote/user/domain/user/domain/User.java new file mode 100644 index 0000000..c6aa320 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/User.java @@ -0,0 +1,92 @@ +package flipnote.user.domain.user.domain; + +import flipnote.user.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, length = 50) + private String nickname; + + private String profileImageUrl; + + @Column(unique = true, length = 20) + private String phone; + + private boolean smsAgree; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Role role; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Status status; + + private LocalDateTime invalidatedAt; + + private LocalDateTime deletedAt; + + @Builder + public User(String email, String password, String name, String nickname, String phone, boolean smsAgree, Role role) { + this.email = email; + this.password = password; + this.name = name; + this.nickname = nickname; + this.phone = phone; + this.smsAgree = smsAgree; + this.role = role != null ? role : Role.USER; + this.status = Status.ACTIVE; + } + + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + this.invalidatedAt = LocalDateTime.now(); + } + + public void updateProfile(String nickname, String phone, boolean smsAgree, String profileImageUrl) { + this.nickname = nickname; + this.phone = phone; + this.smsAgree = smsAgree; + if (profileImageUrl != null) { + this.profileImageUrl = profileImageUrl; + } + } + + public void withdraw() { + this.status = Status.WITHDRAWN; + this.invalidatedAt = LocalDateTime.now(); + this.deletedAt = LocalDateTime.now(); + } + + public enum Role { + USER, ADMIN + } + + public enum Status { + ACTIVE, WITHDRAWN + } +} diff --git a/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java b/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java new file mode 100644 index 0000000..ed5a874 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java @@ -0,0 +1,37 @@ +package flipnote.user.domain.user.domain; + +import flipnote.user.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."), + ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "이미 탈퇴한 사용자입니다."), + + ALREADY_ISSUED_VERIFICATION_CODE(HttpStatus.TOO_MANY_REQUESTS, "이미 인증코드가 발송되었습니다. 잠시 후 다시 시도해 주세요."), + NOT_ISSUED_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "인증코드가 발송되지 않았습니다."), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "인증코드가 올바르지 않습니다."), + UNVERIFIED_EMAIL(HttpStatus.FORBIDDEN, "이메일 인증이 완료되지 않았습니다."), + + ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.TOO_MANY_REQUESTS, "이미 비밀번호 재설정 링크가 발송되었습니다. 잠시 후 다시 시도해 주세요."), + INVALID_PASSWORD_RESET_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 비밀번호 재설정 토큰입니다."), + + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + BLACKLISTED_TOKEN(HttpStatus.UNAUTHORIZED, "무효화된 토큰입니다."), + INVALIDATED_SESSION(HttpStatus.UNAUTHORIZED, "세션이 무효화되었습니다. 다시 로그인해 주세요."), + + INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 OAuth 제공자입니다."), + NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "연결된 소셜 계정이 없습니다."), + ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "이미 연결된 소셜 계정입니다."), + INVALID_SOCIAL_LINK_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 소셜 연동 토큰입니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/flipnote/user/domain/user/domain/UserException.java b/src/main/java/flipnote/user/domain/user/domain/UserException.java new file mode 100644 index 0000000..0f37bd7 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/UserException.java @@ -0,0 +1,15 @@ +package flipnote.user.domain.user.domain; + +import flipnote.user.global.error.ErrorCode; +import lombok.Getter; + +@Getter +public class UserException extends RuntimeException { + + private final ErrorCode errorCode; + + public UserException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/flipnote/user/domain/user/domain/UserRepository.java b/src/main/java/flipnote/user/domain/user/domain/UserRepository.java new file mode 100644 index 0000000..1cc066d --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/UserRepository.java @@ -0,0 +1,19 @@ +package flipnote.user.domain.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + Optional findByIdAndStatus(Long id, User.Status status); + + Optional findByEmailAndStatus(String email, User.Status status); + + List findByIdInAndStatus(List ids, User.Status status); +} diff --git a/src/main/java/flipnote/user/domain/user/domain/VerificationConstants.java b/src/main/java/flipnote/user/domain/user/domain/VerificationConstants.java new file mode 100644 index 0000000..c262f4e --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/VerificationConstants.java @@ -0,0 +1,10 @@ +package flipnote.user.domain.user.domain; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class VerificationConstants { + + public static final int CODE_TTL_MINUTES = 5; +} diff --git a/src/main/java/flipnote/user/domain/user/domain/event/EmailVerificationSendEvent.java b/src/main/java/flipnote/user/domain/user/domain/event/EmailVerificationSendEvent.java new file mode 100644 index 0000000..ba339c5 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/event/EmailVerificationSendEvent.java @@ -0,0 +1,7 @@ +package flipnote.user.domain.user.domain.event; + +public record EmailVerificationSendEvent( + String to, + String code +) { +} diff --git a/src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java b/src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java new file mode 100644 index 0000000..8dec546 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java @@ -0,0 +1,7 @@ +package flipnote.user.domain.user.domain.event; + +public record PasswordResetCreateEvent( + String to, + String link +) { +} diff --git a/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java b/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java new file mode 100644 index 0000000..4e70ec1 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java @@ -0,0 +1,72 @@ +package flipnote.user.domain.user.grpc; + +import flipnote.user.domain.user.domain.User; +import flipnote.user.domain.user.domain.UserRepository; +import flipnote.user.grpc.GetUserRequest; +import flipnote.user.grpc.GetUserResponse; +import flipnote.user.grpc.GetUsersRequest; +import flipnote.user.grpc.GetUsersResponse; +import flipnote.user.grpc.UserQueryServiceGrpc; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.service.GrpcService; + +import java.util.List; + +@Slf4j +@GrpcService +@RequiredArgsConstructor +public class GrpcUserQueryService extends UserQueryServiceGrpc.UserQueryServiceImplBase { + + private final UserRepository userRepository; + + @Override + public void getUser(GetUserRequest request, StreamObserver responseObserver) { + try { + User user = userRepository.findByIdAndStatus(request.getUserId(), User.Status.ACTIVE) + .orElse(null); + + if (user == null) { + responseObserver.onError( + Status.NOT_FOUND.withDescription("사용자를 찾을 수 없습니다.").asRuntimeException() + ); + return; + } + + responseObserver.onNext(toResponse(user)); + responseObserver.onCompleted(); + } catch (Exception e) { + log.error("gRPC getUser error. userId: {}", request.getUserId(), e); + responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException()); + } + } + + @Override + public void getUsers(GetUsersRequest request, StreamObserver responseObserver) { + try { + List userIds = request.getUserIdsList(); + List users = userRepository.findByIdInAndStatus(userIds, User.Status.ACTIVE); + + GetUsersResponse response = GetUsersResponse.newBuilder() + .addAllUsers(users.stream().map(this::toResponse).toList()) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } catch (Exception e) { + log.error("gRPC getUsers error. userIds: {}", request.getUserIdsList(), e); + responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException()); + } + } + + private GetUserResponse toResponse(User user) { + return GetUserResponse.newBuilder() + .setId(user.getId()) + .setEmail(user.getEmail()) + .setNickname(user.getNickname()) + .setProfileImageUrl(user.getProfileImageUrl() != null ? user.getProfileImageUrl() : "") + .build(); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/EmailVerificationRepository.java b/src/main/java/flipnote/user/domain/user/infrastructure/EmailVerificationRepository.java new file mode 100644 index 0000000..39be219 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/EmailVerificationRepository.java @@ -0,0 +1,57 @@ +package flipnote.user.domain.user.infrastructure; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class EmailVerificationRepository { + + private static final String CODE_KEY_PREFIX = "email:verification:code:"; + private static final String VERIFIED_KEY_PREFIX = "email:verification:verified:"; + private static final long CODE_TTL_MINUTES = 5; + private static final long VERIFIED_TTL_MINUTES = 10; + + private final StringRedisTemplate redisTemplate; + + public void saveCode(String email, String code) { + redisTemplate.opsForValue().set( + CODE_KEY_PREFIX + email, + code, + CODE_TTL_MINUTES, + TimeUnit.MINUTES + ); + } + + public String getCode(String email) { + return redisTemplate.opsForValue().get(CODE_KEY_PREFIX + email); + } + + public boolean hasCode(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(CODE_KEY_PREFIX + email)); + } + + public void deleteCode(String email) { + redisTemplate.delete(CODE_KEY_PREFIX + email); + } + + public void markVerified(String email) { + redisTemplate.opsForValue().set( + VERIFIED_KEY_PREFIX + email, + "verified", + VERIFIED_TTL_MINUTES, + TimeUnit.MINUTES + ); + } + + public boolean isVerified(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(VERIFIED_KEY_PREFIX + email)); + } + + public void deleteVerified(String email) { + redisTemplate.delete(VERIFIED_KEY_PREFIX + email); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/GoogleUserInfo.java b/src/main/java/flipnote/user/domain/user/infrastructure/GoogleUserInfo.java new file mode 100644 index 0000000..0307a48 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/GoogleUserInfo.java @@ -0,0 +1,32 @@ +package flipnote.user.domain.user.infrastructure; + +import java.util.Map; + +public class GoogleUserInfo implements OAuth2UserInfo { + + private final Map attributes; + + public GoogleUserInfo(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProviderId() { + return String.valueOf(attributes.get("sub")); + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getEmail() { + return String.valueOf(attributes.get("email")); + } + + @Override + public String getName() { + return String.valueOf(attributes.get("name")); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java b/src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java new file mode 100644 index 0000000..03054f9 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java @@ -0,0 +1,106 @@ +package flipnote.user.domain.user.infrastructure; + +import flipnote.user.domain.user.domain.User; +import flipnote.user.global.config.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.UUID; + +@Component +public class JwtProvider { + + private final SecretKey secretKey; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + + public JwtProvider(JwtProperties jwtProperties) { + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiration = jwtProperties.getAccessTokenExpiration(); + this.refreshTokenExpiration = jwtProperties.getRefreshTokenExpiration(); + } + + public TokenPair generateTokenPair(User user) { + String accessToken = generateToken(user, accessTokenExpiration); + String refreshToken = generateToken(user, refreshTokenExpiration); + return new TokenPair(accessToken, refreshToken); + } + + private String generateToken(User user, long expiration) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + + return Jwts.builder() + .id(UUID.randomUUID().toString()) + .subject(user.getEmail()) + .claim("userId", user.getId()) + .claim("role", user.getRole().name()) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + public Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public TokenClaims extractClaims(String token) { + Claims claims = parseClaims(token); + return new TokenClaims( + claims.get("userId", Long.class), + claims.getSubject(), + claims.get("role", String.class) + ); + } + + public long getRemainingExpiration(String token) { + Claims claims = parseClaims(token); + Date expiration = claims.getExpiration(); + return expiration.getTime() - System.currentTimeMillis(); + } + + public Date getIssuedAt(String token) { + Claims claims = parseClaims(token); + return claims.getIssuedAt(); + } + + public boolean isTokenValid(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public boolean isTokenExpired(String token) { + try { + parseClaims(token); + return false; + } catch (ExpiredJwtException e) { + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public long getAccessTokenExpiration() { + return accessTokenExpiration; + } + + public long getRefreshTokenExpiration() { + return refreshTokenExpiration; + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/MailService.java b/src/main/java/flipnote/user/domain/user/infrastructure/MailService.java new file mode 100644 index 0000000..0420973 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/MailService.java @@ -0,0 +1,8 @@ +package flipnote.user.domain.user.infrastructure; + +public interface MailService { + + void sendVerificationCode(String to, String code, int ttl); + + void sendPasswordResetLink(String to, String link, int ttl); +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java b/src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java new file mode 100644 index 0000000..829d5fd --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java @@ -0,0 +1,12 @@ +package flipnote.user.domain.user.infrastructure; + +public interface OAuth2UserInfo { + + String getProviderId(); + + String getProvider(); + + String getEmail(); + + String getName(); +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java b/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java new file mode 100644 index 0000000..51677ba --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java @@ -0,0 +1,95 @@ +package flipnote.user.domain.user.infrastructure; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import flipnote.user.global.config.OAuthProperties; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class OAuthApiClient { + + private final RestClient restClient; + private final ObjectMapper objectMapper; + + public String requestAccessToken(OAuthProperties.Provider provider, String code, + String codeVerifier, HttpServletRequest request) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", provider.getClientId()); + params.add("client_secret", provider.getClientSecret()); + params.add("redirect_uri", buildRedirectUri(request, provider.getRedirectUri())); + params.add("code", code); + params.add("code_verifier", codeVerifier); + + try { + String responseBody = restClient.post() + .uri(provider.getTokenUri()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params) + .retrieve() + .body(String.class); + + Map responseMap = objectMapper.readValue(responseBody, new TypeReference<>() {}); + return (String) responseMap.get("access_token"); + } catch (Exception e) { + throw new RuntimeException("Failed to get OAuth access token", e); + } + } + + public Map requestUserInfo(OAuthProperties.Provider provider, String accessToken) { + try { + String responseBody = restClient.get() + .uri(provider.getUserInfoUri()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(String.class); + + return objectMapper.readValue(responseBody, new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException("Failed to get OAuth user info", e); + } + } + + public OAuth2UserInfo createUserInfo(String providerName, Map attributes) { + return switch (providerName.toLowerCase()) { + case "google" -> new GoogleUserInfo(attributes); + default -> throw new IllegalArgumentException("Unsupported OAuth provider: " + providerName); + }; + } + + public String buildAuthorizeUri(HttpServletRequest request, OAuthProperties.Provider provider, + String codeChallenge, String state) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(provider.getAuthorizationUri()) + .queryParam("client_id", provider.getClientId()) + .queryParam("redirect_uri", buildRedirectUri(request, provider.getRedirectUri())) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", provider.getScope())) + .queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", "S256"); + + if (state != null) { + builder.queryParam("state", state); + } + + return builder.toUriString(); + } + + private String buildRedirectUri(HttpServletRequest request, String path) { + return ServletUriComponentsBuilder.fromRequestUri(request) + .replacePath(path) + .build() + .toUriString(); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java b/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java new file mode 100644 index 0000000..a122e18 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java @@ -0,0 +1,46 @@ +package flipnote.user.domain.user.infrastructure; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class PasswordResetRepository { + + private static final String TOKEN_KEY_PREFIX = "password:reset:token:"; + private static final String EMAIL_KEY_PREFIX = "password:reset:email:"; + private static final long TTL_MINUTES = 30; + + private final StringRedisTemplate redisTemplate; + + public boolean hasToken(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(EMAIL_KEY_PREFIX + email)); + } + + public void save(String token, String email) { + redisTemplate.opsForValue().set( + TOKEN_KEY_PREFIX + token, + email, + TTL_MINUTES, + TimeUnit.MINUTES + ); + redisTemplate.opsForValue().set( + EMAIL_KEY_PREFIX + email, + token, + TTL_MINUTES, + TimeUnit.MINUTES + ); + } + + public String findEmailByToken(String token) { + return redisTemplate.opsForValue().get(TOKEN_KEY_PREFIX + token); + } + + public void delete(String token, String email) { + redisTemplate.delete(TOKEN_KEY_PREFIX + token); + redisTemplate.delete(EMAIL_KEY_PREFIX + email); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java b/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java new file mode 100644 index 0000000..0e1e24a --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java @@ -0,0 +1,13 @@ +package flipnote.user.domain.user.infrastructure; + +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class PasswordResetTokenGenerator { + + public String generate() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java b/src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java new file mode 100644 index 0000000..7ce310f --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java @@ -0,0 +1,33 @@ +package flipnote.user.domain.user.infrastructure; + +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +public class PkceUtil { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + public String generateCodeVerifier() { + byte[] codeVerifier = new byte[32]; + SECURE_RANDOM.nextBytes(codeVerifier); + return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier); + } + + public String generateCodeChallenge(String codeVerifier) { + try { + byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(bytes); + byte[] digest = md.digest(); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not supported", e); + } + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java b/src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java new file mode 100644 index 0000000..7d04bc4 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java @@ -0,0 +1,68 @@ +package flipnote.user.domain.user.infrastructure; + +import com.resend.Resend; +import com.resend.core.exception.ResendException; +import com.resend.services.emails.model.CreateEmailOptions; +import flipnote.user.global.config.ResendProperties; +import flipnote.user.global.exception.EmailSendException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ResendMailService implements MailService { + + private final ResendProperties resendProperties; + private final Resend resend; + private final SpringTemplateEngine templateEngine; + + @Override + public void sendVerificationCode(String to, String code, int ttl) { + Context context = new Context(); + context.setVariable("code", code); + context.setVariable("validMinutes", ttl); + + String html = templateEngine.process("email/email-verification", context); + + CreateEmailOptions params = CreateEmailOptions.builder() + .from(resendProperties.getFromEmail()) + .to(to) + .subject("이메일 인증번호 안내") + .html(html) + .build(); + + try { + resend.emails().send(params); + } catch (ResendException e) { + log.error("이메일 인증번호 발송 실패: to={}, ttl={}분", to, ttl, e); + throw new EmailSendException(e); + } + } + + @Override + public void sendPasswordResetLink(String to, String link, int ttl) { + Context context = new Context(); + context.setVariable("link", link); + context.setVariable("validMinutes", ttl); + + String html = templateEngine.process("email/password-reset", context); + + CreateEmailOptions params = CreateEmailOptions.builder() + .from(resendProperties.getFromEmail()) + .to(to) + .subject("비밀번호 재설정 안내") + .html(html) + .build(); + + try { + resend.emails().send(params); + } catch (ResendException e) { + log.error("비밀번호 재설정 링크 발송 실패: to={}, ttl={}분", to, ttl, e); + throw new EmailSendException(e); + } + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java b/src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java new file mode 100644 index 0000000..8840231 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java @@ -0,0 +1,31 @@ +package flipnote.user.domain.user.infrastructure; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class SocialLinkTokenRepository { + + private static final String KEY_PREFIX = "oauth:link:state:"; + private static final long TTL_MINUTES = 3; + + private final StringRedisTemplate redisTemplate; + + public void save(Long userId, String state) { + redisTemplate.opsForValue().set(KEY_PREFIX + state, userId.toString(), TTL_MINUTES, TimeUnit.MINUTES); + } + + public Optional findUserIdByState(String state) { + String value = redisTemplate.opsForValue().get(KEY_PREFIX + state); + return Optional.ofNullable(value).map(Long::parseLong); + } + + public void delete(String state) { + redisTemplate.delete(KEY_PREFIX + state); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java b/src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java new file mode 100644 index 0000000..bac7642 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java @@ -0,0 +1,29 @@ +package flipnote.user.domain.user.infrastructure; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class TokenBlacklistRepository { + + private static final String KEY_PREFIX = "token:blacklist:"; + + private final StringRedisTemplate redisTemplate; + + public void add(String token, long expirationMillis) { + redisTemplate.opsForValue().set( + KEY_PREFIX + token, + "blacklisted", + expirationMillis, + TimeUnit.MILLISECONDS + ); + } + + public boolean isBlacklisted(String token) { + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + token)); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java b/src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java new file mode 100644 index 0000000..f7def37 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java @@ -0,0 +1,8 @@ +package flipnote.user.domain.user.infrastructure; + +public record TokenClaims( + Long userId, + String email, + String role +) { +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java b/src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java new file mode 100644 index 0000000..dcb5756 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java @@ -0,0 +1,4 @@ +package flipnote.user.domain.user.infrastructure; + +public record TokenPair(String accessToken, String refreshToken) { +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java b/src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java new file mode 100644 index 0000000..907b0d6 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java @@ -0,0 +1,16 @@ +package flipnote.user.domain.user.infrastructure; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +@Component +public class VerificationCodeGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + + public String generate() { + int code = RANDOM.nextInt(1_000_000); + return String.format("%06d", code); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java b/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java new file mode 100644 index 0000000..39c4c04 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java @@ -0,0 +1,38 @@ +package flipnote.user.domain.user.infrastructure.listener; + +import flipnote.user.domain.user.domain.VerificationConstants; +import flipnote.user.domain.user.domain.event.EmailVerificationSendEvent; +import flipnote.user.domain.user.infrastructure.MailService; +import flipnote.user.global.exception.EmailSendException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailVerificationEventListener { + + private final MailService mailService; + + @Async + @Retryable( + maxAttempts = 3, + retryFor = {EmailSendException.class}, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @EventListener + public void handle(EmailVerificationSendEvent event) { + mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES); + } + + @Recover + public void recover(EmailSendException ex, EmailVerificationSendEvent event) { + log.error("이메일 인증번호 전송 실패: to={}", event.to(), ex); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java b/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java new file mode 100644 index 0000000..0257516 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java @@ -0,0 +1,38 @@ +package flipnote.user.domain.user.infrastructure.listener; + +import flipnote.user.domain.user.domain.PasswordResetConstants; +import flipnote.user.domain.user.domain.event.PasswordResetCreateEvent; +import flipnote.user.domain.user.infrastructure.MailService; +import flipnote.user.global.exception.EmailSendException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PasswordResetEventListener { + + private final MailService mailService; + + @Async + @Retryable( + maxAttempts = 3, + retryFor = {EmailSendException.class}, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @EventListener + public void handle(PasswordResetCreateEvent event) { + mailService.sendPasswordResetLink(event.to(), event.link(), PasswordResetConstants.TOKEN_TTL_MINUTES); + } + + @Recover + public void recover(EmailSendException ex, PasswordResetCreateEvent event) { + log.error("비밀번호 재설정 링크 전송 실패: to={}", event.to(), ex); + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/AuthController.java b/src/main/java/flipnote/user/domain/user/presentation/AuthController.java new file mode 100644 index 0000000..d37393f --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/AuthController.java @@ -0,0 +1,127 @@ +package flipnote.user.domain.user.presentation; + +import flipnote.user.domain.user.application.AuthService; +import flipnote.user.domain.user.infrastructure.JwtProvider; +import flipnote.user.domain.user.infrastructure.TokenPair; +import flipnote.user.domain.user.presentation.dto.request.*; +import flipnote.user.domain.user.presentation.dto.response.SocialLinksResponse; +import flipnote.user.domain.user.presentation.dto.response.TokenValidateResponse; +import flipnote.user.domain.user.presentation.dto.response.UserResponse; +import flipnote.user.global.constants.HttpConstants; +import flipnote.user.global.util.CookieUtil; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final JwtProvider jwtProvider; + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody SignupRequest request) { + UserResponse response = authService.register(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request, + HttpServletResponse response) { + TokenPair tokenPair = authService.login(request); + setTokenCookies(response, tokenPair); + return ResponseEntity.ok().build(); + } + + @PostMapping("/logout") + public ResponseEntity logout( + @CookieValue(name = HttpConstants.REFRESH_TOKEN_COOKIE, required = false) String refreshToken, + HttpServletResponse response) { + authService.logout(refreshToken); + CookieUtil.deleteCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE); + CookieUtil.deleteCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE); + return ResponseEntity.ok().build(); + } + + @PostMapping("/token/refresh") + public ResponseEntity refreshToken( + @CookieValue(name = HttpConstants.REFRESH_TOKEN_COOKIE) String refreshToken, + HttpServletResponse response) { + TokenPair tokenPair = authService.refreshToken(refreshToken); + setTokenCookies(response, tokenPair); + return ResponseEntity.ok().build(); + } + + @PostMapping("/token/validate") + public ResponseEntity validateToken( + @Valid @RequestBody TokenValidateRequest request) { + TokenValidateResponse result = authService.validateToken(request.getToken()); + return ResponseEntity.ok(result); + } + + @PatchMapping("/password") + public ResponseEntity changePassword( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId, + @Valid @RequestBody ChangePasswordRequest request, + HttpServletResponse response) { + authService.changePassword(userId, request); + CookieUtil.deleteCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE); + CookieUtil.deleteCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/email-verification/request") + public ResponseEntity sendEmailVerification( + @Valid @RequestBody EmailVerificationRequest request) { + authService.sendEmailVerificationCode(request.getEmail()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/email-verification") + public ResponseEntity verifyEmail( + @Valid @RequestBody EmailVerifyRequest request) { + authService.verifyEmail(request.getEmail(), request.getCode()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/password-reset/request") + public ResponseEntity requestPasswordReset( + @Valid @RequestBody PasswordResetCreateRequest request) { + authService.requestPasswordReset(request.getEmail()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/password-reset") + public ResponseEntity resetPassword( + @Valid @RequestBody PasswordResetRequest request) { + authService.resetPassword(request.getToken(), request.getPassword()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/social-links") + public ResponseEntity getSocialLinks( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId) { + SocialLinksResponse response = authService.getSocialLinks(userId); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/social-links/{socialLinkId}") + public ResponseEntity deleteSocialLink( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId, + @PathVariable Long socialLinkId) { + authService.deleteSocialLink(userId, socialLinkId); + return ResponseEntity.noContent().build(); + } + + private void setTokenCookies(HttpServletResponse response, TokenPair tokenPair) { + CookieUtil.addCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE, tokenPair.accessToken(), + jwtProvider.getAccessTokenExpiration() / 1000); + CookieUtil.addCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE, tokenPair.refreshToken(), + jwtProvider.getRefreshTokenExpiration() / 1000); + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java b/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java new file mode 100644 index 0000000..37d0251 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java @@ -0,0 +1,101 @@ +package flipnote.user.domain.user.presentation; + +import flipnote.user.domain.user.application.OAuthService; +import flipnote.user.domain.user.domain.UserErrorCode; +import flipnote.user.domain.user.domain.UserException; +import flipnote.user.domain.user.infrastructure.TokenPair; +import flipnote.user.global.config.ClientProperties; +import flipnote.user.global.constants.HttpConstants; +import flipnote.user.global.util.CookieUtil; +import flipnote.user.domain.user.infrastructure.JwtProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class OAuthController { + + private final OAuthService oAuthService; + private final JwtProvider jwtProvider; + private final ClientProperties clientProperties; + + @GetMapping("/oauth2/authorization/{provider}") + public ResponseEntity redirectToProvider( + @PathVariable String provider, + @RequestHeader(value = HttpConstants.USER_ID_HEADER, required = false) Long userId, + HttpServletRequest request) { + OAuthService.AuthorizationRedirect redirect = oAuthService.getAuthorizationUri(provider, request, userId); + + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.SET_COOKIE, redirect.verifierCookie().toString()) + .location(URI.create(redirect.authorizeUri())) + .build(); + } + + @GetMapping("/oauth2/callback/{provider}") + public ResponseEntity handleCallback( + @PathVariable String provider, + @RequestParam String code, + @RequestParam(required = false) String state, + @CookieValue(HttpConstants.OAUTH_VERIFIER_COOKIE) String codeVerifier, + HttpServletRequest request, + HttpServletResponse response) { + + CookieUtil.deleteCookie(response, HttpConstants.OAUTH_VERIFIER_COOKIE); + + boolean isSocialLinkRequest = StringUtils.hasText(state); + if (isSocialLinkRequest) { + return handleSocialLink(provider, code, state, codeVerifier, request); + } + return handleSocialLogin(provider, code, codeVerifier, request, response); + } + + private ResponseEntity handleSocialLogin(String provider, String code, String codeVerifier, + HttpServletRequest request, HttpServletResponse response) { + try { + TokenPair tokenPair = oAuthService.socialLogin(provider, code, codeVerifier, request); + CookieUtil.addCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE, tokenPair.accessToken(), + jwtProvider.getAccessTokenExpiration() / 1000); + CookieUtil.addCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE, tokenPair.refreshToken(), + jwtProvider.getRefreshTokenExpiration() / 1000); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLoginSuccess())) + .build(); + } catch (Exception e) { + log.warn("소셜 로그인 처리 실패. provider: {}", provider, e); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLoginFailure())) + .build(); + } + } + + private ResponseEntity handleSocialLink(String provider, String code, String state, + String codeVerifier, HttpServletRequest request) { + try { + oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkSuccess())) + .build(); + } catch (UserException e) { + log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e); + if (e.getErrorCode() == UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkConflict())) + .build(); + } + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkFailure())) + .build(); + } + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/UserController.java b/src/main/java/flipnote/user/domain/user/presentation/UserController.java new file mode 100644 index 0000000..f1329a2 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/UserController.java @@ -0,0 +1,48 @@ +package flipnote.user.domain.user.presentation; + +import flipnote.user.domain.user.application.UserService; +import flipnote.user.domain.user.presentation.dto.request.UpdateProfileRequest; +import flipnote.user.domain.user.presentation.dto.response.MyInfoResponse; +import flipnote.user.domain.user.presentation.dto.response.UserInfoResponse; +import flipnote.user.domain.user.presentation.dto.response.UserUpdateResponse; +import flipnote.user.global.constants.HttpConstants; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping("/me") + public ResponseEntity getMyInfo( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId) { + MyInfoResponse response = userService.getMyInfo(userId); + return ResponseEntity.ok(response); + } + + @GetMapping("/{userId}") + public ResponseEntity getUserInfo(@PathVariable Long userId) { + UserInfoResponse response = userService.getUserInfo(userId); + return ResponseEntity.ok(response); + } + + @PutMapping + public ResponseEntity updateProfile( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId, + @Valid @RequestBody UpdateProfileRequest request) { + UserUpdateResponse response = userService.updateProfile(userId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping + public ResponseEntity withdraw( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId) { + userService.withdraw(userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/ChangePasswordRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..fc56b16 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/ChangePasswordRequest.java @@ -0,0 +1,18 @@ +package flipnote.user.domain.user.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChangePasswordRequest { + + @NotBlank(message = "현재 비밀번호는 필수입니다") + private String currentPassword; + + @NotBlank(message = "새 비밀번호는 필수입니다") + @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다") + private String newPassword; +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerificationRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerificationRequest.java new file mode 100644 index 0000000..4b83ba9 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerificationRequest.java @@ -0,0 +1,15 @@ +package flipnote.user.domain.user.presentation.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class EmailVerificationRequest { + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerifyRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerifyRequest.java new file mode 100644 index 0000000..aca6ad9 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerifyRequest.java @@ -0,0 +1,20 @@ +package flipnote.user.domain.user.presentation.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class EmailVerifyRequest { + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; + + @NotBlank(message = "인증코드는 필수입니다") + @Size(min = 6, max = 6, message = "인증코드는 6자리여야 합니다") + private String code; +} diff --git a/src/main/java/flipnote/user/dto/LoginRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/LoginRequest.java similarity index 88% rename from src/main/java/flipnote/user/dto/LoginRequest.java rename to src/main/java/flipnote/user/domain/user/presentation/dto/request/LoginRequest.java index efe0fa1..aebdf47 100644 --- a/src/main/java/flipnote/user/dto/LoginRequest.java +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/LoginRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.dto; +package flipnote.user.domain.user.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetCreateRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetCreateRequest.java new file mode 100644 index 0000000..1475c29 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetCreateRequest.java @@ -0,0 +1,15 @@ +package flipnote.user.domain.user.presentation.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PasswordResetCreateRequest { + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java new file mode 100644 index 0000000..0f4c41f --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java @@ -0,0 +1,18 @@ +package flipnote.user.domain.user.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PasswordResetRequest { + + @NotBlank(message = "토큰은 필수입니다") + private String token; + + @NotBlank(message = "새 비밀번호는 필수입니다") + @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다") + private String password; +} diff --git a/src/main/java/flipnote/user/dto/SignupRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/SignupRequest.java similarity index 62% rename from src/main/java/flipnote/user/dto/SignupRequest.java rename to src/main/java/flipnote/user/domain/user/presentation/dto/request/SignupRequest.java index 339e084..029dbe4 100644 --- a/src/main/java/flipnote/user/dto/SignupRequest.java +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/SignupRequest.java @@ -1,7 +1,9 @@ -package flipnote.user.dto; +package flipnote.user.domain.user.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,7 +20,16 @@ public class SignupRequest { @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다") private String password; + @NotBlank(message = "이름은 필수입니다") + private String name; + @NotBlank(message = "닉네임은 필수입니다") @Size(min = 2, max = 50, message = "닉네임은 2자 이상 50자 이하여야 합니다") private String nickname; + + @NotNull(message = "SMS 수신 동의 여부는 필수입니다") + private Boolean smsAgree; + + @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 전화번호 형식이 아닙니다") + private String phone; } diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/TokenValidateRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/TokenValidateRequest.java new file mode 100644 index 0000000..95f7956 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/TokenValidateRequest.java @@ -0,0 +1,13 @@ +package flipnote.user.domain.user.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TokenValidateRequest { + + @NotBlank(message = "토큰은 필수입니다") + private String token; +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java b/src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java new file mode 100644 index 0000000..46933ed --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java @@ -0,0 +1,23 @@ +package flipnote.user.domain.user.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UpdateProfileRequest { + + @NotBlank(message = "닉네임은 필수입니다") + private String nickname; + + @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 전화번호 형식이 아닙니다") + private String phone; + + @NotNull(message = "SMS 수신 동의 여부는 필수입니다") + private Boolean smsAgree; + + private Long imageRefId; +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/MyInfoResponse.java b/src/main/java/flipnote/user/domain/user/presentation/dto/response/MyInfoResponse.java new file mode 100644 index 0000000..788f101 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/response/MyInfoResponse.java @@ -0,0 +1,43 @@ +package flipnote.user.domain.user.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import flipnote.user.domain.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class MyInfoResponse { + + private Long userId; + private String email; + private String nickname; + private String name; + private String phone; + private Boolean smsAgree; + private String profileImageUrl; + private Long imageRefId; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime modifiedAt; + + public static MyInfoResponse from(User user) { + return new MyInfoResponse( + user.getId(), + user.getEmail(), + user.getNickname(), + user.getName(), + user.getPhone(), + user.isSmsAgree(), + user.getProfileImageUrl(), + null, + user.getCreatedAt(), + user.getModifiedAt() + ); + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinkResponse.java b/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinkResponse.java new file mode 100644 index 0000000..6038722 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinkResponse.java @@ -0,0 +1,23 @@ +package flipnote.user.domain.user.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import flipnote.user.domain.user.domain.OAuthLink; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class SocialLinkResponse { + + private Long socialLinkId; + private String provider; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime linkedAt; + + public static SocialLinkResponse from(OAuthLink link) { + return new SocialLinkResponse(link.getId(), link.getProvider(), link.getLinkedAt()); + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinksResponse.java b/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinksResponse.java new file mode 100644 index 0000000..0edba13 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinksResponse.java @@ -0,0 +1,21 @@ +package flipnote.user.domain.user.presentation.dto.response; + +import flipnote.user.domain.user.domain.OAuthLink; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class SocialLinksResponse { + + private List socialLinks; + + public static SocialLinksResponse from(List links) { + List socialLinks = links.stream() + .map(SocialLinkResponse::from) + .toList(); + return new SocialLinksResponse(socialLinks); + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/TokenValidateResponse.java b/src/main/java/flipnote/user/domain/user/presentation/dto/response/TokenValidateResponse.java new file mode 100644 index 0000000..b7f846d --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/response/TokenValidateResponse.java @@ -0,0 +1,13 @@ +package flipnote.user.domain.user.presentation.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TokenValidateResponse { + + private Long userId; + private String email; + private String role; +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java b/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java new file mode 100644 index 0000000..775895e --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java @@ -0,0 +1,19 @@ +package flipnote.user.domain.user.presentation.dto.response; + +import flipnote.user.domain.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserInfoResponse { + + private Long userId; + private String nickname; + private String profileImageUrl; + private Long imageRefId; + + public static UserInfoResponse from(User user) { + return new UserInfoResponse(user.getId(), user.getNickname(), user.getProfileImageUrl(), null); + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserResponse.java b/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserResponse.java new file mode 100644 index 0000000..90386d9 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserResponse.java @@ -0,0 +1,16 @@ +package flipnote.user.domain.user.presentation.dto.response; + +import flipnote.user.domain.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserResponse { + + private Long userId; + + public static UserResponse from(User user) { + return new UserResponse(user.getId()); + } +} diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserUpdateResponse.java b/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserUpdateResponse.java new file mode 100644 index 0000000..bb94c70 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserUpdateResponse.java @@ -0,0 +1,28 @@ +package flipnote.user.domain.user.presentation.dto.response; + +import flipnote.user.domain.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserUpdateResponse { + + private Long userId; + private String nickname; + private String phone; + private Boolean smsAgree; + private String profileImageUrl; + private Long imageRefId; + + public static UserUpdateResponse from(User user) { + return new UserUpdateResponse( + user.getId(), + user.getNickname(), + user.getPhone(), + user.isSmsAgree(), + user.getProfileImageUrl(), + null + ); + } +} diff --git a/src/main/java/flipnote/user/dto/LoginResponse.java b/src/main/java/flipnote/user/dto/LoginResponse.java deleted file mode 100644 index ac68320..0000000 --- a/src/main/java/flipnote/user/dto/LoginResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package flipnote.user.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class LoginResponse { - - private String accessToken; - private String tokenType; - private Long expiresIn; - - public static LoginResponse of(String accessToken, Long expiresIn) { - return new LoginResponse(accessToken, "Bearer", expiresIn); - } -} diff --git a/src/main/java/flipnote/user/dto/UserResponse.java b/src/main/java/flipnote/user/dto/UserResponse.java deleted file mode 100644 index 30ba839..0000000 --- a/src/main/java/flipnote/user/dto/UserResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package flipnote.user.dto; - -import flipnote.user.entity.User; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -@AllArgsConstructor -public class UserResponse { - - private Long id; - private String email; - private String nickname; - private String role; - private LocalDateTime createdAt; - - public static UserResponse from(User user) { - return new UserResponse( - user.getId(), - user.getEmail(), - user.getNickname(), - user.getRole().name(), - user.getCreatedAt() - ); - } -} diff --git a/src/main/java/flipnote/user/entity/User.java b/src/main/java/flipnote/user/entity/User.java deleted file mode 100644 index deafeb0..0000000 --- a/src/main/java/flipnote/user/entity/User.java +++ /dev/null @@ -1,56 +0,0 @@ -package flipnote.user.entity; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "users") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 100) - private String email; - - @Column(nullable = false) - private String password; - - @Column(nullable = false, length = 50) - private String nickname; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private Role role; - - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; - - @Builder - public User(String email, String password, String nickname, Role role) { - this.email = email; - this.password = password; - this.nickname = nickname; - this.role = role != null ? role : Role.USER; - this.createdAt = LocalDateTime.now(); - } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); - } - - public enum Role { - USER, ADMIN - } -} diff --git a/src/main/java/flipnote/user/exception/GlobalExceptionHandler.java b/src/main/java/flipnote/user/exception/GlobalExceptionHandler.java deleted file mode 100644 index 3a75e8b..0000000 --- a/src/main/java/flipnote/user/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,32 +0,0 @@ -package flipnote.user.exception; - -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; - -import java.util.HashMap; -import java.util.Map; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(UserException.class) - public ResponseEntity> handleUserException(UserException e) { - Map error = new HashMap<>(); - error.put("message", e.getMessage()); - return ResponseEntity.status(e.getStatus()).body(error); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { - Map errors = new HashMap<>(); - e.getBindingResult().getAllErrors().forEach(error -> { - String fieldName = ((FieldError) error).getField(); - String message = error.getDefaultMessage(); - errors.put(fieldName, message); - }); - return ResponseEntity.badRequest().body(errors); - } -} diff --git a/src/main/java/flipnote/user/exception/UserException.java b/src/main/java/flipnote/user/exception/UserException.java deleted file mode 100644 index e9da6a8..0000000 --- a/src/main/java/flipnote/user/exception/UserException.java +++ /dev/null @@ -1,27 +0,0 @@ -package flipnote.user.exception; - -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public class UserException extends RuntimeException { - - private final HttpStatus status; - - public UserException(String message, HttpStatus status) { - super(message); - this.status = status; - } - - public static UserException emailAlreadyExists() { - return new UserException("이미 사용 중인 이메일입니다", HttpStatus.CONFLICT); - } - - public static UserException invalidCredentials() { - return new UserException("이메일 또는 비밀번호가 올바르지 않습니다", HttpStatus.UNAUTHORIZED); - } - - public static UserException userNotFound() { - return new UserException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND); - } -} diff --git a/src/main/java/flipnote/user/config/SecurityConfig.java b/src/main/java/flipnote/user/global/config/AppConfig.java similarity index 65% rename from src/main/java/flipnote/user/config/SecurityConfig.java rename to src/main/java/flipnote/user/global/config/AppConfig.java index 50c9bc9..6ea25fc 100644 --- a/src/main/java/flipnote/user/config/SecurityConfig.java +++ b/src/main/java/flipnote/user/global/config/AppConfig.java @@ -1,15 +1,21 @@ -package flipnote.user.config; +package flipnote.user.global.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.client.RestClient; @Configuration -public class SecurityConfig { +public class AppConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public RestClient restClient() { + return RestClient.create(); + } } diff --git a/src/main/java/flipnote/user/global/config/ClientProperties.java b/src/main/java/flipnote/user/global/config/ClientProperties.java new file mode 100644 index 0000000..5334472 --- /dev/null +++ b/src/main/java/flipnote/user/global/config/ClientProperties.java @@ -0,0 +1,25 @@ +package flipnote.user.global.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "app.client") +public class ClientProperties { + + private final String url; + private final Paths paths; + + @Getter + @RequiredArgsConstructor + public static class Paths { + private final String passwordReset; + private final String socialLinkSuccess; + private final String socialLinkFailure; + private final String socialLinkConflict; + private final String socialLoginSuccess; + private final String socialLoginFailure; + } +} diff --git a/src/main/java/flipnote/user/global/config/JpaAuditingConfig.java b/src/main/java/flipnote/user/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..6e3060c --- /dev/null +++ b/src/main/java/flipnote/user/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package flipnote.user.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/flipnote/user/global/config/JwtProperties.java b/src/main/java/flipnote/user/global/config/JwtProperties.java new file mode 100644 index 0000000..6feaa72 --- /dev/null +++ b/src/main/java/flipnote/user/global/config/JwtProperties.java @@ -0,0 +1,15 @@ +package flipnote.user.global.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + private final String secret; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; +} diff --git a/src/main/java/flipnote/user/global/config/OAuthProperties.java b/src/main/java/flipnote/user/global/config/OAuthProperties.java new file mode 100644 index 0000000..5a1a788 --- /dev/null +++ b/src/main/java/flipnote/user/global/config/OAuthProperties.java @@ -0,0 +1,29 @@ +package flipnote.user.global.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "app.oauth2") +public class OAuthProperties { + + private final Map providers; + + @Getter + @Setter + public static class Provider { + private String clientId; + private String clientSecret; + private String redirectUri; + private String authorizationUri; + private String tokenUri; + private String userInfoUri; + private List scope; + } +} diff --git a/src/main/java/flipnote/user/global/config/ResendConfig.java b/src/main/java/flipnote/user/global/config/ResendConfig.java new file mode 100644 index 0000000..ed0b74e --- /dev/null +++ b/src/main/java/flipnote/user/global/config/ResendConfig.java @@ -0,0 +1,18 @@ +package flipnote.user.global.config; + +import com.resend.Resend; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class ResendConfig { + + private final ResendProperties resendProperties; + + @Bean + public Resend resend() { + return new Resend(resendProperties.getApiKey()); + } +} diff --git a/src/main/java/flipnote/user/global/config/ResendProperties.java b/src/main/java/flipnote/user/global/config/ResendProperties.java new file mode 100644 index 0000000..b1f28c6 --- /dev/null +++ b/src/main/java/flipnote/user/global/config/ResendProperties.java @@ -0,0 +1,20 @@ +package flipnote.user.global.config; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Validated +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "app.resend") +public class ResendProperties { + + @NotEmpty + private final String apiKey; + + @NotEmpty + private final String fromEmail; +} diff --git a/src/main/java/flipnote/user/global/constants/HttpConstants.java b/src/main/java/flipnote/user/global/constants/HttpConstants.java new file mode 100644 index 0000000..2f6b519 --- /dev/null +++ b/src/main/java/flipnote/user/global/constants/HttpConstants.java @@ -0,0 +1,12 @@ +package flipnote.user.global.constants; + +public final class HttpConstants { + + private HttpConstants() { + } + + public static final String USER_ID_HEADER = "X-USER-ID"; + public static final String ACCESS_TOKEN_COOKIE = "accessToken"; + public static final String REFRESH_TOKEN_COOKIE = "refreshToken"; + public static final String OAUTH_VERIFIER_COOKIE = "oauth2_auth_request"; +} diff --git a/src/main/java/flipnote/user/global/entity/BaseEntity.java b/src/main/java/flipnote/user/global/entity/BaseEntity.java new file mode 100644 index 0000000..61b9181 --- /dev/null +++ b/src/main/java/flipnote/user/global/entity/BaseEntity.java @@ -0,0 +1,24 @@ +package flipnote.user.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; +} diff --git a/src/main/java/flipnote/user/global/error/ErrorCode.java b/src/main/java/flipnote/user/global/error/ErrorCode.java new file mode 100644 index 0000000..77df38f --- /dev/null +++ b/src/main/java/flipnote/user/global/error/ErrorCode.java @@ -0,0 +1,10 @@ +package flipnote.user.global.error; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + HttpStatus getStatus(); + + String getMessage(); +} diff --git a/src/main/java/flipnote/user/global/error/ErrorResponse.java b/src/main/java/flipnote/user/global/error/ErrorResponse.java new file mode 100644 index 0000000..966a3ae --- /dev/null +++ b/src/main/java/flipnote/user/global/error/ErrorResponse.java @@ -0,0 +1,20 @@ +package flipnote.user.global.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ErrorResponse { + + private final String code; + private final String message; + + public static ErrorResponse of(ErrorCode errorCode) { + return new ErrorResponse(errorCode.toString(), errorCode.getMessage()); + } + + public static ErrorResponse of(String code, String message) { + return new ErrorResponse(code, message); + } +} diff --git a/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java b/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..0a0b801 --- /dev/null +++ b/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java @@ -0,0 +1,49 @@ +package flipnote.user.global.error; + +import flipnote.user.domain.user.domain.UserException; +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.MissingRequestCookieException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(UserException.class) + public ResponseEntity handleUserException(UserException e) { + ErrorResponse response = ErrorResponse.of(e.getErrorCode()); + return ResponseEntity.status(e.getErrorCode().getStatus()).body(response); + } + + @ExceptionHandler(MissingRequestCookieException.class) + public ResponseEntity handleMissingCookie(MissingRequestCookieException e) { + ErrorResponse response = ErrorResponse.of("MISSING_COOKIE", "필수 쿠키가 누락되었습니다."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + Map errors = new HashMap<>(); + e.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String message = error.getDefaultMessage(); + errors.put(fieldName, message); + }); + return ResponseEntity.badRequest().body(errors); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("Unhandled exception", e); + ErrorResponse response = ErrorResponse.of("INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} diff --git a/src/main/java/flipnote/user/global/exception/EmailSendException.java b/src/main/java/flipnote/user/global/exception/EmailSendException.java new file mode 100644 index 0000000..e8a11b5 --- /dev/null +++ b/src/main/java/flipnote/user/global/exception/EmailSendException.java @@ -0,0 +1,8 @@ +package flipnote.user.global.exception; + +public class EmailSendException extends RuntimeException { + + public EmailSendException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/flipnote/user/global/util/CookieUtil.java b/src/main/java/flipnote/user/global/util/CookieUtil.java new file mode 100644 index 0000000..bfeda9b --- /dev/null +++ b/src/main/java/flipnote/user/global/util/CookieUtil.java @@ -0,0 +1,32 @@ +package flipnote.user.global.util; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; + +public final class CookieUtil { + + private CookieUtil() { + } + + public static void addCookie(HttpServletResponse response, String name, String value, long maxAgeSeconds) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(maxAgeSeconds) + .sameSite("Lax") + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + } + + public static void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/src/main/java/flipnote/user/repository/UserRepository.java b/src/main/java/flipnote/user/repository/UserRepository.java deleted file mode 100644 index 9f9e892..0000000 --- a/src/main/java/flipnote/user/repository/UserRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package flipnote.user.repository; - -import flipnote.user.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - - Optional findByEmail(String email); - - boolean existsByEmail(String email); -} diff --git a/src/main/java/flipnote/user/security/JwtProvider.java b/src/main/java/flipnote/user/security/JwtProvider.java deleted file mode 100644 index 37b4119..0000000 --- a/src/main/java/flipnote/user/security/JwtProvider.java +++ /dev/null @@ -1,44 +0,0 @@ -package flipnote.user.security; - -import flipnote.user.entity.User; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.Getter; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Component -public class JwtProvider { - - private final SecretKey secretKey; - - @Getter - private final long expiration; - - public JwtProvider( - @Value("${jwt.secret}") String secret, - @Value("${jwt.expiration}") long expiration) { - this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); - this.expiration = expiration; - } - - public String generateToken(User user) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); - - return Jwts.builder() - .subject(user.getEmail()) - .claim("userId", user.getId()) - .claim("role", user.getRole().name()) - .issuedAt(now) - .expiration(expiryDate) - .signWith(secretKey) - .compact(); - } - -} diff --git a/src/main/java/flipnote/user/service/UserService.java b/src/main/java/flipnote/user/service/UserService.java deleted file mode 100644 index bf16b20..0000000 --- a/src/main/java/flipnote/user/service/UserService.java +++ /dev/null @@ -1,59 +0,0 @@ -package flipnote.user.service; - -import flipnote.user.dto.LoginRequest; -import flipnote.user.dto.LoginResponse; -import flipnote.user.dto.SignupRequest; -import flipnote.user.dto.UserResponse; -import flipnote.user.entity.User; -import flipnote.user.exception.UserException; -import flipnote.user.repository.UserRepository; -import flipnote.user.security.JwtProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class UserService { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - private final JwtProvider jwtProvider; - - @Transactional - public UserResponse signup(SignupRequest request) { - if (userRepository.existsByEmail(request.getEmail())) { - throw UserException.emailAlreadyExists(); - } - - User user = User.builder() - .email(request.getEmail()) - .password(passwordEncoder.encode(request.getPassword())) - .nickname(request.getNickname()) - .build(); - - User savedUser = userRepository.save(user); - return UserResponse.from(savedUser); - } - - public LoginResponse login(LoginRequest request) { - User user = userRepository.findByEmail(request.getEmail()) - .orElseThrow(UserException::invalidCredentials); - - if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { - throw UserException.invalidCredentials(); - } - - String token = jwtProvider.generateToken(user); - return LoginResponse.of(token, jwtProvider.getExpiration() / 1000); - } - - public UserResponse getProfile(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(UserException::userNotFound); - - return UserResponse.from(user); - } -} diff --git a/src/main/proto/user_query.proto b/src/main/proto/user_query.proto new file mode 100644 index 0000000..93b3afb --- /dev/null +++ b/src/main/proto/user_query.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +option java_package = "flipnote.user.grpc"; +option java_outer_classname = "UserQueryProto"; +option java_multiple_files = true; + +package user_query; + +service UserQueryService { + rpc GetUser(GetUserRequest) returns (GetUserResponse); + rpc GetUsers(GetUsersRequest) returns (GetUsersResponse); +} + +message GetUserRequest { + int64 user_id = 1; +} + +message GetUserResponse { + int64 id = 1; + string email = 2; + string nickname = 3; + string profile_image_url = 4; +} + +message GetUsersRequest { + repeated int64 user_ids = 1; +} + +message GetUsersResponse { + repeated GetUserResponse users = 1; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a10bd58..08933c0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,6 +8,11 @@ spring: password: ${DB_PASSWORD:root} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + jpa: hibernate: ddl-auto: ${DDL_AUTO:update} @@ -36,5 +41,36 @@ management: enabled: true jwt: - secret: ${JWT_SECRET:"55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729"} - expiration: ${JWT_EXPIRATION:86400000} + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_EXPIRATION:900000} + refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} + +grpc: + server: + port: ${GRPC_PORT:9091} + +app: + resend: + api-key: ${APP_RESEND_API_KEY} + from-email: FlipNote + client: + url: ${APP_CLIENT_URL:http://localhost:3000} + paths: + password-reset: /password-reset + social-link-success: /social-link/success + social-link-failure: /social-link/failure + social-link-conflict: /social-link/conflict + social-login-success: /social-login/success + social-login-failure: /social-login/failure + oauth2: + providers: + google: + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + redirect-uri: /oauth2/callback/google + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + scope: + - email + - profile diff --git a/src/main/resources/templates/email/email-verification.html b/src/main/resources/templates/email/email-verification.html new file mode 100644 index 0000000..69b9d41 --- /dev/null +++ b/src/main/resources/templates/email/email-verification.html @@ -0,0 +1,42 @@ + + + + + + + 이메일 인증 안내 + + +
+ 인증번호를 확인하고 이메일 인증을 완료해주세요. +
+ + + + + +
+ + + + + + + +
+

+ 이메일 인증번호 안내 +

+

+ 아래 인증번호를 입력해 주세요.
+ 인증번호는 5분간 유효합니다. +

+
+ 123456 +
+
+ 본 메일은 발신 전용입니다. +
+
+ + diff --git a/src/main/resources/templates/email/password-reset.html b/src/main/resources/templates/email/password-reset.html new file mode 100644 index 0000000..ff844b2 --- /dev/null +++ b/src/main/resources/templates/email/password-reset.html @@ -0,0 +1,44 @@ + + + + + + + 비밀번호 재설정 안내 + + +
+ 비밀번호 재설정을 위해 아래 버튼을 눌러주세요. +
+ + + + + +
+ + + + + + + +
+

+ 비밀번호 재설정 안내 +

+

+ 아래 버튼을 눌러 비밀번호를 재설정해 주세요.
+ 이 링크는 30분간 유효합니다. +

+ +
+ 본 메일은 발신 전용입니다. +
+
+ + diff --git a/src/test/java/flipnote/user/TestRedisConfig.java b/src/test/java/flipnote/user/TestRedisConfig.java new file mode 100644 index 0000000..d0706b7 --- /dev/null +++ b/src/test/java/flipnote/user/TestRedisConfig.java @@ -0,0 +1,21 @@ +package flipnote.user; + +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@TestConfiguration +public class TestRedisConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return Mockito.mock(RedisConnectionFactory.class); + } + + @Bean + public StringRedisTemplate stringRedisTemplate() { + return Mockito.mock(StringRedisTemplate.class); + } +} diff --git a/src/test/java/flipnote/user/UserApplicationTests.java b/src/test/java/flipnote/user/UserApplicationTests.java index 7c65307..0652f36 100644 --- a/src/test/java/flipnote/user/UserApplicationTests.java +++ b/src/test/java/flipnote/user/UserApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; @SpringBootTest +@Import(TestRedisConfig.class) class UserApplicationTests { @Test diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 74f0740..ad8017c 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -14,6 +14,40 @@ spring: format_sql: true use_sql_comments: true + thymeleaf: + check-template-location: false + jwt: secret: "55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729" - expiration: 86400000 + access-token-expiration: 1800000 + refresh-token-expiration: 604800000 + +grpc: + server: + port: -1 + +app: + resend: + api-key: test-api-key + from-email: test@test.com + client: + url: http://localhost:3000 + paths: + password-reset: /password-reset + social-link-success: /social-link/success + social-link-failure: /social-link/failure + social-link-conflict: /social-link/conflict + social-login-success: /social-login/success + social-login-failure: /social-login/failure + oauth2: + providers: + google: + client-id: test-client-id + client-secret: test-client-secret + redirect-uri: /oauth2/callback/google + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + scope: + - email + - profile From de6234746c96cec4dc18e44a849584ba67db6d90 Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 18 Feb 2026 19:10:22 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=8B=9C=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=97=AC=EB=B6=80=20=EC=B2=B4=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flipnote/user/domain/user/application/AuthService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/flipnote/user/domain/user/application/AuthService.java b/src/main/java/flipnote/user/domain/user/application/AuthService.java index dbd42c4..d3897ed 100644 --- a/src/main/java/flipnote/user/domain/user/application/AuthService.java +++ b/src/main/java/flipnote/user/domain/user/application/AuthService.java @@ -38,6 +38,10 @@ public class AuthService { @Transactional public UserResponse register(SignupRequest request) { + if (!emailVerificationRepository.isVerified(request.getEmail())) { + throw new UserException(UserErrorCode.UNVERIFIED_EMAIL); + } + if (userRepository.existsByEmail(request.getEmail())) { throw new UserException(UserErrorCode.EMAIL_ALREADY_EXISTS); } From 67f057bc2d8a0721ffb0eaaa1355e496bf81b07f Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 18 Feb 2026 19:51:00 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Chore:=20=EC=8A=A4=ED=94=84=EB=A7=81=20?= =?UTF-8?q?=EB=B6=80=ED=8A=B8=20=EB=B2=84=EC=A0=84=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 48 +++++++++++-------- .../java/flipnote/user/UserApplication.java | 2 - .../domain/user/application/AuthService.java | 8 ++-- .../user/grpc/GrpcUserQueryService.java | 4 +- .../user/infrastructure/OAuthApiClient.java | 13 ++--- .../EmailVerificationEventListener.java | 36 ++++++++------ .../listener/PasswordResetEventListener.java | 36 ++++++++------ src/main/resources/application.yml | 8 ++-- src/test/resources/application.yml | 8 ++-- 9 files changed, 94 insertions(+), 69 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4375fe8..82f366a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,10 @@ +import com.google.protobuf.gradle.id + plugins { java - id("org.springframework.boot") version "3.5.9" + id("org.springframework.boot") version "4.0.2" id("io.spring.dependency-management") version "1.1.7" - id("com.google.protobuf") version "0.9.4" + id("com.google.protobuf") version "0.9.5" } group = "flipnote" @@ -15,16 +17,19 @@ java { } } -configurations { - compileOnly { - extendsFrom(configurations.annotationProcessor.get()) - } -} - repositories { mavenCentral() } +extra["springGrpcVersion"] = "1.0.2" + + +dependencyManagement { + imports { + mavenBom("org.springframework.grpc:spring-grpc-dependencies:1.0.2") + } +} + dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") @@ -37,40 +42,45 @@ dependencies { runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") // gRPC - implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE") - implementation("io.grpc:grpc-stub:1.63.0") - implementation("io.grpc:grpc-protobuf:1.63.0") - compileOnly("javax.annotation:javax.annotation-api:1.3.2") + implementation("io.grpc:grpc-services") + implementation("org.springframework.grpc:spring-grpc-spring-boot-starter") // Email implementation("com.resend:resend-java:3.1.0") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") - // Async & Retry - implementation("org.springframework.retry:spring-retry") - implementation("org.springframework.boot:spring-boot-starter-aop") + implementation("org.springframework.boot:spring-boot-starter-aspectj") compileOnly("org.projectlombok:lombok") runtimeOnly("com.mysql:mysql-connector-j") annotationProcessor("org.projectlombok:lombok") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.grpc:spring-grpc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("com.h2database:h2") } +dependencyManagement { + imports { + mavenBom("org.springframework.grpc:spring-grpc-dependencies:${property("springGrpcVersion")}") + } +} + protobuf { protoc { - artifact = "com.google.protobuf:protoc:3.25.3" + artifact = "com.google.protobuf:protoc" } plugins { - create("grpc") { - artifact = "io.grpc:protoc-gen-grpc-java:1.63.0" + id("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java" } } generateProtoTasks { all().forEach { it.plugins { - create("grpc") + id("grpc") { + option("@generated=omit") + } } } } diff --git a/src/main/java/flipnote/user/UserApplication.java b/src/main/java/flipnote/user/UserApplication.java index 04ce926..668136e 100644 --- a/src/main/java/flipnote/user/UserApplication.java +++ b/src/main/java/flipnote/user/UserApplication.java @@ -3,13 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @ConfigurationPropertiesScan @EnableAsync -@EnableRetry public class UserApplication { public static void main(String[] args) { diff --git a/src/main/java/flipnote/user/domain/user/application/AuthService.java b/src/main/java/flipnote/user/domain/user/application/AuthService.java index d3897ed..a948e9b 100644 --- a/src/main/java/flipnote/user/domain/user/application/AuthService.java +++ b/src/main/java/flipnote/user/domain/user/application/AuthService.java @@ -166,15 +166,15 @@ public void verifyEmail(String email, String code) { } public void requestPasswordReset(String email) { - if (passwordResetRepository.hasToken(email)) { - throw new UserException(UserErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK); - } - // 사용자가 없어도 정상 반환 (이메일 존재 여부 노출 방지) if (!userRepository.existsByEmail(email)) { return; } + if (passwordResetRepository.hasToken(email)) { + throw new UserException(UserErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK); + } + String token = passwordResetTokenGenerator.generate(); passwordResetRepository.save(token, email); diff --git a/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java b/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java index 4e70ec1..e05153e 100644 --- a/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java +++ b/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java @@ -11,12 +11,12 @@ import io.grpc.stub.StreamObserver; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import net.devh.boot.grpc.server.service.GrpcService; +import org.springframework.stereotype.Service; import java.util.List; @Slf4j -@GrpcService +@Service @RequiredArgsConstructor public class GrpcUserQueryService extends UserQueryServiceGrpc.UserQueryServiceImplBase { diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java b/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java index 51677ba..a5f7926 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java +++ b/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java @@ -1,10 +1,7 @@ package flipnote.user.domain.user.infrastructure; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import flipnote.user.global.config.OAuthProperties; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; +import java.util.Map; + import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; @@ -14,7 +11,11 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder; -import java.util.Map; +import flipnote.user.global.config.OAuthProperties; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; @Service @RequiredArgsConstructor diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java b/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java index 39c4c04..f41eaef 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java +++ b/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java @@ -7,9 +7,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Recover; -import org.springframework.retry.annotation.Retryable; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -18,21 +15,32 @@ @RequiredArgsConstructor public class EmailVerificationEventListener { + private static final int MAX_ATTEMPTS = 3; + private static final long INITIAL_DELAY_MS = 2000L; + private final MailService mailService; @Async - @Retryable( - maxAttempts = 3, - retryFor = {EmailSendException.class}, - backoff = @Backoff(delay = 2000, multiplier = 2) - ) @EventListener public void handle(EmailVerificationSendEvent event) { - mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES); - } - - @Recover - public void recover(EmailSendException ex, EmailVerificationSendEvent event) { - log.error("이메일 인증번호 전송 실패: to={}", event.to(), ex); + long delay = INITIAL_DELAY_MS; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES); + return; + } catch (EmailSendException e) { + if (attempt == MAX_ATTEMPTS) { + log.error("이메일 인증번호 전송 실패: to={}", event.to(), e); + return; + } + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + delay *= 2; + } + } } } diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java b/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java index 0257516..17e0d21 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java +++ b/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java @@ -7,9 +7,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Recover; -import org.springframework.retry.annotation.Retryable; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -18,21 +15,32 @@ @RequiredArgsConstructor public class PasswordResetEventListener { + private static final int MAX_ATTEMPTS = 3; + private static final long INITIAL_DELAY_MS = 2000L; + private final MailService mailService; @Async - @Retryable( - maxAttempts = 3, - retryFor = {EmailSendException.class}, - backoff = @Backoff(delay = 2000, multiplier = 2) - ) @EventListener public void handle(PasswordResetCreateEvent event) { - mailService.sendPasswordResetLink(event.to(), event.link(), PasswordResetConstants.TOKEN_TTL_MINUTES); - } - - @Recover - public void recover(EmailSendException ex, PasswordResetCreateEvent event) { - log.error("비밀번호 재설정 링크 전송 실패: to={}", event.to(), ex); + long delay = INITIAL_DELAY_MS; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + mailService.sendPasswordResetLink(event.to(), event.link(), PasswordResetConstants.TOKEN_TTL_MINUTES); + return; + } catch (EmailSendException e) { + if (attempt == MAX_ATTEMPTS) { + log.error("비밀번호 재설정 링크 전송 실패: to={}", event.to(), e); + return; + } + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + delay *= 2; + } + } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 08933c0..c0a99fb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,6 +22,10 @@ spring: format_sql: true dialect: org.hibernate.dialect.MySQLDialect + grpc: + server: + port: ${GRPC_PORT:9091} + server: port: 8081 @@ -45,10 +49,6 @@ jwt: access-token-expiration: ${JWT_ACCESS_EXPIRATION:900000} refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} -grpc: - server: - port: ${GRPC_PORT:9091} - app: resend: api-key: ${APP_RESEND_API_KEY} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ad8017c..111c7df 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -17,15 +17,15 @@ spring: thymeleaf: check-template-location: false + grpc: + server: + port: 0 + jwt: secret: "55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729" access-token-expiration: 1800000 refresh-token-expiration: 604800000 -grpc: - server: - port: -1 - app: resend: api-key: test-api-key From 1400fdae1f96b873dca997c35181b012dd80a8b2 Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 18 Feb 2026 20:03:55 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Feat:=20=EB=AA=A8=EB=93=A0=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/flipnote/user/UserApplication.java | 2 + .../domain/user/application/AuthService.java | 27 ++++++------ .../domain/user/application/UserService.java | 5 +++ .../user/domain/user/domain/User.java | 4 -- .../SessionInvalidationRepository.java | 31 +++++++++++++ .../EmailVerificationEventListener.java | 43 ++++++------------- .../listener/PasswordResetEventListener.java | 43 ++++++------------- .../user/presentation/AuthController.java | 5 ++- 8 files changed, 79 insertions(+), 81 deletions(-) create mode 100644 src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java diff --git a/src/main/java/flipnote/user/UserApplication.java b/src/main/java/flipnote/user/UserApplication.java index 668136e..a922aa7 100644 --- a/src/main/java/flipnote/user/UserApplication.java +++ b/src/main/java/flipnote/user/UserApplication.java @@ -3,11 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.resilience.annotation.EnableResilientMethods; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @ConfigurationPropertiesScan @EnableAsync +@EnableResilientMethods public class UserApplication { public static void main(String[] args) { diff --git a/src/main/java/flipnote/user/domain/user/application/AuthService.java b/src/main/java/flipnote/user/domain/user/application/AuthService.java index a948e9b..7adfd03 100644 --- a/src/main/java/flipnote/user/domain/user/application/AuthService.java +++ b/src/main/java/flipnote/user/domain/user/application/AuthService.java @@ -15,8 +15,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.ZoneId; -import java.util.Date; import java.util.List; @Service @@ -31,6 +29,7 @@ public class AuthService { private final EmailVerificationRepository emailVerificationRepository; private final PasswordResetRepository passwordResetRepository; private final OAuthLinkRepository oAuthLinkRepository; + private final SessionInvalidationRepository sessionInvalidationRepository; private final VerificationCodeGenerator verificationCodeGenerator; private final PasswordResetTokenGenerator passwordResetTokenGenerator; private final ClientProperties clientProperties; @@ -89,15 +88,14 @@ public TokenPair refreshToken(String refreshToken) { } TokenClaims claims = jwtProvider.extractClaims(refreshToken); - User user = findActiveUser(claims.userId()); - if (user.getInvalidatedAt() != null) { - Date issuedAt = jwtProvider.getIssuedAt(refreshToken); - if (issuedAt.before(Date.from(user.getInvalidatedAt() - .atZone(ZoneId.systemDefault()).toInstant()))) { + sessionInvalidationRepository.getInvalidatedAtMillis(claims.userId()).ifPresent(invalidatedAtMillis -> { + if (jwtProvider.getIssuedAt(refreshToken).getTime() < invalidatedAtMillis) { throw new UserException(UserErrorCode.INVALIDATED_SESSION); } - } + }); + + User user = findActiveUser(claims.userId()); long remaining = jwtProvider.getRemainingExpiration(refreshToken); if (remaining > 0) { @@ -116,6 +114,7 @@ public void changePassword(Long userId, ChangePasswordRequest request) { } user.changePassword(passwordEncoder.encode(request.getNewPassword())); + sessionInvalidationRepository.invalidate(user.getId(), jwtProvider.getRefreshTokenExpiration()); } public TokenValidateResponse validateToken(String token) { @@ -128,15 +127,14 @@ public TokenValidateResponse validateToken(String token) { } TokenClaims claims = jwtProvider.extractClaims(token); - User user = findActiveUser(claims.userId()); - if (user.getInvalidatedAt() != null) { - Date issuedAt = jwtProvider.getIssuedAt(token); - if (issuedAt.before(Date.from(user.getInvalidatedAt() - .atZone(ZoneId.systemDefault()).toInstant()))) { + sessionInvalidationRepository.getInvalidatedAtMillis(claims.userId()).ifPresent(invalidatedAtMillis -> { + if (jwtProvider.getIssuedAt(token).getTime() < invalidatedAtMillis) { throw new UserException(UserErrorCode.INVALIDATED_SESSION); } - } + }); + + findActiveUser(claims.userId()); return new TokenValidateResponse(claims.userId(), claims.email(), claims.role()); } @@ -194,6 +192,7 @@ public void resetPassword(String token, String newPassword) { .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); user.changePassword(passwordEncoder.encode(newPassword)); + sessionInvalidationRepository.invalidate(user.getId(), jwtProvider.getRefreshTokenExpiration()); passwordResetRepository.delete(token, email); } diff --git a/src/main/java/flipnote/user/domain/user/application/UserService.java b/src/main/java/flipnote/user/domain/user/application/UserService.java index a8f5f6b..ffa8526 100644 --- a/src/main/java/flipnote/user/domain/user/application/UserService.java +++ b/src/main/java/flipnote/user/domain/user/application/UserService.java @@ -1,6 +1,8 @@ package flipnote.user.domain.user.application; import flipnote.user.domain.user.domain.*; +import flipnote.user.domain.user.infrastructure.JwtProvider; +import flipnote.user.domain.user.infrastructure.SessionInvalidationRepository; import flipnote.user.domain.user.presentation.dto.request.UpdateProfileRequest; import flipnote.user.domain.user.presentation.dto.response.MyInfoResponse; import flipnote.user.domain.user.presentation.dto.response.UserInfoResponse; @@ -15,6 +17,8 @@ public class UserService { private final UserRepository userRepository; + private final SessionInvalidationRepository sessionInvalidationRepository; + private final JwtProvider jwtProvider; public MyInfoResponse getMyInfo(Long userId) { User user = findActiveUser(userId); @@ -39,6 +43,7 @@ public UserUpdateResponse updateProfile(Long userId, UpdateProfileRequest reques public void withdraw(Long userId) { User user = findActiveUser(userId); user.withdraw(); + sessionInvalidationRepository.invalidate(userId, jwtProvider.getRefreshTokenExpiration()); } private User findActiveUser(Long userId) { diff --git a/src/main/java/flipnote/user/domain/user/domain/User.java b/src/main/java/flipnote/user/domain/user/domain/User.java index c6aa320..d9441ce 100644 --- a/src/main/java/flipnote/user/domain/user/domain/User.java +++ b/src/main/java/flipnote/user/domain/user/domain/User.java @@ -46,8 +46,6 @@ public class User extends BaseEntity { @Column(nullable = false, length = 20) private Status status; - private LocalDateTime invalidatedAt; - private LocalDateTime deletedAt; @Builder @@ -64,7 +62,6 @@ public User(String email, String password, String name, String nickname, String public void changePassword(String encodedPassword) { this.password = encodedPassword; - this.invalidatedAt = LocalDateTime.now(); } public void updateProfile(String nickname, String phone, boolean smsAgree, String profileImageUrl) { @@ -78,7 +75,6 @@ public void updateProfile(String nickname, String phone, boolean smsAgree, Strin public void withdraw() { this.status = Status.WITHDRAWN; - this.invalidatedAt = LocalDateTime.now(); this.deletedAt = LocalDateTime.now(); } diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java b/src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java new file mode 100644 index 0000000..006f939 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java @@ -0,0 +1,31 @@ +package flipnote.user.domain.user.infrastructure; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class SessionInvalidationRepository { + + private static final String KEY_PREFIX = "session:invalidated:"; + + private final StringRedisTemplate redisTemplate; + + public void invalidate(Long userId, long ttlMillis) { + redisTemplate.opsForValue().set( + KEY_PREFIX + userId, + String.valueOf(System.currentTimeMillis()), + ttlMillis, + TimeUnit.MILLISECONDS + ); + } + + public Optional getInvalidatedAtMillis(Long userId) { + String value = redisTemplate.opsForValue().get(KEY_PREFIX + userId); + return Optional.ofNullable(value).map(Long::parseLong); + } +} diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java b/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java index f41eaef..121fd23 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java +++ b/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java @@ -1,46 +1,27 @@ package flipnote.user.domain.user.infrastructure.listener; +import org.springframework.context.event.EventListener; +import org.springframework.resilience.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + import flipnote.user.domain.user.domain.VerificationConstants; import flipnote.user.domain.user.domain.event.EmailVerificationSendEvent; import flipnote.user.domain.user.infrastructure.MailService; import flipnote.user.global.exception.EmailSendException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor public class EmailVerificationEventListener { + private final MailService mailService; - private static final int MAX_ATTEMPTS = 3; - private static final long INITIAL_DELAY_MS = 2000L; - - private final MailService mailService; - - @Async - @EventListener - public void handle(EmailVerificationSendEvent event) { - long delay = INITIAL_DELAY_MS; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES); - return; - } catch (EmailSendException e) { - if (attempt == MAX_ATTEMPTS) { - log.error("이메일 인증번호 전송 실패: to={}", event.to(), e); - return; - } - try { - Thread.sleep(delay); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - return; - } - delay *= 2; - } - } - } + @Async + @EventListener + @Retryable(delay = 2000, multiplier = 2.0, maxRetries = 3, includes = EmailSendException.class) + public void handle(EmailVerificationSendEvent event) { + mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES); + } } diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java b/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java index 17e0d21..0fba49d 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java +++ b/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java @@ -1,46 +1,27 @@ package flipnote.user.domain.user.infrastructure.listener; +import org.springframework.context.event.EventListener; +import org.springframework.resilience.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + import flipnote.user.domain.user.domain.PasswordResetConstants; import flipnote.user.domain.user.domain.event.PasswordResetCreateEvent; import flipnote.user.domain.user.infrastructure.MailService; import flipnote.user.global.exception.EmailSendException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor public class PasswordResetEventListener { + private final MailService mailService; - private static final int MAX_ATTEMPTS = 3; - private static final long INITIAL_DELAY_MS = 2000L; - - private final MailService mailService; - - @Async - @EventListener - public void handle(PasswordResetCreateEvent event) { - long delay = INITIAL_DELAY_MS; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - mailService.sendPasswordResetLink(event.to(), event.link(), PasswordResetConstants.TOKEN_TTL_MINUTES); - return; - } catch (EmailSendException e) { - if (attempt == MAX_ATTEMPTS) { - log.error("비밀번호 재설정 링크 전송 실패: to={}", event.to(), e); - return; - } - try { - Thread.sleep(delay); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - return; - } - delay *= 2; - } - } - } + @Async + @EventListener + @Retryable(delay = 2000, multiplier = 2.0, maxRetries = 3, includes = EmailSendException.class) + public void handle(PasswordResetCreateEvent event) { + mailService.sendPasswordResetLink(event.to(), event.link(), PasswordResetConstants.TOKEN_TTL_MINUTES); + } } diff --git a/src/main/java/flipnote/user/domain/user/presentation/AuthController.java b/src/main/java/flipnote/user/domain/user/presentation/AuthController.java index d37393f..aed02d4 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/AuthController.java +++ b/src/main/java/flipnote/user/domain/user/presentation/AuthController.java @@ -98,8 +98,11 @@ public ResponseEntity requestPasswordReset( @PostMapping("/password-reset") public ResponseEntity resetPassword( - @Valid @RequestBody PasswordResetRequest request) { + @Valid @RequestBody PasswordResetRequest request, + HttpServletResponse response) { authService.resetPassword(request.getToken(), request.getPassword()); + CookieUtil.deleteCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE); + CookieUtil.deleteCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE); return ResponseEntity.noContent().build(); } From 7abf0ed7ee4a76a810d0eedf7e38241a5ba4b5b4 Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 18 Feb 2026 20:20:46 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Feat:=20=EC=98=88=EC=99=B8=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=8A=A4=ED=8E=99=EB=8C=80=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/application/AuthService.java | 34 +++++------ .../domain/user/application/OAuthService.java | 10 ++-- .../domain/user/domain/AuthErrorCode.java | 37 ++++++++++++ .../domain/user/domain/UserErrorCode.java | 31 +++------- .../user/presentation/OAuthController.java | 4 +- .../user/global/error/ApiResponse.java | 60 +++++++++++++++++++ .../flipnote/user/global/error/ErrorCode.java | 6 +- .../user/global/error/ErrorResponse.java | 20 ------- .../global/error/GlobalExceptionHandler.java | 33 +++++----- 9 files changed, 146 insertions(+), 89 deletions(-) create mode 100644 src/main/java/flipnote/user/domain/user/domain/AuthErrorCode.java create mode 100644 src/main/java/flipnote/user/global/error/ApiResponse.java delete mode 100644 src/main/java/flipnote/user/global/error/ErrorResponse.java diff --git a/src/main/java/flipnote/user/domain/user/application/AuthService.java b/src/main/java/flipnote/user/domain/user/application/AuthService.java index 7adfd03..64a73d5 100644 --- a/src/main/java/flipnote/user/domain/user/application/AuthService.java +++ b/src/main/java/flipnote/user/domain/user/application/AuthService.java @@ -38,11 +38,11 @@ public class AuthService { @Transactional public UserResponse register(SignupRequest request) { if (!emailVerificationRepository.isVerified(request.getEmail())) { - throw new UserException(UserErrorCode.UNVERIFIED_EMAIL); + throw new UserException(AuthErrorCode.UNVERIFIED_EMAIL); } if (userRepository.existsByEmail(request.getEmail())) { - throw new UserException(UserErrorCode.EMAIL_ALREADY_EXISTS); + throw new UserException(AuthErrorCode.EMAIL_ALREADY_EXISTS); } User user = User.builder() @@ -60,10 +60,10 @@ public UserResponse register(SignupRequest request) { public TokenPair login(LoginRequest request) { User user = userRepository.findByEmailAndStatus(request.getEmail(), User.Status.ACTIVE) - .orElseThrow(() -> new UserException(UserErrorCode.INVALID_CREDENTIALS)); + .orElseThrow(() -> new UserException(AuthErrorCode.INVALID_CREDENTIALS)); if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { - throw new UserException(UserErrorCode.INVALID_CREDENTIALS); + throw new UserException(AuthErrorCode.INVALID_CREDENTIALS); } return jwtProvider.generateTokenPair(user); @@ -80,18 +80,18 @@ public void logout(String refreshToken) { public TokenPair refreshToken(String refreshToken) { if (refreshToken == null || !jwtProvider.isTokenValid(refreshToken)) { - throw new UserException(UserErrorCode.INVALID_TOKEN); + throw new UserException(AuthErrorCode.INVALID_TOKEN); } if (tokenBlacklistRepository.isBlacklisted(refreshToken)) { - throw new UserException(UserErrorCode.BLACKLISTED_TOKEN); + throw new UserException(AuthErrorCode.BLACKLISTED_TOKEN); } TokenClaims claims = jwtProvider.extractClaims(refreshToken); sessionInvalidationRepository.getInvalidatedAtMillis(claims.userId()).ifPresent(invalidatedAtMillis -> { if (jwtProvider.getIssuedAt(refreshToken).getTime() < invalidatedAtMillis) { - throw new UserException(UserErrorCode.INVALIDATED_SESSION); + throw new UserException(AuthErrorCode.INVALIDATED_SESSION); } }); @@ -110,7 +110,7 @@ public void changePassword(Long userId, ChangePasswordRequest request) { User user = findActiveUser(userId); if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) { - throw new UserException(UserErrorCode.PASSWORD_MISMATCH); + throw new UserException(AuthErrorCode.PASSWORD_MISMATCH); } user.changePassword(passwordEncoder.encode(request.getNewPassword())); @@ -119,18 +119,18 @@ public void changePassword(Long userId, ChangePasswordRequest request) { public TokenValidateResponse validateToken(String token) { if (!jwtProvider.isTokenValid(token)) { - throw new UserException(UserErrorCode.INVALID_TOKEN); + throw new UserException(AuthErrorCode.INVALID_TOKEN); } if (tokenBlacklistRepository.isBlacklisted(token)) { - throw new UserException(UserErrorCode.BLACKLISTED_TOKEN); + throw new UserException(AuthErrorCode.BLACKLISTED_TOKEN); } TokenClaims claims = jwtProvider.extractClaims(token); sessionInvalidationRepository.getInvalidatedAtMillis(claims.userId()).ifPresent(invalidatedAtMillis -> { if (jwtProvider.getIssuedAt(token).getTime() < invalidatedAtMillis) { - throw new UserException(UserErrorCode.INVALIDATED_SESSION); + throw new UserException(AuthErrorCode.INVALIDATED_SESSION); } }); @@ -141,7 +141,7 @@ public TokenValidateResponse validateToken(String token) { public void sendEmailVerificationCode(String email) { if (emailVerificationRepository.hasCode(email)) { - throw new UserException(UserErrorCode.ALREADY_ISSUED_VERIFICATION_CODE); + throw new UserException(AuthErrorCode.ALREADY_ISSUED_VERIFICATION_CODE); } String code = verificationCodeGenerator.generate(); @@ -151,12 +151,12 @@ public void sendEmailVerificationCode(String email) { public void verifyEmail(String email, String code) { if (!emailVerificationRepository.hasCode(email)) { - throw new UserException(UserErrorCode.NOT_ISSUED_VERIFICATION_CODE); + throw new UserException(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE); } String savedCode = emailVerificationRepository.getCode(email); if (!code.equals(savedCode)) { - throw new UserException(UserErrorCode.INVALID_VERIFICATION_CODE); + throw new UserException(AuthErrorCode.INVALID_VERIFICATION_CODE); } emailVerificationRepository.deleteCode(email); @@ -170,7 +170,7 @@ public void requestPasswordReset(String email) { } if (passwordResetRepository.hasToken(email)) { - throw new UserException(UserErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK); + throw new UserException(AuthErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK); } String token = passwordResetTokenGenerator.generate(); @@ -185,7 +185,7 @@ public void requestPasswordReset(String email) { public void resetPassword(String token, String newPassword) { String email = passwordResetRepository.findEmailByToken(token); if (email == null) { - throw new UserException(UserErrorCode.INVALID_PASSWORD_RESET_TOKEN); + throw new UserException(AuthErrorCode.INVALID_PASSWORD_RESET_TOKEN); } User user = userRepository.findByEmailAndStatus(email, User.Status.ACTIVE) @@ -204,7 +204,7 @@ public SocialLinksResponse getSocialLinks(Long userId) { @Transactional public void deleteSocialLink(Long userId, Long socialLinkId) { if (!oAuthLinkRepository.existsByIdAndUser_Id(socialLinkId, userId)) { - throw new UserException(UserErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT); + throw new UserException(AuthErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT); } oAuthLinkRepository.deleteById(socialLinkId); } diff --git a/src/main/java/flipnote/user/domain/user/application/OAuthService.java b/src/main/java/flipnote/user/domain/user/application/OAuthService.java index b4b5c16..a9afaf7 100644 --- a/src/main/java/flipnote/user/domain/user/application/OAuthService.java +++ b/src/main/java/flipnote/user/domain/user/application/OAuthService.java @@ -64,7 +64,7 @@ public TokenPair socialLogin(String providerName, String code, String codeVerifi OAuthLink oAuthLink = oAuthLinkRepository .findByProviderAndProviderIdWithUser(userInfo.getProvider(), userInfo.getProviderId()) - .orElseThrow(() -> new UserException(UserErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT)); + .orElseThrow(() -> new UserException(AuthErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT)); return jwtProvider.generateTokenPair(oAuthLink.getUser()); } @@ -73,7 +73,7 @@ public TokenPair socialLogin(String providerName, String code, String codeVerifi public void linkSocialAccount(String providerName, String code, String state, String codeVerifier, HttpServletRequest request) { Long userId = socialLinkTokenRepository.findUserIdByState(state) - .orElseThrow(() -> new UserException(UserErrorCode.INVALID_SOCIAL_LINK_TOKEN)); + .orElseThrow(() -> new UserException(AuthErrorCode.INVALID_SOCIAL_LINK_TOKEN)); socialLinkTokenRepository.delete(state); @@ -81,7 +81,7 @@ public void linkSocialAccount(String providerName, String code, String state, if (oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId( userId, userInfo.getProvider(), userInfo.getProviderId())) { - throw new UserException(UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); + throw new UserException(AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); } User user = userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) @@ -106,12 +106,12 @@ private OAuth2UserInfo getOAuth2UserInfo(String providerName, String code, private OAuthProperties.Provider resolveProvider(String providerName) { Map providers = oAuthProperties.getProviders(); if (providers == null) { - throw new UserException(UserErrorCode.INVALID_OAUTH_PROVIDER); + throw new UserException(AuthErrorCode.INVALID_OAUTH_PROVIDER); } OAuthProperties.Provider provider = providers.get(providerName.toLowerCase()); if (provider == null) { log.warn("지원하지 않는 OAuth Provider: {}", providerName); - throw new UserException(UserErrorCode.INVALID_OAUTH_PROVIDER); + throw new UserException(AuthErrorCode.INVALID_OAUTH_PROVIDER); } return provider; } diff --git a/src/main/java/flipnote/user/domain/user/domain/AuthErrorCode.java b/src/main/java/flipnote/user/domain/user/domain/AuthErrorCode.java new file mode 100644 index 0000000..dacff75 --- /dev/null +++ b/src/main/java/flipnote/user/domain/user/domain/AuthErrorCode.java @@ -0,0 +1,37 @@ +package flipnote.user.domain.user.domain; + +import flipnote.user.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 올바르지 않습니다."), + ALREADY_ISSUED_VERIFICATION_CODE(HttpStatus.TOO_MANY_REQUESTS, "AUTH_002", "이미 인증코드가 발송되었습니다. 잠시 후 다시 시도해 주세요."), + NOT_ISSUED_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_003", "인증코드가 발송되지 않았습니다."), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_004", "인증코드가 올바르지 않습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "AUTH_005", "이미 사용 중인 이메일입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_006", "유효하지 않은 토큰입니다."), + UNVERIFIED_EMAIL(HttpStatus.FORBIDDEN, "AUTH_007", "이메일 인증이 완료되지 않았습니다."), + ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.TOO_MANY_REQUESTS, "AUTH_008", "이미 비밀번호 재설정 링크가 발송되었습니다. 잠시 후 다시 시도해 주세요."), + INVALID_PASSWORD_RESET_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_009", "유효하지 않은 비밀번호 재설정 토큰입니다."), + INVALID_SOCIAL_LINK_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_010", "유효하지 않은 소셜 연동 토큰입니다."), + ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_011", "이미 연결된 소셜 계정입니다."), + NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_012", "연결된 소셜 계정이 없습니다."), + INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 OAuth 제공자입니다."), + BLACKLISTED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_015", "무효화된 토큰입니다."), + INVALIDATED_SESSION(HttpStatus.UNAUTHORIZED, "AUTH_016", "세션이 무효화되었습니다. 다시 로그인해 주세요."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH_017", "현재 비밀번호가 일치하지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public int getStatus() { + return httpStatus.value(); + } +} diff --git a/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java b/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java index ed5a874..adad5db 100644 --- a/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java +++ b/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java @@ -9,29 +9,14 @@ @RequiredArgsConstructor public enum UserErrorCode implements ErrorCode { - EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."), - INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), - PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."), - ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "이미 탈퇴한 사용자입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "사용자를 찾을 수 없습니다."); - ALREADY_ISSUED_VERIFICATION_CODE(HttpStatus.TOO_MANY_REQUESTS, "이미 인증코드가 발송되었습니다. 잠시 후 다시 시도해 주세요."), - NOT_ISSUED_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "인증코드가 발송되지 않았습니다."), - INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "인증코드가 올바르지 않습니다."), - UNVERIFIED_EMAIL(HttpStatus.FORBIDDEN, "이메일 인증이 완료되지 않았습니다."), - - ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.TOO_MANY_REQUESTS, "이미 비밀번호 재설정 링크가 발송되었습니다. 잠시 후 다시 시도해 주세요."), - INVALID_PASSWORD_RESET_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 비밀번호 재설정 토큰입니다."), - - INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), - BLACKLISTED_TOKEN(HttpStatus.UNAUTHORIZED, "무효화된 토큰입니다."), - INVALIDATED_SESSION(HttpStatus.UNAUTHORIZED, "세션이 무효화되었습니다. 다시 로그인해 주세요."), - - INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 OAuth 제공자입니다."), - NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "연결된 소셜 계정이 없습니다."), - ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "이미 연결된 소셜 계정입니다."), - INVALID_SOCIAL_LINK_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 소셜 연동 토큰입니다."); - - private final HttpStatus status; + private final HttpStatus httpStatus; + private final String code; private final String message; + + @Override + public int getStatus() { + return httpStatus.value(); + } } diff --git a/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java b/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java index 37d0251..c8fde3e 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java +++ b/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java @@ -1,7 +1,7 @@ package flipnote.user.domain.user.presentation; import flipnote.user.domain.user.application.OAuthService; -import flipnote.user.domain.user.domain.UserErrorCode; +import flipnote.user.domain.user.domain.AuthErrorCode; import flipnote.user.domain.user.domain.UserException; import flipnote.user.domain.user.infrastructure.TokenPair; import flipnote.user.global.config.ClientProperties; @@ -88,7 +88,7 @@ private ResponseEntity handleSocialLink(String provider, String code, Stri .build(); } catch (UserException e) { log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e); - if (e.getErrorCode() == UserErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { + if (e.getErrorCode() == AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { return ResponseEntity.status(HttpStatus.FOUND) .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkConflict())) .build(); diff --git a/src/main/java/flipnote/user/global/error/ApiResponse.java b/src/main/java/flipnote/user/global/error/ApiResponse.java new file mode 100644 index 0000000..00b659d --- /dev/null +++ b/src/main/java/flipnote/user/global/error/ApiResponse.java @@ -0,0 +1,60 @@ +package flipnote.user.global.error; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.validation.BindingResult; + +import java.util.List; + +@Getter +@Builder +public class ApiResponse { + + private final int status; + private final String code; + private final String message; + private final T data; + + public static ApiResponse error(ErrorCode errorCode) { + return ApiResponse.builder() + .status(errorCode.getStatus()) + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + } + + public static ApiResponse> validationError(BindingResult bindingResult) { + return ApiResponse.>builder() + .status(400) + .code("INVALID_INPUT") + .message("입력값이 올바르지 않습니다.") + .data(FieldError.of(bindingResult)) + .build(); + } + + public static ApiResponse internalError() { + return ApiResponse.builder() + .status(500) + .code("INTERNAL_SERVER_ERROR") + .message("서버 오류가 발생했습니다.") + .build(); + } + + @Getter + @AllArgsConstructor + public static class FieldError { + private String field; + private String rejectedValue; + private String reason; + + public static List of(BindingResult bindingResult) { + return bindingResult.getFieldErrors().stream() + .map(e -> new FieldError( + e.getField(), + e.getRejectedValue() == null ? "" : String.valueOf(e.getRejectedValue()), + e.getDefaultMessage())) + .toList(); + } + } +} diff --git a/src/main/java/flipnote/user/global/error/ErrorCode.java b/src/main/java/flipnote/user/global/error/ErrorCode.java index 77df38f..d043924 100644 --- a/src/main/java/flipnote/user/global/error/ErrorCode.java +++ b/src/main/java/flipnote/user/global/error/ErrorCode.java @@ -1,10 +1,10 @@ package flipnote.user.global.error; -import org.springframework.http.HttpStatus; - public interface ErrorCode { - HttpStatus getStatus(); + int getStatus(); + + String getCode(); String getMessage(); } diff --git a/src/main/java/flipnote/user/global/error/ErrorResponse.java b/src/main/java/flipnote/user/global/error/ErrorResponse.java deleted file mode 100644 index 966a3ae..0000000 --- a/src/main/java/flipnote/user/global/error/ErrorResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package flipnote.user.global.error; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public class ErrorResponse { - - private final String code; - private final String message; - - public static ErrorResponse of(ErrorCode errorCode) { - return new ErrorResponse(errorCode.toString(), errorCode.getMessage()); - } - - public static ErrorResponse of(String code, String message) { - return new ErrorResponse(code, message); - } -} diff --git a/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java b/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java index 0a0b801..b4470eb 100644 --- a/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java +++ b/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java @@ -4,46 +4,41 @@ 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.MissingRequestCookieException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.HashMap; -import java.util.Map; +import java.util.List; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(UserException.class) - public ResponseEntity handleUserException(UserException e) { - ErrorResponse response = ErrorResponse.of(e.getErrorCode()); - return ResponseEntity.status(e.getErrorCode().getStatus()).body(response); + public ResponseEntity> handleUserException(UserException e) { + log.warn("UserException: {}", e.getMessage()); + return ResponseEntity.status(e.getErrorCode().getStatus()).body(ApiResponse.error(e.getErrorCode())); } @ExceptionHandler(MissingRequestCookieException.class) - public ResponseEntity handleMissingCookie(MissingRequestCookieException e) { - ErrorResponse response = ErrorResponse.of("MISSING_COOKIE", "필수 쿠키가 누락되었습니다."); + public ResponseEntity> handleMissingCookie(MissingRequestCookieException e) { + ApiResponse response = ApiResponse.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .code("MISSING_COOKIE") + .message("필수 쿠키가 누락되었습니다.") + .build(); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { - Map errors = new HashMap<>(); - e.getBindingResult().getAllErrors().forEach(error -> { - String fieldName = ((FieldError) error).getField(); - String message = error.getDefaultMessage(); - errors.put(fieldName, message); - }); - return ResponseEntity.badRequest().body(errors); + public ResponseEntity>> handleValidationException(MethodArgumentNotValidException e) { + return ResponseEntity.badRequest().body(ApiResponse.validationError(e.getBindingResult())); } @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception e) { + public ResponseEntity> handleException(Exception e) { log.error("Unhandled exception", e); - ErrorResponse response = ErrorResponse.of("INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.internalError()); } } From c5c32caeca764a8b5a5e7e7f6e5b1095b28d4cdf Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 18 Feb 2026 20:40:14 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Refactor:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AuthService.java | 36 +++++++++++++------ .../application/OAuthService.java | 19 +++++++--- .../user => auth}/domain/AuthErrorCode.java | 2 +- .../domain/PasswordResetConstants.java | 2 +- .../domain}/TokenClaims.java | 2 +- .../domain}/TokenPair.java | 2 +- .../domain/VerificationConstants.java | 2 +- .../event/EmailVerificationSendEvent.java | 2 +- .../event/PasswordResetCreateEvent.java | 2 +- .../infrastructure/jwt}/JwtProvider.java | 6 ++-- .../EmailVerificationEventListener.java | 8 ++--- .../listener/PasswordResetEventListener.java | 8 ++--- .../infrastructure/mail}/MailService.java | 2 +- .../mail}/ResendMailService.java | 2 +- .../infrastructure/oauth}/GoogleUserInfo.java | 2 +- .../infrastructure/oauth}/OAuth2UserInfo.java | 2 +- .../infrastructure/oauth}/OAuthApiClient.java | 2 +- .../infrastructure/oauth}/PkceUtil.java | 2 +- .../redis}/EmailVerificationRepository.java | 2 +- .../redis}/PasswordResetRepository.java | 2 +- .../redis}/PasswordResetTokenGenerator.java | 2 +- .../redis}/SessionInvalidationRepository.java | 2 +- .../redis}/SocialLinkTokenRepository.java | 2 +- .../redis}/TokenBlacklistRepository.java | 2 +- .../redis}/VerificationCodeGenerator.java | 2 +- .../presentation/AuthController.java | 25 ++++++++----- .../presentation/OAuthController.java | 12 +++---- .../dto/request/ChangePasswordRequest.java | 2 +- .../dto/request/EmailVerificationRequest.java | 2 +- .../dto/request/EmailVerifyRequest.java | 2 +- .../dto/request/LoginRequest.java | 2 +- .../request/PasswordResetCreateRequest.java | 2 +- .../dto/request/PasswordResetRequest.java | 2 +- .../dto/request/SignupRequest.java | 2 +- .../dto/request/TokenValidateRequest.java | 2 +- .../dto/response/SocialLinkResponse.java | 4 +-- .../dto/response/SocialLinksResponse.java | 4 +-- .../dto/response/TokenValidateResponse.java | 2 +- .../dto/response/UserResponse.java | 4 +-- .../global/error/GlobalExceptionHandler.java | 2 +- .../exception}/UserException.java | 2 +- .../user/application/UserService.java | 21 ++++++----- .../{domain => }/user/domain/OAuthLink.java | 2 +- .../user/domain/OAuthLinkRepository.java | 2 +- .../user/{domain => }/user/domain/User.java | 2 +- .../user/domain/UserErrorCode.java | 2 +- .../user/domain/UserRepository.java | 2 +- .../user/presentation/UserController.java | 12 +++---- .../dto/request/UpdateProfileRequest.java | 2 +- .../dto/response/MyInfoResponse.java | 4 +-- .../dto/response/UserInfoResponse.java | 4 +-- .../dto/response/UserUpdateResponse.java | 4 +-- .../grpc/GrpcUserQueryService.java | 6 ++-- 53 files changed, 145 insertions(+), 106 deletions(-) rename src/main/java/flipnote/user/{domain/user => auth}/application/AuthService.java (84%) rename src/main/java/flipnote/user/{domain/user => auth}/application/OAuthService.java (87%) rename src/main/java/flipnote/user/{domain/user => auth}/domain/AuthErrorCode.java (98%) rename src/main/java/flipnote/user/{domain/user => auth}/domain/PasswordResetConstants.java (83%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/domain}/TokenClaims.java (65%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/domain}/TokenPair.java (58%) rename src/main/java/flipnote/user/{domain/user => auth}/domain/VerificationConstants.java (83%) rename src/main/java/flipnote/user/{domain/user => auth}/domain/event/EmailVerificationSendEvent.java (64%) rename src/main/java/flipnote/user/{domain/user => auth}/domain/event/PasswordResetCreateEvent.java (64%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/jwt}/JwtProvider.java (94%) rename src/main/java/flipnote/user/{domain/user => auth}/infrastructure/listener/EmailVerificationEventListener.java (74%) rename src/main/java/flipnote/user/{domain/user => auth}/infrastructure/listener/PasswordResetEventListener.java (74%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/mail}/MailService.java (76%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/mail}/ResendMailService.java (97%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/oauth}/GoogleUserInfo.java (92%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/oauth}/OAuth2UserInfo.java (73%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/oauth}/OAuthApiClient.java (98%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/oauth}/PkceUtil.java (95%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/redis}/EmailVerificationRepository.java (97%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/redis}/PasswordResetRepository.java (96%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/redis}/PasswordResetTokenGenerator.java (81%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/redis}/SessionInvalidationRepository.java (94%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/redis}/SocialLinkTokenRepository.java (94%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/redis}/TokenBlacklistRepository.java (94%) rename src/main/java/flipnote/user/{domain/user/infrastructure => auth/infrastructure/redis}/VerificationCodeGenerator.java (87%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/AuthController.java (84%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/OAuthController.java (93%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/request/ChangePasswordRequest.java (89%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/request/EmailVerificationRequest.java (86%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/request/EmailVerifyRequest.java (90%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/request/LoginRequest.java (88%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/request/PasswordResetCreateRequest.java (86%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/request/PasswordResetRequest.java (89%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/request/SignupRequest.java (95%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/request/TokenValidateRequest.java (80%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/response/SocialLinkResponse.java (82%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/response/SocialLinksResponse.java (80%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/response/TokenValidateResponse.java (76%) rename src/main/java/flipnote/user/{domain/user => auth}/presentation/dto/response/UserResponse.java (69%) rename src/main/java/flipnote/user/{domain/user/domain => global/exception}/UserException.java (87%) rename src/main/java/flipnote/user/{domain => }/user/application/UserService.java (71%) rename src/main/java/flipnote/user/{domain => }/user/domain/OAuthLink.java (96%) rename src/main/java/flipnote/user/{domain => }/user/domain/OAuthLinkRepository.java (95%) rename src/main/java/flipnote/user/{domain => }/user/domain/User.java (98%) rename src/main/java/flipnote/user/{domain => }/user/domain/UserErrorCode.java (92%) rename src/main/java/flipnote/user/{domain => }/user/domain/UserRepository.java (92%) rename src/main/java/flipnote/user/{domain => }/user/presentation/UserController.java (77%) rename src/main/java/flipnote/user/{domain => }/user/presentation/dto/request/UpdateProfileRequest.java (91%) rename src/main/java/flipnote/user/{domain => }/user/presentation/dto/response/MyInfoResponse.java (90%) rename src/main/java/flipnote/user/{domain => }/user/presentation/dto/response/UserInfoResponse.java (79%) rename src/main/java/flipnote/user/{domain => }/user/presentation/dto/response/UserUpdateResponse.java (85%) rename src/main/java/flipnote/user/{domain/user => user/presentation}/grpc/GrpcUserQueryService.java (94%) diff --git a/src/main/java/flipnote/user/domain/user/application/AuthService.java b/src/main/java/flipnote/user/auth/application/AuthService.java similarity index 84% rename from src/main/java/flipnote/user/domain/user/application/AuthService.java rename to src/main/java/flipnote/user/auth/application/AuthService.java index 64a73d5..12a5181 100644 --- a/src/main/java/flipnote/user/domain/user/application/AuthService.java +++ b/src/main/java/flipnote/user/auth/application/AuthService.java @@ -1,14 +1,30 @@ -package flipnote.user.domain.user.application; - -import flipnote.user.domain.user.domain.*; -import flipnote.user.domain.user.domain.event.EmailVerificationSendEvent; -import flipnote.user.domain.user.domain.event.PasswordResetCreateEvent; -import flipnote.user.domain.user.infrastructure.*; -import flipnote.user.domain.user.presentation.dto.request.*; -import flipnote.user.domain.user.presentation.dto.response.SocialLinksResponse; -import flipnote.user.domain.user.presentation.dto.response.TokenValidateResponse; -import flipnote.user.domain.user.presentation.dto.response.UserResponse; +package flipnote.user.auth.application; + +import flipnote.user.auth.domain.AuthErrorCode; +import flipnote.user.auth.domain.TokenClaims; +import flipnote.user.auth.domain.TokenPair; +import flipnote.user.auth.domain.event.EmailVerificationSendEvent; +import flipnote.user.auth.domain.event.PasswordResetCreateEvent; +import flipnote.user.auth.infrastructure.jwt.JwtProvider; +import flipnote.user.auth.infrastructure.redis.EmailVerificationRepository; +import flipnote.user.auth.infrastructure.redis.PasswordResetRepository; +import flipnote.user.auth.infrastructure.redis.PasswordResetTokenGenerator; +import flipnote.user.auth.infrastructure.redis.SessionInvalidationRepository; +import flipnote.user.auth.infrastructure.redis.TokenBlacklistRepository; +import flipnote.user.auth.infrastructure.redis.VerificationCodeGenerator; +import flipnote.user.auth.presentation.dto.request.ChangePasswordRequest; +import flipnote.user.auth.presentation.dto.request.LoginRequest; +import flipnote.user.auth.presentation.dto.request.SignupRequest; +import flipnote.user.auth.presentation.dto.response.SocialLinksResponse; +import flipnote.user.auth.presentation.dto.response.TokenValidateResponse; +import flipnote.user.auth.presentation.dto.response.UserResponse; import flipnote.user.global.config.ClientProperties; +import flipnote.user.global.exception.UserException; +import flipnote.user.user.domain.OAuthLink; +import flipnote.user.user.domain.OAuthLinkRepository; +import flipnote.user.user.domain.User; +import flipnote.user.user.domain.UserErrorCode; +import flipnote.user.user.domain.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; diff --git a/src/main/java/flipnote/user/domain/user/application/OAuthService.java b/src/main/java/flipnote/user/auth/application/OAuthService.java similarity index 87% rename from src/main/java/flipnote/user/domain/user/application/OAuthService.java rename to src/main/java/flipnote/user/auth/application/OAuthService.java index a9afaf7..3a6660b 100644 --- a/src/main/java/flipnote/user/domain/user/application/OAuthService.java +++ b/src/main/java/flipnote/user/auth/application/OAuthService.java @@ -1,9 +1,20 @@ -package flipnote.user.domain.user.application; - -import flipnote.user.domain.user.domain.*; -import flipnote.user.domain.user.infrastructure.*; +package flipnote.user.auth.application; + +import flipnote.user.auth.domain.AuthErrorCode; +import flipnote.user.auth.domain.TokenPair; +import flipnote.user.auth.infrastructure.jwt.JwtProvider; +import flipnote.user.auth.infrastructure.oauth.OAuthApiClient; +import flipnote.user.auth.infrastructure.oauth.OAuth2UserInfo; +import flipnote.user.auth.infrastructure.oauth.PkceUtil; +import flipnote.user.auth.infrastructure.redis.SocialLinkTokenRepository; import flipnote.user.global.config.OAuthProperties; import flipnote.user.global.constants.HttpConstants; +import flipnote.user.global.exception.UserException; +import flipnote.user.user.domain.OAuthLink; +import flipnote.user.user.domain.OAuthLinkRepository; +import flipnote.user.user.domain.User; +import flipnote.user.user.domain.UserErrorCode; +import flipnote.user.user.domain.UserRepository; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/flipnote/user/domain/user/domain/AuthErrorCode.java b/src/main/java/flipnote/user/auth/domain/AuthErrorCode.java similarity index 98% rename from src/main/java/flipnote/user/domain/user/domain/AuthErrorCode.java rename to src/main/java/flipnote/user/auth/domain/AuthErrorCode.java index dacff75..6ff29e5 100644 --- a/src/main/java/flipnote/user/domain/user/domain/AuthErrorCode.java +++ b/src/main/java/flipnote/user/auth/domain/AuthErrorCode.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.auth.domain; import flipnote.user.global.error.ErrorCode; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/domain/PasswordResetConstants.java b/src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java similarity index 83% rename from src/main/java/flipnote/user/domain/user/domain/PasswordResetConstants.java rename to src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java index ef5fa9c..94878de 100644 --- a/src/main/java/flipnote/user/domain/user/domain/PasswordResetConstants.java +++ b/src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.auth.domain; import lombok.AccessLevel; import lombok.NoArgsConstructor; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java b/src/main/java/flipnote/user/auth/domain/TokenClaims.java similarity index 65% rename from src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java rename to src/main/java/flipnote/user/auth/domain/TokenClaims.java index f7def37..52bc265 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/TokenClaims.java +++ b/src/main/java/flipnote/user/auth/domain/TokenClaims.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.domain; public record TokenClaims( Long userId, diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java b/src/main/java/flipnote/user/auth/domain/TokenPair.java similarity index 58% rename from src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java rename to src/main/java/flipnote/user/auth/domain/TokenPair.java index dcb5756..81cb9d4 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/TokenPair.java +++ b/src/main/java/flipnote/user/auth/domain/TokenPair.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.domain; public record TokenPair(String accessToken, String refreshToken) { } diff --git a/src/main/java/flipnote/user/domain/user/domain/VerificationConstants.java b/src/main/java/flipnote/user/auth/domain/VerificationConstants.java similarity index 83% rename from src/main/java/flipnote/user/domain/user/domain/VerificationConstants.java rename to src/main/java/flipnote/user/auth/domain/VerificationConstants.java index c262f4e..d92bc97 100644 --- a/src/main/java/flipnote/user/domain/user/domain/VerificationConstants.java +++ b/src/main/java/flipnote/user/auth/domain/VerificationConstants.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.auth.domain; import lombok.AccessLevel; import lombok.NoArgsConstructor; diff --git a/src/main/java/flipnote/user/domain/user/domain/event/EmailVerificationSendEvent.java b/src/main/java/flipnote/user/auth/domain/event/EmailVerificationSendEvent.java similarity index 64% rename from src/main/java/flipnote/user/domain/user/domain/event/EmailVerificationSendEvent.java rename to src/main/java/flipnote/user/auth/domain/event/EmailVerificationSendEvent.java index ba339c5..0373988 100644 --- a/src/main/java/flipnote/user/domain/user/domain/event/EmailVerificationSendEvent.java +++ b/src/main/java/flipnote/user/auth/domain/event/EmailVerificationSendEvent.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain.event; +package flipnote.user.auth.domain.event; public record EmailVerificationSendEvent( String to, diff --git a/src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java b/src/main/java/flipnote/user/auth/domain/event/PasswordResetCreateEvent.java similarity index 64% rename from src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java rename to src/main/java/flipnote/user/auth/domain/event/PasswordResetCreateEvent.java index 8dec546..b1c6daf 100644 --- a/src/main/java/flipnote/user/domain/user/domain/event/PasswordResetCreateEvent.java +++ b/src/main/java/flipnote/user/auth/domain/event/PasswordResetCreateEvent.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain.event; +package flipnote.user.auth.domain.event; public record PasswordResetCreateEvent( String to, diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java b/src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java similarity index 94% rename from src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java rename to src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java index 03054f9..9cd7a6e 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/JwtProvider.java +++ b/src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java @@ -1,6 +1,8 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.jwt; -import flipnote.user.domain.user.domain.User; +import flipnote.user.auth.domain.TokenClaims; +import flipnote.user.auth.domain.TokenPair; +import flipnote.user.user.domain.User; import flipnote.user.global.config.JwtProperties; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java b/src/main/java/flipnote/user/auth/infrastructure/listener/EmailVerificationEventListener.java similarity index 74% rename from src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java rename to src/main/java/flipnote/user/auth/infrastructure/listener/EmailVerificationEventListener.java index 121fd23..d16fdd6 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/listener/EmailVerificationEventListener.java +++ b/src/main/java/flipnote/user/auth/infrastructure/listener/EmailVerificationEventListener.java @@ -1,13 +1,13 @@ -package flipnote.user.domain.user.infrastructure.listener; +package flipnote.user.auth.infrastructure.listener; import org.springframework.context.event.EventListener; import org.springframework.resilience.annotation.Retryable; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; -import flipnote.user.domain.user.domain.VerificationConstants; -import flipnote.user.domain.user.domain.event.EmailVerificationSendEvent; -import flipnote.user.domain.user.infrastructure.MailService; +import flipnote.user.auth.domain.VerificationConstants; +import flipnote.user.auth.domain.event.EmailVerificationSendEvent; +import flipnote.user.auth.infrastructure.mail.MailService; import flipnote.user.global.exception.EmailSendException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java b/src/main/java/flipnote/user/auth/infrastructure/listener/PasswordResetEventListener.java similarity index 74% rename from src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java rename to src/main/java/flipnote/user/auth/infrastructure/listener/PasswordResetEventListener.java index 0fba49d..2518e79 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/listener/PasswordResetEventListener.java +++ b/src/main/java/flipnote/user/auth/infrastructure/listener/PasswordResetEventListener.java @@ -1,13 +1,13 @@ -package flipnote.user.domain.user.infrastructure.listener; +package flipnote.user.auth.infrastructure.listener; import org.springframework.context.event.EventListener; import org.springframework.resilience.annotation.Retryable; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; -import flipnote.user.domain.user.domain.PasswordResetConstants; -import flipnote.user.domain.user.domain.event.PasswordResetCreateEvent; -import flipnote.user.domain.user.infrastructure.MailService; +import flipnote.user.auth.domain.PasswordResetConstants; +import flipnote.user.auth.domain.event.PasswordResetCreateEvent; +import flipnote.user.auth.infrastructure.mail.MailService; import flipnote.user.global.exception.EmailSendException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/MailService.java b/src/main/java/flipnote/user/auth/infrastructure/mail/MailService.java similarity index 76% rename from src/main/java/flipnote/user/domain/user/infrastructure/MailService.java rename to src/main/java/flipnote/user/auth/infrastructure/mail/MailService.java index 0420973..da8c47c 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/MailService.java +++ b/src/main/java/flipnote/user/auth/infrastructure/mail/MailService.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.mail; public interface MailService { diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java b/src/main/java/flipnote/user/auth/infrastructure/mail/ResendMailService.java similarity index 97% rename from src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java rename to src/main/java/flipnote/user/auth/infrastructure/mail/ResendMailService.java index 7d04bc4..4e57af1 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/ResendMailService.java +++ b/src/main/java/flipnote/user/auth/infrastructure/mail/ResendMailService.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.mail; import com.resend.Resend; import com.resend.core.exception.ResendException; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/GoogleUserInfo.java b/src/main/java/flipnote/user/auth/infrastructure/oauth/GoogleUserInfo.java similarity index 92% rename from src/main/java/flipnote/user/domain/user/infrastructure/GoogleUserInfo.java rename to src/main/java/flipnote/user/auth/infrastructure/oauth/GoogleUserInfo.java index 0307a48..b9e5b01 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/GoogleUserInfo.java +++ b/src/main/java/flipnote/user/auth/infrastructure/oauth/GoogleUserInfo.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.oauth; import java.util.Map; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java b/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuth2UserInfo.java similarity index 73% rename from src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java rename to src/main/java/flipnote/user/auth/infrastructure/oauth/OAuth2UserInfo.java index 829d5fd..5a394a5 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/OAuth2UserInfo.java +++ b/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuth2UserInfo.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.oauth; public interface OAuth2UserInfo { diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java b/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuthApiClient.java similarity index 98% rename from src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java rename to src/main/java/flipnote/user/auth/infrastructure/oauth/OAuthApiClient.java index a5f7926..c53c970 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/OAuthApiClient.java +++ b/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuthApiClient.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.oauth; import java.util.Map; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java b/src/main/java/flipnote/user/auth/infrastructure/oauth/PkceUtil.java similarity index 95% rename from src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java rename to src/main/java/flipnote/user/auth/infrastructure/oauth/PkceUtil.java index 7ce310f..544266c 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/PkceUtil.java +++ b/src/main/java/flipnote/user/auth/infrastructure/oauth/PkceUtil.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.oauth; import org.springframework.stereotype.Component; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/EmailVerificationRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java similarity index 97% rename from src/main/java/flipnote/user/domain/user/infrastructure/EmailVerificationRepository.java rename to src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java index 39be219..6218947 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/EmailVerificationRepository.java +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.redis; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetRepository.java similarity index 96% rename from src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java rename to src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetRepository.java index a122e18..c821d5d 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetRepository.java +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetRepository.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.redis; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java b/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java similarity index 81% rename from src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java rename to src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java index 0e1e24a..1e8797f 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/PasswordResetTokenGenerator.java +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.redis; import org.springframework.stereotype.Component; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java similarity index 94% rename from src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java rename to src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java index 006f939..ad93191 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/SessionInvalidationRepository.java +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.redis; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/SocialLinkTokenRepository.java similarity index 94% rename from src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java rename to src/main/java/flipnote/user/auth/infrastructure/redis/SocialLinkTokenRepository.java index 8840231..1c4222b 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/SocialLinkTokenRepository.java +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/SocialLinkTokenRepository.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.redis; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/TokenBlacklistRepository.java similarity index 94% rename from src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java rename to src/main/java/flipnote/user/auth/infrastructure/redis/TokenBlacklistRepository.java index bac7642..9a1d899 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/TokenBlacklistRepository.java +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/TokenBlacklistRepository.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.redis; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; diff --git a/src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java b/src/main/java/flipnote/user/auth/infrastructure/redis/VerificationCodeGenerator.java similarity index 87% rename from src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java rename to src/main/java/flipnote/user/auth/infrastructure/redis/VerificationCodeGenerator.java index 907b0d6..301156d 100644 --- a/src/main/java/flipnote/user/domain/user/infrastructure/VerificationCodeGenerator.java +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/VerificationCodeGenerator.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.infrastructure; +package flipnote.user.auth.infrastructure.redis; import org.springframework.stereotype.Component; diff --git a/src/main/java/flipnote/user/domain/user/presentation/AuthController.java b/src/main/java/flipnote/user/auth/presentation/AuthController.java similarity index 84% rename from src/main/java/flipnote/user/domain/user/presentation/AuthController.java rename to src/main/java/flipnote/user/auth/presentation/AuthController.java index aed02d4..060f8d6 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/AuthController.java +++ b/src/main/java/flipnote/user/auth/presentation/AuthController.java @@ -1,12 +1,19 @@ -package flipnote.user.domain.user.presentation; - -import flipnote.user.domain.user.application.AuthService; -import flipnote.user.domain.user.infrastructure.JwtProvider; -import flipnote.user.domain.user.infrastructure.TokenPair; -import flipnote.user.domain.user.presentation.dto.request.*; -import flipnote.user.domain.user.presentation.dto.response.SocialLinksResponse; -import flipnote.user.domain.user.presentation.dto.response.TokenValidateResponse; -import flipnote.user.domain.user.presentation.dto.response.UserResponse; +package flipnote.user.auth.presentation; + +import flipnote.user.auth.application.AuthService; +import flipnote.user.auth.infrastructure.jwt.JwtProvider; +import flipnote.user.auth.domain.TokenPair; +import flipnote.user.auth.presentation.dto.request.ChangePasswordRequest; +import flipnote.user.auth.presentation.dto.request.EmailVerificationRequest; +import flipnote.user.auth.presentation.dto.request.EmailVerifyRequest; +import flipnote.user.auth.presentation.dto.request.LoginRequest; +import flipnote.user.auth.presentation.dto.request.PasswordResetCreateRequest; +import flipnote.user.auth.presentation.dto.request.PasswordResetRequest; +import flipnote.user.auth.presentation.dto.request.SignupRequest; +import flipnote.user.auth.presentation.dto.request.TokenValidateRequest; +import flipnote.user.auth.presentation.dto.response.SocialLinksResponse; +import flipnote.user.auth.presentation.dto.response.TokenValidateResponse; +import flipnote.user.auth.presentation.dto.response.UserResponse; import flipnote.user.global.constants.HttpConstants; import flipnote.user.global.util.CookieUtil; import jakarta.servlet.http.HttpServletResponse; diff --git a/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java b/src/main/java/flipnote/user/auth/presentation/OAuthController.java similarity index 93% rename from src/main/java/flipnote/user/domain/user/presentation/OAuthController.java rename to src/main/java/flipnote/user/auth/presentation/OAuthController.java index c8fde3e..6f787ed 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/OAuthController.java +++ b/src/main/java/flipnote/user/auth/presentation/OAuthController.java @@ -1,13 +1,13 @@ -package flipnote.user.domain.user.presentation; +package flipnote.user.auth.presentation; -import flipnote.user.domain.user.application.OAuthService; -import flipnote.user.domain.user.domain.AuthErrorCode; -import flipnote.user.domain.user.domain.UserException; -import flipnote.user.domain.user.infrastructure.TokenPair; +import flipnote.user.auth.application.OAuthService; +import flipnote.user.auth.domain.AuthErrorCode; +import flipnote.user.global.exception.UserException; +import flipnote.user.auth.domain.TokenPair; import flipnote.user.global.config.ClientProperties; import flipnote.user.global.constants.HttpConstants; import flipnote.user.global.util.CookieUtil; -import flipnote.user.domain.user.infrastructure.JwtProvider; +import flipnote.user.auth.infrastructure.jwt.JwtProvider; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/ChangePasswordRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/ChangePasswordRequest.java similarity index 89% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/ChangePasswordRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/ChangePasswordRequest.java index fc56b16..68fca89 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/ChangePasswordRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/ChangePasswordRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerificationRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerificationRequest.java similarity index 86% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerificationRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerificationRequest.java index 4b83ba9..9914235 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerificationRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerificationRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerifyRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerifyRequest.java similarity index 90% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerifyRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerifyRequest.java index aca6ad9..add0490 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/EmailVerifyRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerifyRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/LoginRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/LoginRequest.java similarity index 88% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/LoginRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/LoginRequest.java index aebdf47..e84a68e 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/LoginRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/LoginRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetCreateRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetCreateRequest.java similarity index 86% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetCreateRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetCreateRequest.java index 1475c29..805ec9e 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetCreateRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetCreateRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java similarity index 89% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java index 0f4c41f..5bf5a9f 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/PasswordResetRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/SignupRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/SignupRequest.java similarity index 95% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/SignupRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/SignupRequest.java index 029dbe4..a59af8e 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/SignupRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/SignupRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/TokenValidateRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/TokenValidateRequest.java similarity index 80% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/TokenValidateRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/TokenValidateRequest.java index 95f7956..45055ce 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/TokenValidateRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/TokenValidateRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.NotBlank; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinkResponse.java b/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinkResponse.java similarity index 82% rename from src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinkResponse.java rename to src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinkResponse.java index 6038722..1c7f250 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinkResponse.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinkResponse.java @@ -1,7 +1,7 @@ -package flipnote.user.domain.user.presentation.dto.response; +package flipnote.user.auth.presentation.dto.response; import com.fasterxml.jackson.annotation.JsonFormat; -import flipnote.user.domain.user.domain.OAuthLink; +import flipnote.user.user.domain.OAuthLink; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinksResponse.java b/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinksResponse.java similarity index 80% rename from src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinksResponse.java rename to src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinksResponse.java index 0edba13..d5f3bbd 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/response/SocialLinksResponse.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinksResponse.java @@ -1,6 +1,6 @@ -package flipnote.user.domain.user.presentation.dto.response; +package flipnote.user.auth.presentation.dto.response; -import flipnote.user.domain.user.domain.OAuthLink; +import flipnote.user.user.domain.OAuthLink; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/TokenValidateResponse.java b/src/main/java/flipnote/user/auth/presentation/dto/response/TokenValidateResponse.java similarity index 76% rename from src/main/java/flipnote/user/domain/user/presentation/dto/response/TokenValidateResponse.java rename to src/main/java/flipnote/user/auth/presentation/dto/response/TokenValidateResponse.java index b7f846d..6b799b5 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/response/TokenValidateResponse.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/response/TokenValidateResponse.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.response; +package flipnote.user.auth.presentation.dto.response; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserResponse.java b/src/main/java/flipnote/user/auth/presentation/dto/response/UserResponse.java similarity index 69% rename from src/main/java/flipnote/user/domain/user/presentation/dto/response/UserResponse.java rename to src/main/java/flipnote/user/auth/presentation/dto/response/UserResponse.java index 90386d9..66a9541 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserResponse.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/response/UserResponse.java @@ -1,6 +1,6 @@ -package flipnote.user.domain.user.presentation.dto.response; +package flipnote.user.auth.presentation.dto.response; -import flipnote.user.domain.user.domain.User; +import flipnote.user.user.domain.User; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java b/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java index b4470eb..7150a0b 100644 --- a/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java +++ b/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java @@ -1,6 +1,6 @@ package flipnote.user.global.error; -import flipnote.user.domain.user.domain.UserException; +import flipnote.user.global.exception.UserException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/flipnote/user/domain/user/domain/UserException.java b/src/main/java/flipnote/user/global/exception/UserException.java similarity index 87% rename from src/main/java/flipnote/user/domain/user/domain/UserException.java rename to src/main/java/flipnote/user/global/exception/UserException.java index 0f37bd7..b5a1bd8 100644 --- a/src/main/java/flipnote/user/domain/user/domain/UserException.java +++ b/src/main/java/flipnote/user/global/exception/UserException.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.global.exception; import flipnote.user.global.error.ErrorCode; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/application/UserService.java b/src/main/java/flipnote/user/user/application/UserService.java similarity index 71% rename from src/main/java/flipnote/user/domain/user/application/UserService.java rename to src/main/java/flipnote/user/user/application/UserService.java index ffa8526..0461861 100644 --- a/src/main/java/flipnote/user/domain/user/application/UserService.java +++ b/src/main/java/flipnote/user/user/application/UserService.java @@ -1,12 +1,15 @@ -package flipnote.user.domain.user.application; - -import flipnote.user.domain.user.domain.*; -import flipnote.user.domain.user.infrastructure.JwtProvider; -import flipnote.user.domain.user.infrastructure.SessionInvalidationRepository; -import flipnote.user.domain.user.presentation.dto.request.UpdateProfileRequest; -import flipnote.user.domain.user.presentation.dto.response.MyInfoResponse; -import flipnote.user.domain.user.presentation.dto.response.UserInfoResponse; -import flipnote.user.domain.user.presentation.dto.response.UserUpdateResponse; +package flipnote.user.user.application; + +import flipnote.user.auth.infrastructure.jwt.JwtProvider; +import flipnote.user.auth.infrastructure.redis.SessionInvalidationRepository; +import flipnote.user.global.exception.UserException; +import flipnote.user.user.domain.User; +import flipnote.user.user.domain.UserErrorCode; +import flipnote.user.user.domain.UserRepository; +import flipnote.user.user.presentation.dto.request.UpdateProfileRequest; +import flipnote.user.user.presentation.dto.response.MyInfoResponse; +import flipnote.user.user.presentation.dto.response.UserInfoResponse; +import flipnote.user.user.presentation.dto.response.UserUpdateResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/flipnote/user/domain/user/domain/OAuthLink.java b/src/main/java/flipnote/user/user/domain/OAuthLink.java similarity index 96% rename from src/main/java/flipnote/user/domain/user/domain/OAuthLink.java rename to src/main/java/flipnote/user/user/domain/OAuthLink.java index c59ddc6..893b542 100644 --- a/src/main/java/flipnote/user/domain/user/domain/OAuthLink.java +++ b/src/main/java/flipnote/user/user/domain/OAuthLink.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.user.domain; import jakarta.persistence.*; import lombok.AccessLevel; diff --git a/src/main/java/flipnote/user/domain/user/domain/OAuthLinkRepository.java b/src/main/java/flipnote/user/user/domain/OAuthLinkRepository.java similarity index 95% rename from src/main/java/flipnote/user/domain/user/domain/OAuthLinkRepository.java rename to src/main/java/flipnote/user/user/domain/OAuthLinkRepository.java index 4655b9e..fbc55c8 100644 --- a/src/main/java/flipnote/user/domain/user/domain/OAuthLinkRepository.java +++ b/src/main/java/flipnote/user/user/domain/OAuthLinkRepository.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.user.domain; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; diff --git a/src/main/java/flipnote/user/domain/user/domain/User.java b/src/main/java/flipnote/user/user/domain/User.java similarity index 98% rename from src/main/java/flipnote/user/domain/user/domain/User.java rename to src/main/java/flipnote/user/user/domain/User.java index d9441ce..ee76858 100644 --- a/src/main/java/flipnote/user/domain/user/domain/User.java +++ b/src/main/java/flipnote/user/user/domain/User.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.user.domain; import flipnote.user.global.entity.BaseEntity; import jakarta.persistence.*; diff --git a/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java b/src/main/java/flipnote/user/user/domain/UserErrorCode.java similarity index 92% rename from src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java rename to src/main/java/flipnote/user/user/domain/UserErrorCode.java index adad5db..d98aa0c 100644 --- a/src/main/java/flipnote/user/domain/user/domain/UserErrorCode.java +++ b/src/main/java/flipnote/user/user/domain/UserErrorCode.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.user.domain; import flipnote.user.global.error.ErrorCode; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/domain/UserRepository.java b/src/main/java/flipnote/user/user/domain/UserRepository.java similarity index 92% rename from src/main/java/flipnote/user/domain/user/domain/UserRepository.java rename to src/main/java/flipnote/user/user/domain/UserRepository.java index 1cc066d..e59c10d 100644 --- a/src/main/java/flipnote/user/domain/user/domain/UserRepository.java +++ b/src/main/java/flipnote/user/user/domain/UserRepository.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.domain; +package flipnote.user.user.domain; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/flipnote/user/domain/user/presentation/UserController.java b/src/main/java/flipnote/user/user/presentation/UserController.java similarity index 77% rename from src/main/java/flipnote/user/domain/user/presentation/UserController.java rename to src/main/java/flipnote/user/user/presentation/UserController.java index f1329a2..fc53b14 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/UserController.java +++ b/src/main/java/flipnote/user/user/presentation/UserController.java @@ -1,10 +1,10 @@ -package flipnote.user.domain.user.presentation; +package flipnote.user.user.presentation; -import flipnote.user.domain.user.application.UserService; -import flipnote.user.domain.user.presentation.dto.request.UpdateProfileRequest; -import flipnote.user.domain.user.presentation.dto.response.MyInfoResponse; -import flipnote.user.domain.user.presentation.dto.response.UserInfoResponse; -import flipnote.user.domain.user.presentation.dto.response.UserUpdateResponse; +import flipnote.user.user.application.UserService; +import flipnote.user.user.presentation.dto.request.UpdateProfileRequest; +import flipnote.user.user.presentation.dto.response.MyInfoResponse; +import flipnote.user.user.presentation.dto.response.UserInfoResponse; +import flipnote.user.user.presentation.dto.response.UserUpdateResponse; import flipnote.user.global.constants.HttpConstants; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java b/src/main/java/flipnote/user/user/presentation/dto/request/UpdateProfileRequest.java similarity index 91% rename from src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java rename to src/main/java/flipnote/user/user/presentation/dto/request/UpdateProfileRequest.java index 46933ed..2bcbff3 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/request/UpdateProfileRequest.java +++ b/src/main/java/flipnote/user/user/presentation/dto/request/UpdateProfileRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.domain.user.presentation.dto.request; +package flipnote.user.user.presentation.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/MyInfoResponse.java b/src/main/java/flipnote/user/user/presentation/dto/response/MyInfoResponse.java similarity index 90% rename from src/main/java/flipnote/user/domain/user/presentation/dto/response/MyInfoResponse.java rename to src/main/java/flipnote/user/user/presentation/dto/response/MyInfoResponse.java index 788f101..0798946 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/response/MyInfoResponse.java +++ b/src/main/java/flipnote/user/user/presentation/dto/response/MyInfoResponse.java @@ -1,7 +1,7 @@ -package flipnote.user.domain.user.presentation.dto.response; +package flipnote.user.user.presentation.dto.response; import com.fasterxml.jackson.annotation.JsonFormat; -import flipnote.user.domain.user.domain.User; +import flipnote.user.user.domain.User; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java b/src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java similarity index 79% rename from src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java rename to src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java index 775895e..fd08f81 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserInfoResponse.java +++ b/src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java @@ -1,6 +1,6 @@ -package flipnote.user.domain.user.presentation.dto.response; +package flipnote.user.user.presentation.dto.response; -import flipnote.user.domain.user.domain.User; +import flipnote.user.user.domain.User; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserUpdateResponse.java b/src/main/java/flipnote/user/user/presentation/dto/response/UserUpdateResponse.java similarity index 85% rename from src/main/java/flipnote/user/domain/user/presentation/dto/response/UserUpdateResponse.java rename to src/main/java/flipnote/user/user/presentation/dto/response/UserUpdateResponse.java index bb94c70..05d24b8 100644 --- a/src/main/java/flipnote/user/domain/user/presentation/dto/response/UserUpdateResponse.java +++ b/src/main/java/flipnote/user/user/presentation/dto/response/UserUpdateResponse.java @@ -1,6 +1,6 @@ -package flipnote.user.domain.user.presentation.dto.response; +package flipnote.user.user.presentation.dto.response; -import flipnote.user.domain.user.domain.User; +import flipnote.user.user.domain.User; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java b/src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java similarity index 94% rename from src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java rename to src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java index e05153e..54a49de 100644 --- a/src/main/java/flipnote/user/domain/user/grpc/GrpcUserQueryService.java +++ b/src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java @@ -1,7 +1,7 @@ -package flipnote.user.domain.user.grpc; +package flipnote.user.user.presentation.grpc; -import flipnote.user.domain.user.domain.User; -import flipnote.user.domain.user.domain.UserRepository; +import flipnote.user.user.domain.User; +import flipnote.user.user.domain.UserRepository; import flipnote.user.grpc.GetUserRequest; import flipnote.user.grpc.GetUserResponse; import flipnote.user.grpc.GetUsersRequest; From a28e7846360907d274b8db53f097cb80ffd108ce Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 18 Feb 2026 20:42:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?Feat:=20Executor=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flipnote/user/global/config/AppConfig.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/flipnote/user/global/config/AppConfig.java b/src/main/java/flipnote/user/global/config/AppConfig.java index 6ea25fc..c8b5cfe 100644 --- a/src/main/java/flipnote/user/global/config/AppConfig.java +++ b/src/main/java/flipnote/user/global/config/AppConfig.java @@ -2,10 +2,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.client.RestClient; +import java.util.concurrent.Executor; + @Configuration public class AppConfig { @@ -18,4 +21,15 @@ public PasswordEncoder passwordEncoder() { public RestClient restClient() { return RestClient.create(); } + + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(16); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-"); + executor.initialize(); + return executor; + } } From 5ee7b78aab8df643d4c0533f7e4b466a5c1450b9 Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 18 Feb 2026 20:44:28 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Feat:=20=ED=86=A0=ED=81=B0=20=EB=AC=B4?= =?UTF-8?q?=ED=9A=A8=ED=99=94=EC=8B=9C=20=EB=82=A8=EC=9D=80=20ttl=EC=9D=B4?= =?UTF-8?q?=20=EC=97=86=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20=EB=AC=B4=ED=9A=A8?= =?UTF-8?q?=ED=99=94=20=EC=BD=94=EB=93=9C=20=ED=98=B8=EC=B6=9C=20=EC=95=88?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/redis/SessionInvalidationRepository.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java index ad93191..e494496 100644 --- a/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java @@ -16,6 +16,10 @@ public class SessionInvalidationRepository { private final StringRedisTemplate redisTemplate; public void invalidate(Long userId, long ttlMillis) { + if (ttlMillis <= 0) { + return; + } + redisTemplate.opsForValue().set( KEY_PREFIX + userId, String.valueOf(System.currentTimeMillis()),