From e0e3bc1ce6ee6f67bbc8fdf7d26d0be94b789b6f Mon Sep 17 00:00:00 2001 From: buddle031 Date: Wed, 13 May 2026 23:02:15 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EB=B3=B4=EA=B4=80=ED=95=A8=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FavoriteLockerCommandService.java | 27 +++ .../FavoriteLockerItemResponse.java | 15 ++ .../FavoriteLockerQueryService.java | 51 +++++ .../application/FavoriteLockerResponse.java | 12 + .../FavoriteLockerStatusResponse.java | 7 + .../zimdugo/locker/domain/FavoriteLocker.java | 15 ++ .../locker/domain/FavoriteLockerPage.java | 12 + .../locker/domain/FavoriteLockerReader.java | 8 + .../locker/domain/FavoriteLockerStore.java | 12 + .../FavoriteLockerOrderUpdateRequest.java | 10 + .../locker/entrypoint/LockerFavoriteApi.java | 125 +++++++++++ .../entrypoint/LockerFavoriteController.java | 91 ++++++++ .../FavoriteLockerReaderAdapter.java | 113 ++++++++++ .../FavoriteLockerStoreAdapter.java | 86 +++++++ .../LockerReportLatestUpdateProjection.java | 10 + .../LockerReportRepository.java | 21 ++ .../LockerReportStoreAdapter.java | 2 +- .../infrastructure/LockerRepository.java | 3 + .../infrastructure/LockerStoreAdapter.java | 2 +- .../UserLockerFavoriteRepository.java | 34 +++ .../persistence/LockerEntity.java | 8 + .../persistence/UserLockerFavoriteEntity.java | 69 ++++++ .../LockerFavoriteControllerTest.java | 212 ++++++++++++++++++ .../LockerReportRepositoryTest.java | 92 ++++++-- .../UserLockerFavoriteRepositoryTest.java | 180 +++++++++++++++ 25 files changed, 1199 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/zimdugo/locker/application/FavoriteLockerCommandService.java create mode 100644 src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java create mode 100644 src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java create mode 100644 src/main/java/com/zimdugo/locker/application/FavoriteLockerResponse.java create mode 100644 src/main/java/com/zimdugo/locker/application/FavoriteLockerStatusResponse.java create mode 100644 src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java create mode 100644 src/main/java/com/zimdugo/locker/domain/FavoriteLockerPage.java create mode 100644 src/main/java/com/zimdugo/locker/domain/FavoriteLockerReader.java create mode 100644 src/main/java/com/zimdugo/locker/domain/FavoriteLockerStore.java create mode 100644 src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java create mode 100644 src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java create mode 100644 src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java create mode 100644 src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java create mode 100644 src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java create mode 100644 src/main/java/com/zimdugo/locker/infrastructure/LockerReportLatestUpdateProjection.java create mode 100644 src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java create mode 100644 src/main/java/com/zimdugo/locker/infrastructure/persistence/UserLockerFavoriteEntity.java create mode 100644 src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java create mode 100644 src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerCommandService.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerCommandService.java new file mode 100644 index 0000000..48b8a6c --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerCommandService.java @@ -0,0 +1,27 @@ +package com.zimdugo.locker.application; + +import com.zimdugo.locker.domain.FavoriteLockerStore; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class FavoriteLockerCommandService { + + private final FavoriteLockerStore favoriteLockerStore; + + public void add(Long userId, Long lockerId) { + favoriteLockerStore.add(userId, lockerId); + } + + public void remove(Long userId, Long lockerId) { + favoriteLockerStore.remove(userId, lockerId); + } + + public void reorder(Long userId, List lockerIds) { + favoriteLockerStore.reorder(userId, lockerIds); + } +} diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java new file mode 100644 index 0000000..e12bf0e --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java @@ -0,0 +1,15 @@ +package com.zimdugo.locker.application; + +import java.time.LocalDateTime; + +public record FavoriteLockerItemResponse( + Long lockerId, + String name, + String roadAddress, + double latitude, + double longitude, + LocalDateTime favoritedAt, + LocalDateTime lastCompletedVoteAt, + Long distanceMeters +) { +} diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java new file mode 100644 index 0000000..91cbc3a --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java @@ -0,0 +1,51 @@ +package com.zimdugo.locker.application; + +import com.zimdugo.locker.domain.FavoriteLocker; +import com.zimdugo.locker.domain.FavoriteLockerPage; +import com.zimdugo.locker.domain.FavoriteLockerReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class FavoriteLockerQueryService { + + private final FavoriteLockerReader favoriteLockerReader; + + @Transactional(readOnly = true) + public FavoriteLockerResponse getFavorites(Long userId, int page, int size, Double latitude, Double longitude) { + FavoriteLockerPage favoritePage = favoriteLockerReader.findByUserId(userId, page, size, latitude, longitude); + + return new FavoriteLockerResponse( + favoritePage.totalCount(), + favoritePage.page(), + favoritePage.size(), + favoritePage.hasNext(), + favoritePage.favorites().stream() + .map(this::toItemResponse) + .toList() + ); + } + + @Transactional(readOnly = true) + public FavoriteLockerStatusResponse getFavoriteStatus(Long userId, Long lockerId) { + return new FavoriteLockerStatusResponse( + lockerId, + favoriteLockerReader.existsByUserIdAndLockerId(userId, lockerId) + ); + } + + private FavoriteLockerItemResponse toItemResponse(FavoriteLocker favorite) { + return new FavoriteLockerItemResponse( + favorite.lockerId(), + favorite.name(), + favorite.roadAddress(), + favorite.latitude(), + favorite.longitude(), + favorite.favoritedAt(), + favorite.lastCompletedVoteAt(), + favorite.distanceMeters() + ); + } +} diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerResponse.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerResponse.java new file mode 100644 index 0000000..ac8c289 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerResponse.java @@ -0,0 +1,12 @@ +package com.zimdugo.locker.application; + +import java.util.List; + +public record FavoriteLockerResponse( + long totalCount, + int page, + int size, + boolean hasNext, + List items +) { +} diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerStatusResponse.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerStatusResponse.java new file mode 100644 index 0000000..f9f7e38 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerStatusResponse.java @@ -0,0 +1,7 @@ +package com.zimdugo.locker.application; + +public record FavoriteLockerStatusResponse( + Long lockerId, + boolean favorite +) { +} diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java new file mode 100644 index 0000000..cfbc00e --- /dev/null +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java @@ -0,0 +1,15 @@ +package com.zimdugo.locker.domain; + +import java.time.LocalDateTime; + +public record FavoriteLocker( + Long lockerId, + String name, + String roadAddress, + double latitude, + double longitude, + LocalDateTime favoritedAt, + LocalDateTime lastCompletedVoteAt, + Long distanceMeters +) { +} diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLockerPage.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerPage.java new file mode 100644 index 0000000..9e1e015 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerPage.java @@ -0,0 +1,12 @@ +package com.zimdugo.locker.domain; + +import java.util.List; + +public record FavoriteLockerPage( + long totalCount, + int page, + int size, + boolean hasNext, + List favorites +) { +} diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLockerReader.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerReader.java new file mode 100644 index 0000000..ca52786 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerReader.java @@ -0,0 +1,8 @@ +package com.zimdugo.locker.domain; + +public interface FavoriteLockerReader { + + FavoriteLockerPage findByUserId(Long userId, int page, int size, Double latitude, Double longitude); + + boolean existsByUserIdAndLockerId(Long userId, Long lockerId); +} diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLockerStore.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerStore.java new file mode 100644 index 0000000..94e743d --- /dev/null +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerStore.java @@ -0,0 +1,12 @@ +package com.zimdugo.locker.domain; + +import java.util.List; + +public interface FavoriteLockerStore { + + void add(Long userId, Long lockerId); + + void remove(Long userId, Long lockerId); + + void reorder(Long userId, List lockerIds); +} diff --git a/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java b/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java new file mode 100644 index 0000000..567fd55 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java @@ -0,0 +1,10 @@ +package com.zimdugo.locker.entrypoint; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record FavoriteLockerOrderUpdateRequest( + @NotEmpty(message = "validation.not_empty") + List lockerIds +) { +} diff --git a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java new file mode 100644 index 0000000..c6ccee0 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java @@ -0,0 +1,125 @@ +package com.zimdugo.locker.entrypoint; + +import com.zimdugo.core.response.RestResponse; +import com.zimdugo.locker.application.FavoriteLockerResponse; +import com.zimdugo.locker.application.FavoriteLockerStatusResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +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; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Locker Favorite", description = "보관함 즐겨찾기 API") +public interface LockerFavoriteApi { + + @Operation( + summary = "내 즐겨찾기 보관함 조회", + description = "로그인 사용자가 즐겨찾기한 보관함 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 페이지 요청"), + @ApiResponse(responseCode = "401", description = "로그인 필요") + }) + @GetMapping("/me/favorite-lockers") + ResponseEntity> getMyFavoriteLockers( + Authentication authentication, + @RequestParam(name = "page", defaultValue = "0") + @Parameter(description = "페이지 번호(0부터 시작)", example = "0") + @Min(0) + int page, + @RequestParam(name = "size", defaultValue = "20") + @Parameter(description = "페이지 크기", example = "20") + @Min(1) + @Max(50) + int size, + @RequestParam(name = "lat", required = false) + @Parameter(description = "현재 사용자 위도", example = "37.556") + @DecimalMin(value = "-90.0") + @DecimalMax(value = "90.0") + Double latitude, + @RequestParam(name = "lng", required = false) + @Parameter(description = "현재 사용자 경도", example = "126.923") + @DecimalMin(value = "-180.0") + @DecimalMax(value = "180.0") + Double longitude + ); + + @Operation( + summary = "보관함 즐겨찾기 상태 조회", + description = "로그인 사용자의 특정 보관함 즐겨찾기 등록 여부를 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "로그인 필요") + }) + @GetMapping("/me/favorite-lockers/{lockerId}/status") + ResponseEntity> getFavoriteLockerStatus( + Authentication authentication, + @PathVariable("lockerId") + @Parameter(description = "보관함 ID", example = "10") + Long lockerId + ); + + @Operation( + summary = "즐겨찾기 순서 조정", + description = "로그인 사용자의 전체 즐겨찾기 보관함 순서를 전달한 순서대로 재정렬합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "순서 변경 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 순서 변경 요청"), + @ApiResponse(responseCode = "401", description = "로그인 필요") + }) + @PatchMapping("/me/favorite-lockers/order") + ResponseEntity> reorderFavoriteLockers( + Authentication authentication, + @Valid @RequestBody FavoriteLockerOrderUpdateRequest request + ); + + @Operation( + summary = "보관함 즐겨찾기 등록", + description = "로그인 사용자의 즐겨찾기 보관함으로 등록합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "등록 성공"), + @ApiResponse(responseCode = "401", description = "로그인 필요"), + @ApiResponse(responseCode = "404", description = "보관함 없음") + }) + @PostMapping("/me/favorite-lockers/{lockerId}") + ResponseEntity> addFavoriteLocker( + Authentication authentication, + @PathVariable("lockerId") + @Parameter(description = "보관함 ID", example = "10") + Long lockerId + ); + + @Operation( + summary = "보관함 즐겨찾기 해제", + description = "로그인 사용자의 즐겨찾기 보관함에서 제외합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "해제 성공"), + @ApiResponse(responseCode = "401", description = "로그인 필요") + }) + @DeleteMapping("/me/favorite-lockers/{lockerId}") + ResponseEntity> removeFavoriteLocker( + Authentication authentication, + @PathVariable("lockerId") + @Parameter(description = "보관함 ID", example = "10") + Long lockerId + ); +} diff --git a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java new file mode 100644 index 0000000..7a5c41c --- /dev/null +++ b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java @@ -0,0 +1,91 @@ +package com.zimdugo.locker.entrypoint; + +import com.zimdugo.core.exception.BusinessException; +import com.zimdugo.core.exception.ErrorCode; +import com.zimdugo.core.response.RestResponse; +import com.zimdugo.core.response.SuccessCode; +import com.zimdugo.locker.application.FavoriteLockerCommandService; +import com.zimdugo.locker.application.FavoriteLockerQueryService; +import com.zimdugo.locker.application.FavoriteLockerResponse; +import com.zimdugo.locker.application.FavoriteLockerStatusResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class LockerFavoriteController implements LockerFavoriteApi { + + private final FavoriteLockerQueryService favoriteLockerQueryService; + private final FavoriteLockerCommandService favoriteLockerCommandService; + + @Override + public ResponseEntity> getMyFavoriteLockers( + Authentication authentication, + int page, + int size, + Double latitude, + Double longitude + ) { + FavoriteLockerResponse response = favoriteLockerQueryService.getFavorites( + extractUserId(authentication), + page, + size, + latitude, + longitude + ); + return ResponseEntity.ok(RestResponse.of(SuccessCode.OK, response)); + } + + @Override + public ResponseEntity> getFavoriteLockerStatus( + Authentication authentication, + Long lockerId + ) { + FavoriteLockerStatusResponse response = favoriteLockerQueryService.getFavoriteStatus( + extractUserId(authentication), + lockerId + ); + return ResponseEntity.ok(RestResponse.of(SuccessCode.OK, response)); + } + + @Override + public ResponseEntity> reorderFavoriteLockers( + Authentication authentication, + FavoriteLockerOrderUpdateRequest request + ) { + favoriteLockerCommandService.reorder(extractUserId(authentication), request.lockerIds()); + return ResponseEntity.ok(RestResponse.ok(SuccessCode.OK)); + } + + @Override + public ResponseEntity> addFavoriteLocker( + Authentication authentication, + Long lockerId + ) { + favoriteLockerCommandService.add(extractUserId(authentication), lockerId); + return ResponseEntity.ok(RestResponse.ok(SuccessCode.OK)); + } + + @Override + public ResponseEntity> removeFavoriteLocker( + Authentication authentication, + Long lockerId + ) { + favoriteLockerCommandService.remove(extractUserId(authentication), lockerId); + return ResponseEntity.ok(RestResponse.ok(SuccessCode.OK)); + } + + private Long extractUserId(Authentication authentication) { + if (authentication == null || authentication.getName() == null) { + throw new BusinessException(ErrorCode.AUTHENTICATED_USER_NOT_FOUND); + } + + return Long.valueOf(authentication.getName()); + } +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java new file mode 100644 index 0000000..fc4228b --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java @@ -0,0 +1,113 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.locker.domain.FavoriteLocker; +import com.zimdugo.locker.domain.FavoriteLockerPage; +import com.zimdugo.locker.domain.FavoriteLockerReader; +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import static java.util.stream.Collectors.toMap; + +@Component +@RequiredArgsConstructor +public class FavoriteLockerReaderAdapter implements FavoriteLockerReader { + + private static final double EARTH_RADIUS_METERS = 6_371_000; + + private final UserLockerFavoriteRepository userLockerFavoriteRepository; + private final LockerReportRepository lockerReportRepository; + + @Override + public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double latitude, Double longitude) { + Page favorites = + userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( + userId, + PageRequest.of(page, size) + ); + Map lastCompletedVoteAtByLockerId = + getLastCompletedVoteAtByLockerId(favorites.getContent()); + + return new FavoriteLockerPage( + favorites.getTotalElements(), + favorites.getNumber(), + favorites.getSize(), + favorites.hasNext(), + favorites.getContent().stream() + .map(favorite -> toFavoriteLocker(favorite, latitude, longitude, lastCompletedVoteAtByLockerId)) + .toList() + ); + } + + @Override + public boolean existsByUserIdAndLockerId(Long userId, Long lockerId) { + return userLockerFavoriteRepository.existsByUserIdAndLockerIdAndLockerDeletedFalse(userId, lockerId); + } + + private FavoriteLocker toFavoriteLocker( + UserLockerFavoriteEntity favorite, + Double latitude, + Double longitude, + Map lastCompletedVoteAtByLockerId + ) { + Long lockerId = favorite.getLocker().getId(); + return new FavoriteLocker( + lockerId, + favorite.getLocker().getName(), + favorite.getLocker().getRoadAddress(), + favorite.getLocker().getLatitude(), + favorite.getLocker().getLongitude(), + favorite.getCreatedAt(), + lastCompletedVoteAtByLockerId.get(lockerId), + calculateDistanceMeters( + latitude, + longitude, + favorite.getLocker().getLatitude(), + favorite.getLocker().getLongitude() + ) + ); + } + + private Map getLastCompletedVoteAtByLockerId(List favorites) { + List lockerIds = favorites.stream() + .map(favorite -> favorite.getLocker().getId()) + .toList(); + + if (lockerIds.isEmpty()) { + return Map.of(); + } + + return lockerReportRepository.findLatestCompletedVoteAtByLockerIdIn(lockerIds).stream() + .collect(toMap( + LockerReportLatestUpdateProjection::getLockerId, + LockerReportLatestUpdateProjection::getLastCompletedVoteAt + )); + } + + private Long calculateDistanceMeters( + Double latitude, + Double longitude, + double lockerLatitude, + double lockerLongitude + ) { + if (latitude == null || longitude == null) { + return null; + } + + double latitudeDelta = Math.toRadians(lockerLatitude - latitude); + double longitudeDelta = Math.toRadians(lockerLongitude - longitude); + double startLatitude = Math.toRadians(latitude); + double endLatitude = Math.toRadians(lockerLatitude); + + double a = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2) + + Math.cos(startLatitude) * Math.cos(endLatitude) + * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return Math.round(EARTH_RADIUS_METERS * c); + } +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java new file mode 100644 index 0000000..841319e --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java @@ -0,0 +1,86 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.core.exception.BusinessException; +import com.zimdugo.core.exception.ErrorCode; +import com.zimdugo.locker.domain.FavoriteLockerStore; +import com.zimdugo.locker.infrastructure.persistence.LockerEntity; +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.zimdugo.user.infrastructure.UserRepository; +import com.zimdugo.user.infrastructure.persistence.UserEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FavoriteLockerStoreAdapter implements FavoriteLockerStore { + + private final UserLockerFavoriteRepository userLockerFavoriteRepository; + private final UserRepository userRepository; + private final LockerRepository lockerRepository; + + @Override + public void add(Long userId, Long lockerId) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + LockerEntity locker = lockerRepository.findByIdAndDeletedFalse(lockerId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + int displayOrder = nextDisplayOrder(userId); + + try { + userLockerFavoriteRepository.save(new UserLockerFavoriteEntity(user, locker, displayOrder)); + } catch (DataIntegrityViolationException e) { + // Concurrent favorite requests may race on the unique constraint. + if (userLockerFavoriteRepository.existsByUserIdAndLockerIdAndLockerDeletedFalse(userId, lockerId)) { + return; + } + throw e; + } + } + + @Override + public void remove(Long userId, Long lockerId) { + userLockerFavoriteRepository.deleteByUserIdAndLockerId(userId, lockerId); + } + + @Override + public void reorder(Long userId, List lockerIds) { + long favoriteCount = userLockerFavoriteRepository.countByUserIdAndLockerDeletedFalse(userId); + if (favoriteCount != lockerIds.size()) { + throw new IllegalArgumentException("Favorite locker order request must include all favorites."); + } + + List favorites = + userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseAndLockerIdIn( + userId, + lockerIds + ); + if (favorites.size() != lockerIds.size()) { + throw new IllegalArgumentException("Favorite locker order request contains unknown locker ids."); + } + + Map favoriteByLockerId = new HashMap<>(); + for (UserLockerFavoriteEntity favorite : favorites) { + favoriteByLockerId.put(favorite.getLocker().getId(), favorite); + } + + for (int index = 0; index < lockerIds.size(); index++) { + Long lockerId = lockerIds.get(index); + UserLockerFavoriteEntity favorite = favoriteByLockerId.remove(lockerId); + if (favorite == null) { + throw new IllegalArgumentException("Favorite locker order request contains duplicate locker ids."); + } + favorite.updateDisplayOrder(index); + } + } + + private int nextDisplayOrder(Long userId) { + return userLockerFavoriteRepository.findTopByUserIdOrderByDisplayOrderDesc(userId) + .map(UserLockerFavoriteEntity::getDisplayOrder) + .map(order -> order + 1) + .orElse(0); + } +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportLatestUpdateProjection.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportLatestUpdateProjection.java new file mode 100644 index 0000000..148f1a8 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportLatestUpdateProjection.java @@ -0,0 +1,10 @@ +package com.zimdugo.locker.infrastructure; + +import java.time.LocalDateTime; + +public interface LockerReportLatestUpdateProjection { + + Long getLockerId(); + + LocalDateTime getLastCompletedVoteAt(); +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportRepository.java index fd413ae..4dba0ef 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportRepository.java @@ -1,7 +1,28 @@ package com.zimdugo.locker.infrastructure; import com.zimdugo.locker.infrastructure.persistence.LockerReportEntity; +import java.util.Collection; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface LockerReportRepository extends JpaRepository { + + @EntityGraph(attributePaths = "locker") + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + @Query(""" + SELECT lr.locker.id AS lockerId, MAX(lr.updatedAt) AS lastCompletedVoteAt + FROM LockerReportEntity lr + WHERE lr.locker.id IN :lockerIds + AND lr.status = com.zimdugo.locker.domain.LockerReportStatus.COMPLETED + GROUP BY lr.locker.id + """) + List findLatestCompletedVoteAtByLockerIdIn( + @Param("lockerIds") Collection lockerIds + ); } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java index 50bb523..03c1757 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java @@ -22,7 +22,7 @@ public class LockerReportStoreAdapter implements LockerReportStore { @Override public SavedLockerReport create(LockerReportCreateInfo createInfo) { - LockerEntity locker = lockerRepository.findById(createInfo.lockerId()) + LockerEntity locker = lockerRepository.findByIdAndDeletedFalse(createInfo.lockerId()) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); UserEntity user = userRepository.findById(createInfo.userId()) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java index edc6e52..2c94df4 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java @@ -8,6 +8,8 @@ public interface LockerRepository extends JpaRepository { + java.util.Optional findByIdAndDeletedFalse(Long id); + @Query(value = """ -- 조회 기준 좌표 WITH target AS ( @@ -24,6 +26,7 @@ nearby AS ( FROM lockers l CROSS JOIN target WHERE ST_DWithin(l.location, target.point, :radiusMeters) + AND l.deleted = false ) SELECT nearby.id AS id, diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java index 906b823..ba80270 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java @@ -24,7 +24,7 @@ public ReportLocker create(String name, String roadAddress, double latitude, dou @Override public ReportLocker getById(Long id) { - return lockerRepository.findById(id) + return lockerRepository.findByIdAndDeletedFalse(id) .map(this::toDomain) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java new file mode 100644 index 0000000..fce1a9c --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java @@ -0,0 +1,34 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserLockerFavoriteRepository extends JpaRepository { + + @EntityGraph(attributePaths = "locker") + Page findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( + Long userId, + Pageable pageable + ); + + boolean existsByUserIdAndLockerIdAndLockerDeletedFalse(Long userId, Long lockerId); + + void deleteByUserIdAndLockerId(Long userId, Long lockerId); + + java.util.List findByUserIdAndLockerIdIn( + Long userId, + java.util.Collection lockerIds + ); + + java.util.List findByUserIdAndLockerDeletedFalseAndLockerIdIn( + Long userId, + java.util.Collection lockerIds + ); + + long countByUserIdAndLockerDeletedFalse(Long userId); + + java.util.Optional findTopByUserIdOrderByDisplayOrderDesc(Long userId); +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/persistence/LockerEntity.java b/src/main/java/com/zimdugo/locker/infrastructure/persistence/LockerEntity.java index d9eaa8f..4a2556b 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/persistence/LockerEntity.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/persistence/LockerEntity.java @@ -32,10 +32,18 @@ public class LockerEntity { @Column(nullable = false) private double longitude; + @Column(nullable = false, columnDefinition = "boolean default false") + private boolean deleted; + public LockerEntity(String name, String roadAddress, double latitude, double longitude) { this.name = name; this.roadAddress = roadAddress; this.latitude = latitude; this.longitude = longitude; + this.deleted = false; + } + + public void markDeleted() { + this.deleted = true; } } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/persistence/UserLockerFavoriteEntity.java b/src/main/java/com/zimdugo/locker/infrastructure/persistence/UserLockerFavoriteEntity.java new file mode 100644 index 0000000..79211c8 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/persistence/UserLockerFavoriteEntity.java @@ -0,0 +1,69 @@ +package com.zimdugo.locker.infrastructure.persistence; + +import com.zimdugo.user.infrastructure.persistence.UserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "user_locker_favorites", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_locker_favorites_user_locker", + columnNames = {"user_id", "locker_id"} + ), + indexes = { + @Index(name = "idx_user_locker_favorites_user_id", columnList = "user_id"), + @Index(name = "idx_user_locker_favorites_locker_id", columnList = "locker_id") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserLockerFavoriteEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "locker_id", nullable = false) + private LockerEntity locker; + + @Column(nullable = false) + private int displayOrder; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public UserLockerFavoriteEntity(UserEntity user, LockerEntity locker, int displayOrder) { + this.user = user; + this.locker = locker; + this.displayOrder = displayOrder; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java b/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java new file mode 100644 index 0000000..116625f --- /dev/null +++ b/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java @@ -0,0 +1,212 @@ +package com.zimdugo.locker.entrypoint; + +import com.zimdugo.auth.entrypoint.JwtAuthenticationFilter; +import com.zimdugo.auth.entrypoint.OAuth2CallbackUrlCaptureFilter; +import com.zimdugo.common.config.SecurityConfig; +import com.zimdugo.locker.application.FavoriteLockerCommandService; +import com.zimdugo.locker.application.FavoriteLockerItemResponse; +import com.zimdugo.locker.application.FavoriteLockerQueryService; +import com.zimdugo.locker.application.FavoriteLockerResponse; +import com.zimdugo.locker.application.FavoriteLockerStatusResponse; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration; +import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest( + controllers = LockerFavoriteController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = SecurityConfig.class + ), + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + SecurityFilterAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class, + OAuth2ClientAutoConfiguration.class, + OAuth2ClientWebSecurityAutoConfiguration.class + } +) +@AutoConfigureMockMvc(addFilters = false) +class LockerFavoriteControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private FavoriteLockerQueryService favoriteLockerQueryService; + + @MockitoBean + private FavoriteLockerCommandService favoriteLockerCommandService; + + @MockitoBean + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @MockitoBean + private OAuth2CallbackUrlCaptureFilter oAuth2CallbackUrlCaptureFilter; + + @Test + @DisplayName("인증된 사용자의 즐겨찾기 보관함 목록을 최신순 페이지로 조회한다") + void getMyFavoriteLockersReturnsOk() throws Exception { + given(favoriteLockerQueryService.getFavorites(1L, 0, 20, 37.555, 126.922)) + .willReturn(new FavoriteLockerResponse( + 11, + 0, + 20, + false, + List.of(new FavoriteLockerItemResponse( + 10L, + "홍대입구역 보관함", + "서울 마포구 양화로 160", + 37.556, + 126.923, + LocalDateTime.of(2026, 5, 11, 10, 30), + LocalDateTime.of(2026, 5, 10, 18, 0), + 120L + )) + )); + + mockMvc.perform(get("/api/v1/me/favorite-lockers") + .param("lat", "37.555") + .param("lng", "126.922") + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")) + .andExpect(jsonPath("$.data.totalCount").value(11)) + .andExpect(jsonPath("$.data.items[0].lockerId").value(10)) + .andExpect(jsonPath("$.data.items[0].poiName").value("홍대입구역 보관함")) + .andExpect(jsonPath("$.data.items[0].roadAddress").value("서울 마포구 양화로 160")) + .andExpect(jsonPath("$.data.items[0].distanceMeters").value(120)) + .andExpect(jsonPath("$.data.items[0].lastCompletedVoteAt").exists()); + } + + @Test + @DisplayName("인증 정보 없이 즐겨찾기 목록을 조회하면 401을 반환한다") + void getMyFavoriteLockersWithoutAuthenticationReturnsUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/me/favorite-lockers")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A4011")) + .andExpect(jsonPath("$.message").value("auth.authenticated_user_not_found")); + } + + @Test + @DisplayName("특정 보관함의 즐겨찾기 상태를 조회한다") + void getFavoriteLockerStatusReturnsOk() throws Exception { + given(favoriteLockerQueryService.getFavoriteStatus(1L, 10L)) + .willReturn(new FavoriteLockerStatusResponse(10L, true)); + + mockMvc.perform(get("/api/v1/me/favorite-lockers/{lockerId}/status", 10L) + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")) + .andExpect(jsonPath("$.data.lockerId").value(10)) + .andExpect(jsonPath("$.data.favorite").value(true)); + } + + @Test + @DisplayName("삭제된 보관함은 즐겨찾기 상태 조회에서 미등록으로 본다") + void getFavoriteLockerStatusReturnsFalseForDeletedLocker() throws Exception { + given(favoriteLockerQueryService.getFavoriteStatus(1L, 99L)) + .willReturn(new FavoriteLockerStatusResponse(99L, false)); + + mockMvc.perform(get("/api/v1/me/favorite-lockers/{lockerId}/status", 99L) + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.lockerId").value(99)) + .andExpect(jsonPath("$.data.favorite").value(false)); + } + + @Test + @DisplayName("인증 정보 없이 즐겨찾기 상태를 조회하면 401을 반환한다") + void getFavoriteLockerStatusWithoutAuthenticationReturnsUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/me/favorite-lockers/{lockerId}/status", 10L)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A4011")) + .andExpect(jsonPath("$.message").value("auth.authenticated_user_not_found")); + } + + @Test + @DisplayName("즐겨찾기 순서를 변경한다") + void reorderFavoriteLockersReturnsOk() throws Exception { + mockMvc.perform(patch("/api/v1/me/favorite-lockers/order") + .principal(authenticatedUser()) + .contentType("application/json") + .content(""" + { + "lockerIds": [20, 10, 30] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")); + + verify(favoriteLockerCommandService).reorder(1L, List.of(20L, 10L, 30L)); + } + + @Test + @DisplayName("비어 있는 순서 변경 요청은 400을 반환한다") + void reorderFavoriteLockersWithEmptyIdsReturnsBadRequest() throws Exception { + mockMvc.perform(patch("/api/v1/me/favorite-lockers/order") + .principal(authenticatedUser()) + .contentType("application/json") + .content(""" + { + "lockerIds": [] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + + @Test + @DisplayName("보관함을 즐겨찾기로 등록한다") + void addFavoriteLockerReturnsOk() throws Exception { + mockMvc.perform(post("/api/v1/me/favorite-lockers/{lockerId}", 10L) + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")); + + verify(favoriteLockerCommandService).add(1L, 10L); + } + + @Test + @DisplayName("보관함 즐겨찾기를 해제한다") + void removeFavoriteLockerReturnsOk() throws Exception { + mockMvc.perform(delete("/api/v1/me/favorite-lockers/{lockerId}", 10L) + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")); + + verify(favoriteLockerCommandService).remove(1L, 10L); + } + + private UsernamePasswordAuthenticationToken authenticatedUser() { + return new UsernamePasswordAuthenticationToken( + "1", + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + } +} diff --git a/src/test/java/com/zimdugo/locker/infrastructure/LockerReportRepositoryTest.java b/src/test/java/com/zimdugo/locker/infrastructure/LockerReportRepositoryTest.java index ff970ab..e603998 100644 --- a/src/test/java/com/zimdugo/locker/infrastructure/LockerReportRepositoryTest.java +++ b/src/test/java/com/zimdugo/locker/infrastructure/LockerReportRepositoryTest.java @@ -9,6 +9,7 @@ import com.zimdugo.user.infrastructure.persistence.UserEntity; import jakarta.persistence.EntityManager; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +30,7 @@ class LockerReportRepositoryTest { @DisplayName("선택 입력값이 없어도 제보 원본 정보를 저장한다") void saveReportWithOptionalFieldsNull() { UserEntity user = saveUser(); - LockerEntity locker = saveLocker(); + LockerEntity locker = saveLocker("홍대입구역 보관함"); LockerReportEntity report = lockerReportRepository.save(new LockerReportEntity( locker, @@ -66,11 +67,74 @@ void saveReportWithOptionalFieldsNull() { assertThat(savedReport.getUpdatedAt()).isNotNull(); } + @Test + @DisplayName("보관함별 최신 완료 제보 시각을 조회한다") + void findLatestCompletedVoteAtByLockerIdInReturnsLatestUpdatedAt() { + UserEntity user = saveUser("vote-user@example.com", "vote-user"); + LockerEntity targetLocker = saveLocker("대상 보관함"); + LockerEntity otherLocker = saveLocker("다른 보관함"); + + LockerReportEntity olderReport = saveReport(user, targetLocker, "이전 제보"); + LockerReportEntity latestReport = saveReport(user, targetLocker, "최신 제보"); + LockerReportEntity otherReport = saveReport(user, otherLocker, "다른 제보"); + entityManager.flush(); + + updateUpdatedAt(olderReport.getId(), LocalDateTime.of(2026, 5, 10, 10, 0)); + updateUpdatedAt(latestReport.getId(), LocalDateTime.of(2026, 5, 13, 19, 30)); + updateUpdatedAt(otherReport.getId(), LocalDateTime.of(2026, 5, 9, 9, 0)); + entityManager.clear(); + + List result = + lockerReportRepository.findLatestCompletedVoteAtByLockerIdIn(List.of(targetLocker.getId())); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getLockerId()).isEqualTo(targetLocker.getId()); + assertThat(result.get(0).getLastCompletedVoteAt()).isEqualTo(LocalDateTime.of(2026, 5, 13, 19, 30)); + } + + private LockerReportEntity saveReport(UserEntity user, LockerEntity locker, String name) { + LockerReportEntity report = new LockerReportEntity( + locker, + user, + DuplicateHandlingType.CREATE_NEW, + name, + "서울 마포구 양화로 160", + null, + null, + null, + null, + null, + null, + null, + null, + null, + 37.556, + 126.923 + ); + entityManager.persist(report); + return report; + } + + private void updateUpdatedAt(Long reportId, LocalDateTime updatedAt) { + entityManager.createNativeQuery(""" + UPDATE locker_reports + SET updated_at = :updatedAt + WHERE id = :reportId + """) + .setParameter("updatedAt", updatedAt) + .setParameter("reportId", reportId) + .executeUpdate(); + } + private UserEntity saveUser() { + return saveUser("reporter@example.com", "reporter"); + } + + private UserEntity saveUser(String email, String nickname) { UserEntity user = new UserEntity( null, - "reporter@example.com", - "reporter", + email, + nickname, null, UserStatus.ACTIVE, UserRole.USER, @@ -81,18 +145,14 @@ private UserEntity saveUser() { return user; } - private LockerEntity saveLocker() { - entityManager.createNativeQuery(""" - INSERT INTO lockers (name, road_address, latitude, longitude) - VALUES ('홍대입구역 보관함', '서울 마포구 양화로 160', 37.556, 126.923) - """).executeUpdate(); - - Long lockerId = ((Number) entityManager.createNativeQuery(""" - SELECT id - FROM lockers - WHERE name = '홍대입구역 보관함' - """).getSingleResult()).longValue(); - - return entityManager.getReference(LockerEntity.class, lockerId); + private LockerEntity saveLocker(String name) { + LockerEntity locker = new LockerEntity( + name, + "서울 마포구 양화로 160", + 37.556, + 126.923 + ); + entityManager.persist(locker); + return locker; } } diff --git a/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java b/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java new file mode 100644 index 0000000..a384cd3 --- /dev/null +++ b/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java @@ -0,0 +1,180 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.locker.infrastructure.persistence.LockerEntity; +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import com.zimdugo.user.domain.UserRole; +import com.zimdugo.user.domain.UserStatus; +import com.zimdugo.user.infrastructure.persistence.UserEntity; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class UserLockerFavoriteRepositoryTest { + + @Autowired + private UserLockerFavoriteRepository userLockerFavoriteRepository; + + @Autowired + private EntityManager entityManager; + + @Test + @DisplayName("사용자별 즐겨찾기 보관함을 표시 순서 기준으로 조회한다") + void findByUserIdOrderByDisplayOrderAscCreatedAtDescReturnsOnlyUserFavorites() { + UserEntity user = saveUser("favorite-user@example.com", "favorite-user"); + UserEntity otherUser = saveUser("other-favorite-user@example.com", "other-favorite-user"); + LockerEntity firstLocker = saveLocker("첫 번째 보관함"); + LockerEntity secondLocker = saveLocker("두 번째 보관함"); + LockerEntity otherLocker = saveLocker("다른 사용자의 보관함"); + + saveFavorite(user, firstLocker, 1); + saveFavorite(user, secondLocker, 0); + saveFavorite(otherUser, otherLocker, 0); + entityManager.flush(); + entityManager.clear(); + + Page result = + userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( + user.getId(), + PageRequest.of(0, 10) + ); + + assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getContent()) + .extracting(favorite -> favorite.getLocker().getName()) + .containsExactly("두 번째 보관함", "첫 번째 보관함"); + } + + @Test + @DisplayName("삭제된 보관함은 즐겨찾기 목록 조회에서 제외한다") + void findByUserIdExcludesDeletedLockers() { + UserEntity user = saveUser("deleted-locker-user@example.com", "deleted-locker-user"); + LockerEntity activeLocker = saveLocker("정상 보관함"); + LockerEntity deletedLocker = saveLocker("삭제된 보관함"); + + saveFavorite(user, activeLocker, 0); + saveFavorite(user, deletedLocker, 1); + deletedLocker.markDeleted(); + entityManager.flush(); + entityManager.clear(); + + Page result = + userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( + user.getId(), + PageRequest.of(0, 10) + ); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent()) + .extracting(favorite -> favorite.getLocker().getName()) + .containsExactly("정상 보관함"); + } + + @Test + @DisplayName("사용자와 보관함 ID로 즐겨찾기를 삭제한다") + void deleteByUserIdAndLockerIdDeletesFavorite() { + UserEntity user = saveUser("delete-favorite-user@example.com", "delete-favorite-user"); + LockerEntity locker = saveLocker("삭제 대상 보관함"); + saveFavorite(user, locker, 0); + entityManager.flush(); + + userLockerFavoriteRepository.deleteByUserIdAndLockerId(user.getId(), locker.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(userLockerFavoriteRepository.existsByUserIdAndLockerIdAndLockerDeletedFalse( + user.getId(), + locker.getId() + )) + .isFalse(); + } + + @Test + @DisplayName("주어진 보관함 ID 목록으로 사용자의 즐겨찾기를 조회한다") + void findByUserIdAndLockerIdInReturnsMatchingFavorites() { + UserEntity user = saveUser("match-user@example.com", "match-user"); + LockerEntity firstLocker = saveLocker("첫 번째 보관함"); + LockerEntity secondLocker = saveLocker("두 번째 보관함"); + LockerEntity thirdLocker = saveLocker("세 번째 보관함"); + + saveFavorite(user, firstLocker, 0); + saveFavorite(user, secondLocker, 1); + saveFavorite(user, thirdLocker, 2); + entityManager.flush(); + entityManager.clear(); + + List favorites = userLockerFavoriteRepository.findByUserIdAndLockerIdIn( + user.getId(), + List.of(firstLocker.getId(), thirdLocker.getId()) + ); + + assertThat(favorites) + .extracting(favorite -> favorite.getLocker().getId()) + .containsExactlyInAnyOrder(firstLocker.getId(), thirdLocker.getId()); + } + + @Test + @DisplayName("삭제된 보관함은 reorder 대상 조회와 개수에서 제외한다") + void activeFavoriteQueriesExcludeDeletedLockers() { + UserEntity user = saveUser("reorder-user@example.com", "reorder-user"); + LockerEntity activeLocker = saveLocker("정상 보관함"); + LockerEntity deletedLocker = saveLocker("삭제된 보관함"); + + saveFavorite(user, activeLocker, 0); + saveFavorite(user, deletedLocker, 1); + deletedLocker.markDeleted(); + entityManager.flush(); + entityManager.clear(); + + List favorites = + userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseAndLockerIdIn( + user.getId(), + List.of(activeLocker.getId(), deletedLocker.getId()) + ); + + assertThat(userLockerFavoriteRepository.countByUserIdAndLockerDeletedFalse(user.getId())).isEqualTo(1); + assertThat(favorites) + .extracting(favorite -> favorite.getLocker().getName()) + .containsExactly("정상 보관함"); + } + + private UserLockerFavoriteEntity saveFavorite(UserEntity user, LockerEntity locker, int displayOrder) { + UserLockerFavoriteEntity favorite = new UserLockerFavoriteEntity(user, locker, displayOrder); + entityManager.persist(favorite); + return favorite; + } + + private UserEntity saveUser(String email, String nickname) { + UserEntity user = new UserEntity( + null, + email, + nickname, + null, + UserStatus.ACTIVE, + UserRole.USER, + LocalDateTime.now(), + LocalDateTime.now() + ); + entityManager.persist(user); + return user; + } + + private LockerEntity saveLocker(String name) { + LockerEntity locker = new LockerEntity( + name, + "서울 마포구 양화로 160", + 37.556, + 126.923 + ); + entityManager.persist(locker); + return locker; + } +} From b765f2d1055c6c235774457624d57261705a1a76 Mon Sep 17 00:00:00 2001 From: buddle031 Date: Wed, 13 May 2026 23:03:40 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EA=B1=B0=EB=A6=AC=20=EA=B8=B0=EB=B3=B8=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=A0=95=EC=B1=85=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FavoriteLockerProperties.java | 15 +++ .../FavoriteLockerReaderAdapter.java | 36 ++++-- src/main/resources/application.yaml | 6 + .../FavoriteLockerReaderAdapterTest.java | 120 ++++++++++++++++++ 4 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerProperties.java create mode 100644 src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerProperties.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerProperties.java new file mode 100644 index 0000000..b894042 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerProperties.java @@ -0,0 +1,15 @@ +package com.zimdugo.locker.infrastructure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "locker.favorite") +public record FavoriteLockerProperties( + DefaultOrigin defaultOrigin +) { + + public record DefaultOrigin( + double latitude, + double longitude + ) { + } +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java index fc4228b..4ec898a 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java @@ -22,6 +22,7 @@ public class FavoriteLockerReaderAdapter implements FavoriteLockerReader { private final UserLockerFavoriteRepository userLockerFavoriteRepository; private final LockerReportRepository lockerReportRepository; + private final FavoriteLockerProperties favoriteLockerProperties; @Override public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double latitude, Double longitude) { @@ -30,6 +31,7 @@ public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double l userId, PageRequest.of(page, size) ); + Coordinate origin = resolveOrigin(latitude, longitude); Map lastCompletedVoteAtByLockerId = getLastCompletedVoteAtByLockerId(favorites.getContent()); @@ -39,7 +41,7 @@ public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double l favorites.getSize(), favorites.hasNext(), favorites.getContent().stream() - .map(favorite -> toFavoriteLocker(favorite, latitude, longitude, lastCompletedVoteAtByLockerId)) + .map(favorite -> toFavoriteLocker(favorite, origin, lastCompletedVoteAtByLockerId)) .toList() ); } @@ -51,8 +53,7 @@ public boolean existsByUserIdAndLockerId(Long userId, Long lockerId) { private FavoriteLocker toFavoriteLocker( UserLockerFavoriteEntity favorite, - Double latitude, - Double longitude, + Coordinate origin, Map lastCompletedVoteAtByLockerId ) { Long lockerId = favorite.getLocker().getId(); @@ -65,8 +66,8 @@ private FavoriteLocker toFavoriteLocker( favorite.getCreatedAt(), lastCompletedVoteAtByLockerId.get(lockerId), calculateDistanceMeters( - latitude, - longitude, + origin.latitude(), + origin.longitude(), favorite.getLocker().getLatitude(), favorite.getLocker().getLongitude() ) @@ -89,16 +90,23 @@ private Map getLastCompletedVoteAtByLockerId(List(List.of(favorite), PageRequest.of(0, 20), 1)); + given(lockerReportRepository.findLatestCompletedVoteAtByLockerIdIn(List.of(10L))) + .willReturn(List.of(latestUpdateProjection(10L, lastCompletedVoteAt))); + + FavoriteLockerPage result = adapter.findByUserId(1L, 0, 20, null, null); + + assertThat(result.favorites()).hasSize(1); + assertThat(result.favorites().get(0).distanceMeters()) + .isEqualTo(calculateDistanceMeters(defaultLatitude, defaultLongitude, 37.556, 126.923)); + assertThat(result.favorites().get(0).lastCompletedVoteAt()).isEqualTo(lastCompletedVoteAt); + } + + private UserLockerFavoriteEntity favoriteLocker(Long lockerId, double latitude, double longitude) { + UserEntity user = new UserEntity( + null, + "favorite-reader@example.com", + "favorite-reader", + null, + UserStatus.ACTIVE, + UserRole.USER, + LocalDateTime.now(), + LocalDateTime.now() + ); + LockerEntity locker = new LockerEntity( + "홍대입구역 보관함", + "서울 마포구 양화로 160", + latitude, + longitude + ); + ReflectionTestUtils.setField(locker, "id", lockerId); + return new UserLockerFavoriteEntity(user, locker, 0); + } + + private LockerReportLatestUpdateProjection latestUpdateProjection( + Long lockerId, + LocalDateTime lastCompletedVoteAt + ) { + return new LockerReportLatestUpdateProjection() { + @Override + public Long getLockerId() { + return lockerId; + } + + @Override + public LocalDateTime getLastCompletedVoteAt() { + return lastCompletedVoteAt; + } + }; + } + + private long calculateDistanceMeters( + double originLatitude, + double originLongitude, + double lockerLatitude, + double lockerLongitude + ) { + double earthRadiusMeters = 6_371_000; + double latitudeDelta = Math.toRadians(lockerLatitude - originLatitude); + double longitudeDelta = Math.toRadians(lockerLongitude - originLongitude); + double startLatitude = Math.toRadians(originLatitude); + double endLatitude = Math.toRadians(lockerLatitude); + + double a = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2) + + Math.cos(startLatitude) * Math.cos(endLatitude) + * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return Math.round(earthRadiusMeters * c); + } +} From 0f6c4791c59a1687bfb311901a2104b4148d9a48 Mon Sep 17 00:00:00 2001 From: buddle031 Date: Thu, 14 May 2026 21:45:52 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=20POI=20=EB=AA=85=EC=B9=AD=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zimdugo/locker/application/FavoriteLockerItemResponse.java | 2 +- .../zimdugo/locker/application/FavoriteLockerQueryService.java | 2 +- src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java index e12bf0e..5d138d8 100644 --- a/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java @@ -4,7 +4,7 @@ public record FavoriteLockerItemResponse( Long lockerId, - String name, + String poiName, String roadAddress, double latitude, double longitude, diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java index 91cbc3a..f7d151c 100644 --- a/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java @@ -39,7 +39,7 @@ public FavoriteLockerStatusResponse getFavoriteStatus(Long userId, Long lockerId private FavoriteLockerItemResponse toItemResponse(FavoriteLocker favorite) { return new FavoriteLockerItemResponse( favorite.lockerId(), - favorite.name(), + favorite.poiName(), favorite.roadAddress(), favorite.latitude(), favorite.longitude(), diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java index cfbc00e..6af5723 100644 --- a/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java @@ -4,7 +4,7 @@ public record FavoriteLocker( Long lockerId, - String name, + String poiName, String roadAddress, double latitude, double longitude, From c22ab5577f7d75c5ddabac159c70cad6c75cc9b4 Mon Sep 17 00:00:00 2001 From: buddle031 Date: Thu, 14 May 2026 23:39:43 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EA=B3=84=EC=82=B0=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FavoriteLockerOrderUpdateRequest.java | 4 +- .../locker/entrypoint/LockerFavoriteApi.java | 4 ++ .../entrypoint/LockerFavoriteController.java | 6 +- .../FavoriteLockerStoreAdapter.java | 2 +- .../UserLockerFavoriteRepository.java | 4 +- .../LockerFavoriteControllerTest.java | 58 ++++++++++++++++++- .../UserLockerFavoriteRepositoryTest.java | 21 +++++++ 7 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java b/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java index 567fd55..ef20890 100644 --- a/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java +++ b/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java @@ -1,10 +1,12 @@ package com.zimdugo.locker.entrypoint; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import java.util.List; public record FavoriteLockerOrderUpdateRequest( @NotEmpty(message = "validation.not_empty") - List lockerIds + List<@NotNull @Positive Long> lockerIds ) { } diff --git a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java index c6ccee0..7f8aeed 100644 --- a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java +++ b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java @@ -13,6 +13,7 @@ import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; @@ -72,6 +73,7 @@ ResponseEntity> getFavoriteLockerStat Authentication authentication, @PathVariable("lockerId") @Parameter(description = "보관함 ID", example = "10") + @Positive Long lockerId ); @@ -104,6 +106,7 @@ ResponseEntity> addFavoriteLocker( Authentication authentication, @PathVariable("lockerId") @Parameter(description = "보관함 ID", example = "10") + @Positive Long lockerId ); @@ -120,6 +123,7 @@ ResponseEntity> removeFavoriteLocker( Authentication authentication, @PathVariable("lockerId") @Parameter(description = "보관함 ID", example = "10") + @Positive Long lockerId ); } diff --git a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java index 7a5c41c..70be2f6 100644 --- a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java +++ b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java @@ -86,6 +86,10 @@ private Long extractUserId(Authentication authentication) { throw new BusinessException(ErrorCode.AUTHENTICATED_USER_NOT_FOUND); } - return Long.valueOf(authentication.getName()); + try { + return Long.valueOf(authentication.getName()); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.AUTHENTICATED_USER_NOT_FOUND); + } } } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java index 841319e..b16d177 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java @@ -78,7 +78,7 @@ public void reorder(Long userId, List lockerIds) { } private int nextDisplayOrder(Long userId) { - return userLockerFavoriteRepository.findTopByUserIdOrderByDisplayOrderDesc(userId) + return userLockerFavoriteRepository.findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDesc(userId) .map(UserLockerFavoriteEntity::getDisplayOrder) .map(order -> order + 1) .orElse(0); diff --git a/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java index fce1a9c..d0c7598 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java @@ -30,5 +30,7 @@ java.util.List findByUserIdAndLockerDeletedFalseAndLoc long countByUserIdAndLockerDeletedFalse(Long userId); - java.util.Optional findTopByUserIdOrderByDisplayOrderDesc(Long userId); + java.util.Optional findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDesc( + Long userId + ); } diff --git a/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java b/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java index 116625f..43440c8 100644 --- a/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java +++ b/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java @@ -112,6 +112,16 @@ void getMyFavoriteLockersWithoutAuthenticationReturnsUnauthorized() throws Excep .andExpect(jsonPath("$.message").value("auth.authenticated_user_not_found")); } + @Test + @DisplayName("숫자가 아닌 인증 이름이면 401을 반환한다") + void getMyFavoriteLockersWithNonNumericAuthenticationNameReturnsUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/me/favorite-lockers") + .principal(authenticationWithName("user-name"))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A4011")) + .andExpect(jsonPath("$.message").value("auth.authenticated_user_not_found")); + } + @Test @DisplayName("특정 보관함의 즐겨찾기 상태를 조회한다") void getFavoriteLockerStatusReturnsOk() throws Exception { @@ -139,6 +149,15 @@ void getFavoriteLockerStatusReturnsFalseForDeletedLocker() throws Exception { .andExpect(jsonPath("$.data.favorite").value(false)); } + @Test + @DisplayName("0 이하 lockerId로 상태 조회하면 400을 반환한다") + void getFavoriteLockerStatusWithNonPositiveLockerIdReturnsBadRequest() throws Exception { + mockMvc.perform(get("/api/v1/me/favorite-lockers/{lockerId}/status", 0L) + .principal(authenticatedUser())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + @Test @DisplayName("인증 정보 없이 즐겨찾기 상태를 조회하면 401을 반환한다") void getFavoriteLockerStatusWithoutAuthenticationReturnsUnauthorized() throws Exception { @@ -180,6 +199,21 @@ void reorderFavoriteLockersWithEmptyIdsReturnsBadRequest() throws Exception { .andExpect(jsonPath("$.code").value("C400")); } + @Test + @DisplayName("음수 lockerId가 포함된 순서 변경 요청은 400을 반환한다") + void reorderFavoriteLockersWithNegativeLockerIdReturnsBadRequest() throws Exception { + mockMvc.perform(patch("/api/v1/me/favorite-lockers/order") + .principal(authenticatedUser()) + .contentType("application/json") + .content(""" + { + "lockerIds": [20, -1, 30] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + @Test @DisplayName("보관함을 즐겨찾기로 등록한다") void addFavoriteLockerReturnsOk() throws Exception { @@ -191,6 +225,15 @@ void addFavoriteLockerReturnsOk() throws Exception { verify(favoriteLockerCommandService).add(1L, 10L); } + @Test + @DisplayName("0 이하 lockerId로 즐겨찾기 등록하면 400을 반환한다") + void addFavoriteLockerWithNonPositiveLockerIdReturnsBadRequest() throws Exception { + mockMvc.perform(post("/api/v1/me/favorite-lockers/{lockerId}", 0L) + .principal(authenticatedUser())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + @Test @DisplayName("보관함 즐겨찾기를 해제한다") void removeFavoriteLockerReturnsOk() throws Exception { @@ -202,9 +245,22 @@ void removeFavoriteLockerReturnsOk() throws Exception { verify(favoriteLockerCommandService).remove(1L, 10L); } + @Test + @DisplayName("0 이하 lockerId로 즐겨찾기 해제하면 400을 반환한다") + void removeFavoriteLockerWithNonPositiveLockerIdReturnsBadRequest() throws Exception { + mockMvc.perform(delete("/api/v1/me/favorite-lockers/{lockerId}", 0L) + .principal(authenticatedUser())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + private UsernamePasswordAuthenticationToken authenticatedUser() { + return authenticationWithName("1"); + } + + private UsernamePasswordAuthenticationToken authenticationWithName(String name) { return new UsernamePasswordAuthenticationToken( - "1", + name, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); diff --git a/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java b/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java index a384cd3..3ee347c 100644 --- a/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java +++ b/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java @@ -146,6 +146,27 @@ void activeFavoriteQueriesExcludeDeletedLockers() { .containsExactly("정상 보관함"); } + @Test + @DisplayName("삭제된 보관함은 최대 displayOrder 조회에서 제외한다") + void findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDescExcludesDeletedLocker() { + UserEntity user = saveUser("display-order-user@example.com", "display-order-user"); + LockerEntity activeLocker = saveLocker("정상 보관함"); + LockerEntity deletedLocker = saveLocker("삭제된 보관함"); + + saveFavorite(user, activeLocker, 1); + saveFavorite(user, deletedLocker, 5); + deletedLocker.markDeleted(); + entityManager.flush(); + entityManager.clear(); + + UserLockerFavoriteEntity result = + userLockerFavoriteRepository.findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDesc(user.getId()) + .orElseThrow(); + + assertThat(result.getLocker().getName()).isEqualTo("정상 보관함"); + assertThat(result.getDisplayOrder()).isEqualTo(1); + } + private UserLockerFavoriteEntity saveFavorite(UserEntity user, LockerEntity locker, int displayOrder) { UserLockerFavoriteEntity favorite = new UserLockerFavoriteEntity(user, locker, displayOrder); entityManager.persist(favorite); From 65f3f082f56acb976a8587b4c71eda464139b266 Mon Sep 17 00:00:00 2001 From: buddle031 Date: Sat, 16 May 2026 16:31:06 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20swagge?= =?UTF-8?q?r=20=EC=84=A4=EC=A0=95=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/openapi/config/OpenApiConfig.java | 3 +-- .../java/com/zimdugo/core/exception/ErrorCode.java | 3 +++ .../locker/entrypoint/LockerFavoriteApi.java | 4 ++-- .../infrastructure/FavoriteLockerStoreAdapter.java | 14 +++++++------- .../infrastructure/LockerReportStoreAdapter.java | 2 +- .../UserLockerFavoriteRepository.java | 13 ++++++++----- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/zimdugo/common/openapi/config/OpenApiConfig.java b/src/main/java/com/zimdugo/common/openapi/config/OpenApiConfig.java index 28e2ec9..b9d7608 100644 --- a/src/main/java/com/zimdugo/common/openapi/config/OpenApiConfig.java +++ b/src/main/java/com/zimdugo/common/openapi/config/OpenApiConfig.java @@ -20,8 +20,7 @@ public OpenAPI zimdugoOpenAPI() { .info(new Info() .title("Zimdugo API") .description("Zimdugo 백엔드 API 문서")) - .addServersItem(new Server().url("https://api.zimdugo.com").description("Production")) - .addServersItem(new Server().url("http://localhost:8080").description("Local")) + .addServersItem(new Server().url("/").description("Current server")) .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)) .components(new Components() .addSecuritySchemes(SECURITY_SCHEME_NAME, new SecurityScheme() diff --git a/src/main/java/com/zimdugo/core/exception/ErrorCode.java b/src/main/java/com/zimdugo/core/exception/ErrorCode.java index 97beed5..f2a8da6 100644 --- a/src/main/java/com/zimdugo/core/exception/ErrorCode.java +++ b/src/main/java/com/zimdugo/core/exception/ErrorCode.java @@ -25,6 +25,9 @@ public enum ErrorCode implements BaseCode { USER_ALREADY_WITHDRAWN("U4002", "user.already_withdrawn", HttpStatus.BAD_REQUEST), + LOCKER_NOT_FOUND("L4041", "locker.not_found", HttpStatus.NOT_FOUND), + + UNSUPPORTED_SOCIAL_LOGIN("A4005", "auth.unsupported_social_login", HttpStatus.BAD_REQUEST), AUTHENTICATED_USER_NOT_FOUND("A4011", "auth.authenticated_user_not_found", HttpStatus.UNAUTHORIZED); diff --git a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java index 7f8aeed..43c2683 100644 --- a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java +++ b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java @@ -99,7 +99,7 @@ ResponseEntity> reorderFavoriteLockers( @ApiResponses({ @ApiResponse(responseCode = "200", description = "등록 성공"), @ApiResponse(responseCode = "401", description = "로그인 필요"), - @ApiResponse(responseCode = "404", description = "보관함 없음") + @ApiResponse(responseCode = "404", description = "보관함이 존재하지 않음") }) @PostMapping("/me/favorite-lockers/{lockerId}") ResponseEntity> addFavoriteLocker( @@ -112,7 +112,7 @@ ResponseEntity> addFavoriteLocker( @Operation( summary = "보관함 즐겨찾기 해제", - description = "로그인 사용자의 즐겨찾기 보관함에서 제외합니다." + description = "로그인 사용자의 즐겨찾기 보관함에서 제거합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "해제 성공"), diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java index b16d177..a286ad8 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java @@ -27,7 +27,7 @@ public void add(Long userId, Long lockerId) { UserEntity user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); LockerEntity locker = lockerRepository.findByIdAndDeletedFalse(lockerId) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.LOCKER_NOT_FOUND)); int displayOrder = nextDisplayOrder(userId); try { @@ -50,16 +50,16 @@ public void remove(Long userId, Long lockerId) { public void reorder(Long userId, List lockerIds) { long favoriteCount = userLockerFavoriteRepository.countByUserIdAndLockerDeletedFalse(userId); if (favoriteCount != lockerIds.size()) { - throw new IllegalArgumentException("Favorite locker order request must include all favorites."); + throw new BusinessException(ErrorCode.BAD_REQUEST); } List favorites = userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseAndLockerIdIn( - userId, - lockerIds - ); + userId, + lockerIds + ); if (favorites.size() != lockerIds.size()) { - throw new IllegalArgumentException("Favorite locker order request contains unknown locker ids."); + throw new BusinessException(ErrorCode.BAD_REQUEST); } Map favoriteByLockerId = new HashMap<>(); @@ -71,7 +71,7 @@ public void reorder(Long userId, List lockerIds) { Long lockerId = lockerIds.get(index); UserLockerFavoriteEntity favorite = favoriteByLockerId.remove(lockerId); if (favorite == null) { - throw new IllegalArgumentException("Favorite locker order request contains duplicate locker ids."); + throw new BusinessException(ErrorCode.BAD_REQUEST); } favorite.updateDisplayOrder(index); } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java index 03c1757..3873534 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java @@ -23,7 +23,7 @@ public class LockerReportStoreAdapter implements LockerReportStore { @Override public SavedLockerReport create(LockerReportCreateInfo createInfo) { LockerEntity locker = lockerRepository.findByIdAndDeletedFalse(createInfo.lockerId()) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.LOCKER_NOT_FOUND)); UserEntity user = userRepository.findById(createInfo.userId()) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java index d0c7598..97b35ca 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java @@ -1,6 +1,9 @@ package com.zimdugo.locker.infrastructure; import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import java.util.Collection; +import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; @@ -18,19 +21,19 @@ Page findByUserIdAndLockerDeletedFalseOrderByDisplayOr void deleteByUserIdAndLockerId(Long userId, Long lockerId); - java.util.List findByUserIdAndLockerIdIn( + List findByUserIdAndLockerIdIn( Long userId, - java.util.Collection lockerIds + Collection lockerIds ); - java.util.List findByUserIdAndLockerDeletedFalseAndLockerIdIn( + List findByUserIdAndLockerDeletedFalseAndLockerIdIn( Long userId, - java.util.Collection lockerIds + Collection lockerIds ); long countByUserIdAndLockerDeletedFalse(Long userId); - java.util.Optional findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDesc( + Optional findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDesc( Long userId ); } From 4e194b64d9ef32e0d16da0e23da2ea72a8ffab97 Mon Sep 17 00:00:00 2001 From: buddle031 Date: Sat, 16 May 2026 18:39:10 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20active=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FavoriteLockerReaderAdapter.java | 4 +- .../FavoriteLockerStoreAdapter.java | 29 ++++--- .../LockerReportStoreAdapter.java | 2 +- .../infrastructure/LockerRepository.java | 9 +- .../infrastructure/LockerStoreAdapter.java | 4 +- .../UserLockerFavoriteRepository.java | 86 ++++++++++++++++--- .../FavoriteLockerReaderAdapterTest.java | 2 +- .../UserLockerFavoriteRepositoryTest.java | 29 +++---- 8 files changed, 115 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java index 4ec898a..0b7490c 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java @@ -27,7 +27,7 @@ public class FavoriteLockerReaderAdapter implements FavoriteLockerReader { @Override public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double latitude, Double longitude) { Page favorites = - userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( + userLockerFavoriteRepository.findActiveFavoritesByUserId( userId, PageRequest.of(page, size) ); @@ -48,7 +48,7 @@ public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double l @Override public boolean existsByUserIdAndLockerId(Long userId, Long lockerId) { - return userLockerFavoriteRepository.existsByUserIdAndLockerIdAndLockerDeletedFalse(userId, lockerId); + return userLockerFavoriteRepository.countActiveFavoritesByUserIdAndLockerId(userId, lockerId) > 0; } private FavoriteLocker toFavoriteLocker( diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java index a286ad8..ecf7151 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java @@ -26,7 +26,7 @@ public class FavoriteLockerStoreAdapter implements FavoriteLockerStore { public void add(Long userId, Long lockerId) { UserEntity user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - LockerEntity locker = lockerRepository.findByIdAndDeletedFalse(lockerId) + LockerEntity locker = lockerRepository.findActiveById(lockerId) .orElseThrow(() -> new BusinessException(ErrorCode.LOCKER_NOT_FOUND)); int displayOrder = nextDisplayOrder(userId); @@ -34,7 +34,7 @@ public void add(Long userId, Long lockerId) { userLockerFavoriteRepository.save(new UserLockerFavoriteEntity(user, locker, displayOrder)); } catch (DataIntegrityViolationException e) { // Concurrent favorite requests may race on the unique constraint. - if (userLockerFavoriteRepository.existsByUserIdAndLockerIdAndLockerDeletedFalse(userId, lockerId)) { + if (userLockerFavoriteRepository.countActiveFavoritesByUserIdAndLockerId(userId, lockerId) > 0) { return; } throw e; @@ -48,20 +48,22 @@ public void remove(Long userId, Long lockerId) { @Override public void reorder(Long userId, List lockerIds) { - long favoriteCount = userLockerFavoriteRepository.countByUserIdAndLockerDeletedFalse(userId); + long favoriteCount = userLockerFavoriteRepository.countActiveFavoritesByUserId(userId); if (favoriteCount != lockerIds.size()) { throw new BusinessException(ErrorCode.BAD_REQUEST); } - List favorites = - userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseAndLockerIdIn( - userId, - lockerIds - ); - if (favorites.size() != lockerIds.size()) { + long matchedFavoriteCount = userLockerFavoriteRepository.countActiveFavoritesByUserIdAndLockerIds( + userId, + lockerIds + ); + if (matchedFavoriteCount != lockerIds.size()) { throw new BusinessException(ErrorCode.BAD_REQUEST); } + List favorites = + userLockerFavoriteRepository.findActiveFavoritesByUserIdAndLockerIds(userId, lockerIds); + Map favoriteByLockerId = new HashMap<>(); for (UserLockerFavoriteEntity favorite : favorites) { favoriteByLockerId.put(favorite.getLocker().getId(), favorite); @@ -78,9 +80,10 @@ public void reorder(Long userId, List lockerIds) { } private int nextDisplayOrder(Long userId) { - return userLockerFavoriteRepository.findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDesc(userId) - .map(UserLockerFavoriteEntity::getDisplayOrder) - .map(order -> order + 1) - .orElse(0); + Integer maxDisplayOrder = userLockerFavoriteRepository.findMaxDisplayOrderAmongActiveFavoritesByUserId(userId); + if (maxDisplayOrder == null) { + return 0; + } + return maxDisplayOrder + 1; } } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java index 3873534..00c76ae 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java @@ -22,7 +22,7 @@ public class LockerReportStoreAdapter implements LockerReportStore { @Override public SavedLockerReport create(LockerReportCreateInfo createInfo) { - LockerEntity locker = lockerRepository.findByIdAndDeletedFalse(createInfo.lockerId()) + LockerEntity locker = lockerRepository.findActiveById(createInfo.lockerId()) .orElseThrow(() -> new BusinessException(ErrorCode.LOCKER_NOT_FOUND)); UserEntity user = userRepository.findById(createInfo.userId()) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java index 2c94df4..3b60c96 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java @@ -2,13 +2,20 @@ import com.zimdugo.locker.infrastructure.persistence.LockerEntity; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface LockerRepository extends JpaRepository { - java.util.Optional findByIdAndDeletedFalse(Long id); + @Query(""" + select l + from LockerEntity l + where l.id = :id + and l.deleted = false + """) + Optional findActiveById(@Param("id") Long id); @Query(value = """ -- 조회 기준 좌표 diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java index ba80270..b8bae08 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java @@ -24,9 +24,9 @@ public ReportLocker create(String name, String roadAddress, double latitude, dou @Override public ReportLocker getById(Long id) { - return lockerRepository.findByIdAndDeletedFalse(id) + return lockerRepository.findActiveById(id) .map(this::toDomain) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.LOCKER_NOT_FOUND)); } private ReportLocker toDomain(LockerEntity locker) { diff --git a/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java index 97b35ca..483c5bf 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java @@ -3,37 +3,95 @@ import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; import java.util.Collection; import java.util.List; -import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface UserLockerFavoriteRepository extends JpaRepository { @EntityGraph(attributePaths = "locker") - Page findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( - Long userId, + @Query( + value = """ + select favorite + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.deleted = false + order by favorite.displayOrder asc, favorite.createdAt desc + """, + countQuery = """ + select count(favorite) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.deleted = false + """ + ) + Page findActiveFavoritesByUserId( + @Param("userId") Long userId, Pageable pageable ); - boolean existsByUserIdAndLockerIdAndLockerDeletedFalse(Long userId, Long lockerId); + @Query(""" + select count(favorite) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.id = :lockerId + and locker.deleted = false + """) + long countActiveFavoritesByUserIdAndLockerId( + @Param("userId") Long userId, + @Param("lockerId") Long lockerId + ); void deleteByUserIdAndLockerId(Long userId, Long lockerId); - List findByUserIdAndLockerIdIn( - Long userId, - Collection lockerIds + @EntityGraph(attributePaths = "locker") + @Query(""" + select favorite + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.id in :lockerIds + and locker.deleted = false + """) + List findActiveFavoritesByUserIdAndLockerIds( + @Param("userId") Long userId, + @Param("lockerIds") Collection lockerIds ); - List findByUserIdAndLockerDeletedFalseAndLockerIdIn( - Long userId, - Collection lockerIds - ); + @Query(""" + select count(favorite) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.deleted = false + """) + long countActiveFavoritesByUserId(@Param("userId") Long userId); - long countByUserIdAndLockerDeletedFalse(Long userId); + @Query(""" + select max(favorite.displayOrder) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.deleted = false + """) + Integer findMaxDisplayOrderAmongActiveFavoritesByUserId(@Param("userId") Long userId); - Optional findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDesc( - Long userId + @Query(""" + select count(favorite) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.id in :lockerIds + and locker.deleted = false + """) + long countActiveFavoritesByUserIdAndLockerIds( + @Param("userId") Long userId, + @Param("lockerIds") Collection lockerIds ); } diff --git a/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java b/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java index 3df9d81..37b90d7 100644 --- a/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java +++ b/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java @@ -45,7 +45,7 @@ void findByUserIdUsesConfiguredDefaultOriginAndLatestCompletedVoteAt() { UserLockerFavoriteEntity favorite = favoriteLocker(10L, 37.556, 126.923); LocalDateTime lastCompletedVoteAt = LocalDateTime.of(2026, 5, 13, 19, 45); - given(userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( + given(userLockerFavoriteRepository.findActiveFavoritesByUserId( 1L, PageRequest.of(0, 20) )) diff --git a/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java b/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java index 3ee347c..8348a65 100644 --- a/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java +++ b/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java @@ -42,7 +42,7 @@ void findByUserIdOrderByDisplayOrderAscCreatedAtDescReturnsOnlyUserFavorites() { entityManager.clear(); Page result = - userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( + userLockerFavoriteRepository.findActiveFavoritesByUserId( user.getId(), PageRequest.of(0, 10) ); @@ -54,7 +54,7 @@ void findByUserIdOrderByDisplayOrderAscCreatedAtDescReturnsOnlyUserFavorites() { } @Test - @DisplayName("삭제된 보관함은 즐겨찾기 목록 조회에서 제외한다") + @DisplayName("삭제된 보관함은 즐겨찾기 목록 조회에서 제외된다") void findByUserIdExcludesDeletedLockers() { UserEntity user = saveUser("deleted-locker-user@example.com", "deleted-locker-user"); LockerEntity activeLocker = saveLocker("정상 보관함"); @@ -67,7 +67,7 @@ void findByUserIdExcludesDeletedLockers() { entityManager.clear(); Page result = - userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseOrderByDisplayOrderAscCreatedAtDesc( + userLockerFavoriteRepository.findActiveFavoritesByUserId( user.getId(), PageRequest.of(0, 10) ); @@ -90,11 +90,11 @@ void deleteByUserIdAndLockerIdDeletesFavorite() { entityManager.flush(); entityManager.clear(); - assertThat(userLockerFavoriteRepository.existsByUserIdAndLockerIdAndLockerDeletedFalse( + assertThat(userLockerFavoriteRepository.countActiveFavoritesByUserIdAndLockerId( user.getId(), locker.getId() )) - .isFalse(); + .isZero(); } @Test @@ -111,7 +111,7 @@ void findByUserIdAndLockerIdInReturnsMatchingFavorites() { entityManager.flush(); entityManager.clear(); - List favorites = userLockerFavoriteRepository.findByUserIdAndLockerIdIn( + List favorites = userLockerFavoriteRepository.findActiveFavoritesByUserIdAndLockerIds( user.getId(), List.of(firstLocker.getId(), thirdLocker.getId()) ); @@ -122,7 +122,7 @@ void findByUserIdAndLockerIdInReturnsMatchingFavorites() { } @Test - @DisplayName("삭제된 보관함은 reorder 대상 조회와 개수에서 제외한다") + @DisplayName("삭제된 보관함은 reorder 대상 조회와 개수에서 제외된다") void activeFavoriteQueriesExcludeDeletedLockers() { UserEntity user = saveUser("reorder-user@example.com", "reorder-user"); LockerEntity activeLocker = saveLocker("정상 보관함"); @@ -135,20 +135,20 @@ void activeFavoriteQueriesExcludeDeletedLockers() { entityManager.clear(); List favorites = - userLockerFavoriteRepository.findByUserIdAndLockerDeletedFalseAndLockerIdIn( + userLockerFavoriteRepository.findActiveFavoritesByUserIdAndLockerIds( user.getId(), List.of(activeLocker.getId(), deletedLocker.getId()) ); - assertThat(userLockerFavoriteRepository.countByUserIdAndLockerDeletedFalse(user.getId())).isEqualTo(1); + assertThat(userLockerFavoriteRepository.countActiveFavoritesByUserId(user.getId())).isEqualTo(1); assertThat(favorites) .extracting(favorite -> favorite.getLocker().getName()) .containsExactly("정상 보관함"); } @Test - @DisplayName("삭제된 보관함은 최대 displayOrder 조회에서 제외한다") - void findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDescExcludesDeletedLocker() { + @DisplayName("삭제된 보관함은 최대 displayOrder 조회에서 제외된다") + void findMaxDisplayOrderAmongActiveFavoritesByUserIdExcludesDeletedLocker() { UserEntity user = saveUser("display-order-user@example.com", "display-order-user"); LockerEntity activeLocker = saveLocker("정상 보관함"); LockerEntity deletedLocker = saveLocker("삭제된 보관함"); @@ -159,12 +159,9 @@ void findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDescExcludesDeletedL entityManager.flush(); entityManager.clear(); - UserLockerFavoriteEntity result = - userLockerFavoriteRepository.findTopByUserIdAndLockerDeletedFalseOrderByDisplayOrderDesc(user.getId()) - .orElseThrow(); + Integer result = userLockerFavoriteRepository.findMaxDisplayOrderAmongActiveFavoritesByUserId(user.getId()); - assertThat(result.getLocker().getName()).isEqualTo("정상 보관함"); - assertThat(result.getDisplayOrder()).isEqualTo(1); + assertThat(result).isEqualTo(1); } private UserLockerFavoriteEntity saveFavorite(UserEntity user, LockerEntity locker, int displayOrder) { From 4d8fc14a6d1e4f0513bc5c8df4ba8d8d5853173d Mon Sep 17 00:00:00 2001 From: buddle031 Date: Sat, 16 May 2026 19:09:52 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EA=B1=B0=EB=A6=AC=20=EA=B3=84=EC=82=B0=EC=9D=84=20?= =?UTF-8?q?PostGIS=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FavoriteLockerReaderAdapter.java | 51 ++++++++----------- .../LockerDistanceProjection.java | 6 +++ .../infrastructure/LockerRepository.java | 19 +++++++ .../FavoriteLockerReaderAdapterTest.java | 39 +++++++------- 4 files changed, 68 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/zimdugo/locker/infrastructure/LockerDistanceProjection.java diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java index 0b7490c..76509a1 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java @@ -18,10 +18,9 @@ @RequiredArgsConstructor public class FavoriteLockerReaderAdapter implements FavoriteLockerReader { - private static final double EARTH_RADIUS_METERS = 6_371_000; - private final UserLockerFavoriteRepository userLockerFavoriteRepository; private final LockerReportRepository lockerReportRepository; + private final LockerRepository lockerRepository; private final FavoriteLockerProperties favoriteLockerProperties; @Override @@ -34,6 +33,7 @@ public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double l Coordinate origin = resolveOrigin(latitude, longitude); Map lastCompletedVoteAtByLockerId = getLastCompletedVoteAtByLockerId(favorites.getContent()); + Map distanceMetersByLockerId = getDistanceMetersByLockerId(favorites.getContent(), origin); return new FavoriteLockerPage( favorites.getTotalElements(), @@ -41,7 +41,7 @@ public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double l favorites.getSize(), favorites.hasNext(), favorites.getContent().stream() - .map(favorite -> toFavoriteLocker(favorite, origin, lastCompletedVoteAtByLockerId)) + .map(favorite -> toFavoriteLocker(favorite, lastCompletedVoteAtByLockerId, distanceMetersByLockerId)) .toList() ); } @@ -53,8 +53,8 @@ public boolean existsByUserIdAndLockerId(Long userId, Long lockerId) { private FavoriteLocker toFavoriteLocker( UserLockerFavoriteEntity favorite, - Coordinate origin, - Map lastCompletedVoteAtByLockerId + Map lastCompletedVoteAtByLockerId, + Map distanceMetersByLockerId ) { Long lockerId = favorite.getLocker().getId(); return new FavoriteLocker( @@ -65,12 +65,7 @@ private FavoriteLocker toFavoriteLocker( favorite.getLocker().getLongitude(), favorite.getCreatedAt(), lastCompletedVoteAtByLockerId.get(lockerId), - calculateDistanceMeters( - origin.latitude(), - origin.longitude(), - favorite.getLocker().getLatitude(), - favorite.getLocker().getLongitude() - ) + distanceMetersByLockerId.get(lockerId) ); } @@ -90,6 +85,22 @@ private Map getLastCompletedVoteAtByLockerId(List getDistanceMetersByLockerId(List favorites, Coordinate origin) { + List lockerIds = favorites.stream() + .map(favorite -> favorite.getLocker().getId()) + .toList(); + + if (lockerIds.isEmpty()) { + return Map.of(); + } + + return lockerRepository.findDistancesByLockerIds(origin.latitude(), origin.longitude(), lockerIds).stream() + .collect(toMap( + LockerDistanceProjection::getLockerId, + LockerDistanceProjection::getDistanceMeters + )); + } + private Coordinate resolveOrigin(Double latitude, Double longitude) { if (latitude != null && longitude != null) { return new Coordinate(latitude, longitude); @@ -101,24 +112,6 @@ private Coordinate resolveOrigin(Double latitude, Double longitude) { ); } - private Long calculateDistanceMeters( - double latitude, - double longitude, - double lockerLatitude, - double lockerLongitude - ) { - double latitudeDelta = Math.toRadians(lockerLatitude - latitude); - double longitudeDelta = Math.toRadians(lockerLongitude - longitude); - double startLatitude = Math.toRadians(latitude); - double endLatitude = Math.toRadians(lockerLatitude); - - double a = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2) - + Math.cos(startLatitude) * Math.cos(endLatitude) - * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2); - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return Math.round(EARTH_RADIUS_METERS * c); - } - private record Coordinate( double latitude, double longitude diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerDistanceProjection.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerDistanceProjection.java new file mode 100644 index 0000000..61e9958 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerDistanceProjection.java @@ -0,0 +1,6 @@ +package com.zimdugo.locker.infrastructure; + +public interface LockerDistanceProjection { + Long getLockerId(); + Long getDistanceMeters(); +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java index 3b60c96..2e11c17 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java @@ -1,6 +1,7 @@ package com.zimdugo.locker.infrastructure; import com.zimdugo.locker.infrastructure.persistence.LockerEntity; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -56,4 +57,22 @@ List findNearby( @Param("longitude") double longitude, @Param("radiusMeters") int radiusMeters ); + + @Query(value = """ + WITH target AS ( + SELECT ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography AS point + ) + SELECT + l.id AS lockerId, + ROUND(ST_Distance(l.location, target.point))::bigint AS distanceMeters + FROM lockers l + CROSS JOIN target + WHERE l.id IN (:lockerIds) + AND l.deleted = false + """, nativeQuery = true) + List findDistancesByLockerIds( + @Param("latitude") double latitude, + @Param("longitude") double longitude, + @Param("lockerIds") Collection lockerIds + ); } diff --git a/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java b/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java index 37b90d7..2acf65c 100644 --- a/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java +++ b/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java @@ -29,6 +29,9 @@ class FavoriteLockerReaderAdapterTest { @Mock private LockerReportRepository lockerReportRepository; + @Mock + private LockerRepository lockerRepository; + @Test @DisplayName("현재 위치가 없으면 기본 위치로 거리를 계산하고 최신 완료 제보 시각을 반영한다") void findByUserIdUsesConfiguredDefaultOriginAndLatestCompletedVoteAt() { @@ -37,6 +40,7 @@ void findByUserIdUsesConfiguredDefaultOriginAndLatestCompletedVoteAt() { FavoriteLockerReaderAdapter adapter = new FavoriteLockerReaderAdapter( userLockerFavoriteRepository, lockerReportRepository, + lockerRepository, new FavoriteLockerProperties( new FavoriteLockerProperties.DefaultOrigin(defaultLatitude, defaultLongitude) ) @@ -52,12 +56,13 @@ void findByUserIdUsesConfiguredDefaultOriginAndLatestCompletedVoteAt() { .willReturn(new PageImpl<>(List.of(favorite), PageRequest.of(0, 20), 1)); given(lockerReportRepository.findLatestCompletedVoteAtByLockerIdIn(List.of(10L))) .willReturn(List.of(latestUpdateProjection(10L, lastCompletedVoteAt))); + given(lockerRepository.findDistancesByLockerIds(defaultLatitude, defaultLongitude, List.of(10L))) + .willReturn(List.of(distanceProjection(10L, 14_352L))); FavoriteLockerPage result = adapter.findByUserId(1L, 0, 20, null, null); assertThat(result.favorites()).hasSize(1); - assertThat(result.favorites().get(0).distanceMeters()) - .isEqualTo(calculateDistanceMeters(defaultLatitude, defaultLongitude, 37.556, 126.923)); + assertThat(result.favorites().get(0).distanceMeters()).isEqualTo(14_352L); assertThat(result.favorites().get(0).lastCompletedVoteAt()).isEqualTo(lastCompletedVoteAt); } @@ -99,22 +104,20 @@ public LocalDateTime getLastCompletedVoteAt() { }; } - private long calculateDistanceMeters( - double originLatitude, - double originLongitude, - double lockerLatitude, - double lockerLongitude + private LockerDistanceProjection distanceProjection( + Long lockerId, + Long distanceMeters ) { - double earthRadiusMeters = 6_371_000; - double latitudeDelta = Math.toRadians(lockerLatitude - originLatitude); - double longitudeDelta = Math.toRadians(lockerLongitude - originLongitude); - double startLatitude = Math.toRadians(originLatitude); - double endLatitude = Math.toRadians(lockerLatitude); - - double a = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2) - + Math.cos(startLatitude) * Math.cos(endLatitude) - * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2); - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return Math.round(earthRadiusMeters * c); + return new LockerDistanceProjection() { + @Override + public Long getLockerId() { + return lockerId; + } + + @Override + public Long getDistanceMeters() { + return distanceMeters; + } + }; } }