From 68da97fda7e17f5885d2066fe0de035147c29f16 Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Wed, 21 May 2025 14:26:57 +0900 Subject: [PATCH 01/15] =?UTF-8?q?build:=20.gitignore=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c2065bc..03c8c58 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +src/main/resources/application.yml ### STS ### .apt_generated From b15daca6c867ff71cac7d85dd09aac76659502f3 Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Wed, 21 May 2025 16:59:32 +0900 Subject: [PATCH 02/15] =?UTF-8?q?build:=20OAuth2=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 036b2ad..a73754c 100644 --- a/build.gradle +++ b/build.gradle @@ -36,9 +36,11 @@ dependencies { // auth implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + } tasks.named('test') { From 41c3467b204822ea03b9fc423ea7e90ee610a79a Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Wed, 21 May 2025 16:59:54 +0900 Subject: [PATCH 03/15] =?UTF-8?q?style:=20DTO=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/siljeun/domain/auth/dto/LoginRequestDto.java | 5 ----- .../auth/dto/{LoginResponseDto.java => LoginResponse.java} | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 src/main/java/org/example/siljeun/domain/auth/dto/LoginRequestDto.java rename src/main/java/org/example/siljeun/domain/auth/dto/{LoginResponseDto.java => LoginResponse.java} (81%) diff --git a/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequestDto.java b/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequestDto.java deleted file mode 100644 index 448ba4b..0000000 --- a/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequestDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.siljeun.domain.auth.dto; - -public record LoginRequestDto(String username, String password) { - -} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/auth/dto/LoginResponseDto.java b/src/main/java/org/example/siljeun/domain/auth/dto/LoginResponse.java similarity index 81% rename from src/main/java/org/example/siljeun/domain/auth/dto/LoginResponseDto.java rename to src/main/java/org/example/siljeun/domain/auth/dto/LoginResponse.java index 1990ae2..640e04d 100644 --- a/src/main/java/org/example/siljeun/domain/auth/dto/LoginResponseDto.java +++ b/src/main/java/org/example/siljeun/domain/auth/dto/LoginResponse.java @@ -3,7 +3,7 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor -public class LoginResponseDto { +public class LoginResponse { private final String token; From ff1cf7deff349d8f6f7949c9e82e3cf596814358 Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Wed, 21 May 2025 17:00:07 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=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 --- .../auth/controller/AuthController.java | 8 +- .../siljeun/domain/auth/dto/LoginRequest.java | 5 ++ .../domain/auth/service/AuthService.java | 9 +-- .../domain/oauth/client/KakaoApiClient.java | 73 +++++++++++++++++++ .../oauth/controller/OAuthController.java | 25 +++++++ .../domain/oauth/dto/KakaoUserInfo.java | 14 ++++ .../oauth/service/KakaoOAuthService.java | 41 +++++++++++ .../siljeun/domain/user/entity/User.java | 16 +++- .../user/repository/UserRepository.java | 3 + .../global/config/KakaoOAuthProperties.java | 19 +++++ .../global/config/RestTemplateConfig.java | 15 ++++ .../siljeun/global/config/SecurityConfig.java | 14 +++- .../security/CustomOAuth2SuccessHandler.java | 40 ++++++++++ .../JwtAuthenticationFilter.java | 2 +- .../global/{jwt => security}/JwtUtil.java | 2 +- src/main/resources/application.properties | 1 - 16 files changed, 269 insertions(+), 18 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/auth/dto/LoginRequest.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java create mode 100644 src/main/java/org/example/siljeun/global/config/KakaoOAuthProperties.java create mode 100644 src/main/java/org/example/siljeun/global/config/RestTemplateConfig.java create mode 100644 src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java rename src/main/java/org/example/siljeun/global/{jwt => security}/JwtAuthenticationFilter.java (98%) rename src/main/java/org/example/siljeun/global/{jwt => security}/JwtUtil.java (97%) delete mode 100644 src/main/resources/application.properties diff --git a/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java b/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java index b4acd09..e44d655 100644 --- a/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java +++ b/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java @@ -1,8 +1,8 @@ package org.example.siljeun.domain.auth.controller; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.auth.dto.LoginRequestDto; -import org.example.siljeun.domain.auth.dto.LoginResponseDto; +import org.example.siljeun.domain.auth.dto.LoginRequest; +import org.example.siljeun.domain.auth.dto.LoginResponse; import org.example.siljeun.domain.auth.service.AuthService; import org.example.siljeun.global.dto.ResponseDto; import org.springframework.stereotype.Controller; @@ -18,9 +18,9 @@ public class AuthController { private final AuthService authService; @PostMapping("/login") - public ResponseDto login(@RequestBody LoginRequestDto request) { + public ResponseDto login(@RequestBody LoginRequest request) { try { - LoginResponseDto response = authService.login(request.username(), request.password()); + LoginResponse response = authService.login(request.username(), request.password()); return ResponseDto.success("로그인 성공", response); } catch (Exception e) { return ResponseDto.fail("아이디 또는 비밀번호가 올바르지 않습니다."); diff --git a/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequest.java b/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequest.java new file mode 100644 index 0000000..7a12f6a --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/auth/dto/LoginRequest.java @@ -0,0 +1,5 @@ +package org.example.siljeun.domain.auth.dto; + +public record LoginRequest(String username, String password) { + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java b/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java index 0c2cb48..a056f66 100644 --- a/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java +++ b/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java @@ -1,9 +1,8 @@ package org.example.siljeun.domain.auth.service; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.auth.dto.LoginResponseDto; -import org.example.siljeun.global.dto.ResponseDto; -import org.example.siljeun.global.jwt.JwtUtil; +import org.example.siljeun.domain.auth.dto.LoginResponse; +import org.example.siljeun.global.security.JwtUtil; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.core.Authentication; @@ -17,14 +16,14 @@ public class AuthService { private final AuthenticationManagerBuilder authManagerBuilder; private final JwtUtil jwtUtil; - public LoginResponseDto login(String username, String password) { + public LoginResponse login(String username, String password) { Authentication authentication = authManagerBuilder.getObject() .authenticate(new UsernamePasswordAuthenticationToken(username, password)); SecurityContextHolder.getContext().setAuthentication(authentication); String token = jwtUtil.createToken(username); - return new LoginResponseDto(token); + return new LoginResponse(token); } } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java b/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java new file mode 100644 index 0000000..48f2107 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java @@ -0,0 +1,73 @@ +package org.example.siljeun.domain.oauth.client; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.oauth.dto.KakaoUserInfo; +import org.example.siljeun.global.config.KakaoOAuthProperties; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Component +@RequiredArgsConstructor +public class KakaoApiClient { + + private final RestTemplate restTemplate; + private final KakaoOAuthProperties properties; + + public String getAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", properties.getClientId()); + params.add("redirect_uri", properties.getRedirectUri()); + params.add("code", code); + + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity> response = restTemplate.exchange( + properties.getTokenUri(), + HttpMethod.POST, + request, + new ParameterizedTypeReference<>() { + } + ); + + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + throw new RuntimeException("카카오 Access Token 요청 실패"); + } + + return response.getBody().get("access_token").toString(); + } + + public KakaoUserInfo getUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + properties.getUserInfoUri(), + HttpMethod.GET, + request, + KakaoUserInfo.class + ); + + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + throw new RuntimeException("카카오 사용자 정보 요청 실패"); + } + + return response.getBody(); + } + +} diff --git a/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java b/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java new file mode 100644 index 0000000..ffc0e55 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java @@ -0,0 +1,25 @@ +package org.example.siljeun.domain.oauth.controller; + +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.oauth.service.KakaoOAuthService; +import org.example.siljeun.global.dto.ResponseDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/oauth") +public class OAuthController { + + private final KakaoOAuthService kakaoOAuthService; + + @GetMapping("/kakao/callback") + public ResponseEntity kakaoCallback(@RequestParam String code) { + String jwt = kakaoOAuthService.kakaoLogin(code); + return ResponseEntity.ok(jwt); + } + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java new file mode 100644 index 0000000..c1afefe --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java @@ -0,0 +1,14 @@ +package org.example.siljeun.domain.oauth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class KakaoUserInfo { + + private Long id; + private String email; + private String nickname; + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java b/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java new file mode 100644 index 0000000..ffe393d --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java @@ -0,0 +1,41 @@ +package org.example.siljeun.domain.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.oauth.client.KakaoApiClient; +import org.example.siljeun.domain.oauth.dto.KakaoUserInfo; +import org.example.siljeun.domain.user.entity.User; +import org.example.siljeun.domain.user.enums.Provider; +import org.example.siljeun.domain.user.repository.UserRepository; +import org.example.siljeun.global.security.JwtUtil; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoOAuthService { + + private final KakaoApiClient kakaoApiClient; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + public String kakaoLogin(String code) { + // 1. 액세스 토큰 요청 + String accessToken = kakaoApiClient.getAccessToken(code); + + // 2. 사용자 정보 요청 + KakaoUserInfo userInfo = kakaoApiClient.getUserInfo(accessToken); + + // 3. 회원 가입 또는 로그인 처리 + User user = userRepository.findByEmail(userInfo.getEmail()) + .orElseGet(() -> registerUser(userInfo)); + + // 4. JWT 토큰 발급 + return jwtUtil.createToken(user.getUsername()); + } + + private User registerUser(KakaoUserInfo userInfo) { + User user = new User(userInfo.getEmail(), userInfo.getNickname(), Provider.KAKAO, + userInfo.getId()); + return userRepository.save(user); + } + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/user/entity/User.java b/src/main/java/org/example/siljeun/domain/user/entity/User.java index ac05501..bf4d338 100644 --- a/src/main/java/org/example/siljeun/domain/user/entity/User.java +++ b/src/main/java/org/example/siljeun/domain/user/entity/User.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.LocalDateTime; +import lombok.Builder; import lombok.Getter; import org.example.siljeun.domain.user.enums.Provider; import org.example.siljeun.domain.user.enums.Role; @@ -32,7 +33,10 @@ public class User extends BaseEntity { @Column(nullable = false, length = 255) private String password; - @Column(nullable = false, length = 255) + @Column(nullable = false, length = 10) + private String nickname; + + @Column(length = 255) private String address; @Enumerated(EnumType.STRING) @@ -43,9 +47,15 @@ public class User extends BaseEntity { @Column(nullable = false) private Provider provider; - @Column(name = "provider_id", length = 255) - private String providerId; + private Long providerId; private LocalDateTime deletedAt; + public User(String email, String nickname, Provider provider, Long providerId) { + this.email = email; + this.nickname = nickname; + this.provider = provider; + this.providerId = providerId; + } + } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/user/repository/UserRepository.java b/src/main/java/org/example/siljeun/domain/user/repository/UserRepository.java index bb14960..6a5ec64 100644 --- a/src/main/java/org/example/siljeun/domain/user/repository/UserRepository.java +++ b/src/main/java/org/example/siljeun/domain/user/repository/UserRepository.java @@ -7,4 +7,7 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + + Optional findByEmail(String email); + } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/global/config/KakaoOAuthProperties.java b/src/main/java/org/example/siljeun/global/config/KakaoOAuthProperties.java new file mode 100644 index 0000000..54a47f4 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/config/KakaoOAuthProperties.java @@ -0,0 +1,19 @@ +package org.example.siljeun.global.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "kakao") +public class KakaoOAuthProperties { + + private String clientId; + private String redirectUri; + private String tokenUri; + private String userInfoUri; + +} diff --git a/src/main/java/org/example/siljeun/global/config/RestTemplateConfig.java b/src/main/java/org/example/siljeun/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..d8c7359 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/config/RestTemplateConfig.java @@ -0,0 +1,15 @@ +package org.example.siljeun.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + +} diff --git a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java index 5068c7a..8976d6a 100644 --- a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java +++ b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java @@ -2,8 +2,9 @@ import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.user.service.CustomUserDetailsService; -import org.example.siljeun.global.jwt.JwtAuthenticationFilter; -import org.example.siljeun.global.jwt.JwtUtil; +import org.example.siljeun.global.security.CustomOAuth2SuccessHandler; +import org.example.siljeun.global.security.JwtAuthenticationFilter; +import org.example.siljeun.global.security.JwtUtil; import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -19,17 +20,24 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final CustomUserDetailsService userDetailsService; + private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/auth/**", "/oauth2/**", "/login/**").permitAll() .anyRequest().authenticated() ) + .oauth2Login(oauth2 -> oauth2 + .successHandler(customOAuth2SuccessHandler) + .defaultSuccessUrl("/auth/oauth2/success", true) + .failureUrl("/auth/oauth2/failure") + ) .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService), UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java b/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java new file mode 100644 index 0000000..f4f70c2 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java @@ -0,0 +1,40 @@ +package org.example.siljeun.global.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.security.oauth2.core.user.OAuth2User; + + +@Component +@RequiredArgsConstructor +public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + // 1. principal에서 사용자 정보 추출 + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String username = "kakao_" + oAuth2User.getAttribute("id").toString(); + + // 2. JWT 생성 + String token = jwtUtil.createToken(username); + + // 3. JSON 응답 세팅 + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + + // 4. JWT를 JSON 응답 바디로 내려주기 + String json = String.format("{\"token\":\"%s\"}", token); + response.getWriter().write(json); + response.getWriter().flush(); + } + +} diff --git a/src/main/java/org/example/siljeun/global/jwt/JwtAuthenticationFilter.java b/src/main/java/org/example/siljeun/global/security/JwtAuthenticationFilter.java similarity index 98% rename from src/main/java/org/example/siljeun/global/jwt/JwtAuthenticationFilter.java rename to src/main/java/org/example/siljeun/global/security/JwtAuthenticationFilter.java index 31439ce..e8334a3 100644 --- a/src/main/java/org/example/siljeun/global/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/example/siljeun/global/security/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package org.example.siljeun.global.jwt; +package org.example.siljeun.global.security; import jakarta.annotation.Nonnull; import jakarta.servlet.FilterChain; diff --git a/src/main/java/org/example/siljeun/global/jwt/JwtUtil.java b/src/main/java/org/example/siljeun/global/security/JwtUtil.java similarity index 97% rename from src/main/java/org/example/siljeun/global/jwt/JwtUtil.java rename to src/main/java/org/example/siljeun/global/security/JwtUtil.java index adad086..fe6090f 100644 --- a/src/main/java/org/example/siljeun/global/jwt/JwtUtil.java +++ b/src/main/java/org/example/siljeun/global/security/JwtUtil.java @@ -1,4 +1,4 @@ -package org.example.siljeun.global.jwt; +package org.example.siljeun.global.security; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 272c2a3..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=siljeun From 20ef6efb486e6c20f8616b2a0b358b852b23995f Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Thu, 22 May 2025 14:29:31 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=EC=A1=B0=20=EC=9E=A1?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/oauth/client/NaverApiClient.java | 5 +++++ .../domain/oauth/dto/NaverUserInfo.java | 5 +++++ .../oauth/service/NaverOAuthService.java | 8 ++++++++ .../global/config/NaverOAuthProperties.java | 20 +++++++++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java create mode 100644 src/main/java/org/example/siljeun/global/config/NaverOAuthProperties.java diff --git a/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java b/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java new file mode 100644 index 0000000..88c1713 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java @@ -0,0 +1,5 @@ +package org.example.siljeun.domain.oauth.client; + +public class NaverApiClient { + +} diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java new file mode 100644 index 0000000..3aff8c2 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java @@ -0,0 +1,5 @@ +package org.example.siljeun.domain.oauth.dto; + +public class NaverUserInfo { + +} diff --git a/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java b/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java new file mode 100644 index 0000000..0fec45e --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java @@ -0,0 +1,8 @@ +package org.example.siljeun.domain.oauth.service; + +import org.springframework.stereotype.Service; + +@Service +public class NaverOAuthService { + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/global/config/NaverOAuthProperties.java b/src/main/java/org/example/siljeun/global/config/NaverOAuthProperties.java new file mode 100644 index 0000000..2ca3fbc --- /dev/null +++ b/src/main/java/org/example/siljeun/global/config/NaverOAuthProperties.java @@ -0,0 +1,20 @@ +package org.example.siljeun.global.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "naver") +public class NaverOAuthProperties { + + private String clientId; + private String clientSecret; + private String redirectUri; + private String tokenUri; + private String userInfoUri; + +} \ No newline at end of file From e3fc7a0f0a83d793a26b127f008fab0368405b03 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Fri, 23 May 2025 21:52:35 +0900 Subject: [PATCH 06/15] =?UTF-8?q?refactor=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=95=A0=EB=A7=A4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20api=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReservationController.java | 9 ---- .../service/ReservationService.java | 41 ------------------- 2 files changed, 50 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java index 02e9343..34b1f58 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java +++ b/src/main/java/org/example/siljeun/domain/reservation/controller/ReservationController.java @@ -44,13 +44,4 @@ public ResponseEntity> findById( ReservationInfoResponse dto = reservationService.findById(username, reservationId); return ResponseEntity.ok(ResponseDto.success("예매 조회 성공", dto)); } - - @PostMapping() - public ResponseEntity> createReservation( - @RequestBody @Valid ReservationCreateRequest reservationCreateRequest, - @AuthenticationPrincipal PrincipalDetails userDetails - ){ - reservationService.createReservation(reservationCreateRequest, userDetails.getUserId()); - return ResponseEntity.ok(ResponseDto.success("결제 진행하기", null)); - } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 23c38b2..0aac498 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -87,45 +87,4 @@ public ReservationInfoResponse findById(String username, Long reservationId) { return ReservationInfoResponse.from(reservation); } - - @Transactional - public void createReservation(ReservationCreateRequest reservationCreateRequest, Long userId){ - - //유저 확인 - User user = userRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException("유저를 찾을 수 없습니다.")); - - Long scheduleId = reservationCreateRequest.scheduleId(); - //유저가 해당 회차에 선택한 좌석 검증 - String redisSelectedKey = "user:scheduleSelected" + userId + ":" + scheduleId; - log.info("예매 정보 생성 시도 user : " + userId + "scheduleId : " + scheduleId + " key) " + redisSelectedKey ); - String selectedId = redisTemplate.opsForValue().get(redisSelectedKey); - - if (selectedId == null) { - throw new IllegalStateException("선택한 좌석이 없습니다."); - } - - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(Long.valueOf(selectedId)) - .orElseThrow(() -> new EntityNotFoundException("좌석 정보를 찾을 수 없습니다.")); - - //해당 좌석의 상태 검증 - String redisStatusHashKey = "seatStatus:" + scheduleId; - Object redisStatusObj = redisTemplate.opsForHash().get(redisStatusHashKey, selectedId); - - if (redisStatusObj == null || !redisStatusObj.toString().equals(SeatStatus.SELECTED.name())) { - throw new IllegalStateException("좌석 상태가 유효하지 않습니다. 다시 선택해주세요."); - } - - //예매 정보 생성 - Reservation reservation = new Reservation(user, seatScheduleInfo); - reservationRepository.save(reservation); - - //좌석 상태 결제 진행 중으로 변경 - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.HOLD); - seatScheduleInfoRepository.save(seatScheduleInfo); - redisTemplate.opsForHash().put(redisStatusHashKey, selectedId, SeatStatus.HOLD.name()); - - //유저가 선점한 좌석 정보 - 결제 진행 상태일 때의 만료 시간 1시간 - redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(60)); - } } From 1ceb711409a762a2709e22e58d7d02f43c2100b7 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Fri, 23 May 2025 23:55:11 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat=20:=20SeateSchedulerInfo=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C,=20redisKey=20=EC=83=9D=EC=84=B1=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SeatScheduleInfoService.java | 33 ++++++++++++------- .../siljeun/global/util/RedisKeyProvider.java | 11 +++++++ 2 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index 56cc1d9..d0b1ce9 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -11,9 +11,11 @@ import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; import org.example.siljeun.global.lock.DistributedLock; +import org.example.siljeun.global.util.RedisKeyProvider; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.time.Duration; @@ -37,20 +39,17 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { Schedule schedule = seatScheduleInfo.getSchedule(); if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ - log.info("예매 미오픈."); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "예매 불가능한 시간입니다. 예매 오픈 시간 : " + schedule.getTicketingStartTime()); } if (!seatScheduleInfo.isAvailable()) { - log.info("이미 선점된 좌석입니다."); throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); } seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); seatScheduleInfoRepository.save(seatScheduleInfo); - //userId와 schedule Id가 key이고 seatSchduleInfoId로 구성된 value인 형태로 저장 - String redisSelectedKey = "user:scheduleSelected" + userId + ":" + scheduleId; + String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "1인당 1개의 좌석만 예약 가능합니다."); @@ -59,10 +58,8 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { redisTemplate.opsForValue().set(redisSelectedKey, seatScheduleInfoId.toString()); redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); - //seatScheduleInfoId의 seatStatus 상태 변경 - String redisHashKey = "seatStatus:" + scheduleId; - redisTemplate.opsForHash().put(redisHashKey, seatScheduleInfoId.toString(), SeatStatus.SELECTED.name()); - log.info("redisHashKey : " + redisHashKey + " = " + " redisSelectedKey : " + redisSelectedKey); + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + redisTemplate.opsForHash().put(redisKey, seatScheduleInfoId.toString(), SeatStatus.SELECTED.name()); } public Map getSeatStatusMap(Long scheduleId) { @@ -77,9 +74,8 @@ public Map getSeatStatusMap(Long scheduleId) { .map(info -> info.getId().toString()) .toList(); - String redisHashKey = "seatStatus:" + scheduleId; - List redisStatuses = redisTemplate.opsForHash().multiGet(redisHashKey, new ArrayList<>(fieldKeys)); - + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + List redisStatuses = redisTemplate.opsForHash().multiGet(redisKey, new ArrayList<>(fieldKeys)); Map seatStatusMap = new HashMap<>(); for (int i = 0; i < seatScheduleInfos.size(); i++) { @@ -102,7 +98,7 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); - String redisHashKey = "seatStatus:" + schedule.getId(); + String redisHashKey = RedisKeyProvider.seatStatusKey(scheduleId); Map seatStatusMap = new HashMap<>(); for (SeatScheduleInfo seat : seatInfos) { @@ -111,4 +107,17 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); } + + @Transactional + public void updateSeatSchedulerInfoStatus(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ + String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); + String fieldKey = seatScheduleInfoId.toString(); + + String status = (String) redisTemplate.opsForHash().get(redisKey, fieldKey); + + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + + seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); + } } diff --git a/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java b/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java new file mode 100644 index 0000000..cd33c65 --- /dev/null +++ b/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java @@ -0,0 +1,11 @@ +package org.example.siljeun.global.util; + +public class RedisKeyProvider { + public static String seatStatusKey(Long scheduleId){ + return "seatStatus:" + scheduleId; + } + + public static String userSelectedSeatKey(Long userId, Long scheduleId){ + return "user:"+userId+":scheduleSelected:"+scheduleId; + } +} From 1c327750f2ebc867a1143d2d546a7701614c44c2 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sat, 24 May 2025 00:41:24 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor=20:=20=ED=9A=8C=EC=B0=A8?= =?UTF-8?q?=EB=B3=84=20=EC=A2=8C=EC=84=9D=20=EC=83=81=ED=83=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentService.java | 5 +++++ .../service/ReservationService.java | 2 +- .../SeatScheduleInfoController.java | 22 ++++++++++++------- .../service/SeatScheduleInfoService.java | 10 +++++---- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java b/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java index 3f778fd..d1bac49 100644 --- a/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java +++ b/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java @@ -5,6 +5,8 @@ import org.example.siljeun.domain.payment.entity.Payment; import org.example.siljeun.domain.payment.repository.PaymentRepository; import org.example.siljeun.domain.reservation.service.ReservationService; +import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.service.SeatScheduleInfoService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +16,7 @@ public class PaymentService { private final PaymentRepository paymentRepository; private final ReservationService reservationService; + private final SeatScheduleInfoService seatScheduleInfoService; @Transactional public void savePayment(PaymentConfirmRequestDto dto) { @@ -24,6 +27,8 @@ public void savePayment(PaymentConfirmRequestDto dto) { .build(); paymentRepository.save(payment); + + seatScheduleInfoService.updateSeatSchedulerInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); reservationService.save(dto.getUserId(), dto.getSeatScheduleInfoId()); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 0aac498..fca72f8 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -72,7 +72,7 @@ public void delete(String username, Long reservationId) { } reservationRepository.delete(reservation); - reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE); + reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.CANCELLED); } public ReservationInfoResponse findById(String username, Long reservationId) { diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java index e19e85f..da72620 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java @@ -1,27 +1,25 @@ package org.example.siljeun.domain.seatscheduleinfo.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.seatscheduleinfo.dto.request.SeatScheduleUpdateStatusRequest; import org.example.siljeun.domain.seatscheduleinfo.service.SeatScheduleInfoService; import org.example.siljeun.global.dto.ResponseDto; import org.example.siljeun.global.security.PrincipalDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; import java.util.Map; @Controller @RequiredArgsConstructor -@RequestMapping("/schedules/{scheduleId}") public class SeatScheduleInfoController { private final SeatScheduleInfoService seatScheduleInfoService; - @PostMapping("/seat-schedule-infos") + @PostMapping("/schedules/{scheduleId}/seat-schedule-infos") public ResponseEntity> forceSeatScheduleInfoInRedis( @PathVariable Long scheduleId ) @@ -30,7 +28,7 @@ public ResponseEntity> forceSeatScheduleInfoInRedis( return ResponseEntity.ok(ResponseDto.success("Redis 적재 완료 scheduleId : " + scheduleId, null)); } - @PostMapping("/seat-schedule-infos/{seatScheduleInfoId}") + @PostMapping("/schedules/{scheduleId}/seat-schedule-infos/{seatScheduleInfoId}") public ResponseEntity> selectSeat( @PathVariable Long scheduleId, @PathVariable Long seatScheduleInfoId, @@ -40,10 +38,18 @@ public ResponseEntity> selectSeat( return ResponseEntity.ok(ResponseDto.success( "좌석이 선택되었습니다. seatScheduleInfoId : " + seatScheduleInfoId.toString(), null)); } - @GetMapping("/seat-schedule-infos") + @GetMapping("/schedules/{scheduleId}/seat-schedule-infos") public ResponseEntity> getSeatScheduleInfos( @PathVariable Long scheduleId ) { return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); } + + @PatchMapping("/seat-schedule-infos") + public ResponseEntity> updateSeatScheduleInfoStatus( + @RequestBody @Valid SeatScheduleUpdateStatusRequest seatScheduleRequest + ){ + seatScheduleInfoService.updateSeatSchedulerInfoStatus(seatScheduleRequest.seatScheduleInfoId(), seatScheduleRequest.status()); + return ResponseEntity.ok(ResponseDto.success("좌석의 상태가 변경되었습니다.", null)); + } } diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index d0b1ce9..bb1b661 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -109,15 +109,17 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ } @Transactional - public void updateSeatSchedulerInfoStatus(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ + public void updateSeatSchedulerInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus){ + + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + + Long scheduleId = seatScheduleInfo.getSchedule().getId(); String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); String fieldKey = seatScheduleInfoId.toString(); String status = (String) redisTemplate.opsForHash().get(redisKey, fieldKey); - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); - seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); } } From aec9f1f5bcf974c69fb2d2b312360452ffec9ee5 Mon Sep 17 00:00:00 2001 From: jiyun-im-dev Date: Sat, 24 May 2025 14:01:39 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=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 --- .../auth/controller/AuthController.java | 3 + .../domain/auth/service/AuthService.java | 4 +- ...aoApiClient.java => KakaoOAuthClient.java} | 56 +++++++++--------- .../domain/oauth/client/NaverApiClient.java | 5 -- .../oauth/controller/OAuthController.java | 16 +++-- .../domain/oauth/dto/KakaoAccessToken.java | 13 ++++ .../domain/oauth/dto/KakaoAccount.java | 12 ++++ .../domain/oauth/dto/KakaoProfile.java | 12 ++++ .../domain/oauth/dto/KakaoUserInfo.java | 45 +++----------- .../domain/oauth/dto/NaverUserInfo.java | 5 -- .../domain/oauth/dto/OAuth2UserInfo.java | 17 ------ .../oauth/service/KakaoOAuthService.java | 59 +++++++++++++++---- .../oauth/service/NaverOAuthService.java | 8 --- .../siljeun/domain/user/entity/User.java | 16 +++-- .../siljeun/global/config/SecurityConfig.java | 15 +++-- .../security/CustomOAuth2SuccessHandler.java | 7 +++ .../global/security/PrincipalDetails.java | 7 ++- 17 files changed, 168 insertions(+), 132 deletions(-) rename src/main/java/org/example/siljeun/domain/oauth/client/{KakaoApiClient.java => KakaoOAuthClient.java} (50%) delete mode 100644 src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccessToken.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccount.java create mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/KakaoProfile.java delete mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java delete mode 100644 src/main/java/org/example/siljeun/domain/oauth/dto/OAuth2UserInfo.java delete mode 100644 src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java diff --git a/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java b/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java index 0ee8150..24d458e 100644 --- a/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java +++ b/src/main/java/org/example/siljeun/domain/auth/controller/AuthController.java @@ -1,6 +1,7 @@ package org.example.siljeun.domain.auth.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.example.siljeun.domain.auth.dto.request.LoginRequest; import org.example.siljeun.domain.auth.dto.request.SignUpRequest; import org.example.siljeun.domain.auth.dto.response.LoginResponse; @@ -17,6 +18,7 @@ @RestController @RequestMapping("/auth") @RequiredArgsConstructor +@Slf4j public class AuthController { private final AuthService authService; @@ -33,6 +35,7 @@ public ResponseEntity signUp(@RequestBody SignUpRequest request) @PostMapping("/login") public ResponseDto login(@RequestBody LoginRequest request) { try { + log.debug("----- 로그인 메서드 실행 -----"); LoginResponse response = authService.login(request.username(), request.password()); return ResponseDto.success("로그인 성공", response); } catch (Exception e) { diff --git a/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java b/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java index 98cbc21..e17f34b 100644 --- a/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java +++ b/src/main/java/org/example/siljeun/domain/auth/service/AuthService.java @@ -24,8 +24,8 @@ public SignUpResponse signUp(SignUpRequest request) { String password = passwordEncoder.encode(request.password()); // 회원 생성 및 저장 - User user = new User(request.email(), request.username(), password, request.nickname(), - request.role(), request.provider()); + User user = new User(request.email(), request.username(), password, request.name(), + request.nickname(), request.role(), request.provider()); User savedUser = userRepository.save(user); return new SignUpResponse(savedUser.getId(), savedUser.getEmail(), savedUser.getUsername()); diff --git a/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java b/src/main/java/org/example/siljeun/domain/oauth/client/KakaoOAuthClient.java similarity index 50% rename from src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java rename to src/main/java/org/example/siljeun/domain/oauth/client/KakaoOAuthClient.java index 3d1f01a..f76628c 100644 --- a/src/main/java/org/example/siljeun/domain/oauth/client/KakaoApiClient.java +++ b/src/main/java/org/example/siljeun/domain/oauth/client/KakaoOAuthClient.java @@ -1,16 +1,13 @@ package org.example.siljeun.domain.oauth.client; -import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.oauth.dto.KakaoAccessToken; import org.example.siljeun.domain.oauth.dto.KakaoUserInfo; -import org.example.siljeun.global.config.KakaoOAuthProperties; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -18,56 +15,57 @@ @Component @RequiredArgsConstructor -public class KakaoApiClient { +@Slf4j +public class KakaoOAuthClient { private final RestTemplate restTemplate; - private final KakaoOAuthProperties properties; + // 현재 카카오 API 서버에서 인가 코드를 제공한 상태이다 + // 서비스 서버가 인가 코드를 이용해 카카오 API 서버로 액세스 토큰을 요청한다 public String getAccessToken(String code) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap params = new LinkedMultiValueMap<>(); + // 아래 4가지 값은 필수 params.add("grant_type", "authorization_code"); - params.add("client_id", "eaee0e144aeb9afef54d5c449448baea"); - params.add("redirect_uri", "http://localhost:8080/oauth/kakao/callback"); - params.add("code", code); + params.add("client_id", "eaee0e144aeb9afef54d5c449448baea"); // 카카오 REST API 키 + params.add("redirect_uri", "http://localhost:8080/oauth/kakao/callback"); // 여기서 문제 발생? + params.add("code", code); // 인가 코드 HttpEntity> request = new HttpEntity<>(params, headers); - ResponseEntity> response = restTemplate.exchange( + // 명시한 URL로 (인가 코드를 담은) POST 요청을 보내면 카카오 API 서버에서 액세스 토큰을 응답한다 + KakaoAccessToken response = restTemplate.postForEntity( "https://kauth.kakao.com/oauth/token", - HttpMethod.POST, request, - new ParameterizedTypeReference<>() { - } - ); + KakaoAccessToken.class + ).getBody(); - if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { - throw new RuntimeException("카카오 Access Token 요청 실패"); - } + log.debug("----- 액세스 토큰: {} -----", response.accessToken()); - return response.getBody().get("access_token").toString(); + return response.accessToken(); } + // 서비스 서버가 카카오 인증 서버에 저장된 회원 정보를 요청한다 public KakaoUserInfo getUserInfo(String accessToken) { - HttpHeaders headers = new HttpHeaders(); + // HTTP 헤더 설정 + final HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - HttpEntity request = new HttpEntity<>(headers); + // 설정한 HTTP 헤더를 이용해 요청 생성 + final HttpEntity request = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange( + // GET 메서드로 회원 정보를 요청한 후 KakaoUserInfo 객체에 담음 + final KakaoUserInfo response = restTemplate.exchange( "https://kapi.kakao.com/v2/user/me", HttpMethod.GET, request, KakaoUserInfo.class - ); - - if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { - throw new RuntimeException("카카오 사용자 정보 요청 실패"); - } + ).getBody(); - return response.getBody(); + return response; } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java b/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java deleted file mode 100644 index 88c1713..0000000 --- a/src/main/java/org/example/siljeun/domain/oauth/client/NaverApiClient.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.siljeun.domain.oauth.client; - -public class NaverApiClient { - -} diff --git a/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java b/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java index ffc0e55..4c11678 100644 --- a/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java +++ b/src/main/java/org/example/siljeun/domain/oauth/controller/OAuthController.java @@ -1,8 +1,9 @@ package org.example.siljeun.domain.oauth.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.auth.dto.response.LoginResponse; import org.example.siljeun.domain.oauth.service.KakaoOAuthService; -import org.example.siljeun.global.dto.ResponseDto; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -12,14 +13,21 @@ @RestController @RequiredArgsConstructor @RequestMapping("/oauth") +@Slf4j public class OAuthController { private final KakaoOAuthService kakaoOAuthService; + /* + 1. 클라이언트가 카카오 로그인을 요청한다 + 2. /oauth/kakao/callback?code={code}로 리다이렉트된다 + 3. 이때 카카오에서 쿼리 스트링으로 인가 코드를 넘겨준다 + 4. 넘어온 인가 코드를 이용해서 카카오 로그인 API를 호출한다 + */ @GetMapping("/kakao/callback") - public ResponseEntity kakaoCallback(@RequestParam String code) { - String jwt = kakaoOAuthService.kakaoLogin(code); - return ResponseEntity.ok(jwt); + public ResponseEntity kakaoCallback(@RequestParam String code) { + log.debug("---------- METHOD: kakaoCallback ----------"); + return ResponseEntity.ok(kakaoOAuthService.kakaoLogin(code)); } } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccessToken.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccessToken.java new file mode 100644 index 0000000..ad8ad55 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccessToken.java @@ -0,0 +1,13 @@ +package org.example.siljeun.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoAccessToken(String tokenType, + String accessToken, + Integer expiresIn, + String refreshToken, + Integer refreshTokenExpiresIn) { + +} diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccount.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccount.java new file mode 100644 index 0000000..0f271d1 --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoAccount.java @@ -0,0 +1,12 @@ +package org.example.siljeun.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoAccount( + KakaoProfile profile, // 프로필 정보(닉네임, 프로필 사진) + String email +) { + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoProfile.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoProfile.java new file mode 100644 index 0000000..c85a06c --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoProfile.java @@ -0,0 +1,12 @@ +package org.example.siljeun.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoProfile( + String nickname, + String profileImageUrl +) { + +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java index f5c855f..f6660c1 100644 --- a/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java +++ b/src/main/java/org/example/siljeun/domain/oauth/dto/KakaoUserInfo.java @@ -1,41 +1,12 @@ package org.example.siljeun.domain.oauth.dto; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class KakaoUserInfo implements OAuth2UserInfo { - - private Map attributes; - private Map attributesAccount; - private Map attributesProfile; - - public KakaoUserInfo(Map attributes) { - this.attributes = attributes; - this.attributesAccount = (Map) attributes.get("kakao_account"); - this.attributesProfile = (Map) attributesAccount.get("profile"); - } - - @Override - public String getProvider() { - return "Kakao"; - } - - @Override - public String getProviderId() { - return attributes.get("id").toString(); - } - - @Override - public String getEmail() { - return attributesAccount.get("email").toString(); - } - - @Override - public String getNickname() { - return attributesProfile.get("nickname").toString(); - } +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record KakaoUserInfo( + Long id, // 회원 번호 + KakaoAccount kakaoAccount // 카카오 계정 정보 +) { } \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java deleted file mode 100644 index 3aff8c2..0000000 --- a/src/main/java/org/example/siljeun/domain/oauth/dto/NaverUserInfo.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.example.siljeun.domain.oauth.dto; - -public class NaverUserInfo { - -} diff --git a/src/main/java/org/example/siljeun/domain/oauth/dto/OAuth2UserInfo.java b/src/main/java/org/example/siljeun/domain/oauth/dto/OAuth2UserInfo.java deleted file mode 100644 index e47bd09..0000000 --- a/src/main/java/org/example/siljeun/domain/oauth/dto/OAuth2UserInfo.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.example.siljeun.domain.oauth.dto; - -import java.util.Map; - -public interface OAuth2UserInfo { - - public Map getAttributes(); - - String getProvider(); - - String getProviderId(); - - String getEmail(); - - String getNickname(); - -} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java b/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java index 8776f1c..cbd4cb0 100644 --- a/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java +++ b/src/main/java/org/example/siljeun/domain/oauth/service/KakaoOAuthService.java @@ -1,40 +1,73 @@ package org.example.siljeun.domain.oauth.service; import lombok.RequiredArgsConstructor; -import org.example.siljeun.domain.oauth.client.KakaoApiClient; +import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.auth.dto.response.LoginResponse; +import org.example.siljeun.domain.oauth.client.KakaoOAuthClient; import org.example.siljeun.domain.oauth.dto.KakaoUserInfo; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.enums.Provider; +import org.example.siljeun.domain.user.enums.Role; import org.example.siljeun.domain.user.repository.UserRepository; import org.example.siljeun.global.security.JwtUtil; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor +@Slf4j public class KakaoOAuthService { - private final KakaoApiClient kakaoApiClient; + private final KakaoOAuthClient kakaoOAuthClient; private final UserRepository userRepository; private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; - public String kakaoLogin(String code) { - // 1. 액세스 토큰 요청 - String accessToken = kakaoApiClient.getAccessToken(code); + // 인가 코드를 이용해 카카오 로그인 API를 호출한다 + public LoginResponse kakaoLogin(String code) { + // 1. 카카오에 인가 코드를 넘겨서 액세스 토큰을 획득한다 + log.debug("----- 액세스 토큰 발급 -----"); + final String accessToken = kakaoOAuthClient.getAccessToken(code); - // 2. 사용자 정보 요청 - KakaoUserInfo userInfo = kakaoApiClient.getUserInfo(accessToken); + // 2. 카카오에 액세스 토큰을 넘겨서 카카오에 저장된 사용자 정보를 획득한다 + log.debug("----- 사용자 정보 획득 -----"); + final KakaoUserInfo userInfo = kakaoOAuthClient.getUserInfo(accessToken); - // 3. 회원 가입 또는 로그인 처리 - User user = userRepository.findByEmail(userInfo.getEmail()) + // 3. 해당 정보를 이용해 회원 가입 또는 로그인을 처리한다 + log.debug("----- 회원 가입 또는 로그인 -----"); + User user = userRepository.findByEmail(userInfo.kakaoAccount().email()) .orElseGet(() -> registerUser(userInfo)); - // 4. JWT 토큰 발급 - return jwtUtil.createToken(user.getUsername()); + // 4. 서비스 서버에 저장된 회원 정보를 이용해 JWT 토큰을 발급받는다 + log.debug("----- JWT 토큰 발급 -----"); + String token = jwtUtil.createToken(user.getUsername()); + + return new LoginResponse(token); } private User registerUser(KakaoUserInfo userInfo) { - User user = new User(userInfo.getEmail(), userInfo.getNickname(), Provider.KAKAO, - userInfo.getProviderId()); + String username = "kakao" + userInfo.id(); + String password = passwordEncoder.encode(username); + User user = new User( + userInfo.kakaoAccount().email(), + username, + password, + userInfo.kakaoAccount().profile().nickname(), + userInfo.kakaoAccount().profile().nickname(), + Role.USER, + Provider.KAKAO, + userInfo.id() + ); + log.debug("--------------------회원 가입 메서드 실행--------------------"); + log.debug("email: {}, username: {}, password: {}, name: {}, nickname: {}, id: {}", + userInfo.kakaoAccount().email(), + username, + password, + userInfo.kakaoAccount().profile().nickname(), + userInfo.kakaoAccount().profile().nickname(), + userInfo.id() + ); + return userRepository.save(user); } diff --git a/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java b/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java deleted file mode 100644 index 0fec45e..0000000 --- a/src/main/java/org/example/siljeun/domain/oauth/service/NaverOAuthService.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.example.siljeun.domain.oauth.service; - -import org.springframework.stereotype.Service; - -@Service -public class NaverOAuthService { - -} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/domain/user/entity/User.java b/src/main/java/org/example/siljeun/domain/user/entity/User.java index 14ec94f..575d8c1 100644 --- a/src/main/java/org/example/siljeun/domain/user/entity/User.java +++ b/src/main/java/org/example/siljeun/domain/user/entity/User.java @@ -51,23 +51,31 @@ public class User extends BaseEntity { @Column(nullable = false) private Provider provider; - private String providerId; + private Long providerId; private LocalDateTime deletedAt; - public User(String email, String username, String password, String nickname, Role role, - Provider provider) { + // 로컬 회원 가입용 생성자 + public User(String email, String username, String password, String name, String nickname, + Role role, Provider provider) { this.email = email; this.username = username; this.password = password; + this.name = name; this.nickname = nickname; this.role = role; this.provider = provider; } - public User(String email, String nickname, Provider provider, String providerId) { + // 소셜 회원 가입용 생성자 + public User(String email, String username, String password, String name, String nickname, + Role role, Provider provider, Long providerId) { this.email = email; + this.username = username; + this.password = password; + this.name = name; this.nickname = nickname; + this.role = role; this.provider = provider; this.providerId = providerId; } diff --git a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java index 9afd186..70a80e3 100644 --- a/src/main/java/org/example/siljeun/global/config/SecurityConfig.java +++ b/src/main/java/org/example/siljeun/global/config/SecurityConfig.java @@ -31,14 +31,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/**", "/oauth2/**", "/login/**", "/ws/**", "/ws","/checkout.html","/payments","/success.html").permitAll() + .requestMatchers("/auth/**", "/oauth/**", "/oauth2/**", "/login/**", "/ws/**", "/ws", + "/checkout.html", "/payments", "/success.html").permitAll() .anyRequest().authenticated() ) -// .oauth2Login(oauth2 -> oauth2 -// .successHandler(customOAuth2SuccessHandler) -// .defaultSuccessUrl("/auth/oauth2/success", true) -// .failureUrl("/auth/oauth2/failure") -// ) + .oauth2Login(oauth2 -> oauth2 + .successHandler(customOAuth2SuccessHandler) + .defaultSuccessUrl("/auth/oauth2/success") + .failureUrl("/auth/oauth2/failure") + ) +// .formLogin(form -> form +// .loginPage("/login")) .addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService), UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java b/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java index dff18e0..8a32da3 100644 --- a/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java +++ b/src/main/java/org/example/siljeun/global/security/CustomOAuth2SuccessHandler.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; @@ -14,6 +15,7 @@ @Component @RequiredArgsConstructor +@Slf4j public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler { private final JwtUtil jwtUtil; @@ -21,6 +23,8 @@ public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + log.debug("----- 로그인 성공 -----"); + // principal에서 사용자 정보 추출 OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); String username = "kakao_" + oAuth2User.getAttribute("id").toString(); @@ -41,6 +45,9 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.setStatus(HttpServletResponse.SC_OK); response.getWriter().write("{\"message\": \"Login successful\"}"); response.getWriter().flush(); + + // 기본 경로로 리다이렉트 + response.sendRedirect("/"); } } diff --git a/src/main/java/org/example/siljeun/global/security/PrincipalDetails.java b/src/main/java/org/example/siljeun/global/security/PrincipalDetails.java index 40c0d2a..dc7e71c 100644 --- a/src/main/java/org/example/siljeun/global/security/PrincipalDetails.java +++ b/src/main/java/org/example/siljeun/global/security/PrincipalDetails.java @@ -2,12 +2,14 @@ import java.util.Collection; import java.util.List; +import lombok.Getter; import lombok.RequiredArgsConstructor; import org.example.siljeun.domain.user.entity.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +@Getter @RequiredArgsConstructor public class PrincipalDetails implements UserDetails { @@ -49,7 +51,8 @@ public boolean isCredentialsNonExpired() { @Override public boolean isEnabled() { - return user.getDeletedAt() != null; + // 삭제되지 않은 계정은 모두 활성화된 것으로 취급 + return user.getDeletedAt() == null; } -} +} \ No newline at end of file From 94f37d4e38c5fca5c18b720e9ee03c33fc330dac Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sat, 24 May 2025 17:41:56 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat=20:=20TTL=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReservationService.java | 2 +- .../siljeun/domain/seat/enums/SeatStatus.java | 2 +- .../domain/seat/service/SeatService.java | 2 +- .../service/SeatScheduleInfoService.java | 74 +++++++++++++------ .../siljeun/global/util/RedisKeyProvider.java | 14 +++- 5 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index fca72f8..0aac498 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -72,7 +72,7 @@ public void delete(String username, Long reservationId) { } reservationRepository.delete(reservation); - reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.CANCELLED); + reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE); } public ReservationInfoResponse findById(String username, Long reservationId) { diff --git a/src/main/java/org/example/siljeun/domain/seat/enums/SeatStatus.java b/src/main/java/org/example/siljeun/domain/seat/enums/SeatStatus.java index 9941c06..9bd5302 100644 --- a/src/main/java/org/example/siljeun/domain/seat/enums/SeatStatus.java +++ b/src/main/java/org/example/siljeun/domain/seat/enums/SeatStatus.java @@ -2,7 +2,7 @@ public enum SeatStatus { BLOCKED, //미판매 - CANCELLED, //취소 + CANCELLED, //취소 - 취소표는 특정 시간대에 한 번에 풀어놓는 상태를 고려하여 넣어놓았으나 현재 구현 상태에서는 사용하지 않음 RESERVED, //예매됨 HOLD, //결제 진행 중 SELECTED, //선택됨 diff --git a/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java index 13e60b3..1a3e3f8 100644 --- a/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java +++ b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java @@ -15,12 +15,12 @@ @Service @RequiredArgsConstructor +@Transactional public class SeatService { private final VenueRepository venueRepository; private final SeatRepository seatRepository; - @Transactional public void createSeats(Long venueId, List seatCreateRequests){ Venue venue = venueRepository.findById(venueId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 공연장을 찾을 수 없습니다.")); //Throw 예외 설정 필요 diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index bb1b661..21d3a13 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -33,11 +33,11 @@ public class SeatScheduleInfoService { @DistributedLock(key = "'seat:' + #seatScheduleInfoId") public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { + //예외 상황 처리 SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); Schedule schedule = seatScheduleInfo.getSchedule(); - if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "예매 불가능한 시간입니다. 예매 오픈 시간 : " + schedule.getTicketingStartTime()); } @@ -46,20 +46,29 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); } - seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); - seatScheduleInfoRepository.save(seatScheduleInfo); - String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); - if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "1인당 1개의 좌석만 예약 가능합니다."); } - redisTemplate.opsForValue().set(redisSelectedKey, seatScheduleInfoId.toString()); + //DB 상태 변경 + seatScheduleInfo.updateSeatScheduleInfoStatus(SeatStatus.SELECTED); + seatScheduleInfoRepository.save(seatScheduleInfo); + + //유저가 선점한 좌석을 Redis에 저장 (정보 조회용) + redisTemplate.opsForValue() + .set(redisSelectedKey, seatScheduleInfoId.toString()); redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); - String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); - redisTemplate.opsForHash().put(redisKey, seatScheduleInfoId.toString(), SeatStatus.SELECTED.name()); + //TTL 관리를 위한 키 생성 + String redisLockKey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); + redisTemplate.opsForValue().set(redisLockKey, userId.toString()); + + //Redis 상태 변경 + updateSeatSchedulerInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); + + //TTL 적용 + applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); } public Map getSeatStatusMap(Long scheduleId) { @@ -67,8 +76,10 @@ public Map getSeatStatusMap(Long scheduleId) { Schedule schedule = scheduleRepository.findById(scheduleId) .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); - List seatScheduleInfos = - seatScheduleInfoRepository.findAllBySchedule(schedule); + List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); + if(seatScheduleInfos.isEmpty()){ + throw new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다."); + } List fieldKeys = seatScheduleInfos.stream() .map(info -> info.getId().toString()) @@ -76,8 +87,8 @@ public Map getSeatStatusMap(Long scheduleId) { String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); List redisStatuses = redisTemplate.opsForHash().multiGet(redisKey, new ArrayList<>(fieldKeys)); - Map seatStatusMap = new HashMap<>(); + Map seatStatusMap = new HashMap<>(); for (int i = 0; i < seatScheduleInfos.size(); i++) { SeatScheduleInfo info = seatScheduleInfos.get(i); Object redisStatusObj = redisStatuses.get(i); @@ -108,18 +119,39 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); } - @Transactional - public void updateSeatSchedulerInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus){ - - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); - - Long scheduleId = seatScheduleInfo.getSchedule().getId(); + public void updateSeatSchedulerInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); String fieldKey = seatScheduleInfoId.toString(); + redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus); + } - String status = (String) redisTemplate.opsForHash().get(redisKey, fieldKey); - - seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); + private void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ + String member = seatScheduleInfoId.toString(); + + String seatLockkey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); + String zsetSelectedKey = RedisKeyProvider.trackExpiresKey(SeatStatus.SELECTED.name()); + String zsetHoldKey = RedisKeyProvider.trackExpiresKey(SeatStatus.HOLD.name()); + + Duration ttl = null; + long nowMillis = System.currentTimeMillis(); + + redisTemplate.opsForZSet().remove(zsetSelectedKey, member); + redisTemplate.opsForZSet().remove(zsetHoldKey, member); + + switch(seatStatus){ + case SELECTED: + ttl = Duration.ofMinutes(5); + redisTemplate.expire(seatLockkey, ttl); + redisTemplate.opsForZSet().add(zsetSelectedKey, member, nowMillis+ttl.toMillis()); + break; + case HOLD: + ttl = Duration.ofMinutes(60); + redisTemplate.expire(seatLockkey, ttl); + redisTemplate.opsForZSet().add(zsetHoldKey, member, nowMillis+ttl.toMillis()); + break; + default: + redisTemplate.persist(seatLockkey); + break; + } } } diff --git a/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java b/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java index cd33c65..1d5f31f 100644 --- a/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java +++ b/src/main/java/org/example/siljeun/global/util/RedisKeyProvider.java @@ -1,11 +1,23 @@ package org.example.siljeun.global.util; public class RedisKeyProvider { + + //회차에 따른 회차별 좌석 정보 Id와 상태 public static String seatStatusKey(Long scheduleId){ return "seatStatus:" + scheduleId; } + //유저가 선점한 특정 회차의 좌석 상태 정보 Id public static String userSelectedSeatKey(Long userId, Long scheduleId){ - return "user:"+userId+":scheduleSelected:"+scheduleId; + return "user:"+userId+":schedule:"+scheduleId; + } + + //회차별 좌석 상태 정보 점유중 + public static String seatOccupyKey(Long seatScheduleInfoId){ + return "seat:occupy:"+seatScheduleInfoId; + } + + public static String trackExpiresKey(String status){ + return "expires:"+status; } } From 7d9f41611349e3d5a4e4098d06a291dba9312617 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sat, 24 May 2025 19:51:14 +0900 Subject: [PATCH 11/15] =?UTF-8?q?feat=20:=20TTL=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EB=90=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=A7=81=20=EA=B8=B0=EB=8A=A5=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 --- .../payment/service/PaymentService.java | 2 +- .../service/ReservationService.java | 3 - .../SeatScheduleInfoController.java | 14 +-- .../scheduler/SeatExpirationScheduler.java | 87 +++++++++++++++++++ .../siljeun/global/config/RedisConfig.java | 2 + 5 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java diff --git a/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java b/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java index d1bac49..adfba62 100644 --- a/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java +++ b/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java @@ -28,7 +28,7 @@ public void savePayment(PaymentConfirmRequestDto dto) { paymentRepository.save(payment); - seatScheduleInfoService.updateSeatSchedulerInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); + //seatScheduleInfoService.updateSeatSchedulerInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); reservationService.save(dto.getUserId(), dto.getSeatScheduleInfoId()); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 0aac498..603479d 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -15,7 +15,6 @@ import org.example.siljeun.domain.seat.enums.SeatStatus; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.repository.UserRepository; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,8 +29,6 @@ public class ReservationService { private final UserRepository userRepository; private final WaitingQueueService waitingQueueService; private final SeatScheduleInfoRepository seatScheduleInfoRepository; - private final RedisTemplate redisTemplate; - @Transactional public void save(Long userId, Long seatScheduleInfoId) { diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java index da72620..f7e8a2d 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/controller/SeatScheduleInfoController.java @@ -45,11 +45,11 @@ public ResponseEntity> getSeatScheduleInfos( return ResponseEntity.ok(seatScheduleInfoService.getSeatStatusMap(scheduleId)); } - @PatchMapping("/seat-schedule-infos") - public ResponseEntity> updateSeatScheduleInfoStatus( - @RequestBody @Valid SeatScheduleUpdateStatusRequest seatScheduleRequest - ){ - seatScheduleInfoService.updateSeatSchedulerInfoStatus(seatScheduleRequest.seatScheduleInfoId(), seatScheduleRequest.status()); - return ResponseEntity.ok(ResponseDto.success("좌석의 상태가 변경되었습니다.", null)); - } +// @PatchMapping("/seat-schedule-infos") +// public ResponseEntity> updateSeatScheduleInfoStatus( +// @RequestBody @Valid SeatScheduleUpdateStatusRequest seatScheduleRequest +// ){ +// seatScheduleInfoService.updateSeatSchedulerInfoStatus(seatScheduleRequest.seatScheduleInfoId(), seatScheduleRequest.status()); +// return ResponseEntity.ok(ResponseDto.success("좌석의 상태가 변경되었습니다.", null)); +// } } diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java new file mode 100644 index 0000000..15115cd --- /dev/null +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java @@ -0,0 +1,87 @@ +package org.example.siljeun.domain.seatscheduleinfo.scheduler; + +import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; +import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; +import org.example.siljeun.global.util.RedisKeyProvider; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class SeatExpirationScheduler { + + private final RedisTemplate redisTemplate; + private final SeatScheduleInfoRepository seatScheduleInfoRepository; + + @Scheduled(fixedDelay = 60_000) + public void expireSeatsToAvailable() { + long now = System.currentTimeMillis(); + expireByStatus(SeatStatus.SELECTED, now); + expireByStatus(SeatStatus.HOLD, now); + } + + private void expireByStatus(SeatStatus status, long nowMillis) { + String zsetKey = RedisKeyProvider.trackExpiresKey(status.name()); + //상태-> 좌석Id들 1, 2, 3, 4,..... + 만료 시간 + //중에서 만료 시간이 지금 이전인 것들 조회 + Set expiredIds = redisTemplate + .opsForZSet() + .rangeByScore(zsetKey, 0, nowMillis); + if (expiredIds == null || expiredIds.isEmpty()) { + + return; + } + + //만료 시간이 지난 Id들을 Long 타입으로 변경하고 실제 객체를 가져와서 상태를 변경 후 저장 + List ids = expiredIds.stream() + .map(Long::valueOf) + .toList(); + List infos = seatScheduleInfoRepository.findAllById(ids); + infos.forEach(info -> info.updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE)); + seatScheduleInfoRepository.saveAll(infos); + + final Map> hashBatch = new HashMap<>(); + for (SeatScheduleInfo info : infos) { + String hashKey = RedisKeyProvider.seatStatusKey(info.getSchedule().getId()); + hashBatch + .computeIfAbsent(hashKey, k -> new HashMap<>()) + .put(info.getId().toString(), SeatStatus.AVAILABLE); + } + + RedisCallback pipelineWork = connection -> { + // ZSET 제거 + connection.zRem( + zsetKey.getBytes(), + expiredIds.stream() + .map(String::getBytes) + .toArray(byte[][]::new) + ); + + // 해시 업데이트 + for (Map.Entry> e : hashBatch.entrySet()) { + byte[] hashKey = redisTemplate.getStringSerializer().serialize(e.getKey()); + Map serialized = new HashMap<>(); + e.getValue().forEach((field, value) -> + serialized.put( + redisTemplate.getStringSerializer().serialize(field.toString()), + redisTemplate.getStringSerializer().serialize(value.toString()) + ) + ); + connection.hMSet(hashKey, serialized); + } + + return null; + }; + + redisTemplate.executePipelined(pipelineWork); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/siljeun/global/config/RedisConfig.java b/src/main/java/org/example/siljeun/global/config/RedisConfig.java index 4e97908..ab73ffd 100644 --- a/src/main/java/org/example/siljeun/global/config/RedisConfig.java +++ b/src/main/java/org/example/siljeun/global/config/RedisConfig.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; @@ -59,6 +60,7 @@ public RedisTemplate redisJsonTemplate(RedisConnectionFactory co } @Bean + @Primary RedisTemplate redisStringTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); From 83475ee1f6a43bd9ff1084548a9c5d792f0449b2 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sun, 25 May 2025 07:56:54 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat=20:=20=EC=98=88=EB=A7=A4=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C,=20=EA=B2=B0=EC=A0=9C=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=97=90=20=EC=A2=8C=EC=84=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentService.java | 4 ++- .../service/ReservationService.java | 11 +++++--- .../service/SeatScheduleInfoService.java | 26 +++++++++++++++---- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java b/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java index adfba62..dfb99e6 100644 --- a/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java +++ b/src/main/java/org/example/siljeun/domain/payment/service/PaymentService.java @@ -28,7 +28,9 @@ public void savePayment(PaymentConfirmRequestDto dto) { paymentRepository.save(payment); - //seatScheduleInfoService.updateSeatSchedulerInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); + seatScheduleInfoService.updateSeatScheduleInfoStatus(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); + seatScheduleInfoService.applySeatLockTTL(dto.getSeatScheduleInfoId(), SeatStatus.RESERVED); + reservationService.save(dto.getUserId(), dto.getSeatScheduleInfoId()); } } diff --git a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java index 603479d..442f7aa 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java +++ b/src/main/java/org/example/siljeun/domain/reservation/service/ReservationService.java @@ -13,6 +13,7 @@ import org.example.siljeun.domain.seatscheduleinfo.repository.SeatScheduleInfoRepository; import org.example.siljeun.domain.seatscheduleinfo.entity.SeatScheduleInfo; import org.example.siljeun.domain.seat.enums.SeatStatus; +import org.example.siljeun.domain.seatscheduleinfo.service.SeatScheduleInfoService; import org.example.siljeun.domain.user.entity.User; import org.example.siljeun.domain.user.repository.UserRepository; import org.springframework.stereotype.Service; @@ -28,14 +29,13 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final UserRepository userRepository; private final WaitingQueueService waitingQueueService; - private final SeatScheduleInfoRepository seatScheduleInfoRepository; + private final SeatScheduleInfoService seatScheduleInfoService; @Transactional public void save(Long userId, Long seatScheduleInfoId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER)); - SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoService.findById(seatScheduleInfoId); Reservation reservation = new Reservation(user, seatScheduleInfo); reservationRepository.save(reservation); @@ -68,8 +68,11 @@ public void delete(String username, Long reservationId) { throw new CustomException(ErrorCode.INVALID_RESERVATION_USER); } + Long seatScheduleInfoId = reservation.getSeatScheduleInfo().getId(); reservationRepository.delete(reservation); - reservation.getSeatScheduleInfo().updateSeatScheduleInfoStatus(SeatStatus.AVAILABLE); + + seatScheduleInfoService.updateSeatScheduleInfoStatus(seatScheduleInfoId, SeatStatus.AVAILABLE); + seatScheduleInfoService.applySeatLockTTL(seatScheduleInfoId, SeatStatus.AVAILABLE); } public ReservationInfoResponse findById(String username, Long reservationId) { diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index 21d3a13..1c5eda1 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -3,6 +3,8 @@ import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.siljeun.domain.reservation.exception.CustomException; +import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.schedule.entity.Schedule; import org.example.siljeun.domain.schedule.repository.ScheduleRepository; import org.example.siljeun.domain.seat.entity.Seat; @@ -35,7 +37,7 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { //예외 상황 처리 SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId). - orElseThrow(() -> new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다.")); + orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); Schedule schedule = seatScheduleInfo.getSchedule(); if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ @@ -65,7 +67,7 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { redisTemplate.opsForValue().set(redisLockKey, userId.toString()); //Redis 상태 변경 - updateSeatSchedulerInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); + updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); //TTL 적용 applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); @@ -78,7 +80,7 @@ public Map getSeatStatusMap(Long scheduleId) { List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); if(seatScheduleInfos.isEmpty()){ - throw new EntityNotFoundException("해당 회차별 좌석 정보가 존재하지 않습니다."); + throw new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO); } List fieldKeys = seatScheduleInfos.stream() @@ -118,14 +120,23 @@ public void forceSeatScheduleInfoInRedis(Long scheduleId){ redisTemplate.opsForHash().putAll(redisHashKey, seatStatusMap); } + @Transactional + public void updateSeatScheduleInfoStatus(Long seatScheduleInfoId, SeatStatus seatStatus){ + SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + seatScheduleInfo.updateSeatScheduleInfoStatus(seatStatus); + + Long scheduleId = seatScheduleInfo.getSchedule().getId(); + updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, seatStatus); + } - public void updateSeatSchedulerInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ + public void updateSeatScheduleInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); String fieldKey = seatScheduleInfoId.toString(); redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus); } - private void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ + public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ String member = seatScheduleInfoId.toString(); String seatLockkey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); @@ -154,4 +165,9 @@ private void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ break; } } + + public SeatScheduleInfo findById(Long seatScheduleInfoId){ + return seatScheduleInfoRepository.findById(seatScheduleInfoId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUNT_SEAT_SCHEDULE_INFO)); + } } From d9c6e55bcc723febad46771b9b830d97795924fc Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Sun, 25 May 2025 08:08:16 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor(Seat,=20SeatScheduleInfo)=20:=20?= =?UTF-8?q?CustomExeption=20=EC=98=88=EC=99=B8=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/reservation/exception/ErrorCode.java | 8 ++++++++ .../siljeun/domain/seat/service/SeatService.java | 6 ++++-- .../service/SeatScheduleInfoService.java | 10 +++++----- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java index 6c0b852..90cf25a 100644 --- a/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java +++ b/src/main/java/org/example/siljeun/domain/reservation/exception/ErrorCode.java @@ -18,6 +18,14 @@ public enum ErrorCode { // seatScheduleInfo NOT_FOUNT_SEAT_SCHEDULE_INFO(404, "해당 공연에 대한 좌석 정보가 존재하지 않습니다."), + ALREADY_SELECTED_SEAT(409, "이미 선점된 좌석입니다."), + SEAT_LIMIT_ONE_PER_USER(409, "1인당 1개의 좌석만 예약 가능합니다."), + + // venue + NOT_FOUND_VENUE(404, "해당 공연장을 찾을 수 없습니다."), + + // seat + SEAT_CAPACITY_EXCEEDED(400, "좌석 수가 공연장 수용 인원(capacity)을 초과했습니다."), // jwt UNAUTHORIZED(401, "토큰이 유효하지 않습니다."), diff --git a/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java index 1a3e3f8..75a8ff0 100644 --- a/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java +++ b/src/main/java/org/example/siljeun/domain/seat/service/SeatService.java @@ -1,6 +1,8 @@ package org.example.siljeun.domain.seat.service; import lombok.RequiredArgsConstructor; +import org.example.siljeun.domain.reservation.exception.CustomException; +import org.example.siljeun.domain.reservation.exception.ErrorCode; import org.example.siljeun.domain.seat.dto.request.SeatCreateRequest; import org.example.siljeun.domain.seat.entity.Seat; import org.example.siljeun.domain.venue.entity.Venue; @@ -23,10 +25,10 @@ public class SeatService { public void createSeats(Long venueId, List seatCreateRequests){ Venue venue = venueRepository.findById(venueId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 공연장을 찾을 수 없습니다.")); //Throw 예외 설정 필요 + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_VENUE)); if (seatCreateRequests.size() > venue.getSeatCapacity()) { - throw new IllegalArgumentException("좌석 수가 공연장 수용 인원(capacity)을 초과했습니다."); + throw new CustomException(ErrorCode.SEAT_CAPACITY_EXCEEDED); } //공연장 ID, 구역, 열, 번호를 바탕으로 고유하도록 설정하였으나 //좌석 정보가 중복되는 경우를 다루지 않아 추후 리팩토링이 필요함 diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index 1c5eda1..e8820e6 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -41,16 +41,16 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { Schedule schedule = seatScheduleInfo.getSchedule(); if(schedule.getTicketingStartTime().isAfter(LocalDateTime.now())){ - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "예매 불가능한 시간입니다. 예매 오픈 시간 : " + schedule.getTicketingStartTime()); + throw new CustomException(ErrorCode.NOT_TICKETING_TIME); } if (!seatScheduleInfo.isAvailable()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 선점된 좌석입니다."); + throw new CustomException(ErrorCode.ALREADY_SELECTED_SEAT); } String redisSelectedKey = RedisKeyProvider.userSelectedSeatKey(userId, scheduleId); if (Boolean.TRUE.equals(redisTemplate.hasKey(redisSelectedKey))) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "1인당 1개의 좌석만 예약 가능합니다."); + throw new CustomException(ErrorCode.SEAT_LIMIT_ONE_PER_USER); } //DB 상태 변경 @@ -76,7 +76,7 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { public Map getSeatStatusMap(Long scheduleId) { Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); List seatScheduleInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); if(seatScheduleInfos.isEmpty()){ @@ -107,7 +107,7 @@ public Map getSeatStatusMap(Long scheduleId) { public void forceSeatScheduleInfoInRedis(Long scheduleId){ Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new EntityNotFoundException("해당 회차가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE)); List seatInfos = seatScheduleInfoRepository.findAllBySchedule(schedule); From 03142ea2975d5066ebb859d4dc2ed5d0f1166118 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Mon, 26 May 2025 10:23:30 +0900 Subject: [PATCH 14/15] =?UTF-8?q?refactor=20:=20Redis=20Value=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=98=95=EC=8B=9D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/SeatExpirationScheduler.java | 2 +- .../service/SeatScheduleInfoService.java | 13 +++++++------ .../example/siljeun/global/config/RedisConfig.java | 2 ++ src/main/resources/static/checkout.html | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java index 15115cd..4f27231 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/scheduler/SeatExpirationScheduler.java @@ -54,7 +54,7 @@ private void expireByStatus(SeatStatus status, long nowMillis) { String hashKey = RedisKeyProvider.seatStatusKey(info.getSchedule().getId()); hashBatch .computeIfAbsent(hashKey, k -> new HashMap<>()) - .put(info.getId().toString(), SeatStatus.AVAILABLE); + .put(info.getId().toString(), SeatStatus.AVAILABLE.name()); } RedisCallback pipelineWork = connection -> { diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index e8820e6..761542c 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -60,17 +60,18 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { //유저가 선점한 좌석을 Redis에 저장 (정보 조회용) redisTemplate.opsForValue() .set(redisSelectedKey, seatScheduleInfoId.toString()); - redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); + redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(1)); //TTL 관리를 위한 키 생성 String redisLockKey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); redisTemplate.opsForValue().set(redisLockKey, userId.toString()); - + log.info("Redis 상태 변경 시작"); //Redis 상태 변경 updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); - + log.info("Redis 상태 변경 끝"); //TTL 적용 applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); + log.info("추적 시작"); } public Map getSeatStatusMap(Long scheduleId) { @@ -99,7 +100,7 @@ public Map getSeatStatusMap(Long scheduleId) { ? redisStatusObj.toString() : seatScheduleInfos.get(i).getStatus().name(); - seatStatusMap.put("seatScheduleInfo-" + info.getId().toString(), status); + seatStatusMap.put(info.getId().toString(), status); } return seatStatusMap; @@ -133,7 +134,7 @@ public void updateSeatScheduleInfoStatus(Long seatScheduleInfoId, SeatStatus sea public void updateSeatScheduleInfoStatusInRedis(Long scheduleId, Long seatScheduleInfoId, SeatStatus seatStatus){ String redisKey = RedisKeyProvider.seatStatusKey(scheduleId); String fieldKey = seatScheduleInfoId.toString(); - redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus); + redisTemplate.opsForHash().put(redisKey, fieldKey, seatStatus.name()); } public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ @@ -151,7 +152,7 @@ public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ switch(seatStatus){ case SELECTED: - ttl = Duration.ofMinutes(5); + ttl = Duration.ofMinutes(1); redisTemplate.expire(seatLockkey, ttl); redisTemplate.opsForZSet().add(zsetSelectedKey, member, nowMillis+ttl.toMillis()); break; diff --git a/src/main/java/org/example/siljeun/global/config/RedisConfig.java b/src/main/java/org/example/siljeun/global/config/RedisConfig.java index ab73ffd..9097b30 100644 --- a/src/main/java/org/example/siljeun/global/config/RedisConfig.java +++ b/src/main/java/org/example/siljeun/global/config/RedisConfig.java @@ -66,6 +66,8 @@ RedisTemplate redisStringTemplate(RedisConnectionFactory connect template.setConnectionFactory(connectionFactory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); return template; } } diff --git a/src/main/resources/static/checkout.html b/src/main/resources/static/checkout.html index c0d9c69..f3a1dd6 100644 --- a/src/main/resources/static/checkout.html +++ b/src/main/resources/static/checkout.html @@ -37,7 +37,7 @@ // ------ 주문의 결제 금액 설정 ------ await widgets.setAmount({ currency: "KRW", - value: 50000, + value: 5000, }); await Promise.all([ From 22129918ecef9eee659f145c166b0ea2a0546774 Mon Sep 17 00:00:00 2001 From: crocusia <132359536+crocusia@users.noreply.github.com> Date: Mon, 26 May 2025 10:30:05 +0900 Subject: [PATCH 15/15] =?UTF-8?q?refactor=20:=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SeatScheduleInfoService.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java index 761542c..85baad7 100644 --- a/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java +++ b/src/main/java/org/example/siljeun/domain/seatscheduleinfo/service/SeatScheduleInfoService.java @@ -60,18 +60,17 @@ public void selectSeat(Long userId, Long scheduleId, Long seatScheduleInfoId) { //유저가 선점한 좌석을 Redis에 저장 (정보 조회용) redisTemplate.opsForValue() .set(redisSelectedKey, seatScheduleInfoId.toString()); - redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(1)); + redisTemplate.expire(redisSelectedKey, Duration.ofMinutes(5)); //TTL 관리를 위한 키 생성 String redisLockKey = RedisKeyProvider.seatOccupyKey(seatScheduleInfoId); redisTemplate.opsForValue().set(redisLockKey, userId.toString()); - log.info("Redis 상태 변경 시작"); + //Redis 상태 변경 updateSeatScheduleInfoStatusInRedis(scheduleId, seatScheduleInfoId, SeatStatus.SELECTED); - log.info("Redis 상태 변경 끝"); + //TTL 적용 applySeatLockTTL(seatScheduleInfoId, SeatStatus.SELECTED); - log.info("추적 시작"); } public Map getSeatStatusMap(Long scheduleId) { @@ -152,7 +151,7 @@ public void applySeatLockTTL(Long seatScheduleInfoId, SeatStatus seatStatus){ switch(seatStatus){ case SELECTED: - ttl = Duration.ofMinutes(1); + ttl = Duration.ofMinutes(5); redisTemplate.expire(seatLockkey, ttl); redisTemplate.opsForZSet().add(zsetSelectedKey, member, nowMillis+ttl.toMillis()); break;