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");
+ }
+}