diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e732415 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## πŸ“Œ Related Issue + +## πŸš€ Description + +## πŸ“Έ Screenshot + +## πŸ“’ Notes \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/auth/CustomAccessDeniedHandler.java b/src/main/java/org/example/buyingserver/common/auth/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..cb5afa4 --- /dev/null +++ b/src/main/java/org/example/buyingserver/common/auth/CustomAccessDeniedHandler.java @@ -0,0 +1,40 @@ +package org.example.buyingserver.common.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.buyingserver.common.dto.ApiResponse; +import org.example.buyingserver.common.exception.GlobalErrorCode; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + + log.error("Access Denied: {}", request.getRequestURI()); + + ApiResponse apiResponse = ApiResponse.error(GlobalErrorCode.ACCESS_DENIED); + + response.setStatus(GlobalErrorCode.ACCESS_DENIED.getCode()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/auth/CustomAuthenticationEntryPoint.java b/src/main/java/org/example/buyingserver/common/auth/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..3d87356 --- /dev/null +++ b/src/main/java/org/example/buyingserver/common/auth/CustomAuthenticationEntryPoint.java @@ -0,0 +1,47 @@ +package org.example.buyingserver.common.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.buyingserver.common.dto.ApiResponse; +import org.example.buyingserver.common.dto.ErrorCode; +import org.example.buyingserver.common.exception.GlobalErrorCode; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + + log.error("Not Authenticated Request", authException); + + ErrorCode errorCode = + (authException instanceof JwtAuthenticationException jwtEx) + ? jwtEx.getErrorCode() + : GlobalErrorCode.UNAUTHORIZED; + + ApiResponse apiResponse = ApiResponse.error(errorCode); + String responseBody = objectMapper.writeValueAsString(apiResponse); + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(errorCode.getCode()); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(responseBody); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/auth/JwtAuthenticationException.java b/src/main/java/org/example/buyingserver/common/auth/JwtAuthenticationException.java new file mode 100644 index 0000000..aac8631 --- /dev/null +++ b/src/main/java/org/example/buyingserver/common/auth/JwtAuthenticationException.java @@ -0,0 +1,19 @@ +package org.example.buyingserver.common.auth; + +import org.example.buyingserver.common.dto.ErrorCode; +import org.springframework.security.core.AuthenticationException; + + +public class JwtAuthenticationException extends AuthenticationException { + + private final ErrorCode errorCode; + + public JwtAuthenticationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/auth/JwtTokenFilter.java b/src/main/java/org/example/buyingserver/common/auth/JwtTokenFilter.java index 38025e6..0c9eb1f 100644 --- a/src/main/java/org/example/buyingserver/common/auth/JwtTokenFilter.java +++ b/src/main/java/org/example/buyingserver/common/auth/JwtTokenFilter.java @@ -3,106 +3,110 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.SignatureException; -import jakarta.servlet.*; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.example.buyingserver.common.dto.ErrorCodeAndMessage; -import org.example.buyingserver.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.example.buyingserver.common.exception.GlobalErrorCode; import org.example.buyingserver.member.domain.Member; +import org.example.buyingserver.member.exception.MemberErrorCode; import org.example.buyingserver.member.repository.MemberRepository; import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component -public class JwtTokenFilter extends GenericFilter { +@RequiredArgsConstructor +public class JwtTokenFilter extends OncePerRequestFilter { + + private final MemberRepository memberRepository; @Value("${jwt.secret}") private String secretKey; - private final MemberRepository memberRepository; + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { - public JwtTokenFilter(MemberRepository memberRepository) { - this.memberRepository = memberRepository; - } + String path = request.getRequestURI(); - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) - throws IOException, ServletException { + if (isPublicPath(path)) { + filterChain.doFilter(request, response); + return; + } - HttpServletRequest httpRequest = (HttpServletRequest) request; - HttpServletResponse httpResponse = (HttpServletResponse) response; - String path = httpRequest.getRequestURI(); + String token = resolveToken(request); - try { - // JWT 토큰 검증이 ν•„μš” μ—†λŠ” κ²½λ‘œλ“€ - if (path.equals("/member/login") || - path.equals("/member/create") || - path.startsWith("/oauth2/") || - path.startsWith("/login/oauth2/") || - path.startsWith("/swagger-ui/") || - path.startsWith("/v3/api-docs") || - path.equals("/favicon.ico") || - path.equals("/error")) { - chain.doFilter(request, response); - return; - } - - String bearerToken = httpRequest.getHeader("Authorization"); - if (bearerToken == null || !bearerToken.startsWith("Bearer ")) { - throw new BusinessException(ErrorCodeAndMessage.MISSING_AUTHORIZATION_HEADER); - } - - String token = bearerToken.substring(7).trim(); - - Claims claims = Jwts.parserBuilder() - .setSigningKey(secretKey.getBytes()) - .build() - .parseClaimsJws(token) - .getBody(); + Claims claims = parseClaims(token); - String email = claims.getSubject(); + setAuthentication(claims, token); - Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new BusinessException(ErrorCodeAndMessage.MEMBER_NOT_FOUND)); + filterChain.doFilter(request, response); + } - MemberDetails memberDetails = new MemberDetails(member); + private boolean isPublicPath(String path) { + return path.equals("/member/login") || + path.equals("/member/create") || + path.startsWith("/oauth2/") || + path.startsWith("/login/oauth2/") || + path.startsWith("/swagger-ui/") || + path.startsWith("/v3/api-docs") || + path.equals("/favicon.ico") || + path.equals("/error") || + path.startsWith("/posts/lists"); + } - Authentication authentication = new UsernamePasswordAuthenticationToken( - memberDetails, - token, - memberDetails.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(authentication); + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); - chain.doFilter(request, response); + if (bearerToken == null || !bearerToken.startsWith("Bearer ")) { + throw new JwtAuthenticationException(GlobalErrorCode.MISSING_AUTHORIZATION_HEADER); + } - } catch (SignatureException e) { - sendErrorResponse(httpResponse, ErrorCodeAndMessage.TOKEN_INVALID); + return bearerToken.substring(7).trim(); + } + + private Claims parseClaims(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey.getBytes()) + .build() + .parseClaimsJws(token) + .getBody(); } catch (io.jsonwebtoken.ExpiredJwtException e) { - sendErrorResponse(httpResponse, ErrorCodeAndMessage.TOKEN_EXPIRED); + throw new JwtAuthenticationException(GlobalErrorCode.TOKEN_EXPIRED); - } catch (BusinessException e) { - sendErrorResponse(httpResponse, e.getErrorCodeAndMessage()); + } catch (SignatureException e) { + throw new JwtAuthenticationException(GlobalErrorCode.TOKEN_INVALID); } catch (Exception e) { - e.printStackTrace(); - sendErrorResponse(httpResponse, ErrorCodeAndMessage.INTERNAL_SERVER_ERROR); + throw new JwtAuthenticationException(GlobalErrorCode.TOKEN_INVALID); } } - private void sendErrorResponse(HttpServletResponse response, ErrorCodeAndMessage errorCode) throws IOException { - response.setStatus(errorCode.getCode()); - response.setContentType("application/json; charset=UTF-8"); + private void setAuthentication(Claims claims, String token) { + String email = claims.getSubject(); + + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new JwtAuthenticationException(MemberErrorCode.MEMBER_NOT_FOUND)); + + MemberDetails memberDetails = new MemberDetails(member); + + Authentication authentication = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + memberDetails, + token, + memberDetails.getAuthorities() + ); - String json = String.format( - "{\"status\": %d, \"message\": \"%s\"}", - errorCode.getCode(), - errorCode.getMessage()); - response.getWriter().write(json); + SecurityContextHolder.getContext().setAuthentication(authentication); } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/auth/JwtTokenProvider.java b/src/main/java/org/example/buyingserver/common/auth/JwtTokenProvider.java index f037ef4..a9ad8ad 100644 --- a/src/main/java/org/example/buyingserver/common/auth/JwtTokenProvider.java +++ b/src/main/java/org/example/buyingserver/common/auth/JwtTokenProvider.java @@ -3,14 +3,13 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; -import org.example.buyingserver.common.dto.ErrorCodeAndMessage; import org.example.buyingserver.common.exception.BusinessException; +import org.example.buyingserver.common.exception.GlobalErrorCode; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.spec.SecretKeySpec; import java.security.Key; -import java.util.Base64; import java.util.Date; @Component @@ -53,7 +52,7 @@ public String getEmailFromToken(String token) { return claims.getSubject(); // email } catch (Exception e) { - throw new BusinessException(ErrorCodeAndMessage.TOKEN_INVALID); + throw new BusinessException(GlobalErrorCode.TOKEN_INVALID); } } } \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/config/SecurityConfig.java b/src/main/java/org/example/buyingserver/common/config/SecurityConfig.java index 4abce08..eced877 100644 --- a/src/main/java/org/example/buyingserver/common/config/SecurityConfig.java +++ b/src/main/java/org/example/buyingserver/common/config/SecurityConfig.java @@ -1,12 +1,15 @@ package org.example.buyingserver.common.config; import lombok.RequiredArgsConstructor; +import org.example.buyingserver.common.auth.CustomAccessDeniedHandler; +import org.example.buyingserver.common.auth.CustomAuthenticationEntryPoint; import org.example.buyingserver.common.auth.JwtTokenFilter; -import org.example.buyingserver.common.dto.ErrorCodeAndMessage; +import org.example.buyingserver.common.exception.GlobalErrorCode; import org.example.buyingserver.member.service.CustomOAuth2UserService; import org.example.buyingserver.common.auth.OAuth2SuccessHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; @@ -27,6 +30,9 @@ public class SecurityConfig { private final JwtTokenFilter jwtTokenFilter; private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final CustomAuthenticationEntryPoint authenticationEntryPoint; + private final CustomAccessDeniedHandler accessDeniedHandler; + @Bean public PasswordEncoder passwordEncoder() { @@ -35,6 +41,7 @@ public PasswordEncoder passwordEncoder() { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) @@ -42,20 +49,24 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) .authorizeHttpRequests(auth -> auth .requestMatchers( "/member/create", "/member/login", - "/member/google/*", // 후에 μ‚­μ œν•΄λ†”μ•Όν•¨ + "/member/google/*", "/oauth2/**", "/login/oauth2/**", "/swagger-ui/**", "/v3/api-docs/**", "/favicon.ico" ).permitAll() + .requestMatchers(HttpMethod.GET, "/posts/lists", "/posts/{id}").permitAll() .anyRequest().authenticated() ) - // OAuth2 둜그인 ꡬ성 .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) @@ -63,8 +74,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .successHandler(oAuth2SuccessHandler) .failureHandler((request, response, e) -> { System.out.println("[DEBUG] : OAuth2 둜그인 μ‹€νŒ¨ " + e.getMessage()); - ErrorCodeAndMessage error = ErrorCodeAndMessage.UNAUTHORIZED; - response.setStatus(error.getCode()); + + GlobalErrorCode error = GlobalErrorCode.UNAUTHORIZED; + + response.setStatus(error.getStatus()); response.setContentType("text/plain; charset=UTF-8"); response.getWriter().write(error.getMessage()); response.getWriter().flush(); @@ -76,6 +89,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); diff --git a/src/main/java/org/example/buyingserver/common/dto/ApiResponse.java b/src/main/java/org/example/buyingserver/common/dto/ApiResponse.java index 1ae4f11..53ff2a6 100644 --- a/src/main/java/org/example/buyingserver/common/dto/ApiResponse.java +++ b/src/main/java/org/example/buyingserver/common/dto/ApiResponse.java @@ -21,7 +21,15 @@ public static ApiResponse success(ResponseCodeAndMessage responseCodeAndM return new ApiResponse<>(responseCodeAndMessage.getMessage(), responseCodeAndMessage.getCode(), data); } + public static ApiResponse success(ResponseCodePostAndMessage responseCodeAndMessage, T data) { + return new ApiResponse<>(responseCodeAndMessage.getMessage(), responseCodeAndMessage.getCode(), data); + } + public static ApiResponse noContent(ResponseCodeAndMessage responseCodeAndMessage) { return new ApiResponse<>(responseCodeAndMessage.getMessage(), responseCodeAndMessage.getCode(), null); } + + public static ApiResponse error(ErrorCode errorCode) { + return new ApiResponse<>(errorCode.getMessage(), errorCode.getCode(), null); + } } diff --git a/src/main/java/org/example/buyingserver/common/dto/ErrorCode.java b/src/main/java/org/example/buyingserver/common/dto/ErrorCode.java new file mode 100644 index 0000000..c0679c1 --- /dev/null +++ b/src/main/java/org/example/buyingserver/common/dto/ErrorCode.java @@ -0,0 +1,7 @@ +package org.example.buyingserver.common.dto; + +public interface ErrorCode { + int getStatus(); // HTTP μƒνƒœμ½”λ“œμΆ”κ°€ + int getCode(); + String getMessage(); +} diff --git a/src/main/java/org/example/buyingserver/common/dto/ErrorCodeAndMessage.java b/src/main/java/org/example/buyingserver/common/dto/ErrorCodeAndMessage.java deleted file mode 100644 index 4546b7f..0000000 --- a/src/main/java/org/example/buyingserver/common/dto/ErrorCodeAndMessage.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.example.buyingserver.common.dto; - -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum ErrorCodeAndMessage { - - // 4xx - ν΄λΌμ΄μ–ΈνŠΈ 였λ₯˜ - BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "잘λͺ»λœ μš”μ²­μž…λ‹ˆλ‹€."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "인증이 ν•„μš”ν•©λ‹ˆλ‹€."), - FORBIDDEN(HttpStatus.FORBIDDEN.value(), "μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), - NOT_FOUND(HttpStatus.NOT_FOUND.value(), "μš”μ²­ν•œ λ¦¬μ†ŒμŠ€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), - CONFLICT(HttpStatus.CONFLICT.value(), "λ¦¬μ†ŒμŠ€ μƒνƒœκ°€ μΆ©λŒν–ˆμŠ΅λ‹ˆλ‹€."), - INVALID_INPUT(HttpStatus.BAD_REQUEST.value(), "μž…λ ₯값이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), - GOOGLE_TOKEN_REQUEST_FAILED(HttpStatus.BAD_REQUEST.value(), "ꡬ글 OAuth 토큰 λ°œκΈ‰μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), - GOOGLE_PROFILE_REQUEST_FAILED(HttpStatus.BAD_REQUEST.value(), "ꡬ글 μ‚¬μš©μž ν”„λ‘œν•„ 정보λ₯Ό κ°€μ Έμ˜€μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."), - - // 5xx - μ„œλ²„ 였λ₯˜ - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "μ„œλ²„ λ‚΄λΆ€ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."), - - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” νšŒμ›μž…λ‹ˆλ‹€."), - DUPLICATE_EMAIL(HttpStatus.CONFLICT.value(), "이미 λ“±λ‘λœ μ΄λ©”μΌμž…λ‹ˆλ‹€."), - INVALID_PASSWORD(HttpStatus.UNAUTHORIZED.value(), "λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), - - // 인증/토큰 κ΄€λ ¨ - TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "만료된 ν† ν°μž…λ‹ˆλ‹€."), - TOKEN_INVALID(HttpStatus.UNAUTHORIZED.value(), "μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μž…λ‹ˆλ‹€."), - INVALID_TOKEN_FORMAT(401, "토큰 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), - MISSING_AUTHORIZATION_HEADER(401, "Authorization 헀더가 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); - - private final int code; - private final String message; - - ErrorCodeAndMessage(int code, String message) { - this.code = code; - this.message = message; - } -} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/dto/ErrorResponse.java b/src/main/java/org/example/buyingserver/common/dto/ErrorResponse.java index 25e1ea5..0c51ad0 100644 --- a/src/main/java/org/example/buyingserver/common/dto/ErrorResponse.java +++ b/src/main/java/org/example/buyingserver/common/dto/ErrorResponse.java @@ -12,7 +12,7 @@ public class ErrorResponse { private final String message; private final int code; - public static ErrorResponse fail(ErrorCodeAndMessage errorCodeAndMessage) { + public static ErrorResponse fail(ErrorCode errorCodeAndMessage) { return new ErrorResponse( errorCodeAndMessage.getMessage(), errorCodeAndMessage.getCode() diff --git a/src/main/java/org/example/buyingserver/common/dto/ResponseCodePostAndMessage.java b/src/main/java/org/example/buyingserver/common/dto/ResponseCodePostAndMessage.java new file mode 100644 index 0000000..a85a904 --- /dev/null +++ b/src/main/java/org/example/buyingserver/common/dto/ResponseCodePostAndMessage.java @@ -0,0 +1,16 @@ +package org.example.buyingserver.common.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ResponseCodePostAndMessage { + //κ²Œμ‹œλ¬Ό 성곡 κ΄€λ ¨ + SUCCESS_POST_CREATED(200, "κ²Œμ‹œλ¬Όμ΄ μ„±κ³΅μ μœΌλ‘œ μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + SUCCESS_POST_FETCHED(200, "κ²Œμ‹œλ¬Ό λͺ©λ‘λ“€μ„ μ„±κ³΅μ μœΌλ‘œ κ°€μ Έμ™”μŠ΅λ‹ˆλ‹€."), + SUCCESS_POST_DETAIL_FETCHED(200, "κ²Œμ‹œλ¬Ό μƒμ„Έλ‚΄μš©μ„ μ„±κ³΅μ μœΌλ‘œ κ°€μ Έμ™”μŠ΅λ‹ˆλ‹€."); + + private final int code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/exception/ApplicationException.java b/src/main/java/org/example/buyingserver/common/exception/ApplicationException.java index d823364..75da9f9 100644 --- a/src/main/java/org/example/buyingserver/common/exception/ApplicationException.java +++ b/src/main/java/org/example/buyingserver/common/exception/ApplicationException.java @@ -1,14 +1,14 @@ package org.example.buyingserver.common.exception; import lombok.Getter; -import org.example.buyingserver.common.dto.ErrorCodeAndMessage; +import org.example.buyingserver.common.dto.ErrorCode; @Getter public class ApplicationException extends RuntimeException { - private final ErrorCodeAndMessage errorCodeAndMessage; + private final ErrorCode errorCode; - public ApplicationException(ErrorCodeAndMessage errorCodeAndMessage) { - super(errorCodeAndMessage.getMessage()); - this.errorCodeAndMessage = errorCodeAndMessage; + public ApplicationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; } } \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/exception/BusinessException.java b/src/main/java/org/example/buyingserver/common/exception/BusinessException.java index 7bfe165..c1f079d 100644 --- a/src/main/java/org/example/buyingserver/common/exception/BusinessException.java +++ b/src/main/java/org/example/buyingserver/common/exception/BusinessException.java @@ -1,9 +1,9 @@ package org.example.buyingserver.common.exception; -import org.example.buyingserver.common.dto.ErrorCodeAndMessage; +import org.example.buyingserver.common.dto.ErrorCode; public class BusinessException extends ApplicationException { - public BusinessException(ErrorCodeAndMessage errorCodeAndMessage) { - super(errorCodeAndMessage); + public BusinessException(ErrorCode errorCode) { + super(errorCode); } } \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/common/exception/GlobalErrorCode.java b/src/main/java/org/example/buyingserver/common/exception/GlobalErrorCode.java new file mode 100644 index 0000000..6baf2f0 --- /dev/null +++ b/src/main/java/org/example/buyingserver/common/exception/GlobalErrorCode.java @@ -0,0 +1,35 @@ +package org.example.buyingserver.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.example.buyingserver.common.dto.ErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum GlobalErrorCode implements ErrorCode { + + // 4xx - ν΄λΌμ΄μ–ΈνŠΈ 였λ₯˜ (1000λ²ˆλŒ€) + BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), 1000, "잘λͺ»λœ μš”μ²­μž…λ‹ˆλ‹€."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), 1001, "인증이 ν•„μš”ν•©λ‹ˆλ‹€."), + FORBIDDEN(HttpStatus.FORBIDDEN.value(), 1002, "μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), + NOT_FOUND(HttpStatus.NOT_FOUND.value(), 1003, "μš”μ²­ν•œ λ¦¬μ†ŒμŠ€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + CONFLICT(HttpStatus.CONFLICT.value(), 1004, "λ¦¬μ†ŒμŠ€ μƒνƒœκ°€ μΆ©λŒν–ˆμŠ΅λ‹ˆλ‹€."), + INVALID_INPUT(HttpStatus.BAD_REQUEST.value(), 1005, "μž…λ ₯값이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + GOOGLE_TOKEN_REQUEST_FAILED(HttpStatus.BAD_REQUEST.value(), 1006, "ꡬ글 OAuth 토큰 λ°œκΈ‰μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + GOOGLE_PROFILE_REQUEST_FAILED(HttpStatus.BAD_REQUEST.value(), 1007, "ꡬ글 μ‚¬μš©μž ν”„λ‘œν•„ 정보λ₯Ό κ°€μ Έμ˜€μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."), + ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), 1008, "μ ‘κ·Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€."), + + // 5xx - μ„œλ²„ 였λ₯˜ (2000λ²ˆλŒ€) + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), 2000, "μ„œλ²„ λ‚΄λΆ€ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."), + + // 인증/토큰 κ΄€λ ¨ (3000λ²ˆλŒ€) + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), 3000, "만료된 ν† ν°μž…λ‹ˆλ‹€."), + TOKEN_INVALID(HttpStatus.UNAUTHORIZED.value(), 3001, "μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μž…λ‹ˆλ‹€."), + INVALID_TOKEN_FORMAT(HttpStatus.UNAUTHORIZED.value(), 3002, "토큰 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + MISSING_AUTHORIZATION_HEADER(HttpStatus.UNAUTHORIZED.value(), 3003, "Authorization 헀더가 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + + private final int status; + private final int code; + private final String message; +} diff --git a/src/main/java/org/example/buyingserver/common/exception/GlobalExceptionHandler.java b/src/main/java/org/example/buyingserver/common/handler/GlobalExceptionHandler.java similarity index 69% rename from src/main/java/org/example/buyingserver/common/exception/GlobalExceptionHandler.java rename to src/main/java/org/example/buyingserver/common/handler/GlobalExceptionHandler.java index 9a889f7..f054f8e 100644 --- a/src/main/java/org/example/buyingserver/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/example/buyingserver/common/handler/GlobalExceptionHandler.java @@ -1,8 +1,10 @@ -package org.example.buyingserver.common.exception; +package org.example.buyingserver.common.handler; import lombok.extern.slf4j.Slf4j; -import org.example.buyingserver.common.dto.ErrorCodeAndMessage; +import org.example.buyingserver.common.dto.ErrorCode; import org.example.buyingserver.common.dto.ErrorResponse; +import org.example.buyingserver.common.exception.BusinessException; +import org.example.buyingserver.common.exception.GlobalErrorCode; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -16,10 +18,13 @@ public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResponseEntity handleBusinessException(BusinessException e) { - log.error("[BusinessException] {}: {}", e.getErrorCodeAndMessage(), e.getMessage(), e); + ErrorCode errorCode = e.getErrorCode(); + + log.error("[BusinessException] {}: {}", errorCode, e.getMessage(), e); + return ResponseEntity - .status(HttpStatus.valueOf(e.getErrorCodeAndMessage().getCode())) - .body(ErrorResponse.fail(e.getErrorCodeAndMessage())); + .status(HttpStatus.valueOf(errorCode.getCode())) + .body(ErrorResponse.fail(errorCode)); } @ExceptionHandler(MethodArgumentNotValidException.class) @@ -27,7 +32,7 @@ public ResponseEntity handleValidationException(MethodArgumentNot log.error("[ValidationException] {}", e.getMessage(), e); return ResponseEntity .status(HttpStatus.BAD_REQUEST) - .body(ErrorResponse.fail(ErrorCodeAndMessage.INVALID_INPUT)); + .body(ErrorResponse.fail(GlobalErrorCode.INVALID_INPUT)); } @ExceptionHandler(Exception.class) @@ -35,7 +40,7 @@ public ResponseEntity handleUnexpectedException(Exception e) { log.error("[UnexpectedException] {}", e.getMessage(), e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ErrorResponse.fail(ErrorCodeAndMessage.INTERNAL_SERVER_ERROR)); + .body(ErrorResponse.fail(GlobalErrorCode.INTERNAL_SERVER_ERROR)); } @ExceptionHandler(HttpMessageNotReadableException.class) @@ -43,7 +48,7 @@ public ResponseEntity handleHttpMessageNotReadableException(HttpM log.error("[HttpMessageNotReadableException] {}", e.getMessage(), e); return ResponseEntity .status(HttpStatus.BAD_REQUEST) - .body(ErrorResponse.fail(ErrorCodeAndMessage.INVALID_INPUT)); + .body(ErrorResponse.fail(GlobalErrorCode.INVALID_INPUT)); } } diff --git a/src/main/java/org/example/buyingserver/member/dto/MemberCardDto.java b/src/main/java/org/example/buyingserver/member/dto/MemberCardDto.java new file mode 100644 index 0000000..1468b8d --- /dev/null +++ b/src/main/java/org/example/buyingserver/member/dto/MemberCardDto.java @@ -0,0 +1,12 @@ +package org.example.buyingserver.member.dto; + +import org.example.buyingserver.member.domain.Member; + +public record MemberCardDto( + Long id, + String nickname +) { + public static MemberCardDto from(Member member) { + return new MemberCardDto(member.getId(), member.getNickname()); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/member/exception/DuplicateEmailException.java b/src/main/java/org/example/buyingserver/member/exception/DuplicateEmailException.java new file mode 100644 index 0000000..8d2e208 --- /dev/null +++ b/src/main/java/org/example/buyingserver/member/exception/DuplicateEmailException.java @@ -0,0 +1,9 @@ +package org.example.buyingserver.member.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class DuplicateEmailException extends BusinessException { + public DuplicateEmailException() { + super(MemberErrorCode.DUPLICATE_EMAIL); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/member/exception/InvalidPasswordException.java b/src/main/java/org/example/buyingserver/member/exception/InvalidPasswordException.java new file mode 100644 index 0000000..e3b69b4 --- /dev/null +++ b/src/main/java/org/example/buyingserver/member/exception/InvalidPasswordException.java @@ -0,0 +1,9 @@ +package org.example.buyingserver.member.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class InvalidPasswordException extends BusinessException { + public InvalidPasswordException() { + super(MemberErrorCode.INVALID_PASSWORD); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/member/exception/MemberErrorCode.java b/src/main/java/org/example/buyingserver/member/exception/MemberErrorCode.java new file mode 100644 index 0000000..81003ac --- /dev/null +++ b/src/main/java/org/example/buyingserver/member/exception/MemberErrorCode.java @@ -0,0 +1,19 @@ +package org.example.buyingserver.member.exception; + +import org.example.buyingserver.common.dto.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorCode implements ErrorCode { + + MEMBER_NOT_FOUND(404, 4001, "νšŒμ› 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + INVALID_PASSWORD(400, 4002, "λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + DUPLICATE_EMAIL(400, 4003, "이미 κ°€μž…λœ μ΄λ©”μΌμž…λ‹ˆλ‹€."), + MISSING_AUTHORIZATION_HEADER(400, 4004, "Authorization 헀더가 λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + + private final int status; + private final int code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/member/exception/MemberNotFoundException.java b/src/main/java/org/example/buyingserver/member/exception/MemberNotFoundException.java new file mode 100644 index 0000000..f7b6686 --- /dev/null +++ b/src/main/java/org/example/buyingserver/member/exception/MemberNotFoundException.java @@ -0,0 +1,9 @@ +package org.example.buyingserver.member.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class MemberNotFoundException extends BusinessException { + public MemberNotFoundException() { + super(MemberErrorCode.MEMBER_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/member/exception/MissingAuthHeaderException.java b/src/main/java/org/example/buyingserver/member/exception/MissingAuthHeaderException.java new file mode 100644 index 0000000..688aa0e --- /dev/null +++ b/src/main/java/org/example/buyingserver/member/exception/MissingAuthHeaderException.java @@ -0,0 +1,9 @@ +package org.example.buyingserver.member.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class MissingAuthHeaderException extends BusinessException { + public MissingAuthHeaderException() { + super(MemberErrorCode.MISSING_AUTHORIZATION_HEADER); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/member/handler/MemberExceptionHandler.java b/src/main/java/org/example/buyingserver/member/handler/MemberExceptionHandler.java new file mode 100644 index 0000000..b3ce0e6 --- /dev/null +++ b/src/main/java/org/example/buyingserver/member/handler/MemberExceptionHandler.java @@ -0,0 +1,39 @@ +package org.example.buyingserver.member.handler; + +import org.example.buyingserver.common.dto.ApiResponse; +import org.example.buyingserver.member.exception.*; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "org.example.buyingserver.member") +public class MemberExceptionHandler { + + @ExceptionHandler(MemberNotFoundException.class) + public ResponseEntity> handle(MemberNotFoundException e) { + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ApiResponse.error(e.getErrorCode())); + } + + @ExceptionHandler(InvalidPasswordException.class) + public ResponseEntity> handle(InvalidPasswordException e) { + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ApiResponse.error(e.getErrorCode())); + } + + @ExceptionHandler(DuplicateEmailException.class) + public ResponseEntity> handle(DuplicateEmailException e) { + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ApiResponse.error(e.getErrorCode())); + } + + @ExceptionHandler(MissingAuthHeaderException.class) + public ResponseEntity> handle(MissingAuthHeaderException e) { + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ApiResponse.error(e.getErrorCode())); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/member/service/MemberService.java b/src/main/java/org/example/buyingserver/member/service/MemberService.java index 0437ad8..457a6df 100644 --- a/src/main/java/org/example/buyingserver/member/service/MemberService.java +++ b/src/main/java/org/example/buyingserver/member/service/MemberService.java @@ -1,21 +1,21 @@ package org.example.buyingserver.member.service; import lombok.extern.slf4j.Slf4j; +import lombok.RequiredArgsConstructor; import org.example.buyingserver.common.auth.JwtTokenProvider; import org.example.buyingserver.member.dto.*; -import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; -import org.example.buyingserver.common.dto.ErrorCodeAndMessage; -import org.example.buyingserver.common.exception.BusinessException; import org.example.buyingserver.member.domain.Member; +import org.example.buyingserver.member.exception.*; import org.example.buyingserver.member.repository.MemberRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @RequiredArgsConstructor @Service public class MemberService { + private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; @@ -23,7 +23,9 @@ public class MemberService { @Transactional public MemberCreateResponseDto create(MemberCreateRequestDto dto) { + validateDuplicateEmail(dto.email()); + Member member = Member.create( dto.email(), dto.password(), @@ -40,31 +42,42 @@ public MemberCreateResponseDto create(MemberCreateRequestDto dto) { ); } + @Transactional(readOnly = true) public MemberLoginResponseDto login(MemberLoginDto memberLoginDto) { + Member member = memberRepository.findByEmail(memberLoginDto.email()) - .orElseThrow(() -> new BusinessException(ErrorCodeAndMessage.MEMBER_NOT_FOUND)); + .orElseThrow(MemberNotFoundException::new); + if (!passwordEncoder.matches(memberLoginDto.password(), member.getPassword())) { - throw new BusinessException(ErrorCodeAndMessage.INVALID_PASSWORD); + throw new InvalidPasswordException(); } + String token = jwtTokenProvider.createToken(member.getEmail()); + return MemberLoginResponseDto.of(member.getId(), token); } + public MemberProfileDto getProfileByToken(String bearerToken) { + if (bearerToken == null || !bearerToken.startsWith("Bearer ")) { - throw new BusinessException(ErrorCodeAndMessage.MISSING_AUTHORIZATION_HEADER); + throw new MissingAuthHeaderException(); } + String token = bearerToken.substring(7); String email = jwtTokenProvider.getEmailFromToken(token); + Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new BusinessException(ErrorCodeAndMessage.MEMBER_NOT_FOUND)); + .orElseThrow(MemberNotFoundException::new); + return new MemberProfileDto(member.getEmail(), member.getNickname()); } + private void validateDuplicateEmail(String email) { if (memberRepository.findByEmail(email).isPresent()) { - throw new BusinessException(ErrorCodeAndMessage.DUPLICATE_EMAIL); + throw new DuplicateEmailException(); } } } \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/controller/PostController.java b/src/main/java/org/example/buyingserver/post/controller/PostController.java new file mode 100644 index 0000000..4fe9549 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/controller/PostController.java @@ -0,0 +1,59 @@ +package org.example.buyingserver.post.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.buyingserver.common.auth.MemberDetails; +import org.example.buyingserver.common.dto.ApiResponse; +import org.example.buyingserver.common.dto.ResponseCodePostAndMessage; +import org.example.buyingserver.member.domain.Member; +import org.example.buyingserver.post.dto.PostCreateRequestDto; +import org.example.buyingserver.post.dto.PostCreateResponseDto; +import org.example.buyingserver.post.dto.PostDetailResponseDto; +import org.example.buyingserver.post.dto.PostListResponseDto; +import org.example.buyingserver.post.service.PostService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.logging.Handler; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/posts") +public class PostController { + + private final PostService postService; + + @PostMapping + public ResponseEntity> postCreate( + @RequestBody PostCreateRequestDto requestDto, + @AuthenticationPrincipal MemberDetails details + ) { + Member member = details.getMember(); + PostCreateResponseDto response = postService.create(requestDto, member); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(ApiResponse.success(ResponseCodePostAndMessage.SUCCESS_POST_CREATED, response)); + } + + @GetMapping("/lists") + public ResponseEntity> getSellingPosts() { + PostListResponseDto response = postService.getPosts(); + return ResponseEntity.ok( + ApiResponse.success(ResponseCodePostAndMessage.SUCCESS_POST_FETCHED, response) + ); + } + + @GetMapping("/{id}") + public ResponseEntity> getPostDetail( + @PathVariable Long id + ) { + PostDetailResponseDto response = postService.getPostDetail(id); + return ResponseEntity.ok( + ApiResponse.success(ResponseCodePostAndMessage.SUCCESS_POST_DETAIL_FETCHED, response) + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/domain/Post.java b/src/main/java/org/example/buyingserver/post/domain/Post.java new file mode 100644 index 0000000..038a2c9 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/domain/Post.java @@ -0,0 +1,121 @@ +package org.example.buyingserver.post.domain; + +import jakarta.persistence.*; +import lombok.*; +import org.example.buyingserver.post.exception.*; +import org.example.buyingserver.member.domain.Member; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "post") +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false, length = 100) + private String title; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(nullable = false) + private int price; + + @Column(nullable = false) + private int quantity; + + @Column(columnDefinition = "TEXT") + private String thumbnailUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostStatus status = PostStatus.SELLING; + + @CreationTimestamp + @Column(updatable = false, nullable = false) + private LocalDateTime createdAt; + + @Column + private LocalDateTime deletedAt; + + @Builder + private Post(Member member, + String title, + String content, + int price, + int quantity, + String thumbnailUrl, + PostStatus status) { + this.member = member; + this.title = title; + this.content = content; + this.price = price; + this.quantity = quantity; + this.thumbnailUrl = thumbnailUrl; + this.status = (status != null) ? status : PostStatus.SELLING; + } + + public static Post create(Member member, + String title, + String content, + int price, + int quantity, + String thumbnailUrl) { + return Post.builder() + .member(member) + .title(title) + .content(content) + .price(price) + .quantity(quantity) + .thumbnailUrl(thumbnailUrl) + .build(); + } + + public void markAsReserved() { + if (this.status == PostStatus.DELETED) { + throw new PostAlreadyDeletedException(); + } + if (this.status == PostStatus.RESERVED) { + throw new PostAlreadyReservedException(); + } + this.status = PostStatus.RESERVED; + } + + public void markAsSold() { + if (this.status == PostStatus.DELETED) { + throw new PostAlreadyDeletedException(); + } + this.status = PostStatus.SOLD; + } + + public void cancelReservation() { + if (this.status != PostStatus.RESERVED) { + throw new PostNotReservedException(); + } + this.status = PostStatus.SELLING; + } + + public void markAsDeleted() { + if (this.status == PostStatus.DELETED) { + return; + } + this.status = PostStatus.DELETED; + this.deletedAt = LocalDateTime.now(); + } + + + public void updateContentAndQuantity(String content, int quantity) { + this.content = content; + this.quantity = quantity; + } +} diff --git a/src/main/java/org/example/buyingserver/post/domain/PostImage.java b/src/main/java/org/example/buyingserver/post/domain/PostImage.java new file mode 100644 index 0000000..649f034 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/domain/PostImage.java @@ -0,0 +1,44 @@ +package org.example.buyingserver.post.domain; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "post_image") +public class PostImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(columnDefinition = "TEXT") + private String imageUrl; + + @Column(nullable = false) + private Integer imageOrder; + + + @Builder + private PostImage(Post post, String imageUrl, Integer imageOrder) { + this.post = post; + this.imageUrl = imageUrl; + this.imageOrder = imageOrder; + } + + public static PostImage create(Post post, String imageUrl, Integer imageOrder) { + return PostImage.builder() + .post(post) + .imageUrl(imageUrl) + .imageOrder(imageOrder) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/domain/PostStatus.java b/src/main/java/org/example/buyingserver/post/domain/PostStatus.java new file mode 100644 index 0000000..a3600e4 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/domain/PostStatus.java @@ -0,0 +1,5 @@ +package org.example.buyingserver.post.domain; + +public enum PostStatus { + SELLING,RESERVED,SOLD,DELETED +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/dto/PostCardDto.java b/src/main/java/org/example/buyingserver/post/dto/PostCardDto.java new file mode 100644 index 0000000..1ff6a1b --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/dto/PostCardDto.java @@ -0,0 +1,25 @@ +package org.example.buyingserver.post.dto; + +import org.example.buyingserver.member.dto.MemberCardDto; +import org.example.buyingserver.post.domain.Post; +import org.example.buyingserver.post.domain.PostStatus; + +public record PostCardDto( + Long id, + MemberCardDto member, + String title, + String thumbnailUrl, + Integer price, + PostStatus status +) { + public static PostCardDto fromEntity(Post post) { + return new PostCardDto( + post.getId(), + MemberCardDto.from(post.getMember()), + post.getTitle(), + post.getThumbnailUrl(), + post.getPrice(), + post.getStatus() + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/dto/PostCreateRequestDto.java b/src/main/java/org/example/buyingserver/post/dto/PostCreateRequestDto.java new file mode 100644 index 0000000..76ec56b --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/dto/PostCreateRequestDto.java @@ -0,0 +1,11 @@ +package org.example.buyingserver.post.dto; + +import java.util.List; + +public record PostCreateRequestDto( + String title, + String content, + Integer price, + Integer quantity, + List images +) {} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/dto/PostCreateResponseDto.java b/src/main/java/org/example/buyingserver/post/dto/PostCreateResponseDto.java new file mode 100644 index 0000000..39c4285 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/dto/PostCreateResponseDto.java @@ -0,0 +1,8 @@ +package org.example.buyingserver.post.dto; + +public record PostCreateResponseDto(Long postId) { + + public static PostCreateResponseDto from(Long postId) { + return new PostCreateResponseDto(postId); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/dto/PostDetailResponseDto.java b/src/main/java/org/example/buyingserver/post/dto/PostDetailResponseDto.java new file mode 100644 index 0000000..6086643 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/dto/PostDetailResponseDto.java @@ -0,0 +1,36 @@ +package org.example.buyingserver.post.dto; + +import org.example.buyingserver.member.dto.MemberCardDto; +import org.example.buyingserver.post.domain.Post; +import org.example.buyingserver.post.domain.PostImage; +import org.example.buyingserver.post.domain.PostStatus; + +import java.util.List; + +public record PostDetailResponseDto( + Long id, + MemberCardDto member, + String title, + String content, + Integer price, + PostStatus status, + Integer quantity, + List images +) { + public static PostDetailResponseDto from(Post post, List postImages) { + List imageUrls = postImages.stream() + .map(PostImage::getImageUrl) + .toList(); + + return new PostDetailResponseDto( + post.getId(), + MemberCardDto.from(post.getMember()), + post.getTitle(), + post.getContent(), + post.getPrice(), + post.getStatus(), + post.getQuantity(), + imageUrls + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/dto/PostListResponseDto.java b/src/main/java/org/example/buyingserver/post/dto/PostListResponseDto.java new file mode 100644 index 0000000..70b4939 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/dto/PostListResponseDto.java @@ -0,0 +1,11 @@ +package org.example.buyingserver.post.dto; + +import java.util.List; + +public record PostListResponseDto( + List posts +) { + public static PostListResponseDto from(List postCards) { + return new PostListResponseDto(postCards); + } +} diff --git a/src/main/java/org/example/buyingserver/post/exception/PostAlreadyDeletedException.java b/src/main/java/org/example/buyingserver/post/exception/PostAlreadyDeletedException.java new file mode 100644 index 0000000..7b3990f --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/exception/PostAlreadyDeletedException.java @@ -0,0 +1,9 @@ +package org.example.buyingserver.post.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class PostAlreadyDeletedException extends BusinessException { + public PostAlreadyDeletedException() { + super(PostErrorCode.POST_ALREADY_DELETED); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/exception/PostAlreadyReservedException.java b/src/main/java/org/example/buyingserver/post/exception/PostAlreadyReservedException.java new file mode 100644 index 0000000..6516cc4 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/exception/PostAlreadyReservedException.java @@ -0,0 +1,9 @@ +package org.example.buyingserver.post.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class PostAlreadyReservedException extends BusinessException { + public PostAlreadyReservedException() { + super(PostErrorCode.POST_ALREADY_RESERVED); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/exception/PostCannotReserveDeletedException.java b/src/main/java/org/example/buyingserver/post/exception/PostCannotReserveDeletedException.java new file mode 100644 index 0000000..3d3664b --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/exception/PostCannotReserveDeletedException.java @@ -0,0 +1,9 @@ +package org.example.buyingserver.post.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class PostCannotReserveDeletedException extends BusinessException { + public PostCannotReserveDeletedException() { + super(PostErrorCode.POST_CANNOT_RESERVE_DELETED); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/exception/PostErrorCode.java b/src/main/java/org/example/buyingserver/post/exception/PostErrorCode.java new file mode 100644 index 0000000..ea01dde --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/exception/PostErrorCode.java @@ -0,0 +1,21 @@ +package org.example.buyingserver.post.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.example.buyingserver.common.dto.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum PostErrorCode implements ErrorCode { + + POST_NOT_FOUND(404, 4001, "κ²Œμ‹œλ¬Όμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + POST_NOT_RESERVED(400, 4002, "μ˜ˆμ•½λœ κ²Œμ‹œλ¬Όμ΄ μ•„λ‹™λ‹ˆλ‹€."), + POST_ALREADY_DELETED(400, 4003, "이미 μ‚­μ œλœ κ²Œμ‹œλ¬Όμž…λ‹ˆλ‹€."), + POST_ALREADY_RESERVED(400, 4004, "이미 μ˜ˆμ•½λœ κ²Œμ‹œλ¬Όμž…λ‹ˆλ‹€."), + POST_DETAIL_NOT_FOUND(404, 4005, "κ²Œμ‹œκΈ€ μƒμ„Έλ‚΄μš©μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), + POST_CANNOT_RESERVE_DELETED(400, 4006, "μ‚­μ œλœ κ²Œμ‹œλ¬Όμ„ μ˜ˆμ•½ν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + + private final int status; // HTTP μƒνƒœ μ½”λ“œ + private final int code; // λΉ„μ¦ˆλ‹ˆμŠ€ μ½”λ“œ + private final String message; +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/exception/PostNotFoundException.java b/src/main/java/org/example/buyingserver/post/exception/PostNotFoundException.java new file mode 100644 index 0000000..e83912d --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/exception/PostNotFoundException.java @@ -0,0 +1,10 @@ +package org.example.buyingserver.post.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class PostNotFoundException extends BusinessException { + + public PostNotFoundException() { + super(PostErrorCode.POST_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/exception/PostNotReservedException.java b/src/main/java/org/example/buyingserver/post/exception/PostNotReservedException.java new file mode 100644 index 0000000..a2389c8 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/exception/PostNotReservedException.java @@ -0,0 +1,9 @@ +package org.example.buyingserver.post.exception; + +import org.example.buyingserver.common.exception.BusinessException; + +public class PostNotReservedException extends BusinessException { + public PostNotReservedException() { + super(PostErrorCode.POST_NOT_RESERVED); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/handler/PostExceptionHandler.java b/src/main/java/org/example/buyingserver/post/handler/PostExceptionHandler.java new file mode 100644 index 0000000..19bec92 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/handler/PostExceptionHandler.java @@ -0,0 +1,18 @@ +package org.example.buyingserver.post.handler; + +import org.example.buyingserver.common.dto.ApiResponse; +import org.example.buyingserver.post.exception.PostNotFoundException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "org.example.buyingserver.post") +public class PostExceptionHandler { + + @ExceptionHandler(PostNotFoundException.class) + public ResponseEntity> handlePostNotFound(PostNotFoundException e) { + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ApiResponse.error(e.getErrorCode())); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/repository/PostImageRepository.java b/src/main/java/org/example/buyingserver/post/repository/PostImageRepository.java new file mode 100644 index 0000000..77a22ad --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/repository/PostImageRepository.java @@ -0,0 +1,15 @@ +package org.example.buyingserver.post.repository; + +import org.example.buyingserver.post.domain.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostImageRepository extends JpaRepository { + + // 상세 ν™”λ©΄μš©: μ •λ ¬λœ 이미지 λͺ©λ‘ + List findAllByPostId(Long postId); + + // μˆ˜μ •/μ‚­μ œ μ‹œ 일괄 μ •λ¦¬μš© + void deleteByPostId(Long postId); +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/repository/PostRepository.java b/src/main/java/org/example/buyingserver/post/repository/PostRepository.java new file mode 100644 index 0000000..39ba7af --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/repository/PostRepository.java @@ -0,0 +1,16 @@ +package org.example.buyingserver.post.repository; + +import org.example.buyingserver.post.domain.Post; +import org.example.buyingserver.post.domain.PostStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PostRepository extends JpaRepository { + + Optional findByIdAndStatusNot(Long id, PostStatus status); + List findAllByStatusIn(List statuses); + boolean existsByIdAndStatusNot(Long id, PostStatus status); + Optional findById(Long id); +} \ No newline at end of file diff --git a/src/main/java/org/example/buyingserver/post/service/PostService.java b/src/main/java/org/example/buyingserver/post/service/PostService.java new file mode 100644 index 0000000..cd5ed28 --- /dev/null +++ b/src/main/java/org/example/buyingserver/post/service/PostService.java @@ -0,0 +1,80 @@ +package org.example.buyingserver.post.service; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.example.buyingserver.post.exception.PostNotFoundException; +import org.example.buyingserver.member.domain.Member; +import org.example.buyingserver.post.domain.Post; +import org.example.buyingserver.post.domain.PostImage; +import org.example.buyingserver.post.domain.PostStatus; +import org.example.buyingserver.post.dto.*; +import org.example.buyingserver.post.repository.PostImageRepository; +import org.example.buyingserver.post.repository.PostRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PostService { + + private final PostRepository postRepository; + private final PostImageRepository postImageRepository; + + @Transactional + public PostCreateResponseDto create(PostCreateRequestDto dto, Member member) { + String thumbnailUrl = extractThumbnail(dto); + + Post post = Post.create( + member, + dto.title(), + dto.content(), + dto.price(), + dto.quantity(), + thumbnailUrl + ); + + postRepository.save(post); + savePostImagesIfExists(dto, post); + + return new PostCreateResponseDto(post.getId()); + } + + public PostListResponseDto getPosts() { + List posts = postRepository.findAllByStatusIn( + List.of(PostStatus.SELLING, PostStatus.RESERVED) + ); + + List cards = posts.stream() + .map(PostCardDto::fromEntity) + .toList(); + + return PostListResponseDto.from(cards); + } + + public PostDetailResponseDto getPostDetail(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + + List postImages = postImageRepository.findAllByPostId(postId); + + return PostDetailResponseDto.from(post, postImages); + } + + // 썸넀일 μΆ”μΆœ + private String extractThumbnail(PostCreateRequestDto dto) { + if (dto.images() == null || dto.images().isEmpty()) { + return null; + } + return dto.images().get(0); + } + + // 이미지 μ—¬λŸ¬ μž₯ μ €μž₯ + private void savePostImagesIfExists(PostCreateRequestDto dto, Post post) { + if (dto.images() != null && !dto.images().isEmpty()) { + for (int i = 0; i < dto.images().size(); i++) { + postImageRepository.save(PostImage.create(post, dto.images().get(i), i + 1)); + } + } + } +} diff --git a/src/main/resources/db/migration/V2__create_post_tables.sql b/src/main/resources/db/migration/V2__create_post_tables.sql new file mode 100644 index 0000000..70fb49c --- /dev/null +++ b/src/main/resources/db/migration/V2__create_post_tables.sql @@ -0,0 +1,34 @@ +-- -------------------------------- +-- κ²Œμ‹œλ¬Ό (post) +-- -------------------------------- +CREATE TABLE post ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + title VARCHAR(100) NOT NULL, + content TEXT, + price INT NOT NULL, + quantity INT NOT NULL, + thumbnail_url TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'SELLING', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + deleted_at DATETIME(6) NULL, + CONSTRAINT fk_post_member FOREIGN KEY (member_id) REFERENCES member(id) +); + +-- -------------------------------- +-- κ²Œμ‹œλ¬Ό 이미지 (post_image) +-- -------------------------------- +CREATE TABLE post_image ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + post_id BIGINT NOT NULL, + image_url TEXT NOT NULL, + image_order INT NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_post_image_post FOREIGN KEY (post_id) REFERENCES post(id) +); + +-- -------------------------------- +-- 인덱슀 μΆ”κ°€ (쑰회 μ„±λŠ₯ μ΅œμ ν™”) +-- -------------------------------- +CREATE INDEX idx_post_member_id ON post(member_id); +CREATE INDEX idx_post_image_post_id ON post_image(post_id);