diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 812d7804..a2a9ac47 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,23 +1,15 @@ -## TO-DO +## 해결하려는 문제가 무엇인가요? -어떤 것을 하셨나요? -어떤 것 작업했는지 작성해주세요! 아래는 예시입니다. -자세하면 자세할수록 좋습니다! +- close # -- [ ] build.gradle 의존성 추가 -- [ ] login controller 구현 -- [ ] 파이팅 ~ +## 어떻게 해결했나요? -## 추가 정보 +- -하고 싶은 말이 있으면 작성해주세요! +## 코드 리뷰시 요청 사항 -## 리뷰어 +- -꼭 봐줬으면 하는 사람이 있으면 @<< 를 통해서 태그! - - -## 관련 이슈 닫기 - -closes (#이슈 번호) +## 더 하고 싶은 말 +- diff --git a/.gitignore b/.gitignore index 04219c70..c53d3163 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ out/ ### Mac OS ### .DS_Store + +## applicaion-secret +application-secret.yml + diff --git a/README.md b/README.md index 0e7793b6..d5cf5233 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # Team10_BE -10조 백엔드 \ No newline at end of file +10조 백엔드 + +## 사용한 버전 + +Gradle JVM : 22.0.2 +spring boot : 3.3.1 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9a083e96..86ba4c57 100644 --- a/build.gradle +++ b/build.gradle @@ -18,22 +18,53 @@ repositories { } dependencies { + // Spring Boot Basic implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' - runtimeOnly 'com.h2database:h2' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // mysql + implementation 'mysql:mysql-connector-java:8.0.33' + + // Spring Security implementation 'org.springframework.security:spring-security-crypto:5.7.1' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + +// H2 Database + runtimeOnly 'com.h2database:h2' + + // JSON implementation 'org.json:json:20210307' implementation 'com.google.code.gson:gson:2.10.1' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // AWS + implementation(platform("software.amazon.awssdk:bom:2.27.21")) + implementation("software.amazon.awssdk:s3") + } + tasks.named('test') { useJUnitPlatform() } diff --git a/src/main/java/poomasi/Application.java b/src/main/java/poomasi/Application.java index 0b351a88..a8175566 100644 --- a/src/main/java/poomasi/Application.java +++ b/src/main/java/poomasi/Application.java @@ -2,8 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@SpringBootApplication +@EnableJpaAuditing +@SpringBootApplication(exclude = SecurityAutoConfiguration.class) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); diff --git a/src/main/java/poomasi/domain/auth/config/SecurityBeanGenerator.java b/src/main/java/poomasi/domain/auth/config/SecurityBeanGenerator.java new file mode 100644 index 00000000..2a067e4d --- /dev/null +++ b/src/main/java/poomasi/domain/auth/config/SecurityBeanGenerator.java @@ -0,0 +1,47 @@ +package poomasi.domain.auth.config; + +import jdk.jfr.Description; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; +import poomasi.domain.auth.token.blacklist.service.TokenBlacklistService; +import poomasi.domain.auth.token.refreshtoken.service.TokenStorageService; +import poomasi.domain.auth.token.util.JwtUtil; +import poomasi.domain.auth.token.refreshtoken.service.TokenRedisService; +import poomasi.domain.member.service.MemberService; + +@RequiredArgsConstructor +@Configuration +public class SecurityBeanGenerator { + + private final TokenStorageService tokenStorageService; + private final MemberService memberService; + private final TokenBlacklistService tokenBlacklistService; + + @Bean + @Description("AuthenticationProvider를 위한 Spring bean") + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + @Description("open endpoint를 위한 spring bean") + MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) { + return new MvcRequestMatcher.Builder(introspector); + } + + @Bean + JwtUtil jwtUtil(){ + return new JwtUtil(tokenBlacklistService, + tokenStorageService, + memberService); + } + +} diff --git a/src/main/java/poomasi/domain/auth/config/SecurityConfig.java b/src/main/java/poomasi/domain/auth/config/SecurityConfig.java new file mode 100644 index 00000000..4bef2180 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/config/SecurityConfig.java @@ -0,0 +1,142 @@ +package poomasi.domain.auth.config; + +import lombok.AllArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import poomasi.domain.auth.security.filter.CustomUsernamePasswordAuthenticationFilter; +import poomasi.domain.auth.security.filter.JwtAuthenticationFilter; +import poomasi.domain.auth.security.handler.CustomSuccessHandler; +import poomasi.domain.auth.security.userdetail.OAuth2UserDetailServiceImpl; +import poomasi.domain.auth.security.handler.*; +import poomasi.domain.auth.security.userdetail.UserDetailsServiceImpl; +import poomasi.domain.auth.token.util.JwtUtil; + + +@AllArgsConstructor +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(securedEnabled = true , prePostEnabled = false) // 인가 처리에 대한 annotation +public class SecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final JwtUtil jwtUtil; + private final MvcRequestMatcher.Builder mvc; + private final CustomSuccessHandler customSuccessHandler; + private final UserDetailsServiceImpl userDetailsService; + + + @Autowired + private OAuth2UserDetailServiceImpl oAuth2UserDetailServiceImpl; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Description("순서 : Oauth2 -> jwt -> login -> logout") + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + //form login disable + http.formLogin(AbstractHttpConfigurer::disable); + + //basic login disable + http.httpBasic(AbstractHttpConfigurer::disable); + + //csrf 해제 + http.csrf(AbstractHttpConfigurer::disable); + + //cors 해제 + http.cors(AbstractHttpConfigurer::disable); + + //세션 해제 + http.sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + //기본 로그아웃 해제 + http.logout(AbstractHttpConfigurer::disable); + + /* + // 기본 경로 및 테스트 경로 + http.authorizeHttpRequests((authorize) -> authorize + .requestMatchers(HttpMethod.GET, "/api/farm/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/product/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/review/**").permitAll() + .requestMatchers("/api/sign-up", "/api/login", "api/reissue").permitAll() + .requestMatchers("/api/need-auth/**").authenticated() + .anyRequest(). + authenticated() + );*/ + + + http.authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/**").permitAll() + .requestMatchers("/api/auth-test/**", "/api/cart/**").authenticated() + .anyRequest() + .authenticated() + ); + + /* + 로그아웃 필터 등록하기 + LogoutHandler[] handlers = { + new CookieClearingLogoutHandler(), + new ClearAuthenticationHandler() + }; + CustomLogoutFilter customLogoutFilter = new CustomLogoutFilter(jwtUtil, new CustomLogoutSuccessHandler(), handlers); + customLogoutFilter.setFilterProcessesUrl("/api/logout"); + customLogoutFilter. + http.addFilterAt(customLogoutFilter, LogoutFilter.class); + + http.logout( (logout) -> + logout. + logoutSuccessHandler(new CustomLogoutSuccessHandler()) + .addLogoutHandler(new CookieClearingLogoutHandler()) + .addLogoutHandler(new ClearAuthenticationHandler()) + ); + */ + + /* + oauth2 인증은 현재 해제해놨습니다 -> 차후 code를 front에서 어떤 경로로 받을 것인지 + 아니면 kakao에서 바로 redirect를 백엔드로 할 지 정해지면 + processing url 작성하겠습니다 + + http + .oauth2Login((oauth2) -> oauth2 + .userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig + .userService(oAuth2UserDetailServiceImpl)) + .successHandler(customSuccessHandler) + ); + */ + http.oauth2Login(AbstractHttpConfigurer::disable); + + CustomUsernamePasswordAuthenticationFilter customUsernameFilter = + new CustomUsernamePasswordAuthenticationFilter(authenticationManager(authenticationConfiguration), jwtUtil); + customUsernameFilter.setFilterProcessesUrl("/api/login"); + + http.addFilterAt(customUsernameFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService), UsernamePasswordAuthenticationFilter.class); + //http.addFilterAfter(customLogoutFilter, JwtAuthenticationFilter.class); + + return http.build(); + } + +} + + + + diff --git a/src/main/java/poomasi/domain/auth/security/AuthTestController.java b/src/main/java/poomasi/domain/auth/security/AuthTestController.java new file mode 100644 index 00000000..3694523e --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/AuthTestController.java @@ -0,0 +1,53 @@ +package poomasi.domain.auth.security; + + +import jdk.jfr.Description; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import poomasi.domain.auth.security.userdetail.UserDetailsImpl; +import poomasi.domain.member.entity.Member; + +@Slf4j +@Description("접근 제어 확인 controller") +@RestController +public class AuthTestController { + + @Autowired + private AuthTestService authTestService; + + @Secured("ROLE_CUSTOMER") + @GetMapping("/api/auth-test/customer") + public String customer() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Object impl = authentication.getPrincipal(); + Member member = ((UserDetailsImpl) impl).getMember(); + + log.info("email : " + member.getEmail()); + log.info("member : " + member.getId()); + + return "hi. customer"; + } + + @Secured("ROLE_FARMER") + @GetMapping("/api/auth-test/farmer") + public String farmer() { + return "hi. farmer"; + } + + @GetMapping("/api/auth-test") + public String needAuth() { + return "auth"; + } + + @GetMapping("/api/auth-test/test") + public String Test(){ + authTestService.Test(); + return "Success"; + } + +} diff --git a/src/main/java/poomasi/domain/auth/security/AuthTestService.java b/src/main/java/poomasi/domain/auth/security/AuthTestService.java new file mode 100644 index 00000000..1a6e21be --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/AuthTestService.java @@ -0,0 +1,25 @@ +package poomasi.domain.auth.security; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import poomasi.domain.auth.security.userdetail.UserDetailsImpl; +import poomasi.domain.member.entity.Member; + +@Slf4j +@Service +public class AuthTestService { + + public String Test(){ + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Object impl = authentication.getPrincipal(); + Member member = ((UserDetailsImpl) impl).getMember(); + + log.info("member : " + member.getEmail()); + log.info("member : " + member.getId().toString()); + + return "SUCCESS"; + } + +} diff --git a/src/main/java/poomasi/domain/auth/security/filter/CustomLogoutFilter.java b/src/main/java/poomasi/domain/auth/security/filter/CustomLogoutFilter.java new file mode 100644 index 00000000..5843d8f6 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/filter/CustomLogoutFilter.java @@ -0,0 +1,70 @@ + + +package poomasi.domain.auth.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.web.filter.OncePerRequestFilter; +import poomasi.domain.auth.token.util.JwtUtil; + +import java.io.IOException; +import java.io.PrintWriter; + +@Slf4j +public class CustomLogoutFilter extends LogoutFilter { + + private JwtUtil jwtUtil; + + public CustomLogoutFilter(JwtUtil jwtUtil, LogoutSuccessHandler logoutSuccessHandler, LogoutHandler... handlers) { + super(logoutSuccessHandler, handlers); + this.jwtUtil=jwtUtil; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain); + } + + public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { + + log.info("[logout filter] - 로그아웃 진행합니다."); + + // POST : /api/logout 아니라면 넘기기 + String requestURI = request.getRequestURI(); + String requestMethod = request.getMethod(); + if (!"/api/logout".equals(requestURI) || !requestMethod.equals("POST")) { + log.info("[logout url not matching] "); + filterChain.doFilter(request, response); + return; + } + + + boolean isLogoutSuccess = true; + + if(isLogoutSuccess){ + PrintWriter out = response.getWriter(); + out.println("logout success~. "); + return; + } + + /* + * 로그아웃 로직 + * access token , refresh token 관리하기 + * */ + PrintWriter out = response.getWriter(); + out.println("logout success~. "); + //return; + //filterChain.doFilter(request, response); + } +} + diff --git a/src/main/java/poomasi/domain/auth/security/filter/CustomUsernamePasswordAuthenticationFilter.java b/src/main/java/poomasi/domain/auth/security/filter/CustomUsernamePasswordAuthenticationFilter.java new file mode 100644 index 00000000..27b81b61 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/filter/CustomUsernamePasswordAuthenticationFilter.java @@ -0,0 +1,93 @@ +package poomasi.domain.auth.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jdk.jfr.Description; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import poomasi.domain.auth.security.userdetail.UserDetailsImpl; +import poomasi.domain.auth.token.util.JwtUtil; + + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + + @Description("인증 시도 메서드") + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + log.info("email - password 기반으로 인증을 시도 합니다 : CustomUsernamePasswordAuthenticationFilter"); + ObjectMapper loginRequestMapper = new ObjectMapper(); + String email = null; + String password = null; + + try { + BufferedReader reader = request.getReader(); + Map credentials = loginRequestMapper.readValue(reader, Map.class); + email = credentials.get("email"); + password = credentials.get("password"); + log.info("유저 정보를 출력합니다. email : "+ email + "password : " + password); + } catch (IOException e) { + throw new RuntimeException(e); + } + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password); + log.info("CustomUsernamePasswordAuthenticationFilter : authentication token 생성 완료"); + return this.authenticationManager.authenticate(authToken); + + } + + @Override + @Description("로그인 성공 시, accessToken과 refreshToken 발급") + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException { + UserDetailsImpl customUserDetails = (UserDetailsImpl) authentication.getPrincipal(); + String username = customUserDetails.getUsername(); + String role = customUserDetails.getAuthority(); + Long memberId = customUserDetails.getMember().getId(); + + String accessToken = jwtUtil.generateTokenInFilter(username, role, "access", memberId); + String refreshToken = jwtUtil.generateTokenInFilter(username, role, "refresh", memberId); + + log.info("usename password 기반 로그인 성공 . cookie에 토큰을 넣어 발급합니다."); + response.setHeader("access", accessToken); + response.addCookie(createCookie("refresh", refreshToken)); + response.setStatus(HttpStatus.OK.value()); + + // 나중에 주석 해야 함 + PrintWriter out = response.getWriter(); + out.println("access : " + accessToken + ", refresh : " + refreshToken); + out.close(); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) { + log.info("usename password 기반 로그인 실패. "); + response.setStatus(401); + } + + private Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(24*60*60); + cookie.setHttpOnly(true); + return cookie; + } + +} diff --git a/src/main/java/poomasi/domain/auth/security/filter/JwtAuthenticationFilter.java b/src/main/java/poomasi/domain/auth/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..122d31af --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package poomasi.domain.auth.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jdk.jfr.Description; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.web.filter.OncePerRequestFilter; +import poomasi.domain.auth.security.userdetail.UserDetailsImpl; +import poomasi.domain.auth.token.util.JwtUtil; +import poomasi.domain.member.entity.Member; +import poomasi.domain.member.entity.Role; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Collection; + +@Description("access token을 검증하는 필터") +@AllArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + log.info("jwt 인증 필터입니다"); + String requestHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + String accessToken = null; + + if (requestHeader == null || !requestHeader.startsWith("Bearer ")) { + log.info("access token을 header로 갖지 않았으므로 다음 usernamepassword 필터로 이동합니다"); + filterChain.doFilter(request, response); + }else{ + //access 추출하기 + log.info("access token 추출하기"); + accessToken = requestHeader.substring(7); + } + + log.info("access token 추출 완료: " + accessToken); + + if (accessToken == null) { + log.info("access token이 존재하지 않아서 다음 filter로 넘어갑니다."); + filterChain.doFilter(request, response); + return; + } + + // 만료 검사 + if(jwtUtil.isTokenExpired(accessToken)){ + log.warn("[인증 실패] - 토큰이 만료되었습니다."); + PrintWriter writer = response.getWriter(); + writer.print("만료된 토큰입니다."); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // 유효성 검사 + if(!jwtUtil.validateTokenInFilter(accessToken)) { + log.warn("JWT 필터 - [인증 실패] - 위조된 토큰입니다."); + PrintWriter writer = response.getWriter(); + writer.print("위조된 토큰입니다."); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + log.info("토큰 검증 완료"); + String username = jwtUtil.getEmailFromTokenInFilter(accessToken); + UserDetailsImpl userDetailsImpl = (UserDetailsImpl) userDetailsService.loadUserByUsername(username); + + // (ID, password, auth) + Authentication authToken = new UsernamePasswordAuthenticationToken(userDetailsImpl, null, userDetailsImpl.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + + } + + + +} diff --git a/src/main/java/poomasi/domain/auth/security/handler/ClearAuthenticationHandler.java b/src/main/java/poomasi/domain/auth/security/handler/ClearAuthenticationHandler.java new file mode 100644 index 00000000..f4ceee19 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/handler/ClearAuthenticationHandler.java @@ -0,0 +1,17 @@ +package poomasi.domain.auth.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +@Slf4j +public class ClearAuthenticationHandler implements LogoutHandler { + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + log.info("[logout handler] - security context 제거"); + SecurityContextHolder.clearContext(); + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/security/handler/CookieClearingLogoutHandler.java b/src/main/java/poomasi/domain/auth/security/handler/CookieClearingLogoutHandler.java new file mode 100644 index 00000000..4a9dd8a9 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/handler/CookieClearingLogoutHandler.java @@ -0,0 +1,27 @@ +package poomasi.domain.auth.security.handler; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +@Slf4j +public class CookieClearingLogoutHandler implements LogoutHandler { + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + cookie.setValue(null); + cookie.setMaxAge(0); // 쿠키 제거 + cookie.setPath("/"); // 적용할 경로 설정 + response.addCookie(cookie); + } + log.info("Cookies cleared"); + } + log.info("[logout handler] - cookie 제거"); + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/security/handler/CustomLogoutSuccessHandler.java b/src/main/java/poomasi/domain/auth/security/handler/CustomLogoutSuccessHandler.java new file mode 100644 index 00000000..351bd24a --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/handler/CustomLogoutSuccessHandler.java @@ -0,0 +1,29 @@ +package poomasi.domain.auth.security.handler; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; + +import java.io.IOException; + +@Slf4j +public class CustomLogoutSuccessHandler implements LogoutSuccessHandler { + + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + + log.info("[logout success handler] - cookie 제거"); + expireCookie(response, "access"); + expireCookie(response, "refresh"); + } + + private void expireCookie(HttpServletResponse response, String key) { + Cookie cookie = new Cookie(key, null); // 쿠키를 null로 설정 + cookie.setMaxAge(0); // 쿠키의 최대 생명 주기를 0으로 설정 + cookie.setPath("/"); // 쿠키의 경로를 설정 (원래 설정한 경로와 동일하게) + response.addCookie(cookie); // 응답에 쿠키 추가 + } +} diff --git a/src/main/java/poomasi/domain/auth/security/handler/CustomSuccessHandler.java b/src/main/java/poomasi/domain/auth/security/handler/CustomSuccessHandler.java new file mode 100644 index 00000000..829e00bd --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/handler/CustomSuccessHandler.java @@ -0,0 +1,51 @@ +package poomasi.domain.auth.security.handler; + +/* + * TODO : Oauth2.0 로그인이 성공하면 access, refresh를 발급해야 함. + * + * */ + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jdk.jfr.Description; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + @Description("TODO : Oauth2.0 로그인이 성공하면 server access, refresh token을 발급하는 메서드") + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + + // 로직은 완성되었습니다 ~ + // Oauth2.0 로그인이 성공하면 server access, refresh token을 발급하는 메서드 + // + log.info("Oauth2 success handler."); + response.setHeader("access", ""); + response.addCookie(createCookie("refresh", "")); + response.setStatus(HttpStatus.OK.value()); + + } + + private Cookie createCookie(String key, String value) { + + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(60*60*60); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setHttpOnly(true); + + return cookie; + } +} diff --git a/src/main/java/poomasi/domain/auth/security/oauth2/dto/response/OAuth2KakaoResponse.java b/src/main/java/poomasi/domain/auth/security/oauth2/dto/response/OAuth2KakaoResponse.java new file mode 100644 index 00000000..12739679 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/oauth2/dto/response/OAuth2KakaoResponse.java @@ -0,0 +1,36 @@ +package poomasi.domain.auth.security.oauth2.dto.response; + + +import poomasi.domain.member.entity.LoginType; + +import java.util.Map; + +public record OAuth2KakaoResponse(String id, Map attribute) implements OAuth2Response { + + + public OAuth2KakaoResponse(String id, Map attribute) { + this.id = id; + this.attribute = attribute; + } + + @Override + public String getProviderId() { + return id; + } + + @Override + public String getEmail() { + return String.valueOf(attribute.get("email")); + } + + @Override + public String getName() { + return attribute.get("name").toString(); + } + + @Override + public LoginType getLoginType(){ + return LoginType.KAKAO; + } + +} diff --git a/src/main/java/poomasi/domain/auth/security/oauth2/dto/response/OAuth2Response.java b/src/main/java/poomasi/domain/auth/security/oauth2/dto/response/OAuth2Response.java new file mode 100644 index 00000000..56497ac7 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/oauth2/dto/response/OAuth2Response.java @@ -0,0 +1,10 @@ +package poomasi.domain.auth.security.oauth2.dto.response; + +import poomasi.domain.member.entity.LoginType; + +public interface OAuth2Response { + LoginType getLoginType(); + String getProviderId(); + String getEmail(); + String getName(); +} diff --git a/src/main/java/poomasi/domain/auth/security/userdetail/OAuth2UserDetailServiceImpl.java b/src/main/java/poomasi/domain/auth/security/userdetail/OAuth2UserDetailServiceImpl.java new file mode 100644 index 00000000..78dda49a --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/userdetail/OAuth2UserDetailServiceImpl.java @@ -0,0 +1,79 @@ +package poomasi.domain.auth.security.userdetail; + +import jdk.jfr.Description; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import poomasi.domain.auth.security.oauth2.dto.response.OAuth2KakaoResponse; +import poomasi.domain.auth.security.oauth2.dto.response.OAuth2Response; +import poomasi.domain.member.entity.LoginType; +import poomasi.domain.member.entity.Member; +import poomasi.domain.member.entity.MemberProfile; +import poomasi.domain.member.entity.Role; +import poomasi.domain.member.repository.MemberRepository; + +import java.util.Map; + +@Service +@Description("소셜 서비스와 로컬 계정 연동 할 것이라면 여기서 연동 해야 함") +@Slf4j +public class OAuth2UserDetailServiceImpl extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + public OAuth2UserDetailServiceImpl(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + OAuth2User oAuth2User = super.loadUser(userRequest); + OAuth2Response oAuth2UserInfo = null; + + if(userRequest.getClientRegistration().getRegistrationId().equals("kakao")) { + + String providerId = String.valueOf(oAuth2User.getAttributes().get("id")); + oAuth2UserInfo = new OAuth2KakaoResponse( + providerId, + (Map)oAuth2User.getAttributes().get("kakao_account") + ); + } else{ + log.warn("지원하지 않은 로그인 서비스 입니다."); + } + + String providerId = oAuth2UserInfo.getProviderId(); + String email = oAuth2UserInfo.getEmail(); + Role role = Role.ROLE_CUSTOMER; + LoginType loginType = oAuth2UserInfo.getLoginType(); + + + //일단 없으면 가입시키는 쪽으로 구현ㄴ + Member member = memberRepository.findByEmail(email).orElse(null); + if(member == null) { + member = Member.builder() + .email(email) + .role(role) + .loginType(loginType) // loginType에 맞게 변경 + .provideId(providerId) + .memberProfile(new MemberProfile()) + .build(); + + memberRepository.save(member); + + } + + //있다면 그냥 member 등록하기 + + if(member.getLoginType()==LoginType.LOCAL){ + //member.setProviderId(providerId); -> 로그인 시 Id 조회함 + } + + // 카카오 회원으로 로그인이 되어 있다면 -> context에 저장 + return new UserDetailsImpl(member, oAuth2User.getAttributes()); + } + +} diff --git a/src/main/java/poomasi/domain/auth/security/userdetail/UserDetailsImpl.java b/src/main/java/poomasi/domain/auth/security/userdetail/UserDetailsImpl.java new file mode 100644 index 00000000..629e425d --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/userdetail/UserDetailsImpl.java @@ -0,0 +1,87 @@ +package poomasi.domain.auth.security.userdetail; + +import jdk.jfr.Description; +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import poomasi.domain.member.entity.Member; +import poomasi.domain.member.entity.Role; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + + +@Description("security context에 저장 될 객체") +@Data +public class UserDetailsImpl implements UserDetails, OAuth2User { + + private Member member; + private Collection authorities; + private Map attributes; + + public UserDetailsImpl(Member member) { + this.member = member; + } + + public UserDetailsImpl(Member member, Map attributes ) { + this.member = member; + this.attributes = attributes; + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList(); + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return String.valueOf(member.getRole()); + } + }); + return collection; + } + + public Role getRole(){ + return member.getRole(); + } + + public String getAuthority() { + return member.getRole().name(); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + //Oauth2 member name + @Override + public String getName() { + return null; + } + + public Member getMember(){ + return member; + } +} diff --git a/src/main/java/poomasi/domain/auth/security/userdetail/UserDetailsServiceImpl.java b/src/main/java/poomasi/domain/auth/security/userdetail/UserDetailsServiceImpl.java new file mode 100644 index 00000000..e14131fa --- /dev/null +++ b/src/main/java/poomasi/domain/auth/security/userdetail/UserDetailsServiceImpl.java @@ -0,0 +1,27 @@ +package poomasi.domain.auth.security.userdetail; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import poomasi.domain.member.entity.Member; +import poomasi.domain.member.repository.MemberRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + private final MemberRepository memberRepository; + public UserDetailsServiceImpl(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(BusinessError.MEMBER_NOT_FOUND)); + return new UserDetailsImpl(member); + } + +} diff --git a/src/main/java/poomasi/domain/auth/signup/controller/SignupController.java b/src/main/java/poomasi/domain/auth/signup/controller/SignupController.java new file mode 100644 index 00000000..3be51a8c --- /dev/null +++ b/src/main/java/poomasi/domain/auth/signup/controller/SignupController.java @@ -0,0 +1,25 @@ +package poomasi.domain.auth.signup.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import poomasi.domain.auth.signup.dto.response.SignUpResponse; +import poomasi.domain.auth.signup.service.SignupService; +import poomasi.domain.auth.signup.dto.request.SignupRequest; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class SignupController { + + private final SignupService signupService; + + @PostMapping("/sign-up") + public ResponseEntity signUp(@RequestBody SignupRequest signupRequest) { + return ResponseEntity.ok(signupService + .signUp(signupRequest)); + } + +} + + diff --git a/src/main/java/poomasi/domain/auth/signup/dto/request/SignupRequest.java b/src/main/java/poomasi/domain/auth/signup/dto/request/SignupRequest.java new file mode 100644 index 00000000..7799d74a --- /dev/null +++ b/src/main/java/poomasi/domain/auth/signup/dto/request/SignupRequest.java @@ -0,0 +1,4 @@ +package poomasi.domain.auth.signup.dto.request; + +public record SignupRequest(String email, String password) { +} diff --git a/src/main/java/poomasi/domain/auth/signup/dto/response/SignUpResponse.java b/src/main/java/poomasi/domain/auth/signup/dto/response/SignUpResponse.java new file mode 100644 index 00000000..70da0d1c --- /dev/null +++ b/src/main/java/poomasi/domain/auth/signup/dto/response/SignUpResponse.java @@ -0,0 +1,4 @@ +package poomasi.domain.auth.signup.dto.response; + +public record SignUpResponse(String email, String message) { +} diff --git a/src/main/java/poomasi/domain/auth/signup/service/SignupService.java b/src/main/java/poomasi/domain/auth/signup/service/SignupService.java new file mode 100644 index 00000000..fe8c0859 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/signup/service/SignupService.java @@ -0,0 +1,44 @@ +package poomasi.domain.auth.signup.service; + +import jdk.jfr.Description; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.auth.signup.dto.request.SignupRequest; +import poomasi.domain.auth.signup.dto.response.SignUpResponse; +import poomasi.domain.member.entity.LoginType; +import poomasi.domain.member.repository.MemberRepository; +import poomasi.domain.member.entity.Member; +import poomasi.global.error.BusinessException; + +import static poomasi.domain.member.entity.Role.ROLE_CUSTOMER; +import static poomasi.global.error.BusinessError.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SignupService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Description("카카오톡으로 먼저 회원가입이 되어 있는 경우, 계정 연동을 진행합니다. ") + @Transactional + public SignUpResponse signUp(SignupRequest signupRequest) { + String email = signupRequest.email(); + String password = signupRequest.password(); + + memberRepository.findByEmail(email) + .ifPresent(member -> { throw new BusinessException(DUPLICATE_MEMBER_EMAIL); }); + + Member newMember = new Member(email, + passwordEncoder.encode(password), + LoginType.LOCAL, + ROLE_CUSTOMER); + + memberRepository.save(newMember); + return new SignUpResponse(email, "회원 가입 성공"); + } +} + diff --git a/src/main/java/poomasi/domain/auth/token/blacklist/config/TokenBlacklistServiceConfig.java b/src/main/java/poomasi/domain/auth/token/blacklist/config/TokenBlacklistServiceConfig.java new file mode 100644 index 00000000..458c3702 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/blacklist/config/TokenBlacklistServiceConfig.java @@ -0,0 +1,24 @@ +package poomasi.domain.auth.token.blacklist.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import poomasi.domain.auth.token.blacklist.service.BlacklistJpaService; +import poomasi.domain.auth.token.blacklist.service.TokenBlacklistService; +import poomasi.domain.auth.token.blacklist.service.BlacklistRedisService; + +@Configuration +public class TokenBlacklistServiceConfig { + + @Value("${spring.token.blacklist.type}") + private String tokenBlacklistType; + + @Bean + public TokenBlacklistService tokenBlacklistService(BlacklistRedisService blacklistRedisService, BlacklistJpaService blacklistJpaService) { + if ("redis".equals(tokenBlacklistType)) { + return blacklistRedisService; + } else { + return blacklistJpaService; + } + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/blacklist/entity/Blacklist.java b/src/main/java/poomasi/domain/auth/token/blacklist/entity/Blacklist.java new file mode 100644 index 00000000..0e66a2fd --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/blacklist/entity/Blacklist.java @@ -0,0 +1,28 @@ +package poomasi.domain.auth.token.blacklist.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +public class Blacklist { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String tokenKey; + + @Column(nullable = false) + private String data; + + @Column(nullable = false) + private LocalDateTime expireAt; +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/blacklist/repository/BlacklistRepository.java b/src/main/java/poomasi/domain/auth/token/blacklist/repository/BlacklistRepository.java new file mode 100644 index 00000000..2d3684f8 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/blacklist/repository/BlacklistRepository.java @@ -0,0 +1,17 @@ +package poomasi.domain.auth.token.blacklist.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import poomasi.domain.auth.token.blacklist.entity.Blacklist; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface BlacklistRepository extends JpaRepository { + void deleteByTokenKey(String key); + Optional findByTokenKeyAndExpireAtAfter(String key, LocalDateTime now); + boolean existsByTokenKeyAndExpireAtAfter(String key, LocalDateTime now); + void deleteAllByExpireAtBefore(LocalDateTime now); + +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/blacklist/service/BlacklistJpaService.java b/src/main/java/poomasi/domain/auth/token/blacklist/service/BlacklistJpaService.java new file mode 100644 index 00000000..6b7bd092 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/blacklist/service/BlacklistJpaService.java @@ -0,0 +1,53 @@ +package poomasi.domain.auth.token.blacklist.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.auth.token.blacklist.entity.Blacklist; +import poomasi.domain.auth.token.blacklist.repository.BlacklistRepository; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BlacklistJpaService implements TokenBlacklistService{ + private final BlacklistRepository blacklistRepository; + + @Override + @Transactional + public void setBlackList(String key, String data, Duration duration) { + LocalDateTime expireAt = LocalDateTime.now().plusSeconds(duration.getSeconds()); + + Blacklist blacklist = new Blacklist(); + blacklist.setTokenKey(key); + blacklist.setData(data); + blacklist.setExpireAt(expireAt); + + blacklistRepository.save(blacklist); + } + + @Override + public Optional getBlackList(String key) { + return blacklistRepository.findByTokenKeyAndExpireAtAfter(key, LocalDateTime.now()) + .map(Blacklist::getData); + } + + @Override + @Transactional + public void deleteBlackList(String key) { + blacklistRepository.deleteByTokenKey(key); + } + + @Override + public boolean hasKeyBlackList(String key) { + return blacklistRepository.existsByTokenKeyAndExpireAtAfter(key, LocalDateTime.now()); + } + + @Transactional + public void removeExpiredTokens() { + blacklistRepository.deleteAllByExpireAtBefore(LocalDateTime.now()); + } +} diff --git a/src/main/java/poomasi/domain/auth/token/blacklist/service/BlacklistRedisService.java b/src/main/java/poomasi/domain/auth/token/blacklist/service/BlacklistRedisService.java new file mode 100644 index 00000000..369bcbf5 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/blacklist/service/BlacklistRedisService.java @@ -0,0 +1,48 @@ +package poomasi.domain.auth.token.blacklist.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.*; + +import static poomasi.global.config.redis.error.RedisExceptionHandler.handleRedisException; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BlacklistRedisService implements TokenBlacklistService { + private final RedisTemplate redisBlackListTemplate; + + @Transactional + public void setBlackList(String key, String data, Duration duration) { + handleRedisException(() -> { + ValueOperations values = redisBlackListTemplate.opsForValue(); + values.set(key, data, duration); + return null; + }, "블랙리스트에 값을 설정하는 중 오류 발생: " + key); + } + + public Optional getBlackList(String key) { + return handleRedisException(() -> { + ValueOperations values = redisBlackListTemplate.opsForValue(); + Object result = values.get(key); + return Optional.ofNullable(result).map(Object::toString); + }, "블랙리스트에서 값을 가져오는 중 오류 발생: " + key); + } + + @Transactional + public void deleteBlackList(String key) { + handleRedisException(() -> redisBlackListTemplate.delete(key), "블랙리스트에서 값을 삭제하는 중 오류 발생: " + key); + } + + public boolean hasKeyBlackList(String key) { + return handleRedisException(() -> Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key)), "블랙리스트에서 키 존재 여부 확인 중 오류 발생: " + key); + } + +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/blacklist/service/TokenBlacklistService.java b/src/main/java/poomasi/domain/auth/token/blacklist/service/TokenBlacklistService.java new file mode 100644 index 00000000..519231d7 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/blacklist/service/TokenBlacklistService.java @@ -0,0 +1,15 @@ +package poomasi.domain.auth.token.blacklist.service; + +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Optional; + +@Service +public interface TokenBlacklistService { + void setBlackList(String key, String data, Duration duration); + Optional getBlackList(String key); + void deleteBlackList(String key); + boolean hasKeyBlackList(String key); + +} diff --git a/src/main/java/poomasi/domain/auth/token/entity/TokenType.java b/src/main/java/poomasi/domain/auth/token/entity/TokenType.java new file mode 100644 index 00000000..3b21f148 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/entity/TokenType.java @@ -0,0 +1,6 @@ +package poomasi.domain.auth.token.entity; + +public enum TokenType { + ACCESS, + REFRESH +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/refreshtoken/config/TokenStorageServiceConfig.java b/src/main/java/poomasi/domain/auth/token/refreshtoken/config/TokenStorageServiceConfig.java new file mode 100644 index 00000000..75458385 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/refreshtoken/config/TokenStorageServiceConfig.java @@ -0,0 +1,24 @@ +package poomasi.domain.auth.token.refreshtoken.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import poomasi.domain.auth.token.refreshtoken.service.TokenJpaService; +import poomasi.domain.auth.token.refreshtoken.service.TokenRedisService; +import poomasi.domain.auth.token.refreshtoken.service.TokenStorageService; + +@Configuration +public class TokenStorageServiceConfig { + + @Value("${spring.token.storage.type}") + private String tokenStorageType; + + @Bean + public TokenStorageService tokenStorageService(TokenRedisService tokenRedisService, TokenJpaService tokenJpaService) { + if ("redis".equals(tokenStorageType)) { + return tokenRedisService; + } else { + return tokenJpaService; + } + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/refreshtoken/entity/RefreshToken.java b/src/main/java/poomasi/domain/auth/token/refreshtoken/entity/RefreshToken.java new file mode 100644 index 00000000..211f178e --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/refreshtoken/entity/RefreshToken.java @@ -0,0 +1,27 @@ +package poomasi.domain.auth.token.refreshtoken.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "refresh_tokens") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String tokenKey; + + @Column(nullable = false) + private String data; + + @Column(nullable = false) + private LocalDateTime expireAt; +} diff --git a/src/main/java/poomasi/domain/auth/token/refreshtoken/repository/TokenRepository.java b/src/main/java/poomasi/domain/auth/token/refreshtoken/repository/TokenRepository.java new file mode 100644 index 00000000..86062443 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/refreshtoken/repository/TokenRepository.java @@ -0,0 +1,15 @@ +package poomasi.domain.auth.token.refreshtoken.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import poomasi.domain.auth.token.refreshtoken.entity.RefreshToken; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface TokenRepository extends JpaRepository { + void deleteAllByData(String Data); + void deleteAllByExpireAtBefore(LocalDateTime now); + Optional findByTokenKeyAndExpireAtAfter(String key, LocalDateTime now); +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/refreshtoken/service/RefreshTokenService.java b/src/main/java/poomasi/domain/auth/token/refreshtoken/service/RefreshTokenService.java new file mode 100644 index 00000000..c7111a58 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/refreshtoken/service/RefreshTokenService.java @@ -0,0 +1,39 @@ +package poomasi.domain.auth.token.refreshtoken.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.global.error.BusinessException; + +import java.time.Duration; + +import static poomasi.global.error.BusinessError.REFRESH_TOKEN_NOT_FOUND; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class RefreshTokenService { + + private final TokenStorageService tokenStorageService; + + @Value("${jwt.refresh-token-expiration-time}") + private long REFRESH_TOKEN_EXPIRE_TIME; + + @Transactional + public void putRefreshToken(final String refreshToken, Long memberId) { + tokenStorageService.setValues(refreshToken, memberId.toString(), Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME)); + } + + public Long getRefreshToken(final String refreshToken, Long memberId) { + String result = tokenStorageService.getValues(refreshToken, memberId.toString()) + .orElseThrow(() -> new BusinessException(REFRESH_TOKEN_NOT_FOUND)); + return Long.parseLong(result); + } + + @Transactional + public void removeMemberRefreshToken(final Long memberId) { + tokenStorageService.removeRefreshTokenById(memberId); + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenJpaService.java b/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenJpaService.java new file mode 100644 index 00000000..5e5e628f --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenJpaService.java @@ -0,0 +1,46 @@ +package poomasi.domain.auth.token.refreshtoken.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.auth.token.refreshtoken.entity.RefreshToken; +import poomasi.domain.auth.token.refreshtoken.repository.TokenRepository; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TokenJpaService implements TokenStorageService { + + private final TokenRepository tokenRepository; + + @Override + @Transactional + public void setValues(String key, String data, Duration duration) { + RefreshToken tokenEntity = new RefreshToken(); + tokenEntity.setTokenKey(key); + tokenEntity.setData(data); + tokenEntity.setExpireAt(LocalDateTime.now().plusSeconds(duration.getSeconds())); + tokenRepository.save(tokenEntity); + } + + @Override + public Optional getValues(String key, String data) { + return tokenRepository.findByTokenKeyAndExpireAtAfter(key, LocalDateTime.now()) + .map(RefreshToken::getData); + } + + @Override + @Transactional + public void removeRefreshTokenById(final Long memberId) { + tokenRepository.deleteAllByData(String.valueOf(memberId)); + } + + @Transactional + public void removeExpiredTokens() { + tokenRepository.deleteAllByExpireAtBefore(LocalDateTime.now()); + } +} diff --git a/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenRedisService.java b/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenRedisService.java new file mode 100644 index 00000000..96916a4d --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenRedisService.java @@ -0,0 +1,92 @@ +package poomasi.domain.auth.token.refreshtoken.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.global.config.redis.error.RedisOperationException; + +import java.time.Duration; +import java.util.*; + +import static poomasi.global.config.redis.error.RedisExceptionHandler.handleRedisException; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TokenRedisService implements TokenStorageService { + private final RedisTemplate redisTemplate; + private final RedisConnectionFactory redisConnectionFactory; + + @Transactional + public void setValues(String key, String data, Duration duration) { + String redisKey = generateKey(data, key); + handleRedisException(() -> { + ValueOperations values = redisTemplate.opsForValue(); + values.set(redisKey, data, duration); + return null; + }, "Redis에 값을 설정하는 중 오류 발생: " + redisKey); + } + + public Optional getValues(String key, String data) { + String redisKey = generateKey(data, key); + return handleRedisException(() -> { + ValueOperations values = redisTemplate.opsForValue(); + Object result = values.get(redisKey); + return Optional.ofNullable(result).map(Object::toString); + }, "Redis에서 값을 가져오는 중 오류 발생: " + redisKey); + } + + @Transactional + public void removeRefreshTokenById(Long memberId) { + List keys = scanKeysByPattern(generateKey(String.valueOf(memberId), "*")); + for (String key : keys) { + deleteValues(key, memberId.toString()); + } + } + + @Transactional + public void deleteValues(String key, String data) { + String redisKey = generateKey(data, key); + handleRedisException(() -> redisTemplate.delete(redisKey), "Redis에서 값을 삭제하는 중 오류 발생: " + redisKey); + } + + public List scanKeysByPattern(String pattern) { + return handleRedisException(() -> { + List keys = new ArrayList<>(); + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build(); + + try (RedisConnection connection = redisConnectionFactory.getConnection()) { + Cursor cursor = connection.scan(options); + while (cursor.hasNext()) { + keys.add(new String(cursor.next())); + } + } catch (Exception e) { + throw new RedisOperationException("Redis SCAN 중 오류 발생"); + } + return keys; + }, "SCAN 중 오류 발생: " + pattern); + } + + public boolean hasKey(String key, String data) { + String redisKey = generateKey(data, key); + return handleRedisException(() -> Boolean.TRUE.equals(redisTemplate.hasKey(redisKey)), "Redis에서 키 존재 여부 확인 중 오류 발생: " + redisKey); + } + + public List getKeysByPattern(String pattern) { + Set keys = redisTemplate.keys(pattern); + return keys != null ? new ArrayList<>(keys) : Collections.emptyList(); + } + + private String generateKey(String memberId, String token) { + return "refreshToken:" + memberId + ":" + token; + } + +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenStorageService.java b/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenStorageService.java new file mode 100644 index 00000000..1b170b72 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/refreshtoken/service/TokenStorageService.java @@ -0,0 +1,13 @@ +package poomasi.domain.auth.token.refreshtoken.service; + +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Optional; + +@Service +public interface TokenStorageService { + void setValues(String key, String data, Duration duration); + Optional getValues(String key, String data); + void removeRefreshTokenById(final Long memberId); +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/reissue/controller/ReissueTokenController.java b/src/main/java/poomasi/domain/auth/token/reissue/controller/ReissueTokenController.java new file mode 100644 index 00000000..fc2dc627 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/reissue/controller/ReissueTokenController.java @@ -0,0 +1,23 @@ +package poomasi.domain.auth.token.reissue.controller; + + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import poomasi.domain.auth.token.reissue.dto.ReissueRequest; +import poomasi.domain.auth.token.reissue.dto.ReissueResponse; +import poomasi.domain.auth.token.reissue.service.ReissueTokenService; + +@RestController +public class ReissueTokenController { + + @Autowired + private ReissueTokenService reissueTokenService; + + @GetMapping("/api/reissue") + public ResponseEntity reissue(@RequestBody ReissueRequest reissueRequest){ + return ResponseEntity.ok(reissueTokenService.reissueToken(reissueRequest)); + } +} diff --git a/src/main/java/poomasi/domain/auth/token/reissue/dto/ReissueRequest.java b/src/main/java/poomasi/domain/auth/token/reissue/dto/ReissueRequest.java new file mode 100644 index 00000000..c18fb929 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/reissue/dto/ReissueRequest.java @@ -0,0 +1,4 @@ +package poomasi.domain.auth.token.reissue.dto; + +public record ReissueRequest(String refreshToken) { +} diff --git a/src/main/java/poomasi/domain/auth/token/reissue/dto/ReissueResponse.java b/src/main/java/poomasi/domain/auth/token/reissue/dto/ReissueResponse.java new file mode 100644 index 00000000..258ce50d --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/reissue/dto/ReissueResponse.java @@ -0,0 +1,4 @@ +package poomasi.domain.auth.token.reissue.dto; + +public record ReissueResponse(String accessToken, String refreshToken) { +} diff --git a/src/main/java/poomasi/domain/auth/token/reissue/service/ReissueTokenService.java b/src/main/java/poomasi/domain/auth/token/reissue/service/ReissueTokenService.java new file mode 100644 index 00000000..bc9884fb --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/reissue/service/ReissueTokenService.java @@ -0,0 +1,44 @@ +package poomasi.domain.auth.token.reissue.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import poomasi.domain.auth.token.reissue.dto.ReissueRequest; +import poomasi.domain.auth.token.refreshtoken.service.RefreshTokenService; +import poomasi.domain.auth.token.reissue.dto.ReissueResponse; +import poomasi.global.error.BusinessException; +import poomasi.domain.auth.token.util.JwtUtil; + +import static poomasi.global.error.BusinessError.*; + +@Service +@RequiredArgsConstructor +public class ReissueTokenService { + + private final JwtUtil jwtUtil; + private final RefreshTokenService refreshTokenService; + + // 토큰 재발급 + public ReissueResponse reissueToken(ReissueRequest reissueRequest) { + String refreshToken = reissueRequest.refreshToken(); + Long memberId = jwtUtil.getIdFromToken(refreshToken); + + checkRefreshToken(refreshToken, memberId); + + return getTokenResponse(memberId); + } + + public ReissueResponse getTokenResponse(Long memberId) { + String newAccessToken = jwtUtil.generateAccessTokenById(memberId); + refreshTokenService.removeMemberRefreshToken(memberId); + + String newRefreshToken = jwtUtil.generateRefreshTokenById(memberId); + refreshTokenService.putRefreshToken(newRefreshToken, memberId); + + return new ReissueResponse(newAccessToken, newRefreshToken); + } + + private void checkRefreshToken(final String refreshToken, Long memberId) { + if(!jwtUtil.validateRefreshToken(refreshToken, memberId)) + throw new BusinessException(REFRESH_TOKEN_NOT_VALID); + } +} diff --git a/src/main/java/poomasi/domain/auth/token/util/JwtUtil.java b/src/main/java/poomasi/domain/auth/token/util/JwtUtil.java new file mode 100644 index 00000000..46501256 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/util/JwtUtil.java @@ -0,0 +1,216 @@ +package poomasi.domain.auth.token.util; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import poomasi.domain.auth.token.blacklist.service.TokenBlacklistService; +import poomasi.domain.auth.token.refreshtoken.service.TokenStorageService; +import poomasi.domain.member.entity.Member; +import poomasi.domain.member.service.MemberService; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static poomasi.domain.auth.token.entity.TokenType.ACCESS; +import static poomasi.domain.auth.token.entity.TokenType.REFRESH; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + private SecretKey secretKey; + + @Value("${jwt.access-token-expiration-time}") + private long ACCESS_TOKEN_EXPIRATION_TIME; + + @Value("${jwt.refresh-token-expiration-time}") + private long REFRESH_TOKEN_EXPIRATION_TIME; + + private final TokenBlacklistService tokenBlacklistService; + private final TokenStorageService tokenStorageService; + private final MemberService memberService; + + @PostConstruct + public void init() { + secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + + public String generateTokenInFilter(String email, String role , String tokenType, Long memberId){ + Map claims = this.createClaimsInFilter(email, role, tokenType); + String memberIdString = memberId.toString(); + + return Jwts.builder() + .setClaims(claims) + .setSubject(memberIdString) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION_TIME)) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + private Map createClaimsInFilter(String email, String role, String tokenType) { + Map claims = new HashMap<>(); + claims.put("email", email); + claims.put("role", role); + claims.put("tokenType" , tokenType); + return claims; + } + + public Boolean validateTokenInFilter(String token){ + + log.info("jwt util에서 토큰 검증을 진행합니다 . ."); + + try { + Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + return true; + } catch (Exception e) { + log.info("jwt util에서 토큰 검증 하다가 exception 터졌습니다."); + log.info(e.getMessage()); + return false; + } + + } + + public String getRoleFromTokenInFilter(final String token) { + return getClaimFromToken(token, "role", String.class); + } + + public String getEmailFromTokenInFilter(final String token) { + return getClaimFromToken(token, "email", String.class); + } + + // <--------------------------------------------> + // 토큰 생성 + + public String generateAccessTokenById(final Long memberId) { + Map claims = createClaims(memberId); + claims.put("type", ACCESS); + return Jwts.builder() + .setClaims(claims) + .setSubject(String.valueOf(memberId)) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION_TIME)) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + public String generateRefreshTokenById(final Long memberId) { + Map claims = createClaims(memberId); + claims.put("type", REFRESH); + return Jwts.builder() + .setClaims(claims) + .setSubject(String.valueOf(memberId)) + .setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION_TIME)) + .setIssuedAt(new Date(System.currentTimeMillis())) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + public Map createClaims(Long memberId) { + Map claims = new HashMap<>(); + Member member = memberService.findMemberById(memberId); + + claims.put("id", memberId); + claims.put("email", member.getEmail()); + claims.put("role", member.getRole()); + + return claims; + } + + // 토큰 이용해서 추출 + public Long getIdFromToken(final String token) { + return getClaimFromToken(token, "id", Long.class); + } + + public Date getExpirationDateFromToken(final String token) { + return getAllClaimsFromToken(token).getExpiration(); + } + + private T getClaimFromToken(final String token, String claimKey, Class claimType) { + Claims claims = getAllClaimsFromToken(token); + return claims.get(claimKey, claimType); + } + + private Claims getAllClaimsFromToken(final String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + // 토큰 유효성 검사 + public Boolean validateRefreshToken(final String refreshToken, final Long memberId) { + if (!validateToken(refreshToken)) { + return false; + } + String storedMemberId = tokenStorageService.getValues(refreshToken, memberId.toString()) + .orElse(null); + + if (storedMemberId == null || !storedMemberId.equals(memberId.toString())) { + log.warn("리프레시 토큰과 멤버 ID가 일치하지 않습니다."); + return false; + } + + return true; + } + + public Boolean validateAccessToken(final String accessToken){ + if (!validateToken(accessToken)) { + return false; + } + if ( tokenBlacklistService.hasKeyBlackList(accessToken)){ + log.warn("로그아웃한 JWT token입니다."); + return false; + } + return true; + } + + public Boolean validateToken(final String token) { + try { + Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + return true; + } catch (SecurityException e) { + log.error("잘못된 JWT 서명입니다."); + } catch (MalformedJwtException e) { + log.error("잘못된 JWT token입니다."); + } catch (ExpiredJwtException e) { + log.error("만료된 JWT token입니다."); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT token입니다."); + } catch (IllegalArgumentException e) { + log.error("JWT token이 비어있습니다."); + } + + return false; + } + + // 토큰 만료 여부 확인 + public boolean isTokenExpired(String token) { + try { + Date expiration = getExpirationDateFromToken(token); + return expiration.before(new Date()); + } catch (ExpiredJwtException e) { + return true; + } + } + + public long getAccessTokenExpiration() { + return ACCESS_TOKEN_EXPIRATION_TIME; + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/domain/auth/token/util/TokenCleanupScheduler.java b/src/main/java/poomasi/domain/auth/token/util/TokenCleanupScheduler.java new file mode 100644 index 00000000..fb918879 --- /dev/null +++ b/src/main/java/poomasi/domain/auth/token/util/TokenCleanupScheduler.java @@ -0,0 +1,29 @@ +package poomasi.domain.auth.token.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import poomasi.domain.auth.token.blacklist.service.BlacklistJpaService; +import poomasi.domain.auth.token.refreshtoken.service.TokenJpaService; + +@Component +@RequiredArgsConstructor +public class TokenCleanupScheduler { + private final BlacklistJpaService blacklistJpaService; + private final TokenJpaService tokenJpaService; + + // spring.token.blacklist.type이 "jpa"일 때만 실행 + @Scheduled(fixedRate = 3600000) // 한 시간마다 실행 (1시간 = 3600000 밀리초) + @ConditionalOnProperty(name = "spring.token.blacklist.type", havingValue = "jpa") + public void cleanUpBlacklistExpiredTokens() { + blacklistJpaService.removeExpiredTokens(); + } + + // spring.token.storage.type이 "jpa"일 때만 실행 + @Scheduled(fixedRate = 86400000) // 하루마다 실행 (24시간 = 86400000 밀리초) + @ConditionalOnProperty(name = "spring.token.storage.type", havingValue = "jpa") + public void cleanUpTokenExpiredTokens() { + tokenJpaService.removeExpiredTokens(); + } +} diff --git a/src/main/java/poomasi/domain/farm/_schedule/controller/FarmScheduleController.java b/src/main/java/poomasi/domain/farm/_schedule/controller/FarmScheduleController.java new file mode 100644 index 00000000..dec48e68 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/controller/FarmScheduleController.java @@ -0,0 +1,22 @@ +package poomasi.domain.farm._schedule.controller; + +import lombok.RequiredArgsConstructor; +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; +import poomasi.domain.farm._schedule.dto.FarmScheduleRequest; +import poomasi.domain.farm._schedule.service.FarmScheduleService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/farm") +public class FarmScheduleController { + private final FarmScheduleService farmScheduleService; + + @GetMapping("/schedule") + public ResponseEntity getFarmSchedule(@RequestParam Long farmId, @RequestParam Integer year, @RequestParam Integer month) { + return ResponseEntity.ok(farmScheduleService.getFarmSchedulesByYearAndMonth(new FarmScheduleRequest(farmId, year, month))); + } +} diff --git a/src/main/java/poomasi/domain/farm/_schedule/controller/FarmScheduleFarmerController.java b/src/main/java/poomasi/domain/farm/_schedule/controller/FarmScheduleFarmerController.java new file mode 100644 index 00000000..d0308f17 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/controller/FarmScheduleFarmerController.java @@ -0,0 +1,25 @@ +package poomasi.domain.farm._schedule.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import poomasi.domain.farm._schedule.dto.FarmScheduleUpdateRequest; +import poomasi.domain.farm._schedule.service.FarmScheduleService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/farm") +public class FarmScheduleFarmerController { + private final FarmScheduleService farmScheduleService; + + @PostMapping("/schedule") + public ResponseEntity addFarmSchedule(@Valid @RequestBody FarmScheduleUpdateRequest request) { + farmScheduleService.addFarmSchedule(request); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleRequest.java b/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleRequest.java new file mode 100644 index 00000000..335e2535 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleRequest.java @@ -0,0 +1,9 @@ +package poomasi.domain.farm._schedule.dto; + +public record FarmScheduleRequest( + Long farmId, + Integer year, + Integer month +) { +} + diff --git a/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleResponse.java b/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleResponse.java new file mode 100644 index 00000000..4fa4f13d --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleResponse.java @@ -0,0 +1,20 @@ +package poomasi.domain.farm._schedule.dto; + +import lombok.Builder; +import poomasi.domain.farm._schedule.entity.FarmSchedule; +import poomasi.domain.farm._schedule.entity.ScheduleStatus; + +import java.time.LocalDate; + +@Builder +public record FarmScheduleResponse( + LocalDate date, + ScheduleStatus status +) { + public static FarmScheduleResponse fromEntity(FarmSchedule farmSchedule) { + return FarmScheduleResponse.builder() + .date(farmSchedule.getDate()) + .status(farmSchedule.getStatus()) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleUpdateRequest.java b/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleUpdateRequest.java new file mode 100644 index 00000000..40e6bdd0 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/dto/FarmScheduleUpdateRequest.java @@ -0,0 +1,29 @@ +package poomasi.domain.farm._schedule.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import poomasi.domain.farm._schedule.entity.FarmSchedule; +import poomasi.domain.farm._schedule.entity.ScheduleStatus; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.List; + +public record FarmScheduleUpdateRequest( + Long farmId, + @NotNull(message = "시작 날짜는 필수 값입니다.") + LocalDate startDate, + @NotNull(message = "종료 날짜는 필수 값입니다.") + LocalDate endDate, + ScheduleStatus status, + @NotEmpty(message = "예약 가능한 요일은 필수 값입니다.") + List availableDays // 예약 가능한 요일 리스트 +) { + public FarmSchedule toEntity(LocalDate date) { + return FarmSchedule.builder() + .farmId(farmId) + .date(date) + .status(status) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/farm/_schedule/entity/FarmSchedule.java b/src/main/java/poomasi/domain/farm/_schedule/entity/FarmSchedule.java new file mode 100644 index 00000000..949a343a --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/entity/FarmSchedule.java @@ -0,0 +1,40 @@ +package poomasi.domain.farm._schedule.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Comment; + +import java.time.LocalDate; + +@Entity +@Getter +@Table(name = "farm_schedule") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FarmSchedule { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("농장") + private Long farmId; + + @Comment("예약 가능 날짜") + private LocalDate date; + + @Setter + @Comment("예약 가능 여부") + @Enumerated(EnumType.STRING) + private ScheduleStatus status; + + @Builder + public FarmSchedule(Long farmId, LocalDate date, ScheduleStatus status) { + this.farmId = farmId; + this.date = date; + this.status = status; + } + + public void updateStatus(ScheduleStatus status) { + this.status = status; + } + +} diff --git a/src/main/java/poomasi/domain/farm/_schedule/entity/ScheduleStatus.java b/src/main/java/poomasi/domain/farm/_schedule/entity/ScheduleStatus.java new file mode 100644 index 00000000..dae5b8ae --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/entity/ScheduleStatus.java @@ -0,0 +1,15 @@ +package poomasi.domain.farm._schedule.entity; + +public enum ScheduleStatus { + PENDING, + RESERVED, + ; + + public boolean isAvailable() { + return this == PENDING; + } + + public boolean isReserved() { + return this == RESERVED; + } +} diff --git a/src/main/java/poomasi/domain/farm/_schedule/repository/FarmScheduleRepository.java b/src/main/java/poomasi/domain/farm/_schedule/repository/FarmScheduleRepository.java new file mode 100644 index 00000000..1bc64247 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/repository/FarmScheduleRepository.java @@ -0,0 +1,19 @@ +package poomasi.domain.farm._schedule.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import poomasi.domain.farm._schedule.entity.FarmSchedule; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface FarmScheduleRepository extends JpaRepository { + @Query("SELECT f FROM FarmSchedule f WHERE f.farmId = :farmId AND f.date BETWEEN :startDate AND :endDate") + List findByFarmIdAndDateRange(Long farmId, LocalDate startDate, LocalDate endDate); + + Optional findByFarmIdAndDate(Long aLong, LocalDate date); + +} diff --git a/src/main/java/poomasi/domain/farm/_schedule/service/FarmScheduleService.java b/src/main/java/poomasi/domain/farm/_schedule/service/FarmScheduleService.java new file mode 100644 index 00000000..dd59b7bd --- /dev/null +++ b/src/main/java/poomasi/domain/farm/_schedule/service/FarmScheduleService.java @@ -0,0 +1,79 @@ +package poomasi.domain.farm._schedule.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import poomasi.domain.farm._schedule.dto.FarmScheduleRequest; +import poomasi.domain.farm._schedule.dto.FarmScheduleResponse; +import poomasi.domain.farm._schedule.dto.FarmScheduleUpdateRequest; +import poomasi.domain.farm._schedule.entity.FarmSchedule; +import poomasi.domain.farm._schedule.entity.ScheduleStatus; +import poomasi.domain.farm._schedule.repository.FarmScheduleRepository; +import poomasi.global.error.BusinessException; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static poomasi.global.error.BusinessError.*; + +@Service +@RequiredArgsConstructor +public class FarmScheduleService { + private final FarmScheduleRepository farmScheduleRepository; + + public void addFarmSchedule(FarmScheduleUpdateRequest request) { + List existingSchedules = farmScheduleRepository.findByFarmIdAndDateRange(request.farmId(), request.startDate(), request.endDate()); + + if (request.startDate().isAfter(request.endDate())) { + throw new BusinessException(START_DATE_SHOULD_BE_BEFORE_END_DATE); + } + + Set existingDates = existingSchedules.stream() + .map(FarmSchedule::getDate) + .collect(Collectors.toSet()); + + for (LocalDate date = request.startDate(); !date.isAfter(request.endDate()); date = date.plusDays(1)) { + if (request.availableDays().contains(date.getDayOfWeek())) { + if (existingDates.contains(date)) { + throw new BusinessException(FARM_SCHEDULE_ALREADY_EXISTS); + } + + FarmSchedule newSchedule = request.toEntity(date); + farmScheduleRepository.save(newSchedule); + } + } + } + + public List getFarmSchedulesByYearAndMonth(FarmScheduleRequest request) { + LocalDate startDate = LocalDate.of(request.year(), request.month(), 1); + LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + + return farmScheduleRepository.findByFarmIdAndDateRange(request.farmId(), startDate, endDate).stream() + .map(FarmScheduleResponse::fromEntity) + .toList(); + } + + public FarmSchedule getFarmScheduleByFarmIdAndDate(Long farmId, LocalDate date) { + return farmScheduleRepository.findByFarmIdAndDate(farmId, date) + .orElseThrow(() -> new BusinessException(FARM_SCHEDULE_NOT_FOUND)); + } + + public FarmSchedule getValidFarmScheduleByFarmIdAndDate(Long farmId, LocalDate date) { + FarmSchedule farmSchedule = getFarmScheduleByFarmIdAndDate(farmId, date); + + if (farmSchedule.getStatus() == ScheduleStatus.RESERVED) { + throw new BusinessException(FARM_SCHEDULE_ALREADY_RESERVED); + } + + return farmSchedule; + } + + public void updateFarmScheduleStatus(Long farmScheduleId, ScheduleStatus status) { + FarmSchedule farmSchedule = farmScheduleRepository.findById(farmScheduleId) + .orElseThrow(() -> new BusinessException(FARM_SCHEDULE_NOT_FOUND)); + + farmSchedule.setStatus(status); + farmScheduleRepository.save(farmSchedule); + } +} diff --git a/src/main/java/poomasi/domain/farm/controller/FarmController.java b/src/main/java/poomasi/domain/farm/controller/FarmController.java new file mode 100644 index 00000000..cb3c81af --- /dev/null +++ b/src/main/java/poomasi/domain/farm/controller/FarmController.java @@ -0,0 +1,28 @@ +package poomasi.domain.farm.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import poomasi.domain.farm.service.FarmPlatformService; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/farm") +public class FarmController { + private final FarmPlatformService farmPlatformService; + + @GetMapping("/{farmId}") + public ResponseEntity getFarm(@PathVariable Long farmId) { + return ResponseEntity.ok(farmPlatformService.getFarmByFarmId(farmId)); + } + + @GetMapping("") + public ResponseEntity getFarmList(Pageable pageable) { + return ResponseEntity.ok(farmPlatformService.getFarmList(pageable)); + } +} diff --git a/src/main/java/poomasi/domain/farm/controller/FarmFarmerController.java b/src/main/java/poomasi/domain/farm/controller/FarmFarmerController.java new file mode 100644 index 00000000..eed59cd3 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/controller/FarmFarmerController.java @@ -0,0 +1,41 @@ +package poomasi.domain.farm.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import poomasi.domain.farm.dto.FarmRegisterRequest; +import poomasi.domain.farm.dto.FarmUpdateRequest; +import poomasi.domain.farm.service.FarmFarmerService; +import poomasi.domain.farm._schedule.service.FarmScheduleService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/farm") +public class FarmFarmerController { + private final FarmFarmerService farmFarmerService; + private final FarmScheduleService farmScheduleService; + + // TODO: 판매자만 접근가능하도록 인증/인가 annotation 추가 + @PostMapping("") + public ResponseEntity registerFarm(@RequestBody FarmRegisterRequest request) { + return ResponseEntity.ok(farmFarmerService.registerFarm(request)); + } + + @PostMapping("/update") + public ResponseEntity updateFarm(@Valid @RequestBody FarmUpdateRequest request) { + // TODO: 판매자 ID(Spring Security Context)로 대체 + Long farmerId = 1L; + return ResponseEntity.ok(farmFarmerService.updateFarm(farmerId, request)); + } + + @DeleteMapping("/{farmId}") + public ResponseEntity deleteFarm(@PathVariable Long farmId) { + // TODO: 판매자 ID + Long farmerId = 1L; + + farmFarmerService.deleteFarm(farmerId, farmId); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/poomasi/domain/farm/dto/FarmRegisterRequest.java b/src/main/java/poomasi/domain/farm/dto/FarmRegisterRequest.java new file mode 100644 index 00000000..49447af8 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/dto/FarmRegisterRequest.java @@ -0,0 +1,28 @@ +package poomasi.domain.farm.dto; + +import poomasi.domain.farm.entity.Farm; + +public record FarmRegisterRequest( + String name, + Long memberId, + String address, + String addressDetail, + Double latitude, + Double longitude, + String phoneNumber, + String description, + Long experiencePrice +) { + public Farm toEntity() { + return Farm.builder() + .name(name) + .ownerId(memberId) + .address(address) + .addressDetail(addressDetail) + .latitude(latitude) + .longitude(longitude) + .description(description) + .experiencePrice(experiencePrice) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/farm/dto/FarmResponse.java b/src/main/java/poomasi/domain/farm/dto/FarmResponse.java new file mode 100644 index 00000000..632957e3 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/dto/FarmResponse.java @@ -0,0 +1,28 @@ +package poomasi.domain.farm.dto; + +import poomasi.domain.farm.entity.Farm; + + +public record FarmResponse( // FIXME: 사용자 정보 추가 및 설명/전화번호 추가 + Long id, + String name, + String address, + String addressDetail, + Double latitude, + Double longitude, + String description, + Long experiencePrice +) { + public static FarmResponse fromEntity(Farm farm) { + return new FarmResponse( + farm.getId(), + farm.getName(), + farm.getAddress(), + farm.getAddressDetail(), + farm.getLatitude(), + farm.getLongitude(), + farm.getDescription(), + farm.getExperiencePrice() + ); + } +} diff --git a/src/main/java/poomasi/domain/farm/dto/FarmUpdateRequest.java b/src/main/java/poomasi/domain/farm/dto/FarmUpdateRequest.java new file mode 100644 index 00000000..23e5ebbe --- /dev/null +++ b/src/main/java/poomasi/domain/farm/dto/FarmUpdateRequest.java @@ -0,0 +1,18 @@ +package poomasi.domain.farm.dto; + +import jakarta.validation.constraints.NotNull; +import poomasi.domain.farm.entity.Farm; + +public record FarmUpdateRequest( + @NotNull(message = "Farm ID는 필수 값입니다.") Long farmId, + String name, + String description, + String address, + String addressDetail, + Double latitude, + Double longitude +) { + public Farm toEntity(Farm farm) { + return farm.updateFarm(this); + } +} diff --git a/src/main/java/poomasi/domain/farm/entity/Farm.java b/src/main/java/poomasi/domain/farm/entity/Farm.java new file mode 100644 index 00000000..62335214 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/entity/Farm.java @@ -0,0 +1,88 @@ +package poomasi.domain.farm.entity; + +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.UpdateTimestamp; +import poomasi.domain.farm.dto.FarmUpdateRequest; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "farm") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE farm SET deleted_at=current_timestamp WHERE id = ?") +public class Farm { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + // FIXME: owner_id는 Member의 id를 참조해야 합니다. + @Comment("농장 소유자 ID") + @Column(name = "owner_id") + private Long ownerId; + + @Comment("농장 간단 설명") + private String description; + + @Comment("도로명 주소") + private String address; + + @Comment("상세 주소") + private String addressDetail; + + @Comment("위도") + private Double latitude; + + @Comment("경도") + private Double longitude; + + @Comment("농장 상태") + @Enumerated(EnumType.STRING) + private FarmStatus status = FarmStatus.OPEN; + + @Comment("체험 비용") + private Long experiencePrice; + + @Comment("삭제 일시") + private LocalDateTime deletedAt; + + @CreationTimestamp + @Column(name = "created_at") + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "updated_at") + @UpdateTimestamp + private LocalDateTime updatedAt = LocalDateTime.now(); + + @Builder + public Farm(String name, Long ownerId, String address, String addressDetail, Double latitude, Double longitude, String description, Long experiencePrice) { + this.name = name; + this.ownerId = ownerId; + this.address = address; + this.addressDetail = addressDetail; + this.latitude = latitude; + this.longitude = longitude; + this.description = description; + this.experiencePrice = experiencePrice; + } + + public Farm updateFarm(FarmUpdateRequest farmUpdateRequest) { + this.name = farmUpdateRequest.name(); + this.address = farmUpdateRequest.address(); + this.addressDetail = farmUpdateRequest.addressDetail(); + this.latitude = farmUpdateRequest.latitude(); + this.longitude = farmUpdateRequest.longitude(); + this.description = farmUpdateRequest.description(); + return this; + } +} diff --git a/src/main/java/poomasi/domain/farm/entity/FarmStatus.java b/src/main/java/poomasi/domain/farm/entity/FarmStatus.java new file mode 100644 index 00000000..f1723a19 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/entity/FarmStatus.java @@ -0,0 +1,5 @@ +package poomasi.domain.farm.entity; + +public enum FarmStatus { + OPEN, CLOSE, WAITING +} diff --git a/src/main/java/poomasi/domain/farm/repository/FarmRepository.java b/src/main/java/poomasi/domain/farm/repository/FarmRepository.java new file mode 100644 index 00000000..96b40c67 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/repository/FarmRepository.java @@ -0,0 +1,20 @@ +package poomasi.domain.farm.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import poomasi.domain.farm.entity.Farm; + +import java.util.Optional; + +@Repository +public interface FarmRepository extends JpaRepository { + Page findAll(Pageable pageable); + + Page findByDeletedAtIsNull(Pageable pageable); + + Optional getFarmByOwnerIdAndDeletedAtIsNull(Long ownerId); + + Optional findByIdAndDeletedAtIsNull(Long id); +} diff --git a/src/main/java/poomasi/domain/farm/service/FarmFarmerService.java b/src/main/java/poomasi/domain/farm/service/FarmFarmerService.java new file mode 100644 index 00000000..5eec83fd --- /dev/null +++ b/src/main/java/poomasi/domain/farm/service/FarmFarmerService.java @@ -0,0 +1,50 @@ +package poomasi.domain.farm.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import poomasi.domain.farm.dto.FarmRegisterRequest; +import poomasi.domain.farm.dto.FarmUpdateRequest; +import poomasi.domain.farm.entity.Farm; +import poomasi.domain.farm.repository.FarmRepository; +import poomasi.global.error.BusinessException; + +import static poomasi.global.error.BusinessError.*; + +@Service +@RequiredArgsConstructor +public class FarmFarmerService { + private final FarmRepository farmRepository; + + public Long registerFarm(FarmRegisterRequest request) { + // TODO: 판매자 인가? + + farmRepository.getFarmByOwnerIdAndDeletedAtIsNull(request.memberId()).ifPresent(farm -> { + throw new BusinessException(FARM_ALREADY_EXISTS); + }); + + return farmRepository.save(request.toEntity()).getId(); + + } + + public Long updateFarm(Long farmerId, FarmUpdateRequest request) { + Farm farm = this.getFarmByFarmId(request.farmId()); + + if (!farm.getOwnerId().equals(farmerId)) { + throw new BusinessException(FARM_OWNER_MISMATCH); + } + + return farmRepository.save(request.toEntity(farm)).getId(); + } + + public Farm getFarmByFarmId(Long farmId) { + return farmRepository.findByIdAndDeletedAtIsNull(farmId).orElseThrow(() -> new BusinessException(FARM_NOT_FOUND)); + } + + public void deleteFarm(Long farmerId, Long farmId) { + Farm farm = this.getFarmByFarmId(farmId); + if (!farm.getOwnerId().equals(farmerId)) { + throw new BusinessException(FARM_OWNER_MISMATCH); + } + farmRepository.delete(farm); + } +} diff --git a/src/main/java/poomasi/domain/farm/service/FarmPlatformService.java b/src/main/java/poomasi/domain/farm/service/FarmPlatformService.java new file mode 100644 index 00000000..54032ecf --- /dev/null +++ b/src/main/java/poomasi/domain/farm/service/FarmPlatformService.java @@ -0,0 +1,25 @@ +package poomasi.domain.farm.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import poomasi.domain.farm.dto.FarmResponse; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class FarmPlatformService { + private final FarmService farmService; + + public FarmResponse getFarmByFarmId(Long farmId) { + return FarmResponse.fromEntity(farmService.getFarmByFarmId(farmId)); + } + + public List getFarmList(Pageable pageable) { + return farmService.getFarmList(pageable).stream() + .map(FarmResponse::fromEntity) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/poomasi/domain/farm/service/FarmService.java b/src/main/java/poomasi/domain/farm/service/FarmService.java new file mode 100644 index 00000000..c0c108d9 --- /dev/null +++ b/src/main/java/poomasi/domain/farm/service/FarmService.java @@ -0,0 +1,38 @@ +package poomasi.domain.farm.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import poomasi.domain.farm.dto.FarmResponse; +import poomasi.domain.farm.entity.Farm; +import poomasi.domain.farm.entity.FarmStatus; +import poomasi.domain.farm.repository.FarmRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class FarmService { + private final FarmRepository farmRepository; + + public Farm getValidFarmByFarmId(Long farmId) { + Farm farm = getFarmByFarmId(farmId); + if (farm.getStatus() != FarmStatus.OPEN) { + throw new BusinessException(BusinessError.FARM_NOT_OPEN); + } + return farm; + } + + public Farm getFarmByFarmId(Long farmId) { + return farmRepository.findByIdAndDeletedAtIsNull(farmId) + .orElseThrow(() -> new BusinessException(BusinessError.FARM_NOT_FOUND)); + } + + public List getFarmList(Pageable pageable) { + return farmRepository.findByDeletedAtIsNull(pageable).stream() + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/poomasi/domain/member/controller/MemberController.java b/src/main/java/poomasi/domain/member/controller/MemberController.java new file mode 100644 index 00000000..a3c0951c --- /dev/null +++ b/src/main/java/poomasi/domain/member/controller/MemberController.java @@ -0,0 +1,21 @@ +package poomasi.domain.member.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import poomasi.domain.member.service.MemberService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/member") +public class MemberController { + + private final MemberService memberService; + + @PutMapping("/toFarmer/{memberId}") + public ResponseEntity upgradeToFarmer(@PathVariable Long memberId, + @RequestBody Boolean hasFarmerQualification) { + memberService.upgradeToFarmer(memberId, hasFarmerQualification); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/poomasi/domain/member/entity/LoginType.java b/src/main/java/poomasi/domain/member/entity/LoginType.java new file mode 100644 index 00000000..748ca559 --- /dev/null +++ b/src/main/java/poomasi/domain/member/entity/LoginType.java @@ -0,0 +1,6 @@ +package poomasi.domain.member.entity; + +public enum LoginType { + LOCAL, + KAKAO +} diff --git a/src/main/java/poomasi/domain/member/entity/Member.java b/src/main/java/poomasi/domain/member/entity/Member.java new file mode 100644 index 00000000..3ca82998 --- /dev/null +++ b/src/main/java/poomasi/domain/member/entity/Member.java @@ -0,0 +1,87 @@ +package poomasi.domain.member.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.SQLDelete; +import poomasi.domain.wishlist.entity.WishList; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Entity +@Table(name = "member") +@NoArgsConstructor +@SQLDelete(sql = "UPDATE member SET deleted_at = current_timestamp WHERE id = ?") +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = true, length = 50) + private String email; + + @Column(nullable = true) + private String password; + + @Setter + @Enumerated(EnumType.STRING) + @Column(nullable = true) + private LoginType loginType; + + @Setter + @Enumerated(EnumType.STRING) + @Column(nullable = true) + private Role role; + + @Column(nullable = true) + private String provideId; + + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private MemberProfile memberProfile; + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List wishLists; + + private LocalDateTime deletedAt; + + public Member(String email, String password, LoginType loginType, Role role) { + this.email = email; + this.password = password; + this.loginType = loginType; + this.role = role; + } + + public Member(String email, Role role) { + this.email = email; + this.role = role; + } + + public void setMemberProfile(MemberProfile memberProfile) { + this.memberProfile = memberProfile; + if (memberProfile != null) { + memberProfile.setMember(this); + } + } + + @Builder + public Member(String email, Role role, LoginType loginType, String provideId, MemberProfile memberProfile) { + this.email = email; + this.role = role; + this.loginType = loginType; + this.provideId = provideId; + this.memberProfile = memberProfile; + } + + public boolean isFarmer() { + return role == Role.ROLE_FARMER; + } + + public boolean isAdmin() { + return role == Role.ROLE_ADMIN; + } +} diff --git a/src/main/java/poomasi/domain/member/entity/MemberProfile.java b/src/main/java/poomasi/domain/member/entity/MemberProfile.java new file mode 100644 index 00000000..6a599a71 --- /dev/null +++ b/src/main/java/poomasi/domain/member/entity/MemberProfile.java @@ -0,0 +1,61 @@ +package poomasi.domain.member.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "member_profile") +public class MemberProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = true, length = 50) + private String name; + + @Column(nullable = true, length = 20) + private String phoneNumber; + + @Column(nullable = true, length = 255) + private String address; + + @Column(nullable = true, length = 255) + private String addressDetail; + + @Column(nullable=true, length=255) + private Long coordinateX; + + @Column(nullable=true, length=255) + private Long coordinateY; + + @Column(nullable = true, length = 50) + private boolean isBanned; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Setter + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", referencedColumnName = "id") + private Member member; + + public MemberProfile(String name, String phoneNumber, String address, Member member) { + this.name = name; + this.phoneNumber = phoneNumber; + this.address = address; + this.isBanned = false; + this.createdAt = LocalDateTime.now(); + this.member = member; + } + + public MemberProfile() { + this.name = "UNKNOWN"; // name not null 조건 때문에 임시로 넣었습니다. nullable도 true로 넣었는데 안 되네요 + this.createdAt = LocalDateTime.now(); + } + +} diff --git a/src/main/java/poomasi/domain/member/entity/Role.java b/src/main/java/poomasi/domain/member/entity/Role.java new file mode 100644 index 00000000..957883c6 --- /dev/null +++ b/src/main/java/poomasi/domain/member/entity/Role.java @@ -0,0 +1,15 @@ +package poomasi.domain.member.entity; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + ROLE_ADMIN, // 관리자 + ROLE_FARMER, // 농부 역할 + ROLE_CUSTOMER; // 구매자 역할 + + @Override + public String getAuthority() { + return name(); + } +} + diff --git a/src/main/java/poomasi/domain/member/repository/MemberRepository.java b/src/main/java/poomasi/domain/member/repository/MemberRepository.java new file mode 100644 index 00000000..f9bcb1c3 --- /dev/null +++ b/src/main/java/poomasi/domain/member/repository/MemberRepository.java @@ -0,0 +1,12 @@ +package poomasi.domain.member.repository; + + +import org.springframework.data.jpa.repository.JpaRepository; +import poomasi.domain.member.entity.Member; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); + Optional findByIdAndDeletedAtIsNull(Long id); +} diff --git a/src/main/java/poomasi/domain/member/service/MemberService.java b/src/main/java/poomasi/domain/member/service/MemberService.java new file mode 100644 index 00000000..53388950 --- /dev/null +++ b/src/main/java/poomasi/domain/member/service/MemberService.java @@ -0,0 +1,47 @@ +package poomasi.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.member.entity.Member; +import poomasi.domain.member.repository.MemberRepository; +import poomasi.global.error.BusinessException; + +import static poomasi.domain.member.entity.Role.ROLE_FARMER; +import static poomasi.global.error.BusinessError.INVALID_FARMER_QUALIFICATION; +import static poomasi.global.error.BusinessError.MEMBER_NOT_FOUND; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + + @Transactional + public void upgradeToFarmer(Long memberId, Boolean hasFarmerQualification) { + Member member = findMemberById(memberId); + + if (!hasFarmerQualification) { + throw new BusinessException(INVALID_FARMER_QUALIFICATION); + } + + member.setRole(ROLE_FARMER); + memberRepository.save(member); + } + + public boolean isFarmer(Long memberId) { + Member member = findMemberById(memberId); + return member.isFarmer(); + } + + public Member findMemberById(Long memberId) { + return memberRepository.findByIdAndDeletedAtIsNull(memberId) + .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + } + + public boolean isAdmin(Long memberId) { + Member member = findMemberById(memberId); + return member.isAdmin(); + } +} diff --git a/src/main/java/poomasi/domain/product/_category/controller/CategoryAdminController.java b/src/main/java/poomasi/domain/product/_category/controller/CategoryAdminController.java new file mode 100644 index 00000000..46bd6e05 --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/controller/CategoryAdminController.java @@ -0,0 +1,39 @@ +package poomasi.domain.product._category.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import poomasi.domain.product._category.dto.CategoryRequest; +import poomasi.domain.product._category.service.CategoryAdminService; + +@RestController +@RequiredArgsConstructor +public class CategoryAdminController { + + private final CategoryAdminService categoryAdminService; + + @PostMapping("/api/categories") + public ResponseEntity registerCategory(@RequestBody CategoryRequest categoryRequest) { + Long categoryId = categoryAdminService.registerCategory(categoryRequest); + return new ResponseEntity<>(categoryId, HttpStatus.CREATED); + } + + @PutMapping("/api/categories/{categoryId}") + public ResponseEntity modifyCategory(@PathVariable Long categoryId, + @RequestBody CategoryRequest categoryRequest) { + categoryAdminService.modifyCategory(categoryId, categoryRequest); + return new ResponseEntity<>(categoryId, HttpStatus.OK); + } + + @DeleteMapping("/api/categories/{categoryId}") + public ResponseEntity deleteCategory(@PathVariable Long categoryId) { + categoryAdminService.deleteCategory(categoryId); + return new ResponseEntity<>(categoryId, HttpStatus.OK); + } +} diff --git a/src/main/java/poomasi/domain/product/_category/controller/CategoryController.java b/src/main/java/poomasi/domain/product/_category/controller/CategoryController.java new file mode 100644 index 00000000..1979cd47 --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/controller/CategoryController.java @@ -0,0 +1,32 @@ +package poomasi.domain.product._category.controller; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import poomasi.domain.product._category.dto.CategoryResponse; +import poomasi.domain.product._category.dto.ProductListInCategoryResponse; +import poomasi.domain.product._category.service.CategoryService; + +@RestController +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryService categoryService; + + @GetMapping("/api/categories") + public ResponseEntity getAllCategories() { + List categories = categoryService.getAllCategories(); + return new ResponseEntity<>(categories, HttpStatus.OK); + } + + @GetMapping("/api/categories/{categoryId}") + public ResponseEntity getCategoryById(@PathVariable Long categoryId) { + List productList = categoryService.getProductInCategory( + categoryId); + return new ResponseEntity<>(productList, HttpStatus.OK); + } +} diff --git a/src/main/java/poomasi/domain/product/_category/dto/CategoryRequest.java b/src/main/java/poomasi/domain/product/_category/dto/CategoryRequest.java new file mode 100644 index 00000000..5fc45c31 --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/dto/CategoryRequest.java @@ -0,0 +1,12 @@ +package poomasi.domain.product._category.dto; + +import poomasi.domain.product._category.entity.Category; + +public record CategoryRequest( + String name +) { + + public Category toEntity() { + return new Category(name); + } +} diff --git a/src/main/java/poomasi/domain/product/_category/dto/CategoryResponse.java b/src/main/java/poomasi/domain/product/_category/dto/CategoryResponse.java new file mode 100644 index 00000000..efaadcb8 --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/dto/CategoryResponse.java @@ -0,0 +1,15 @@ +package poomasi.domain.product._category.dto; + +import lombok.Builder; +import poomasi.domain.product._category.entity.Category; + +@Builder +public record CategoryResponse(Long id, String name) { + + public static CategoryResponse fromEntity(Category category) { + return CategoryResponse.builder() + .id(category.getId()) + .name(category.getName()) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/product/_category/dto/ProductListInCategoryResponse.java b/src/main/java/poomasi/domain/product/_category/dto/ProductListInCategoryResponse.java new file mode 100644 index 00000000..12eff02f --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/dto/ProductListInCategoryResponse.java @@ -0,0 +1,26 @@ +package poomasi.domain.product._category.dto; + +import lombok.Builder; +import poomasi.domain.product.entity.Product; + +@Builder +public record ProductListInCategoryResponse( + Long categoryId, + String name, + String description, + String imageUrl, + Integer quantity, + Long price +) { + + public static ProductListInCategoryResponse fromEntity(Product product) { + return ProductListInCategoryResponse.builder() + .categoryId(product.getCategoryId()) + .name(product.getName()) + .description(product.getDescription()) + .imageUrl(product.getImageUrl()) + .quantity(product.getStock()) + .price(product.getPrice()) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/product/_category/entity/Category.java b/src/main/java/poomasi/domain/product/_category/entity/Category.java new file mode 100644 index 00000000..b1957cdb --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/entity/Category.java @@ -0,0 +1,46 @@ +package poomasi.domain.product._category.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; +import poomasi.domain.product._category.dto.CategoryRequest; +import poomasi.domain.product.entity.Product; + +@Entity +@Getter +@NoArgsConstructor +public class Category { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "categoryId") + List products = new ArrayList<>(); + + public Category(String name) { + this.name = name; + } + + public void modifyName(CategoryRequest categoryRequest) { + this.name = categoryRequest.name(); + } + + public void deleteProduct(Product product) { + this.products.remove(product); + } + + public void addProduct(Product saveProduct) { + this.products.add(saveProduct); + } +} diff --git a/src/main/java/poomasi/domain/product/_category/repository/CategoryRepository.java b/src/main/java/poomasi/domain/product/_category/repository/CategoryRepository.java new file mode 100644 index 00000000..f635b418 --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/repository/CategoryRepository.java @@ -0,0 +1,8 @@ +package poomasi.domain.product._category.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import poomasi.domain.product._category.entity.Category; + +public interface CategoryRepository extends JpaRepository { + +} diff --git a/src/main/java/poomasi/domain/product/_category/service/CategoryAdminService.java b/src/main/java/poomasi/domain/product/_category/service/CategoryAdminService.java new file mode 100644 index 00000000..f99c1c46 --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/service/CategoryAdminService.java @@ -0,0 +1,42 @@ +package poomasi.domain.product._category.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.product._category.dto.CategoryRequest; +import poomasi.domain.product._category.entity.Category; +import poomasi.domain.product._category.repository.CategoryRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +@RequiredArgsConstructor +public class CategoryAdminService { + + private final CategoryRepository categoryRepository; + + public Long registerCategory(CategoryRequest categoryRequest) { + //admin인지 확인 + + Category category = categoryRequest.toEntity(); + category = categoryRepository.save(category); + return category.getId(); + } + + @Transactional + public void modifyCategory(Long categoryId, CategoryRequest categoryRequest) { + //admin인지 확인 + + Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> new BusinessException(BusinessError.CATEGORY_NOT_FOUND)); + category.modifyName(categoryRequest); + } + + @Transactional + public void deleteCategory(Long categoryId) { + //admin인지 확인 + Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> new BusinessException(BusinessError.CATEGORY_NOT_FOUND)); + categoryRepository.delete(category); + } +} diff --git a/src/main/java/poomasi/domain/product/_category/service/CategoryService.java b/src/main/java/poomasi/domain/product/_category/service/CategoryService.java new file mode 100644 index 00000000..51594960 --- /dev/null +++ b/src/main/java/poomasi/domain/product/_category/service/CategoryService.java @@ -0,0 +1,40 @@ +package poomasi.domain.product._category.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import poomasi.domain.product._category.dto.CategoryResponse; +import poomasi.domain.product._category.dto.ProductListInCategoryResponse; +import poomasi.domain.product._category.entity.Category; +import poomasi.domain.product._category.repository.CategoryRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +@RequiredArgsConstructor +public class CategoryService { + + private final CategoryRepository categoryRepository; + + public List getAllCategories() { + List categories = categoryRepository.findAll(); + return categories.stream() + .map(CategoryResponse::fromEntity) + .toList(); + } + + public List getProductInCategory(Long categoryId) { + Category category = getCategory(categoryId); + return category.getProducts() + .stream() + .map(ProductListInCategoryResponse::fromEntity) + .toList(); + } + + public Category getCategory(Long categoryId) { + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new BusinessException(BusinessError.CATEGORY_NOT_FOUND)); + + } + +} diff --git a/src/main/java/poomasi/domain/product/controller/ProductController.java b/src/main/java/poomasi/domain/product/controller/ProductController.java new file mode 100644 index 00000000..5d9b4444 --- /dev/null +++ b/src/main/java/poomasi/domain/product/controller/ProductController.java @@ -0,0 +1,32 @@ +package poomasi.domain.product.controller; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import poomasi.domain.product.dto.ProductResponse; +import poomasi.domain.product.service.ProductService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/product") +public class ProductController { + + private final ProductService productService; + + @GetMapping("") + public ResponseEntity getAllProducts() { + List products = productService.getAllProducts(); + return new ResponseEntity<>(products, HttpStatus.OK); + } + + @GetMapping("/{productId}") + public ResponseEntity getProduct(@PathVariable Long productId) { + ProductResponse product = productService.getProductByProductId(productId); + return ResponseEntity.ok(product); + } +} diff --git a/src/main/java/poomasi/domain/product/controller/ProductFarmerController.java b/src/main/java/poomasi/domain/product/controller/ProductFarmerController.java new file mode 100644 index 00000000..cb97f6a1 --- /dev/null +++ b/src/main/java/poomasi/domain/product/controller/ProductFarmerController.java @@ -0,0 +1,57 @@ +package poomasi.domain.product.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import poomasi.domain.product.dto.ProductRegisterRequest; +import poomasi.domain.product.dto.UpdateProductQuantityRequest; +import poomasi.domain.product.service.ProductFarmerService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/product") +@Slf4j +public class ProductFarmerController { + + private final ProductFarmerService productFarmerService; + + @PostMapping("") + public ResponseEntity registerProduct(@RequestBody ProductRegisterRequest product) { + Long productId = productFarmerService.registerProduct(product); + return new ResponseEntity<>(productId, HttpStatus.CREATED); + } + + @PutMapping("/{productId}") + public ResponseEntity modifyProduct(@RequestBody ProductRegisterRequest product, + @PathVariable Long productId) { + productFarmerService.modifyProduct(product, productId); + return new ResponseEntity<>(productId, HttpStatus.OK); + } + + @DeleteMapping("/{productId}") + public ResponseEntity deleteProduct(@PathVariable Long productId) { + // TODO: farmerId를 SecurityContextHolder에서 가져와서 비교해야함. + + productFarmerService.deleteProduct(productId); + return new ResponseEntity<>(HttpStatus.OK); + } + + + @PatchMapping("/{productId}") + public ResponseEntity updateProductQuantity(@PathVariable Long productId, + @RequestBody UpdateProductQuantityRequest request) { + log.debug("Product ID: {}", productId); + log.debug("Update Request: {}", request); + productFarmerService.addQuantity(productId, request); + return new ResponseEntity<>(HttpStatus.OK); + } +} diff --git a/src/main/java/poomasi/domain/product/dto/ProductRegisterRequest.java b/src/main/java/poomasi/domain/product/dto/ProductRegisterRequest.java new file mode 100644 index 00000000..96278af7 --- /dev/null +++ b/src/main/java/poomasi/domain/product/dto/ProductRegisterRequest.java @@ -0,0 +1,27 @@ +package poomasi.domain.product.dto; + +import poomasi.domain.product.entity.Product; + +public record ProductRegisterRequest( + Long categoryId, + Long farmerId, //등록한 사람 + String name, + String description, + String imageUrl, + Integer stock, + Long price +) { + + public Product toEntity() { + return Product.builder() + .categoryId(categoryId) + .farmerId(farmerId) + .name(name) + .stock(stock) + .description(description) + .imageUrl(imageUrl) + .stock(stock) + .price(price) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/product/dto/ProductResponse.java b/src/main/java/poomasi/domain/product/dto/ProductResponse.java new file mode 100644 index 00000000..89eca75c --- /dev/null +++ b/src/main/java/poomasi/domain/product/dto/ProductResponse.java @@ -0,0 +1,28 @@ +package poomasi.domain.product.dto; + +import lombok.Builder; +import poomasi.domain.product.entity.Product; + +@Builder +public record ProductResponse( + Long id, + String name, + Long price, + Integer stock, + String description, + String imageUrl, + Long categoryId +) { + + public static ProductResponse fromEntity(Product product) { + return ProductResponse.builder() + .id(product.getId()) + .name(product.getName()) + .price(product.getPrice()) + .stock(product.getStock()) + .description(product.getDescription()) + .imageUrl(product.getImageUrl()) + .categoryId(product.getCategoryId()) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/product/dto/UpdateProductQuantityRequest.java b/src/main/java/poomasi/domain/product/dto/UpdateProductQuantityRequest.java new file mode 100644 index 00000000..bff0b8b7 --- /dev/null +++ b/src/main/java/poomasi/domain/product/dto/UpdateProductQuantityRequest.java @@ -0,0 +1,6 @@ +package poomasi.domain.product.dto; + +public record UpdateProductQuantityRequest(Integer quantity) { + +} + diff --git a/src/main/java/poomasi/domain/product/entity/Product.java b/src/main/java/poomasi/domain/product/entity/Product.java new file mode 100644 index 00000000..f39e5cb0 --- /dev/null +++ b/src/main/java/poomasi/domain/product/entity/Product.java @@ -0,0 +1,110 @@ +package poomasi.domain.product.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.UpdateTimestamp; +import poomasi.domain.product.dto.ProductRegisterRequest; +import poomasi.domain.review.entity.Review; + +@Entity +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE product SET deleted_at = current_timestamp WHERE id = ?") +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("카테고리 ID") + private Long categoryId; + + @Comment("등록한 사람") + private Long farmerId; //등록한 사람 + + @Comment("상품명") + private String name; + + @Comment("상품 설명") + private String description; + + @Comment("이미지 URL") + private String imageUrl; + + @Comment("재고") + private Integer stock; + + @Comment("가격") + private Long price; + + @Comment("삭제 일시") + private LocalDateTime deletedAt; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @OneToMany(cascade = CascadeType.REMOVE, orphanRemoval = true) + @JoinColumn(name = "entityId") + List reviewList = new ArrayList<>(); + + @Comment("평균 평점") + private double averageRating = 0.0; + + @Builder + public Product(Long productId, + Long categoryId, + Long farmerId, + String name, + String description, + String imageUrl, + Integer stock, + Long price) { + this.categoryId = categoryId; + this.farmerId = farmerId; + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.stock = stock; + this.price = price; + } + + public Product modify(ProductRegisterRequest productRegisterRequest) { + this.categoryId = productRegisterRequest.categoryId(); + this.name = productRegisterRequest.name(); + this.description = productRegisterRequest.description(); + this.imageUrl = productRegisterRequest.imageUrl(); + this.stock = productRegisterRequest.stock(); + this.price = productRegisterRequest.price(); + return this; + } + + public void addStock(Integer stock) { + this.stock += stock; + } + + public void addReview(Review pReview) { + this.reviewList.add(pReview); + this.averageRating = reviewList.stream() + .mapToDouble(Review::getRating) // 각 리뷰의 평점을 double로 변환 + .average() // 평균 계산 + .orElse(0.0); + } + +} diff --git a/src/main/java/poomasi/domain/product/repository/ProductRepository.java b/src/main/java/poomasi/domain/product/repository/ProductRepository.java new file mode 100644 index 00000000..17ce4ed0 --- /dev/null +++ b/src/main/java/poomasi/domain/product/repository/ProductRepository.java @@ -0,0 +1,16 @@ +package poomasi.domain.product.repository; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import poomasi.domain.product.entity.Product; + +@Repository +public interface ProductRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + List findAllByDeletedAtIsNull(); + +} diff --git a/src/main/java/poomasi/domain/product/service/ProductFarmerService.java b/src/main/java/poomasi/domain/product/service/ProductFarmerService.java new file mode 100644 index 00000000..b37d4605 --- /dev/null +++ b/src/main/java/poomasi/domain/product/service/ProductFarmerService.java @@ -0,0 +1,76 @@ +package poomasi.domain.product.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.product._category.entity.Category; +import poomasi.domain.product._category.repository.CategoryRepository; +import poomasi.domain.product.dto.ProductRegisterRequest; +import poomasi.domain.product.dto.UpdateProductQuantityRequest; +import poomasi.domain.product.entity.Product; +import poomasi.domain.product.repository.ProductRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +@RequiredArgsConstructor +public class ProductFarmerService { + + private final ProductRepository productRepository; + private final CategoryRepository categoryRepository; + //private final MemberService memberService; + + public Long registerProduct(ProductRegisterRequest request) { + //memberService.isFarmer(request.farmerId()); + Category category = getCategory(request.categoryId()); + + Product saveProduct = productRepository.save(request.toEntity()); + category.addProduct(saveProduct); + return saveProduct.getId(); + } + + + @Transactional + public void modifyProduct(ProductRegisterRequest productRequest, Long productId) { + // TODO: 주인인지 알아보기 + Product product = getProductByProductId(productId); + Long categoryId = product.getCategoryId(); + Category oldCategory = getCategory(categoryId); + + oldCategory.deleteProduct(product);//원래 카테고리에서 삭제 + product = productRepository.save(product.modify(productRequest)); //상품 갱신 + + categoryId = productRequest.categoryId(); + Category newCategory = getCategory(categoryId); + newCategory.addProduct(product); + } + + @Transactional + public void deleteProduct(Long productId) { + //TODO: 주인인지 알아보기 + Product product = getProductByProductId(productId); + Long categoryId = product.getCategoryId(); + Category category = getCategory(categoryId); + + category.deleteProduct(product); + productRepository.delete(product); + } + + @Transactional + public void addQuantity(Long productId, UpdateProductQuantityRequest request) { + Product productByProductId = getProductByProductId(productId); + productByProductId.addStock(request.quantity()); + + productRepository.save(productByProductId); + } + + private Product getProductByProductId(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new BusinessException(BusinessError.PRODUCT_NOT_FOUND)); + } + + private Category getCategory(Long categoryId) { + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new BusinessException(BusinessError.CATEGORY_NOT_FOUND)); + } +} diff --git a/src/main/java/poomasi/domain/product/service/ProductService.java b/src/main/java/poomasi/domain/product/service/ProductService.java new file mode 100644 index 00000000..ca7b482c --- /dev/null +++ b/src/main/java/poomasi/domain/product/service/ProductService.java @@ -0,0 +1,43 @@ +package poomasi.domain.product.service; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import poomasi.domain.product.dto.ProductResponse; +import poomasi.domain.product.entity.Product; +import poomasi.domain.product.repository.ProductRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + public List getAllProducts() { + return productRepository.findAllByDeletedAtIsNull() + .stream() + .map(ProductResponse::fromEntity) + .toList(); + } + + public ProductResponse getProductByProductId(Long productId) { + return ProductResponse.fromEntity(findProductById(productId)); + } + + + public Product findValidProductById(Long productId) { + Product product = findProductById(productId); + if (product.getStock() == 0) { + throw new BusinessException(BusinessError.PRODUCT_STOCK_ZERO); + } + return product; + } + + public Product findProductById(Long productId) { + return productRepository.findByIdAndDeletedAtIsNull(productId) + .orElseThrow(() -> new BusinessException(BusinessError.PRODUCT_NOT_FOUND)); + } +} diff --git a/src/main/java/poomasi/domain/reservation/controller/ReservationFarmerController.java b/src/main/java/poomasi/domain/reservation/controller/ReservationFarmerController.java new file mode 100644 index 00000000..808bfa20 --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/controller/ReservationFarmerController.java @@ -0,0 +1,22 @@ +package poomasi.domain.reservation.controller; + +import lombok.RequiredArgsConstructor; +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.RestController; +import poomasi.domain.reservation.service.ReservationFarmerService; + +@RequestMapping("/api/v1/farmer/reservations") +@RestController +@RequiredArgsConstructor +public class ReservationFarmerController { + private final ReservationFarmerService reservationFarmerService; + + @GetMapping("") + public ResponseEntity getReservations() { + // FIXME : 임시로 farmerId를 1로 고정 + Long farmerId = 1L; + return ResponseEntity.ok(reservationFarmerService.getReservationsByFarmerId(farmerId)); + } +} diff --git a/src/main/java/poomasi/domain/reservation/controller/ReservationPlatformController.java b/src/main/java/poomasi/domain/reservation/controller/ReservationPlatformController.java new file mode 100644 index 00000000..e1fa6b3a --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/controller/ReservationPlatformController.java @@ -0,0 +1,39 @@ +package poomasi.domain.reservation.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import poomasi.domain.reservation.dto.request.ReservationRequest; +import poomasi.domain.reservation.dto.response.ReservationResponse; +import poomasi.domain.reservation.service.ReservationPlatformService; + +@RestController +@RequestMapping("/api/reservation") +@RequiredArgsConstructor +public class ReservationPlatformController { + private final ReservationPlatformService reservationPlatformService; + + @PostMapping("/create") + public ResponseEntity createReservation(@RequestBody ReservationRequest request) { + ReservationResponse reservation = reservationPlatformService.createReservation(request); + return ResponseEntity.ok(reservation); + } + + @GetMapping("/get/{reservationId}") + public ResponseEntity getReservation(@PathVariable Long reservationId) { + // FIXME: 로그인한 사용자의 ID를 가져오도록 수정 + Long memberId = 1L; + ReservationResponse reservation = reservationPlatformService.getReservation(memberId, reservationId); + return ResponseEntity.ok(reservation); + } + + @PostMapping("/cancel/{reservationId}") + public ResponseEntity cancelReservation(@PathVariable Long reservationId) { + // FIXME: 로그인한 사용자의 ID를 가져오도록 수정 + Long memberId = 1L; + + reservationPlatformService.cancelReservation(memberId, reservationId); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/poomasi/domain/reservation/dto/request/ReservationRequest.java b/src/main/java/poomasi/domain/reservation/dto/request/ReservationRequest.java new file mode 100644 index 00000000..4ffdf108 --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/dto/request/ReservationRequest.java @@ -0,0 +1,30 @@ +package poomasi.domain.reservation.dto.request; + +import lombok.Builder; +import poomasi.domain.farm._schedule.entity.FarmSchedule; +import poomasi.domain.farm.entity.Farm; +import poomasi.domain.member.entity.Member; +import poomasi.domain.reservation.entity.Reservation; + +import java.time.LocalDate; + +@Builder +public record ReservationRequest( + Long farmId, + Long memberId, + LocalDate reservationDate, + + int memberCount, + String request +) { + public Reservation toEntity(Member member, Farm farm, FarmSchedule farmSchedule) { + return Reservation.builder() + .member(member) + .farm(farm) + .scheduleId(farmSchedule) + .reservationDate(reservationDate) + .memberCount(memberCount) + .request(request) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/reservation/dto/response/ReservationResponse.java b/src/main/java/poomasi/domain/reservation/dto/response/ReservationResponse.java new file mode 100644 index 00000000..0b397eff --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/dto/response/ReservationResponse.java @@ -0,0 +1,19 @@ +package poomasi.domain.reservation.dto.response; + +import lombok.Builder; +import poomasi.domain.reservation.entity.ReservationStatus; + +import java.time.LocalDate; + +@Builder +public record ReservationResponse( + Long farmId, + Long memberId, + Long scheduleId, + LocalDate reservationDate, + int memberCount, + ReservationStatus status, + String request + +) { +} diff --git a/src/main/java/poomasi/domain/reservation/entity/Reservation.java b/src/main/java/poomasi/domain/reservation/entity/Reservation.java new file mode 100644 index 00000000..87a353bb --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/entity/Reservation.java @@ -0,0 +1,104 @@ +package poomasi.domain.reservation.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import poomasi.domain.farm._schedule.entity.FarmSchedule; +import poomasi.domain.farm.entity.Farm; +import poomasi.domain.member.entity.Member; +import poomasi.domain.reservation.dto.response.ReservationResponse; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "reservation", indexes = { + @Index(name = "idx_farm_id", columnList = "farm_id"), + @Index(name = "idx_user_id", columnList = "user_id") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Reservation { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("농장") + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "farm_id", nullable = false) + private Farm farm; + + @Comment("예약자") + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Comment("예약 시간") + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "schedule_id", nullable = false) + private FarmSchedule scheduleId; + + @Comment("예약 날짜") + @Column(nullable = false) + private LocalDate reservationDate; + + @Comment("예약 인원") + @Column(nullable = false) + private int memberCount; + + @Comment("예약 상태") + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReservationStatus status; + + @Comment("요청 사항") + @Column(nullable = false) + private String request; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @Comment("예약 취소 일자") + private LocalDateTime canceledAt; + + + @Builder + public Reservation(Farm farm, Member member, FarmSchedule scheduleId, LocalDate reservationDate, int memberCount, ReservationStatus status, String request) { + this.farm = farm; + this.member = member; + this.scheduleId = scheduleId; + this.reservationDate = reservationDate; + this.memberCount = memberCount; + this.status = status; + this.request = request; + } + + public ReservationResponse toResponse() { + return ReservationResponse.builder() + .farmId(farm.getId()) + .memberId(member.getId()) + .scheduleId(scheduleId.getId()) + .reservationDate(reservationDate) + .memberCount(memberCount) + .status(status) + .request(request) + .build(); + } + + public boolean isCanceled() { + return status == ReservationStatus.CANCELED; + } + + public void cancel() { + this.status = ReservationStatus.CANCELED; + this.canceledAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/poomasi/domain/reservation/entity/ReservationStatus.java b/src/main/java/poomasi/domain/reservation/entity/ReservationStatus.java new file mode 100644 index 00000000..dc6f6e4e --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/entity/ReservationStatus.java @@ -0,0 +1,10 @@ +package poomasi.domain.reservation.entity; + +public enum ReservationStatus { + WAITING, // 대기 + ACCEPTED, // 수락 + REJECTED, // 거절 + CANCELED, // 취소 + DONE // 완료 + ; +} diff --git a/src/main/java/poomasi/domain/reservation/repository/ReservationRepository.java b/src/main/java/poomasi/domain/reservation/repository/ReservationRepository.java new file mode 100644 index 00000000..7bdbe093 --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/repository/ReservationRepository.java @@ -0,0 +1,14 @@ +package poomasi.domain.reservation.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import poomasi.domain.reservation.entity.Reservation; + +import java.util.List; + +@Repository +public interface ReservationRepository extends JpaRepository { + List findAllByFarmId(Long farmId); + + List findAllByMemberId(Long memberId); +} diff --git a/src/main/java/poomasi/domain/reservation/service/ReservationFarmerService.java b/src/main/java/poomasi/domain/reservation/service/ReservationFarmerService.java new file mode 100644 index 00000000..48194840 --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/service/ReservationFarmerService.java @@ -0,0 +1,21 @@ +package poomasi.domain.reservation.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.reservation.dto.response.ReservationResponse; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReservationFarmerService { + private final ReservationService reservationService; + + @Transactional(readOnly = true) + public List getReservationsByFarmerId(Long farmerId) { + return reservationService.getReservationsByFarmerId(farmerId); + } + + +} diff --git a/src/main/java/poomasi/domain/reservation/service/ReservationPlatformService.java b/src/main/java/poomasi/domain/reservation/service/ReservationPlatformService.java new file mode 100644 index 00000000..f78b30e7 --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/service/ReservationPlatformService.java @@ -0,0 +1,69 @@ +package poomasi.domain.reservation.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import poomasi.domain.farm._schedule.entity.FarmSchedule; +import poomasi.domain.farm._schedule.entity.ScheduleStatus; +import poomasi.domain.farm._schedule.service.FarmScheduleService; +import poomasi.domain.farm.entity.Farm; +import poomasi.domain.farm.service.FarmService; +import poomasi.domain.member.entity.Member; +import poomasi.domain.member.service.MemberService; +import poomasi.domain.reservation.dto.request.ReservationRequest; +import poomasi.domain.reservation.dto.response.ReservationResponse; +import poomasi.domain.reservation.entity.Reservation; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +@RequiredArgsConstructor +public class ReservationPlatformService { + private final ReservationService reservationService; + private final MemberService memberService; + private final FarmService farmService; + private final FarmScheduleService farmScheduleService; + + private final int RESERVATION_CANCELLATION_PERIOD = 3; + + public ReservationResponse createReservation(ReservationRequest request) { + Member member = memberService.findMemberById(request.memberId()); + Farm farm = farmService.getValidFarmByFarmId(request.farmId()); + FarmSchedule farmSchedule = farmScheduleService.getValidFarmScheduleByFarmIdAndDate(request.farmId(), request.reservationDate()); + + // TODO: 예약 가능한지 확인하는 로직 추가 + + Reservation reservation = reservationService.createReservation(request.toEntity(member, farm, farmSchedule)); + + + return reservation.toResponse(); + } + + public ReservationResponse getReservation(Long memberId, Long reservationId) { + Reservation reservation = reservationService.getReservationById(reservationId); + if (!reservation.getMember().getId().equals(memberId) || !memberService.isAdmin(memberId)) { + throw new BusinessException(BusinessError.RESERVATION_NOT_ACCESSIBLE); + } + + return reservation.toResponse(); + } + + public void cancelReservation(Long memberId, Long reservationId) { + Reservation reservation = reservationService.getReservationById(reservationId); + + if (!reservation.getMember().getId().equals(memberId) || !memberService.isAdmin(memberId)) { + throw new BusinessException(BusinessError.RESERVATION_NOT_ACCESSIBLE); + } + + if (reservation.isCanceled()) { + throw new BusinessException(BusinessError.RESERVATION_ALREADY_CANCELED); + } + + // 우리 아직 예약 취소 규정 정해놓지 않았으니까 일단은 3일 전에만 취소 가능하다고 가정 + if (reservation.getReservationDate().isBefore(reservation.getReservationDate().minusDays(RESERVATION_CANCELLATION_PERIOD))) { + throw new BusinessException(BusinessError.RESERVATION_CANCELLATION_PERIOD_EXPIRED); + } + + reservationService.cancelReservation(reservation); + farmScheduleService.updateFarmScheduleStatus(reservation.getScheduleId().getId(), ScheduleStatus.PENDING); + } +} diff --git a/src/main/java/poomasi/domain/reservation/service/ReservationService.java b/src/main/java/poomasi/domain/reservation/service/ReservationService.java new file mode 100644 index 00000000..622625d7 --- /dev/null +++ b/src/main/java/poomasi/domain/reservation/service/ReservationService.java @@ -0,0 +1,48 @@ +package poomasi.domain.reservation.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import poomasi.domain.reservation.dto.response.ReservationResponse; +import poomasi.domain.reservation.entity.Reservation; +import poomasi.domain.reservation.repository.ReservationRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReservationService { + private final ReservationRepository reservationRepository; + + public Reservation getReservation(Long id) { + return reservationRepository.findById(id).orElseThrow(() -> new BusinessException(BusinessError.RESERVATION_NOT_FOUND)); + } + + public List getReservationsByMemberId(Long memberId) { + return reservationRepository.findAllByMemberId(memberId); + } + + public List getReservationsByFarmId(Long farmId) { + return reservationRepository.findAllByFarmId(farmId); + } + + public Reservation createReservation(Reservation reservation) { + return reservationRepository.save(reservation); + } + + public Reservation getReservationById(Long reservationId) { + return reservationRepository.findById(reservationId).orElseThrow(() -> new BusinessException(BusinessError.RESERVATION_NOT_FOUND)); + } + + public void cancelReservation(Reservation reservation) { + reservation.cancel(); + reservationRepository.save(reservation); + } + + public List getReservationsByFarmerId(Long farmerId) { + return reservationRepository.findAllByFarmId(farmerId).stream() + .map(Reservation::toResponse) + .toList(); + } +} diff --git a/src/main/java/poomasi/domain/review/controller/ReviewController.java b/src/main/java/poomasi/domain/review/controller/ReviewController.java new file mode 100644 index 00000000..a9c2f580 --- /dev/null +++ b/src/main/java/poomasi/domain/review/controller/ReviewController.java @@ -0,0 +1,32 @@ +package poomasi.domain.review.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import poomasi.domain.review.dto.ReviewRequest; +import poomasi.domain.review.service.ReviewService; + +@Controller +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + @DeleteMapping("/api/reviews/{reviewId}") + public ResponseEntity deleteProductReview(@PathVariable Long reviewId) { + reviewService.deleteReview(reviewId); + return new ResponseEntity<>(HttpStatus.OK); + } + + @PutMapping("/api/reviews/{reviewId}") + public ResponseEntity modifyProductReview(@PathVariable Long reviewId, + @RequestBody ReviewRequest reviewRequest) { + reviewService.modifyReview(reviewId, reviewRequest); + return new ResponseEntity<>(HttpStatus.OK); + } +} diff --git a/src/main/java/poomasi/domain/review/controller/farm/FarmReviewController.java b/src/main/java/poomasi/domain/review/controller/farm/FarmReviewController.java new file mode 100644 index 00000000..8c78705a --- /dev/null +++ b/src/main/java/poomasi/domain/review/controller/farm/FarmReviewController.java @@ -0,0 +1,35 @@ +package poomasi.domain.review.controller.farm; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestBody; +import poomasi.domain.review.dto.ReviewRequest; +import poomasi.domain.review.dto.ReviewResponse; +import poomasi.domain.review.service.farm.FarmReviewService; + +@Controller +@RequiredArgsConstructor +public class FarmReviewController { + + private final FarmReviewService farmReviewService; + + @GetMapping("/api/farm/{farmId}/reviews") + public ResponseEntity getProductReviews(@PathVariable Long farmId) { + List response = farmReviewService.getFarmReview(farmId); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @PostMapping("/api/farm/{farmId}/reviews") + public ResponseEntity registerProductReview(@PathVariable Long farmId, + @RequestBody ReviewRequest reviewRequest) { + Long reviewId = farmReviewService.registerFarmReview(farmId, + reviewRequest); + return new ResponseEntity<>(reviewId, HttpStatus.CREATED); + } +} diff --git a/src/main/java/poomasi/domain/review/controller/product/ProductReviewController.java b/src/main/java/poomasi/domain/review/controller/product/ProductReviewController.java new file mode 100644 index 00000000..98fd6249 --- /dev/null +++ b/src/main/java/poomasi/domain/review/controller/product/ProductReviewController.java @@ -0,0 +1,35 @@ +package poomasi.domain.review.controller.product; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestBody; +import poomasi.domain.review.dto.ReviewRequest; +import poomasi.domain.review.dto.ReviewResponse; +import poomasi.domain.review.service.product.ProductReviewService; + +@Controller +@RequiredArgsConstructor +public class ProductReviewController { + + private final ProductReviewService productReviewService; + + @GetMapping("/api/products/{productId}/reviews") + public ResponseEntity getProductReviews(@PathVariable Long productId) { + List response = productReviewService.getProductReview(productId); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @PostMapping("/api/products/{productId}/reviews") + public ResponseEntity registerProductReview(@PathVariable Long productId, + @RequestBody ReviewRequest reviewRequest) { + Long reviewId = productReviewService.registerProductReview(productId, + reviewRequest); + return new ResponseEntity<>(reviewId, HttpStatus.CREATED); + } +} diff --git a/src/main/java/poomasi/domain/review/dto/ReviewRequest.java b/src/main/java/poomasi/domain/review/dto/ReviewRequest.java new file mode 100644 index 00000000..0e61fa12 --- /dev/null +++ b/src/main/java/poomasi/domain/review/dto/ReviewRequest.java @@ -0,0 +1,13 @@ +package poomasi.domain.review.dto; + +import poomasi.domain.review.entity.Review; + +public record ReviewRequest( + Float rating, + String content +) { + + public Review toEntity(Long entityId) { + return new Review(this.rating, this.content, entityId); + } +} diff --git a/src/main/java/poomasi/domain/review/dto/ReviewResponse.java b/src/main/java/poomasi/domain/review/dto/ReviewResponse.java new file mode 100644 index 00000000..dfd362b2 --- /dev/null +++ b/src/main/java/poomasi/domain/review/dto/ReviewResponse.java @@ -0,0 +1,23 @@ +package poomasi.domain.review.dto; + +import poomasi.domain.review.entity.Review; + +public record ReviewResponse + (Long id, + Long productId, + //Long reviewerId, + Float rating, + String content + //List imageUrls + ) { + + public static ReviewResponse fromEntity(Review review) { + return new ReviewResponse( + review.getId(), + review.getEntityId(), + //productReview.getReviewer().getId(), + review.getRating(), + review.getContent() + ); + } +} diff --git a/src/main/java/poomasi/domain/review/entity/EntityType.java b/src/main/java/poomasi/domain/review/entity/EntityType.java new file mode 100644 index 00000000..d857fabd --- /dev/null +++ b/src/main/java/poomasi/domain/review/entity/EntityType.java @@ -0,0 +1,6 @@ +package poomasi.domain.review.entity; + +public enum EntityType { + PRODUCT, + FARM +} diff --git a/src/main/java/poomasi/domain/review/entity/Review.java b/src/main/java/poomasi/domain/review/entity/Review.java new file mode 100644 index 00000000..b72da487 --- /dev/null +++ b/src/main/java/poomasi/domain/review/entity/Review.java @@ -0,0 +1,63 @@ +package poomasi.domain.review.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import poomasi.domain.review.dto.ReviewRequest; + +@Entity +@Getter +@NoArgsConstructor +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("별점") + private Float rating; + + @Comment("리뷰 내용") + private String content; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @Comment("엔티티 아이디") + private Long entityId; + + @Comment("엔티티 타입") + @Enumerated(EnumType.STRING) + private EntityType entityType; + +// @Comment("작성자") +// @ManyToOne +// private Member reviewer; + + public Review(Float rating, String content, Long entityId) { + this.rating = rating; + this.content = content; + this.entityId = entityId; + } + + public void modifyReview(ReviewRequest reviewRequest) { + this.rating = reviewRequest.rating(); + this.content = reviewRequest.content(); + } + + public void setReviewType(EntityType entityType) { + this.entityType = entityType; + } +} diff --git a/src/main/java/poomasi/domain/review/repository/ReviewRepository.java b/src/main/java/poomasi/domain/review/repository/ReviewRepository.java new file mode 100644 index 00000000..6a506d4f --- /dev/null +++ b/src/main/java/poomasi/domain/review/repository/ReviewRepository.java @@ -0,0 +1,17 @@ +package poomasi.domain.review.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import poomasi.domain.review.entity.Review; + +@Repository +public interface ReviewRepository extends JpaRepository { + + @Query("select r from Review r where r.entityId = :productId and r.entityType = 'PRODUCT'") + List findByProductId(Long productId); + + @Query("select r from Review r where r.entityId = :farmId and r.entityType = 'FARM'") + List findByFarmId(Long farmId); +} diff --git a/src/main/java/poomasi/domain/review/service/ReviewService.java b/src/main/java/poomasi/domain/review/service/ReviewService.java new file mode 100644 index 00000000..3115163c --- /dev/null +++ b/src/main/java/poomasi/domain/review/service/ReviewService.java @@ -0,0 +1,34 @@ +package poomasi.domain.review.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.review.dto.ReviewRequest; +import poomasi.domain.review.entity.Review; +import poomasi.domain.review.repository.ReviewRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + + @Transactional + public void modifyReview(Long reviewId, ReviewRequest reviewRequest) { + Review pReview = getReviewById(reviewId); + pReview.modifyReview(reviewRequest); + } + + @Transactional + public void deleteReview(Long reviewId) { + Review review = getReviewById(reviewId); + reviewRepository.delete(review); + } + + private Review getReviewById(Long reviewId) { + return reviewRepository.findById(reviewId) + .orElseThrow(() -> new BusinessException(BusinessError.REVIEW_NOT_FOUND)); + } +} diff --git a/src/main/java/poomasi/domain/review/service/farm/FarmReviewService.java b/src/main/java/poomasi/domain/review/service/farm/FarmReviewService.java new file mode 100644 index 00000000..131668a3 --- /dev/null +++ b/src/main/java/poomasi/domain/review/service/farm/FarmReviewService.java @@ -0,0 +1,46 @@ +package poomasi.domain.review.service.farm; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.farm.entity.Farm; +import poomasi.domain.farm.repository.FarmRepository; +import poomasi.domain.review.dto.ReviewRequest; +import poomasi.domain.review.dto.ReviewResponse; +import poomasi.domain.review.entity.EntityType; +import poomasi.domain.review.entity.Review; +import poomasi.domain.review.repository.ReviewRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +@RequiredArgsConstructor +public class FarmReviewService { + + private final ReviewRepository reviewRepository; + private final FarmRepository farmRepository; + + public List getFarmReview(Long farmId) { + getFarmByFarmId(farmId); //상품이 존재하는지 체크 + + return reviewRepository.findByFarmId(farmId).stream() + .map(ReviewResponse::fromEntity).toList(); + } + + @Transactional + public Long registerFarmReview(Long entityId, ReviewRequest reviewRequest) { + // s3 이미지 저장하고 주소 받아와서 review에 추가해주기 + + Review pReview = reviewRequest.toEntity(entityId); + pReview.setReviewType(EntityType.FARM); + pReview = reviewRepository.save(pReview); + + return pReview.getId(); + } + + private Farm getFarmByFarmId(Long farmId) { + return farmRepository.findById(farmId) + .orElseThrow(() -> new BusinessException(BusinessError.FARM_NOT_FOUND)); + } +} diff --git a/src/main/java/poomasi/domain/review/service/product/ProductReviewService.java b/src/main/java/poomasi/domain/review/service/product/ProductReviewService.java new file mode 100644 index 00000000..cadec05c --- /dev/null +++ b/src/main/java/poomasi/domain/review/service/product/ProductReviewService.java @@ -0,0 +1,47 @@ +package poomasi.domain.review.service.product; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.product.entity.Product; +import poomasi.domain.product.repository.ProductRepository; +import poomasi.domain.review.dto.ReviewRequest; +import poomasi.domain.review.dto.ReviewResponse; +import poomasi.domain.review.entity.EntityType; +import poomasi.domain.review.entity.Review; +import poomasi.domain.review.repository.ReviewRepository; +import poomasi.global.error.BusinessError; +import poomasi.global.error.BusinessException; + +@Service +@RequiredArgsConstructor +public class ProductReviewService { + + private final ReviewRepository reviewRepository; + private final ProductRepository productRepository; + + public List getProductReview(Long productId) { + getProductByProductId(productId); //상품이 존재하는지 체크 + + return reviewRepository.findByProductId(productId).stream() + .map(ReviewResponse::fromEntity).toList(); + } + + @Transactional + public Long registerProductReview(Long entityId, ReviewRequest reviewRequest) { + // s3 이미지 저장하고 주소 받아와서 review에 추가해주기 + + Review pReview = reviewRequest.toEntity(entityId); + pReview.setReviewType(EntityType.PRODUCT); + pReview = reviewRepository.save(pReview); + + return pReview.getId(); + } + + private Product getProductByProductId(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new BusinessException(BusinessError.PRODUCT_NOT_FOUND)); + } + +} diff --git a/src/main/java/poomasi/domain/wishlist/controller/WishListPlatformController.java b/src/main/java/poomasi/domain/wishlist/controller/WishListPlatformController.java new file mode 100644 index 00000000..f7b228e6 --- /dev/null +++ b/src/main/java/poomasi/domain/wishlist/controller/WishListPlatformController.java @@ -0,0 +1,33 @@ +package poomasi.domain.wishlist.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import poomasi.domain.wishlist.dto.WishListDeleteRequest; +import poomasi.domain.wishlist.dto.request.WishListAddRequest; +import poomasi.domain.wishlist.service.WishListPlatformService; + +@RequestMapping("/api/v1/wish-list") +@RestController +@RequiredArgsConstructor +public class WishListPlatformController { + private final WishListPlatformService wishListPlatformService; + + @PostMapping("/add") + public ResponseEntity addWishList(@RequestBody WishListAddRequest request) { + wishListPlatformService.addWishList(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/delete") + public ResponseEntity deleteWishList(@RequestBody WishListDeleteRequest request) { + wishListPlatformService.deleteWishList(request); + return ResponseEntity.ok().build(); + } + + @GetMapping("/find") + public ResponseEntity findWishListByMemberId(@RequestBody Long memberId) { + // FIXME : memberID는 SecurityContextHolder에서 가져오도록 수정 + return ResponseEntity.ok(wishListPlatformService.findWishListByMemberId(memberId)); + } +} diff --git a/src/main/java/poomasi/domain/wishlist/dto/WishListDeleteRequest.java b/src/main/java/poomasi/domain/wishlist/dto/WishListDeleteRequest.java new file mode 100644 index 00000000..9651bd52 --- /dev/null +++ b/src/main/java/poomasi/domain/wishlist/dto/WishListDeleteRequest.java @@ -0,0 +1,7 @@ +package poomasi.domain.wishlist.dto; + +public record WishListDeleteRequest( + Long memberId, + Long productId +) { +} diff --git a/src/main/java/poomasi/domain/wishlist/dto/WishListResponse.java b/src/main/java/poomasi/domain/wishlist/dto/WishListResponse.java new file mode 100644 index 00000000..954b50de --- /dev/null +++ b/src/main/java/poomasi/domain/wishlist/dto/WishListResponse.java @@ -0,0 +1,21 @@ +package poomasi.domain.wishlist.dto; + +import poomasi.domain.wishlist.entity.WishList; + +public record WishListResponse( + Long productId, + String productName, + Long price, + String imageUrl, + String description +) { + public static WishListResponse fromEntity(WishList wishList) { + return new WishListResponse( + wishList.getProduct().getId(), + wishList.getProduct().getName(), + wishList.getProduct().getPrice(), + wishList.getProduct().getImageUrl(), + wishList.getProduct().getDescription() + ); + } +} diff --git a/src/main/java/poomasi/domain/wishlist/dto/request/WishListAddRequest.java b/src/main/java/poomasi/domain/wishlist/dto/request/WishListAddRequest.java new file mode 100644 index 00000000..2783a879 --- /dev/null +++ b/src/main/java/poomasi/domain/wishlist/dto/request/WishListAddRequest.java @@ -0,0 +1,17 @@ +package poomasi.domain.wishlist.dto.request; + +import poomasi.domain.member.entity.Member; +import poomasi.domain.product.entity.Product; +import poomasi.domain.wishlist.entity.WishList; + +public record WishListAddRequest( + Long memberId, + Long productId +) { + public WishList toEntity(Member member, Product product) { + return WishList.builder() + .member(member) + .product(product) + .build(); + } +} diff --git a/src/main/java/poomasi/domain/wishlist/entity/WishList.java b/src/main/java/poomasi/domain/wishlist/entity/WishList.java new file mode 100644 index 00000000..8b0ce31b --- /dev/null +++ b/src/main/java/poomasi/domain/wishlist/entity/WishList.java @@ -0,0 +1,48 @@ +package poomasi.domain.wishlist.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.CurrentTimestamp; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import poomasi.domain.member.entity.Member; +import poomasi.domain.product.entity.Product; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE product SET deleted_at = current_timestamp WHERE id = ?") +public class WishList { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("회원") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Comment("상품") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @Comment("등록일시") + @CurrentTimestamp + private LocalDateTime createdAt; + + @Comment("삭제일시") + private LocalDateTime deletedAt; + + @Builder + public WishList(Member member, Product product) { + this.member = member; + this.product = product; + } + +} diff --git a/src/main/java/poomasi/domain/wishlist/repository/WishListRepository.java b/src/main/java/poomasi/domain/wishlist/repository/WishListRepository.java new file mode 100644 index 00000000..1a7584b6 --- /dev/null +++ b/src/main/java/poomasi/domain/wishlist/repository/WishListRepository.java @@ -0,0 +1,12 @@ +package poomasi.domain.wishlist.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import poomasi.domain.wishlist.entity.WishList; + +import java.util.List; + +public interface WishListRepository extends JpaRepository { + List findByMemberId(Long memberId); + + void deleteByMemberIdAndProductId(Long memberId, Long productId); +} diff --git a/src/main/java/poomasi/domain/wishlist/service/WishListPlatformService.java b/src/main/java/poomasi/domain/wishlist/service/WishListPlatformService.java new file mode 100644 index 00000000..fc22b570 --- /dev/null +++ b/src/main/java/poomasi/domain/wishlist/service/WishListPlatformService.java @@ -0,0 +1,34 @@ +package poomasi.domain.wishlist.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.wishlist.dto.WishListDeleteRequest; +import poomasi.domain.wishlist.dto.WishListResponse; +import poomasi.domain.wishlist.dto.request.WishListAddRequest; +import poomasi.domain.wishlist.entity.WishList; + +import java.util.List; + +@RequiredArgsConstructor +@Service +public class WishListPlatformService { + private final WishListService wishListService; + + @Transactional + public void addWishList(WishListAddRequest request) { + wishListService.addWishList(request); + } + + @Transactional + public void deleteWishList(WishListDeleteRequest request) { + wishListService.deleteWishList(request); + } + + @Transactional(readOnly = true) + public List findWishListByMemberId(Long memberId) { + return wishListService.findWishListByMemberId(memberId).stream() + .map(WishListResponse::fromEntity) + .toList(); + } +} diff --git a/src/main/java/poomasi/domain/wishlist/service/WishListService.java b/src/main/java/poomasi/domain/wishlist/service/WishListService.java new file mode 100644 index 00000000..a9cf39fa --- /dev/null +++ b/src/main/java/poomasi/domain/wishlist/service/WishListService.java @@ -0,0 +1,40 @@ +package poomasi.domain.wishlist.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import poomasi.domain.member.entity.Member; +import poomasi.domain.member.service.MemberService; +import poomasi.domain.product.entity.Product; +import poomasi.domain.product.service.ProductService; +import poomasi.domain.wishlist.dto.WishListDeleteRequest; +import poomasi.domain.wishlist.dto.request.WishListAddRequest; +import poomasi.domain.wishlist.entity.WishList; +import poomasi.domain.wishlist.repository.WishListRepository; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class WishListService { + private final WishListRepository wishListRepository; + private final MemberService memberService; + private final ProductService productService; + + @Transactional + public void addWishList(WishListAddRequest request) { + Member member = memberService.findMemberById(request.memberId()); + Product product = productService.findProductById(request.productId()); + wishListRepository.save(request.toEntity(member, product)); + } + + @Transactional + public void deleteWishList(WishListDeleteRequest request) { + wishListRepository.deleteByMemberIdAndProductId(request.memberId(), request.productId()); + } + + @Transactional(readOnly = true) + public List findWishListByMemberId(Long memberId) { + return wishListRepository.findByMemberId(memberId); + } +} diff --git a/src/main/java/poomasi/global/config/aws/AwsProperties.java b/src/main/java/poomasi/global/config/aws/AwsProperties.java new file mode 100644 index 00000000..05b36b15 --- /dev/null +++ b/src/main/java/poomasi/global/config/aws/AwsProperties.java @@ -0,0 +1,20 @@ +package poomasi.global.config.aws; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties("aws") +public class AwsProperties { + private String access; + private String secret; + private S3Properties s3; + + @Data + public static class S3Properties { + private String bucket; + private String region; + } +} diff --git a/src/main/java/poomasi/global/config/redis/config/RedisConfig.java b/src/main/java/poomasi/global/config/redis/config/RedisConfig.java new file mode 100644 index 00000000..a728995f --- /dev/null +++ b/src/main/java/poomasi/global/config/redis/config/RedisConfig.java @@ -0,0 +1,39 @@ +package poomasi.global.config.redis.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + // Key-Value 형태로 직렬화 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + redisTemplate.setDefaultSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/global/config/redis/error/RedisConnectionException.java b/src/main/java/poomasi/global/config/redis/error/RedisConnectionException.java new file mode 100644 index 00000000..5161ba30 --- /dev/null +++ b/src/main/java/poomasi/global/config/redis/error/RedisConnectionException.java @@ -0,0 +1,7 @@ +package poomasi.global.config.redis.error; + +public class RedisConnectionException extends RuntimeException { + public RedisConnectionException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/global/config/redis/error/RedisExceptionHandler.java b/src/main/java/poomasi/global/config/redis/error/RedisExceptionHandler.java new file mode 100644 index 00000000..2943f270 --- /dev/null +++ b/src/main/java/poomasi/global/config/redis/error/RedisExceptionHandler.java @@ -0,0 +1,21 @@ +package poomasi.global.config.redis.error; + +import lombok.extern.slf4j.Slf4j; + +import java.util.function.Supplier; + +@Slf4j +public class RedisExceptionHandler { + + public static T handleRedisException(Supplier action, String errorMessage) { + try { + return action.get(); + } catch (RedisConnectionException e) { + log.error(errorMessage, e); + throw new RedisConnectionException(errorMessage); + } catch (Exception e) { + log.error(errorMessage, e); + throw new RedisOperationException(errorMessage); + } + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/global/config/redis/error/RedisOperationException.java b/src/main/java/poomasi/global/config/redis/error/RedisOperationException.java new file mode 100644 index 00000000..e99ee912 --- /dev/null +++ b/src/main/java/poomasi/global/config/redis/error/RedisOperationException.java @@ -0,0 +1,7 @@ +package poomasi.global.config.redis.error; + +public class RedisOperationException extends RuntimeException { + public RedisOperationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/poomasi/global/config/s3/S3Config.java b/src/main/java/poomasi/global/config/s3/S3Config.java new file mode 100644 index 00000000..7489330a --- /dev/null +++ b/src/main/java/poomasi/global/config/s3/S3Config.java @@ -0,0 +1,52 @@ +package poomasi.global.config.s3; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import poomasi.global.config.aws.AwsProperties; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + + private final AwsProperties awsProperties; + + @Bean("awsCredentials") + public AwsCredentialsProvider awsCredentials() { + return DefaultCredentialsProvider.create(); + } + + @Bean + public S3Presigner s3Presigner(@Qualifier("awsCredentials") AwsCredentialsProvider awsCredentials) { + return S3Presigner + .builder() + .credentialsProvider(awsCredentials) + .region(Region.of(awsProperties.getS3().getRegion())) + .build(); + } + + @Bean("s3AsyncClient") + public S3AsyncClient s3AsyncClient(@Qualifier("awsCredentials") AwsCredentialsProvider awsCredentials) { + return S3AsyncClient + .builder() + .credentialsProvider(awsCredentials) + .region(Region.of(awsProperties.getS3().getRegion())) + .build(); + } + + @Bean("s3Client") + public S3Client s3Client(@Qualifier("awsCredentials") AwsCredentialsProvider awsCredentials) { + return S3Client + .builder() + .credentialsProvider(awsCredentials) + .region(Region.of(awsProperties.getS3().getRegion())) + .build(); + } +} diff --git a/src/main/java/poomasi/global/config/s3/S3PresignedUrlService.java b/src/main/java/poomasi/global/config/s3/S3PresignedUrlService.java new file mode 100644 index 00000000..f7c470c6 --- /dev/null +++ b/src/main/java/poomasi/global/config/s3/S3PresignedUrlService.java @@ -0,0 +1,80 @@ +package poomasi.global.config.s3; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import poomasi.global.util.EncryptionUtil; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class S3PresignedUrlService { + private final S3Presigner s3Presigner; + private final EncryptionUtil encryptionUtil; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final Long SIGNATURE_DURATION = 10L; + + + public String createPresignedGetUrl(String bucketName, String keyName) { + GetObjectRequest objectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(keyName) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(SIGNATURE_DURATION)) + .getObjectRequest(objectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + log.info("Presigned URL: [{}]", presignedRequest.url().toString()); + log.info("HTTP method: [{}]", presignedRequest.httpRequest().method()); + + return presignedRequest.url().toExternalForm(); + + } + + public String createPresignedPutUrl(String bucketName, String keyPrefix, Map metadata) { + LocalDateTime now = LocalDateTime.now(); + String date = now.format(DATE_FORMATTER); + String encodedTime = encryptionUtil.encodeTime(now).substring(0, 10); + + String keyName = String.format("%s/%s/%s.jpg", keyPrefix, date, encodedTime); + + + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(keyName) + .metadata(metadata) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(SIGNATURE_DURATION)) + .putObjectRequest(objectRequest) + .build(); + + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + String myURL = presignedRequest.url().toString(); + log.info("Presigned URL to upload a file to: [{}]", myURL); + log.info("HTTP method: [{}]", presignedRequest.httpRequest().method()); + + return presignedRequest.url().toExternalForm(); + } + + +} + +// reference: https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/example_s3_Scenario_PresignedUrl_section.html diff --git a/src/main/java/poomasi/global/config/s3/TestController.java b/src/main/java/poomasi/global/config/s3/TestController.java new file mode 100644 index 00000000..97e2eb96 --- /dev/null +++ b/src/main/java/poomasi/global/config/s3/TestController.java @@ -0,0 +1,25 @@ +package poomasi.global.config.s3; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class TestController { + private final S3PresignedUrlService s3PresignedUrlService; + + @GetMapping("/presigned-url-put") + public ResponseEntity presignedUrlPut() { + String presignedPutUrl = s3PresignedUrlService.createPresignedPutUrl("poomasi", "test", null); + return ResponseEntity.ok(presignedPutUrl); + } + + @GetMapping("/presigned-url-get") + public ResponseEntity presignedUrlGet(@RequestParam String keyname) { + String presignedGetUrl = s3PresignedUrlService.createPresignedGetUrl("poomasi", keyname); + return ResponseEntity.ok(presignedGetUrl); + } +} diff --git a/src/main/java/poomasi/global/error/ApplicationError.java b/src/main/java/poomasi/global/error/ApplicationError.java new file mode 100644 index 00000000..f9b7a253 --- /dev/null +++ b/src/main/java/poomasi/global/error/ApplicationError.java @@ -0,0 +1,13 @@ +package poomasi.global.error; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ApplicationError { + ENCRYPT_ERROR("암호화 에러입니다."); + + private final String message; + +} diff --git a/src/main/java/poomasi/global/error/ApplicationException.java b/src/main/java/poomasi/global/error/ApplicationException.java new file mode 100644 index 00000000..c4f02647 --- /dev/null +++ b/src/main/java/poomasi/global/error/ApplicationException.java @@ -0,0 +1,11 @@ +package poomasi.global.error; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApplicationException extends RuntimeException { + + private final ApplicationError applicationError; +} diff --git a/src/main/java/poomasi/global/error/BusinessError.java b/src/main/java/poomasi/global/error/BusinessError.java new file mode 100644 index 00000000..d9ce696a --- /dev/null +++ b/src/main/java/poomasi/global/error/BusinessError.java @@ -0,0 +1,57 @@ +package poomasi.global.error; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum BusinessError { + // Product + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "상품을 찾을 수 없습니다."), + PRODUCT_STOCK_ZERO(HttpStatus.BAD_REQUEST, "재고가 없습니다."), + + // Category + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다."), + + // Review + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."), + + // Member + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "회원이 존재하지 않습니다."), + DUPLICATE_MEMBER_EMAIL(HttpStatus.CONFLICT, "중복된 이메일입니다."), + INVALID_FARMER_QUALIFICATION(HttpStatus.BAD_REQUEST, "농부 자격 증명이 필요합니다."), + + // Auth + INVALID_CREDENTIAL(HttpStatus.UNAUTHORIZED, "잘못된 비밀번호 입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "리프레시 토큰이 없습니다."), + REFRESH_TOKEN_NOT_VALID(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 유효하지 않습니다."), + + // Farm + FARM_NOT_FOUND(HttpStatus.NOT_FOUND, "농장을 찾을 수 없습니다."), + FARM_OWNER_MISMATCH(HttpStatus.FORBIDDEN, "해당 농장의 소유자가 아닙니다."), + FARM_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 농장이 존재합니다."), + FARM_NOT_OPEN(HttpStatus.BAD_REQUEST, "오픈되지 않은 농장입니다."), + + // FarmSchedule + FARM_SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 날짜의 스케줄을 찾을 수 없습니다."), + FARM_SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 스케줄이 존재합니다."), + FARM_SCHEDULE_ALREADY_RESERVED(HttpStatus.CONFLICT, "해당 날짜에 이미 예약이 존재합니다."), + FARM_SCHEDULE_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "예약이 불가능한 날짜입니다."), + + + // Reservation + RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "예약을 찾을 수 없습니다."), + RESERVATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 예약이 존재합니다."), + RESERVATION_NOT_ACCESSIBLE(HttpStatus.FORBIDDEN, "접근할 수 없는 예약입니다."), + RESERVATION_ALREADY_CANCELED(HttpStatus.BAD_REQUEST, "이미 취소된 예약입니다."), + RESERVATION_CANCELLATION_PERIOD_EXPIRED(HttpStatus.BAD_REQUEST, "예약 취소 기간이 지났습니다."), + + // ETC + START_DATE_SHOULD_BE_BEFORE_END_DATE(HttpStatus.BAD_REQUEST, "시작 날짜는 종료 날짜보다 이전이어야 합니다."); + + private final HttpStatus httpStatus; + + private final String message; +} diff --git a/src/main/java/poomasi/global/error/BusinessException.java b/src/main/java/poomasi/global/error/BusinessException.java new file mode 100644 index 00000000..170c7344 --- /dev/null +++ b/src/main/java/poomasi/global/error/BusinessException.java @@ -0,0 +1,11 @@ +package poomasi.global.error; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class BusinessException extends RuntimeException { + + private final BusinessError businessError; +} diff --git a/src/main/java/poomasi/global/error/ExceptionAdvice.java b/src/main/java/poomasi/global/error/ExceptionAdvice.java new file mode 100644 index 00000000..5e5e536b --- /dev/null +++ b/src/main/java/poomasi/global/error/ExceptionAdvice.java @@ -0,0 +1,33 @@ +package poomasi.global.error; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class ExceptionAdvice { + + @ExceptionHandler(BusinessException.class) + public ErrorResponse businessExceptionHandler(BusinessException exception) { + BusinessError businessError = exception.getBusinessError(); + log.warn("[{}] : {}", businessError.name(), businessError.getMessage()); + return ErrorResponse + .builder(exception, businessError.getHttpStatus(), businessError.getMessage()) + .title(businessError.name()) + .build(); + } + + @ExceptionHandler(ApplicationException.class) + public ErrorResponse applicationExceptionHandler(ApplicationException exception) { + ApplicationError applicationError = exception.getApplicationError(); + log.error("[{}] : {}", applicationError.name(), applicationError.getMessage(), + exception); + return ErrorResponse + .builder(exception, HttpStatus.INTERNAL_SERVER_ERROR, applicationError.getMessage()) + .title(exception.getClass().getSimpleName()) + .build(); + } +} diff --git a/src/main/java/poomasi/global/util/EncryptionUtil.java b/src/main/java/poomasi/global/util/EncryptionUtil.java new file mode 100644 index 00000000..76812280 --- /dev/null +++ b/src/main/java/poomasi/global/util/EncryptionUtil.java @@ -0,0 +1,25 @@ +package poomasi.global.util; + +import org.springframework.stereotype.Component; +import poomasi.global.error.ApplicationError; +import poomasi.global.error.ApplicationException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.Base64; + +@Component +public class EncryptionUtil { + + public String encodeTime(LocalDateTime time) { + try { + String timeString = time.toString(); + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(timeString.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (Exception e) { + throw new ApplicationException(ApplicationError.ENCRYPT_ERROR); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 43940a74..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=team10-poomasi diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..2e299032 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: team10-poomasi + config: + import: optional:application-secret.yml + token: + storage: #리프레시 토큰 저장소 + type: jpa + #type: redis + blacklist: #블랙리스트 저장소 + type: jpa + #type: redis + diff --git a/src/test/java/poomasi/global/config/s3/S3PresignedUrlServiceTest.java b/src/test/java/poomasi/global/config/s3/S3PresignedUrlServiceTest.java new file mode 100644 index 00000000..7bf948ae --- /dev/null +++ b/src/test/java/poomasi/global/config/s3/S3PresignedUrlServiceTest.java @@ -0,0 +1,76 @@ +package poomasi.global.config.s3; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import poomasi.global.config.aws.AwsProperties; +import poomasi.global.util.EncryptionUtil; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +public class S3PresignedUrlServiceTest { + + private S3PresignedUrlService s3PresignedUrlService; + + @Autowired + private AwsProperties awsProperties; + + @BeforeEach + public void setUp() { + String accessKey = awsProperties.getAccess(); + String secretKey = awsProperties.getSecret(); + String region = awsProperties.getS3().getRegion(); + + // 자격 증명 설정 + AwsBasicCredentials awsCreds = AwsBasicCredentials.create( + accessKey, + secretKey + ); + + // S3Presigner 인스턴스 생성 + S3Presigner presigner = S3Presigner.builder() + .credentialsProvider(StaticCredentialsProvider.create(awsCreds)) + .region(Region.of(region)) + .build(); + + // S3PresignedUrlService 초기화 + s3PresignedUrlService = new S3PresignedUrlService(presigner, new EncryptionUtil()); + } + + @Test + public void testCreatePresignedGetUrl() { + String objectKey = "object_key"; + String bucketName = awsProperties.getS3().getBucket(); + + String presignedUrl = s3PresignedUrlService.createPresignedGetUrl(bucketName, objectKey); + + assertNotNull(presignedUrl); + System.out.println("Presigned GET URL: " + presignedUrl); + } + + @Test + public void testCreatePresignedPutUrl() { + String keyPrefix = "uploads"; + String bucketName = awsProperties.getS3().getBucket(); + + // 메타데이터 생성 + Map metadata = new HashMap<>(); + metadata.put("Content-Type", "image/jpg"); + metadata.put("x-amz-meta-title", "Test Image"); + + // presigned PUT URL 생성 + String presignedUrl = s3PresignedUrlService.createPresignedPutUrl(bucketName, keyPrefix, metadata); + + assertNotNull(presignedUrl); + System.out.println("Presigned PUT URL: " + presignedUrl); + } +}