diff --git a/build.gradle b/build.gradle index 13efd8a..1cff173 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,11 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.boot:spring-boot-configuration-processor' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { diff --git a/src/main/java/com/umc/springboot/domain/auth/service/AuthService.java b/src/main/java/com/umc/springboot/domain/auth/service/AuthService.java index 0dda893..420c7f3 100644 --- a/src/main/java/com/umc/springboot/domain/auth/service/AuthService.java +++ b/src/main/java/com/umc/springboot/domain/auth/service/AuthService.java @@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service @@ -29,24 +28,26 @@ public class AuthService { private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; private final JwtProvider jwtProvider; private final RedisUtil redisUtil; private final UserConverter userConverter; private final UserService userService; + private static final String REFRESH_TOKEN_PREFIX = "user:refresh:"; @Value("${cookie.secure}") private boolean secure; /** - * 일반 로그인을 처리하는 메서드 + * 일반 로그인 처리 + * + *

현재는 "이메일 + 전화번호" 조합으로 인증을 수행한다. * - * @param loginRequest 사용자 로그인 요청 객체 (이메일, 비밀번호 포함) - * @param response 액세스 토큰과 리프레시 토큰을 담기 위한 HTTP 응답 객체 - * @return 로그인한 사용자 정보가 담긴 {@link UserResponse} 객체 - * @throws CustomException 이메일에 해당하는 사용자가 없을 경우 {@link AuthErrorCode#INVALID_PASSWORD} - * @throws CustomException 비밀번호가 일치하지 않을 경우 {@link AuthErrorCode#INVALID_PASSWORD} + * @param loginRequest 이메일, 전화번호를 담은 요청 DTO + * @param response 발급된 액세스/리프레시 토큰을 실어보낼 HttpServletResponse + * @return 로그인한 사용자 정보 + * @throws CustomException 이메일에 해당하는 유저가 없거나, 전화번호가 일치하지 않는 경우 + * {@link AuthErrorCode#INVALID_PASSWORD} */ public UserResponse login(UserRequest.LoginRequest loginRequest, HttpServletResponse response) { User user = validateUserCredentials(loginRequest); @@ -54,12 +55,11 @@ public UserResponse login(UserRequest.LoginRequest loginRequest, HttpServletResp } /** - * 테스트용 로그인 계정(ID = 1)을 통해 로그인을 처리하는 메서드 + * 테스트용 사용자(예: ID = 1)로 로그인 처리 * - * @param response 액세스 토큰과 리프레시 토큰을 담기 위한 HTTP 응답 객체 - * @return 로그인한 테스트 사용자 정보가 담긴 {@link UserResponse} 객체 - * @throws CustomException ID가 1인 테스트 사용자가 존재하지 않을 경우 - * {@link AuthErrorCode#AUTHENTICATION_NOT_FOUND} + * @param response 발급된 액세스/리프레시 토큰을 실어보낼 HttpServletResponse + * @return 테스트 사용자 정보 + * @throws CustomException 해당 ID의 사용자가 없을 경우 {@link UserErrorCode#USER_NOT_FOUND} */ public UserResponse testLogin(HttpServletResponse response) { User user = @@ -70,13 +70,14 @@ public UserResponse testLogin(HttpServletResponse response) { } /** - * 로그아웃 처리 메서드 + * 로그아웃 처리 * - *

요청 헤더에서 액세스 토큰을 추출하여 Redis 블랙리스트에 저장하고, 리프레시 토큰을 Redis에서 삭제하여 재사용을 차단합니다. + *

1) 액세스 토큰을 블랙리스트에 등록
+ * 2) Redis 에 저장된 리프레시 토큰 삭제
3) 클라이언트의 refreshToken 쿠키 만료 처리 * - * @param request HTTP 요청 객체 (헤더에서 Access Token 추출용) - * @param response HTTP 응답 객체 (리프레시 쿠키 삭제용) - * @throws CustomException 액세스 토큰이 유효하지 않거나 없을 경우 {@link AuthErrorCode#INVALID_ACCESS_TOKEN} + * @param request Authorization 헤더에서 액세스 토큰을 읽기 위한 HttpServletRequest + * @param response refreshToken 쿠키 삭제를 위한 HttpServletResponse + * @throws CustomException 액세스 토큰이 없거나 유효하지 않은 경우 {@link AuthErrorCode#INVALID_ACCESS_TOKEN} */ public void logout(HttpServletRequest request, HttpServletResponse response) { String accessToken = resolveAccessToken(request); @@ -84,28 +85,28 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { throw new CustomException(AuthErrorCode.INVALID_ACCESS_TOKEN); } - // 블랙리스트 등록 (accessToken → "logout" 값, 만료시간까지) + // 1) 액세스 토큰 블랙리스트 등록 (만료 시점까지) long expiration = jwtProvider.extractExpiration(accessToken).getTime() - System.currentTimeMillis(); redisUtil.setData("blacklist:" + accessToken, "logout", expiration / 1000); - // refresh 토큰 Redis에서 삭제 + // 2) Redis 에서 리프레시 토큰 삭제 Long userId = jwtProvider.extractUserId(accessToken); - redisUtil.deleteData("user:refresh:" + userId); + redisUtil.deleteData(REFRESH_TOKEN_PREFIX + userId); - // 쿠키에서 refreshToken 제거 + // 3) 쿠키에서 refreshToken 제거 deleteRefreshTokenCookie(response); } /** - * 액세스 토큰 재발급 처리 메서드 + * 액세스 토큰 재발급 * - *

쿠키에서 리프레시 토큰을 추출한 후 Redis에 저장된 토큰과 비교하여 유효성을 검증합니다. 검증에 성공하면 새로운 액세스 토큰을 생성하여 응답 헤더에 - * 포함시킵니다. + *

1) 쿠키에서 리프레시 토큰을 읽어온 뒤 유효성 검증
+ * 2) Redis 에 저장된 리프레시 토큰과 일치하는지 확인
3) 새로운 액세스 토큰을 생성하여 Authorization 헤더에 담아 응답 * - * @param request HTTP 요청 객체 (쿠키에서 리프레시 토큰 추출용) - * @param response HTTP 응답 객체 (새로운 액세스 토큰 설정용) - * @throws CustomException 리프레시 토큰이 없거나 유효하지 않거나, 저장된 토큰과 일치하지 않는 경우 + * @param request 리프레시 토큰 쿠키 확인용 HttpServletRequest + * @param response 새 액세스 토큰을 담아보낼 HttpServletResponse + * @throws CustomException 리프레시 토큰이 없거나, 유효하지 않거나, 저장된 값과 다를 경우 * {@link AuthErrorCode#REFRESH_TOKEN_REQUIRED} */ public void reissueAccessToken(HttpServletRequest request, HttpServletResponse response) { @@ -119,17 +120,19 @@ public void reissueAccessToken(HttpServletRequest request, HttpServletResponse r Long userId = jwtProvider.extractUserId(refreshToken); // 3. Redis에 저장된 리프레시 토큰과 비교 - String storedToken = redisUtil.getData("user:refresh:" + userId); + String storedToken = redisUtil.getData(REFRESH_TOKEN_PREFIX + userId); if (!refreshToken.equals(storedToken)) { throw new CustomException(AuthErrorCode.REFRESH_TOKEN_REQUIRED); } // 4. 새로운 accessToken 생성 후 응답 헤더에 설정 String newAccessToken = jwtProvider.createAccessToken(userId); - response.setHeader("Authorization", "Bearer " + newAccessToken); + setAccessTokenHeader(response, newAccessToken); } - // 사용자 인증 (이메일 + 비밀번호 검증) + /** + * 이메일 + 전화번호로 사용자 인증 + */ private User validateUserCredentials(UserRequest.LoginRequest loginRequest) { User user = userRepository @@ -143,7 +146,9 @@ private User validateUserCredentials(UserRequest.LoginRequest loginRequest) { return user; } - // 토큰 발급 및 응답 세팅 + /** + * 액세스 / 리프레시 토큰 발급 후 응답 헤더·쿠키에 세팅 + */ private UserResponse issueTokensAndSetResponse(User user, HttpServletResponse response) { String accessToken = jwtProvider.createAccessToken(user.getId()); String refreshToken = jwtProvider.createRefreshToken(user.getId()); @@ -161,8 +166,14 @@ private void setAccessTokenHeader(HttpServletResponse response, String accessTok response.setHeader("Authorization", "Bearer " + accessToken); } + /** + * refreshToken 쿠키 설정 + * + *

로컬 개발 환경(secure=false)과 배포 환경(secure=true)을 분리해서 설정한다. + */ private void setRefreshTokenCookie( HttpServletResponse response, String refreshToken, long maxAgeSec) { + ResponseCookie.ResponseCookieBuilder cookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) @@ -170,10 +181,13 @@ private void setRefreshTokenCookie( .maxAge(Duration.ofSeconds(maxAgeSec)); if (secure) { - cookie.secure(true).sameSite("None").domain(".danchu.site"); // cross-site 방지 (배포용 HTTPS 설정) + // 배포 환경: HTTPS + SameSite=None 옵션만 사용 (도메인은 기본값 사용) + cookie.secure(true).sameSite("None"); } else { - cookie.secure(false).sameSite("Lax"); // localhost + // 로컬 환경 + cookie.secure(false).sameSite("Lax"); } + response.addHeader(HttpHeaders.SET_COOKIE, cookie.build().toString()); } @@ -198,28 +212,36 @@ private String resolveAccessToken(HttpServletRequest request) { return null; } + /** + * refreshToken 쿠키 제거 (즉시 만료) + */ private void deleteRefreshTokenCookie(HttpServletResponse response) { ResponseCookie.ResponseCookieBuilder cookie = - ResponseCookie.from("refreshToken", "").httpOnly(true).path("/").maxAge(Duration.ZERO); + ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .path("/") + .maxAge(Duration.ZERO); if (secure) { - cookie.secure(true).sameSite("None").domain(".danchu.site"); + cookie.secure(true).sameSite("None"); } else { cookie.secure(false).sameSite("Lax"); } + response.addHeader(HttpHeaders.SET_COOKIE, cookie.build().toString()); } /** - * 현재 세션(토큰)을 무효화합니다. - AccessToken: 블랙리스트 등록 - RefreshToken: Redis 삭제 - 쿠키: refreshToken 즉시 만료 + * 현재 세션(토큰)을 무효화 * - *

logout()을 호출하되 예외가 나도 흡수해서 탈퇴 트랜잭션에 영향 주지 않음. + *

내부적으로 logout()을 호출하되, 예외가 발생해도 삼켜서 다른 트랜잭션에 영향을 주지 않는다. */ public void invalidateCurrentSessionQuietly( HttpServletRequest request, HttpServletResponse response) { try { logout(request, response); } catch (CustomException ignore) { + // 토큰 검증 실패 등 예외가 나더라도 최소한 쿠키는 정리 deleteRefreshTokenCookie(response); } } diff --git a/src/main/java/com/umc/springboot/domain/mission/controller/MissionController.java b/src/main/java/com/umc/springboot/domain/mission/controller/MissionController.java index bc549a0..9574826 100644 --- a/src/main/java/com/umc/springboot/domain/mission/controller/MissionController.java +++ b/src/main/java/com/umc/springboot/domain/mission/controller/MissionController.java @@ -1,6 +1,7 @@ package com.umc.springboot.domain.mission.controller; import com.umc.springboot.domain.mission.dto.request.MissionCreateRequest; +import com.umc.springboot.domain.mission.dto.request.UserMissionUpdateRequest; import com.umc.springboot.domain.mission.dto.response.MissionResponse; import com.umc.springboot.domain.mission.dto.response.UserMissionResponse; import com.umc.springboot.domain.mission.service.MissionService; @@ -9,9 +10,12 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestBody; @@ -40,7 +44,7 @@ public ResponseEntity> createMissionForStore( @Valid @RequestBody MissionCreateRequest request ) { Long userId = SecurityUtil.getCurrentUserId(); - + MissionResponse response = missionService.createMissionForStore(storeId, request); return ResponseEntity.ok(BaseResponse.success("미션 생성에 성공했습니다.", response)); } @@ -57,4 +61,48 @@ public ResponseEntity> challengeMission( UserMissionResponse response = missionService.challengeMission(userId, missionId); return ResponseEntity.ok(BaseResponse.success("미션 도전에 성공했습니다.", response)); } + + /** + * 특정 가게의 미션 목록 조회 + */ + @Operation(summary = "특정 가게의 미션 목록 조회") + @GetMapping(value = "/stores/{storeId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getMissionsByStore( + @PathVariable Long storeId + ) { + List responses = missionService.getMissionsByStore(storeId); + return ResponseEntity.ok(BaseResponse.success("가게 미션 목록 조회에 성공했습니다.", responses)); + } + + /** + * 내가 진행중인 미션 목록 조회 + */ + @Operation(summary = "내가 진행중인 미션 목록 조회") + @GetMapping(value = "/me/in-progress", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getMyInProgressMissions() { + Long userId = SecurityUtil.getCurrentUserId(); + List responses = missionService.getInProgressMissionsByUser(userId); + return ResponseEntity.ok(BaseResponse.success("진행중인 미션 목록 조회에 성공했습니다.", responses)); + } + + /** + * 진행 중인 미션 완료 처리 + */ + @PatchMapping( + value = "/user-missions/{userMissionId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @Operation(summary = "유저 미션 상태 변경") + public ResponseEntity> updateUserMissionStatus( + @PathVariable Long userMissionId, + @RequestBody UserMissionUpdateRequest request + ) { + Long userId = SecurityUtil.getCurrentUserId(); + UserMissionResponse response = + missionService.updateUserMissionStatus(userId, userMissionId, request.getIsCompleted()); + + return ResponseEntity.ok(BaseResponse.success("유저 미션 상태 변경에 성공했습니다.", response)); + } } + diff --git a/src/main/java/com/umc/springboot/domain/mission/dto/request/UserMissionUpdateRequest.java b/src/main/java/com/umc/springboot/domain/mission/dto/request/UserMissionUpdateRequest.java new file mode 100644 index 0000000..132bc53 --- /dev/null +++ b/src/main/java/com/umc/springboot/domain/mission/dto/request/UserMissionUpdateRequest.java @@ -0,0 +1,13 @@ +package com.umc.springboot.domain.mission.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UserMissionUpdateRequest { + + private Boolean isCompleted; +} diff --git a/src/main/java/com/umc/springboot/domain/mission/entity/UserMission.java b/src/main/java/com/umc/springboot/domain/mission/entity/UserMission.java index e6cf537..88b7f53 100644 --- a/src/main/java/com/umc/springboot/domain/mission/entity/UserMission.java +++ b/src/main/java/com/umc/springboot/domain/mission/entity/UserMission.java @@ -39,4 +39,8 @@ public class UserMission extends BaseTimeEntity { @Column(name = "is_completed", nullable = false) private Boolean isCompleted; + + public void updateCompletion(boolean completed) { + this.isCompleted = completed; + } } \ No newline at end of file diff --git a/src/main/java/com/umc/springboot/domain/mission/repository/UserMissionRepository.java b/src/main/java/com/umc/springboot/domain/mission/repository/UserMissionRepository.java index 972a01e..7c2701c 100644 --- a/src/main/java/com/umc/springboot/domain/mission/repository/UserMissionRepository.java +++ b/src/main/java/com/umc/springboot/domain/mission/repository/UserMissionRepository.java @@ -1,9 +1,17 @@ package com.umc.springboot.domain.mission.repository; import com.umc.springboot.domain.mission.entity.UserMission; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface UserMissionRepository extends JpaRepository { boolean existsByUserIdAndMissionId(Long userId, Long missionId); + + // 내가 진행중인 미션 목록 (완료되지 않은 것만) + List findByUserIdAndIsCompletedFalse(Long userId); + + // 특정 유저의 특정 UserMission 조회 (본인 것만 완료할 수 있게) + Optional findByIdAndUserId(Long id, Long userId); } diff --git a/src/main/java/com/umc/springboot/domain/mission/service/MissionService.java b/src/main/java/com/umc/springboot/domain/mission/service/MissionService.java index 2fc4bd0..b2cf69e 100644 --- a/src/main/java/com/umc/springboot/domain/mission/service/MissionService.java +++ b/src/main/java/com/umc/springboot/domain/mission/service/MissionService.java @@ -14,6 +14,8 @@ import com.umc.springboot.domain.user.repository.UserRepository; import com.umc.springboot.global.exception.CustomException; import com.umc.springboot.global.exception.GlobalErrorCode; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -71,4 +73,59 @@ public UserMissionResponse challengeMission(Long userId, Long missionId) { return MissionConverter.toUserMissionResponse(userMission); } + + /** + * 특정 가게의 미션 목록 조회 + */ + @Transactional + public List getMissionsByStore(Long storeId) { + // 가게 존재 여부 체크 (없으면 404) + storeRepository.findById(storeId) + .orElseThrow(() -> new CustomException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + List missions = missionRepository.findByStoreId(storeId); + + return missions.stream() + .map(MissionConverter::toResponse) + .collect(Collectors.toList()); + } + + /** + * 내가 진행중인 미션 목록 조회 (isCompleted = false) + */ + @Transactional + public List getInProgressMissionsByUser(Long userId) { + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + List userMissions = + userMissionRepository.findByUserIdAndIsCompletedFalse(user.getId()); + + return userMissions.stream() + .map(MissionConverter::toUserMissionResponse) + .collect(Collectors.toList()); + } + + /** + * 진행 중인 미션 완료 처리 + */ + @Transactional + public UserMissionResponse updateUserMissionStatus(Long userId, Long userMissionId, + Boolean isCompleted) { + + // 본인 데이터인지 확인 + UserMission userMission = userMissionRepository.findByIdAndUserId(userMissionId, userId) + .orElseThrow(() -> new CustomException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + if (isCompleted == null) { + throw new CustomException(GlobalErrorCode.INVALID_INPUT_VALUE); + } + + // 상태 변경 + userMission.updateCompletion(isCompleted); + + return MissionConverter.toUserMissionResponse(userMission); + } + } diff --git a/src/main/java/com/umc/springboot/domain/review/controller/ReviewController.java b/src/main/java/com/umc/springboot/domain/review/controller/ReviewController.java index b8f36a7..d6e1ac4 100644 --- a/src/main/java/com/umc/springboot/domain/review/controller/ReviewController.java +++ b/src/main/java/com/umc/springboot/domain/review/controller/ReviewController.java @@ -2,6 +2,7 @@ import com.umc.springboot.domain.review.dto.request.ReviewCreateRequest; import com.umc.springboot.domain.review.dto.request.ReviewRequest; +import com.umc.springboot.domain.review.dto.response.PageableResponse; import com.umc.springboot.domain.review.dto.response.ReviewResponse; import com.umc.springboot.domain.review.service.ReviewService; import com.umc.springboot.global.response.BaseResponse; @@ -9,14 +10,14 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; +import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -29,6 +30,7 @@ @RequiredArgsConstructor @RequestMapping("/api/reviews") @Tag(name = "Review", description = "리뷰 조회/작성 API") +@Validated public class ReviewController { private final ReviewService reviewService; @@ -52,29 +54,77 @@ public ResponseEntity> createReview( } /** - * 내 리뷰 목록 조회 + * 내 리뷰 페이징 조회 */ @Operation( - summary = "내 리뷰 목록 조회", + summary = "내 리뷰 페이징 조회", description = """ - 현재 로그인 사용자의 리뷰 목록을 조회합니다. + 현재 로그인 사용자의 리뷰를 페이지 단위로 조회합니다. + - page: 1 이상의 정수(1 페이지부터 시작) + - size: 10 고정 - 필터: storeName(정확 일치), ratingBand(1~5) - - ratingBand=4 ⇒ [4.0,5.0), ratingBand=5 ⇒ 5.0만 - """) + """ + ) @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity>> getReviews( - @RequestParam(required = false) String storeName, - @RequestParam(required = false) @Min(1) @Max(5) Integer ratingBand, - @PageableDefault(size = 10, sort = "createdAt,desc") Pageable pageable + public ResponseEntity>> getMyReviewsPaged( + @RequestParam(required = false) Long storeId, + @RequestParam(name = "page", defaultValue = "1") Integer page ) { Long userId = SecurityUtil.getCurrentUserId(); + int pageIndex = page - 1; + + Pageable pageable = PageRequest.of( + pageIndex, + 10, + Sort.by(Sort.Direction.DESC, "createdAt") + ); + ReviewRequest filter = ReviewRequest.builder() - .storeName(storeName) - .ratingBand(ratingBand) + .storeId(storeId) .build(); - Page page = reviewService.getReviewsByUser(userId, filter, pageable); - return ResponseEntity.ok(BaseResponse.success("리뷰 조회에 성공했습니다.", page)); + Page result = reviewService.getReviewsByUser(userId, filter, pageable); + + PageableResponse body = PageableResponse.from(result); + + return ResponseEntity.ok( + BaseResponse.success("내 리뷰 페이징 조회에 성공했습니다.", body) + ); + } + + + /** + * 가게 리뷰 페이징 조회 + */ + @Operation( + summary = "가게 리뷰 목록 조회", + description = """ + 특정 가게에 대한 리뷰를 페이지 단위로 조회합니다. + - page: 1 이상의 정수(1 페이지부터 시작) + - size: 10으로 고정 + - 정렬: 작성일시(createdAt) 내림차순 + """ + ) + @GetMapping(value = "/stores/{storeId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getStoreReviews( + @PathVariable Long storeId, + @RequestParam(name = "page", defaultValue = "1") Integer page + ) { + int pageIndex = page - 1; + + Pageable pageable = PageRequest.of( + pageIndex, + 10, + Sort.by(Sort.Direction.DESC, "createdAt") + ); + + Page result = reviewService.getReviewsByStore(storeId, pageable); + + PageableResponse body = PageableResponse.from(result); + + return ResponseEntity.ok( + BaseResponse.success("가게 리뷰 조회에 성공했습니다.", body) + ); } } diff --git a/src/main/java/com/umc/springboot/domain/review/dto/request/ReviewRequest.java b/src/main/java/com/umc/springboot/domain/review/dto/request/ReviewRequest.java index fe935f7..6114e98 100644 --- a/src/main/java/com/umc/springboot/domain/review/dto/request/ReviewRequest.java +++ b/src/main/java/com/umc/springboot/domain/review/dto/request/ReviewRequest.java @@ -1,8 +1,6 @@ package com.umc.springboot.domain.review.dto.request; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -15,11 +13,6 @@ @Schema(title = "ReviewRequest", description = "리뷰 조회 필터 요청 DTO") public class ReviewRequest { - @Schema(description = "가게명(정확 일치). 미지정 시 전체", example = "반이학생마라탕마라반") - private String storeName; - - @Min(value = 1, message = "별점대는 최소 1 이상이어야 합니다.") - @Max(value = 5, message = "별점대는 최대 5 이하여야 합니다.") - @Schema(description = "별점대(1~5). 4 ⇒ [4.0,5.0), 5 ⇒ 5.0만", example = "4") - private Integer ratingBand; -} \ No newline at end of file + @Schema(description = "가게 ID. 미지정 시 전체 조회", example = "12") + private Long storeId; +} diff --git a/src/main/java/com/umc/springboot/domain/review/dto/response/PageableResponse.java b/src/main/java/com/umc/springboot/domain/review/dto/response/PageableResponse.java new file mode 100644 index 0000000..af48821 --- /dev/null +++ b/src/main/java/com/umc/springboot/domain/review/dto/response/PageableResponse.java @@ -0,0 +1,54 @@ +package com.umc.springboot.domain.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(title = "Pageable 응답 DTO", description = "페이징이 적용된 응답 형식") +public class PageableResponse { + + @Schema(description = "현재 페이지 데이터 리스트") + private List content; + + @Schema(description = "현재 페이지 번호(1부터 시작)", example = "1") + private int currentPage; + + @Schema(description = "전체 페이지 수", example = "5") + private int totalPages; + + @Schema(description = "전체 요소 수", example = "15") + private long totalElements; + + @Schema(description = "첫 페이지 여부", example = "true") + private boolean isFirst; + + @Schema(description = "마지막 페이지 여부", example = "false") + private boolean isLast; + + @Schema(description = "페이지 당 데이터 수", example = "10") + private int pageSize; + + @Schema(description = "현재 페이지의 요소 수", example = "10") + private int numberOfElements; + + public static PageableResponse from(Page page) { + return PageableResponse.builder() + .content(page.getContent()) + .currentPage(page.getNumber() + 1) // 프론트 기준: 1 페이지부터 + .totalPages(page.getTotalPages()) + .totalElements(page.getTotalElements()) + .isFirst(page.isFirst()) + .isLast(page.isLast()) + .pageSize(page.getSize()) + .numberOfElements(page.getNumberOfElements()) + .build(); + } +} diff --git a/src/main/java/com/umc/springboot/domain/review/repository/ReviewQueryRepositoryImpl.java b/src/main/java/com/umc/springboot/domain/review/repository/ReviewQueryRepositoryImpl.java index edfe3ce..9c596be 100644 --- a/src/main/java/com/umc/springboot/domain/review/repository/ReviewQueryRepositoryImpl.java +++ b/src/main/java/com/umc/springboot/domain/review/repository/ReviewQueryRepositoryImpl.java @@ -4,7 +4,6 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import com.umc.springboot.domain.review.dto.request.ReviewRequest; import com.umc.springboot.domain.review.entity.Review; -import java.math.BigDecimal; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -51,20 +50,9 @@ private BooleanBuilder buildWhere(Long userId, ReviewRequest filter) { return where; } - if (filter.getStoreName() != null && !filter.getStoreName().isBlank()) { - where.and(review.store.name.eq(filter.getStoreName())); - } - - if (filter.getRatingBand() != null) { - int band = filter.getRatingBand(); - BigDecimal lower = BigDecimal.valueOf(band).setScale(1); - - if (band == 5) { - where.and(review.rating.eq(BigDecimal.valueOf(5.0).setScale(1))); - } else { - BigDecimal upper = BigDecimal.valueOf(band + 1).setScale(1); - where.and(review.rating.goe(lower).and(review.rating.lt(upper))); - } + // storeId 필터 + if (filter.getStoreId() != null) { + where.and(review.store.id.eq(filter.getStoreId())); } return where; diff --git a/src/main/java/com/umc/springboot/domain/review/repository/ReviewRepository.java b/src/main/java/com/umc/springboot/domain/review/repository/ReviewRepository.java index f418572..cca1b51 100644 --- a/src/main/java/com/umc/springboot/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/umc/springboot/domain/review/repository/ReviewRepository.java @@ -2,6 +2,8 @@ import com.umc.springboot.domain.review.entity.Review; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -16,4 +18,5 @@ public interface ReviewRepository extends JpaRepository, ReviewQue boolean existsByUserIdAndStoreId(Long userId, Long storeId); + Page findByStoreId(Long storeId, Pageable pageable); } diff --git a/src/main/java/com/umc/springboot/domain/review/service/ReviewService.java b/src/main/java/com/umc/springboot/domain/review/service/ReviewService.java index f7fa12c..a8114c0 100644 --- a/src/main/java/com/umc/springboot/domain/review/service/ReviewService.java +++ b/src/main/java/com/umc/springboot/domain/review/service/ReviewService.java @@ -94,19 +94,12 @@ public ReviewResponse createReview(Long userId, Long storeId, ReviewCreateReques } /** - * 내 리뷰 목록 조회 (기존 코드 유지) + * 내 리뷰 목록 조회 */ - public Page getReviewsByUser(Long userId, ReviewRequest filter, - Pageable pageable) { - - // ratingBand 검증 - if (filter.getRatingBand() != null) { - int band = filter.getRatingBand(); - if (band < 1 || band > 5) { - throw new CustomException(GlobalErrorCode.INVALID_INPUT_VALUE); - } - } + public Page getReviewsByUser(Long userId, + ReviewRequest filter, Pageable pageable) { + // 필터 검증 로직은 필요 없고, 그대로 QueryRepository에 넘김 Page page = reviewRepository.findMyReviews(userId, filter, pageable); // 이하 기존 코드 그대로 @@ -135,4 +128,46 @@ public Page getReviewsByUser(Long userId, ReviewRequest filter, return new PageImpl<>(content, pageable, page.getTotalElements()); } + + + /** + * 가게별 리뷰 목록 페이징 조회 + */ + public Page getReviewsByStore(Long storeId, Pageable pageable) { + + // 가게 존재 여부 검증 + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new CustomException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + // 해당 가게의 리뷰 페이지 조회 + Page page = reviewRepository.findByStoreId(store.getId(), pageable); + + // 리뷰 ID 목록 추출 + List reviewIds = page.getContent().stream() + .map(Review::getId) + .toList(); + + Map> imagesByReviewId = Collections.emptyMap(); + + if (!reviewIds.isEmpty()) { + List allImages = reviewImageRepository.findByReviewIdIn(reviewIds); + + imagesByReviewId = allImages.stream() + .collect(Collectors.groupingBy( + img -> img.getReview().getId(), + Collectors.mapping(ReviewImage::getImageUrl, Collectors.toList()) + )); + } + + Map> finalImagesMap = imagesByReviewId; + + List content = page.getContent().stream() + .map(r -> ReviewConverter.toResponse( + r, + finalImagesMap.getOrDefault(r.getId(), List.of()) + )) + .toList(); + + return new PageImpl<>(content, pageable, page.getTotalElements()); + } } diff --git a/src/main/java/com/umc/springboot/global/config/SecurityConfig.java b/src/main/java/com/umc/springboot/global/config/SecurityConfig.java index 095a6cb..559f9f0 100644 --- a/src/main/java/com/umc/springboot/global/config/SecurityConfig.java +++ b/src/main/java/com/umc/springboot/global/config/SecurityConfig.java @@ -1,37 +1,54 @@ package com.umc.springboot.global.config; -import org.springframework.beans.factory.annotation.Autowired; +import com.umc.springboot.global.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { - // CorsConfig에서 만든 CORS 설정을 주입받기 - @Autowired - private UrlBasedCorsConfigurationSource corsConfigurationSource; + private final UrlBasedCorsConfigurationSource corsConfigurationSource; + private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - return http.csrf(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(corsConfigurationSource)) // CORS 설정 추가 + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) +// .sessionManagement(session -> +// session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) +// ) .authorizeHttpRequests(auth -> auth .requestMatchers( - "/api/auth/**", "/swagger-ui/**", - "/v3/api-docs/**" + "/v3/api-docs/**", + "/favicon.ico" ).permitAll() + .requestMatchers("/api/auth/**").permitAll() + .anyRequest().authenticated() ) + .formLogin(form -> form + .defaultSuccessUrl("/swagger-ui/index.html", true) + .permitAll() + ) + + .httpBasic(Customizer.withDefaults()) + + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } @@ -39,4 +56,4 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/umc/springboot/global/jwt/JwtAuthenticationFilter.java b/src/main/java/com/umc/springboot/global/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ef04b68 --- /dev/null +++ b/src/main/java/com/umc/springboot/global/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,88 @@ +package com.umc.springboot.global.jwt; + +import com.umc.springboot.global.security.CustomUserDetails; +import com.umc.springboot.global.util.RedisUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final RedisUtil redisUtil; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String header = request.getHeader("Authorization"); + + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + + // 1. 블랙리스트 체크 (로그아웃된 토큰인지) + String blacklisted = redisUtil.getData("blacklist:" + token); + if (blacklisted != null) { + log.debug("블랙리스트에 등록된 토큰입니다."); + filterChain.doFilter(request, response); + return; + } + + try { + // 2. 토큰 유효성 검증 + if (jwtProvider.validateToken(token)) { + + Long userId = jwtProvider.extractUserId(token); + + // 3. userId 기반 CustomUserDetails 생성 + CustomUserDetails userDetails = new CustomUserDetails(userId); + + // 4. Authentication 생성 후 SecurityContext에 저장 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + // JwtProvider에서 던지는 CustomException 등은 여기서 로깅만 하고 흘려보냄 + log.debug("JWT 검증 중 예외 발생: {}", e.getMessage()); + } + } + + // 다음 필터로 진행 + filterChain.doFilter(request, response); + } + + /** + * 로그인/회원가입, Swagger 등은 JWT 검증 필터를 타지 않도록 예외 처리 + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getServletPath(); + return path.startsWith("/api/auth") + || path.startsWith("/swagger-ui") + || path.startsWith("/v3/api-docs"); + } +}