diff --git a/compose.yaml b/compose.yaml index af92759..69f5516 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,8 @@ services: restart: unless-stopped env_file: - .env + secrets: + - firebase-service-account environment: REDIS_HOST: redis REDIS_PORT: 6379 @@ -164,6 +166,10 @@ configs: } } +secrets: + firebase-service-account: + file: /opt/quespot/secrets/firebase-service-account.json + volumes: redis-data: certbot-webroot: diff --git a/src/main/java/com/quespot/domain/like/repository/LikeRepository.java b/src/main/java/com/quespot/domain/like/repository/LikeRepository.java index 59858c8..8f45827 100644 --- a/src/main/java/com/quespot/domain/like/repository/LikeRepository.java +++ b/src/main/java/com/quespot/domain/like/repository/LikeRepository.java @@ -30,6 +30,19 @@ void upsert(@Param("userId") Long userId, boolean existsByUserIdAndTargetTypeAndTargetId(Long userId, LikeTargetType targetType, Long targetId); + @Query(""" + select l.targetId + from UserLike l + where l.userId = :userId + and l.targetType = :targetType + and l.targetId in :targetIds + """) + List findLikedTargetIds( + @Param("userId") Long userId, + @Param("targetType") LikeTargetType targetType, + @Param("targetIds") List targetIds + ); + // likes엔 FK가 없어 연관관계 대신 `join ... on`으로 붙인다. 비활성 미션은 // 목록에서 빠진다(좋아요 행은 남겨둔다 — 해제는 사용자가 한다). @Query(""" diff --git a/src/main/java/com/quespot/domain/mission/controller/MissionController.java b/src/main/java/com/quespot/domain/mission/controller/MissionDiscoveryController.java similarity index 71% rename from src/main/java/com/quespot/domain/mission/controller/MissionController.java rename to src/main/java/com/quespot/domain/mission/controller/MissionDiscoveryController.java index d4b34a0..af855c2 100644 --- a/src/main/java/com/quespot/domain/mission/controller/MissionController.java +++ b/src/main/java/com/quespot/domain/mission/controller/MissionDiscoveryController.java @@ -2,9 +2,11 @@ import com.quespot.domain.mission.dto.res.MissionDetailResponseDTO; import com.quespot.domain.mission.dto.res.MissionListResponseDTO; +import com.quespot.domain.mission.dto.res.RecommendedMissionListResponseDTO; import com.quespot.domain.mission.enums.MissionCategory; import com.quespot.domain.mission.exception.code.MissionSuccessCode; import com.quespot.domain.mission.service.MissionQueryService; +import com.quespot.domain.mission.service.MissionRecommendationService; import com.quespot.global.apiPayload.ApiResponse; import com.quespot.global.security.principal.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; @@ -30,9 +32,10 @@ @RequiredArgsConstructor @RequestMapping("/api/missions") @Tag(name = "MissionDiscovery", description = "미션 탐색 API") -public class MissionController { +public class MissionDiscoveryController { private final MissionQueryService missionQueryService; + private final MissionRecommendationService missionRecommendationService; @GetMapping @Operation( @@ -105,4 +108,33 @@ public ApiResponse getMission( ) ); } + + @GetMapping("/recommendations") + @Operation( + summary = "추천 미션 조회", + description = """ + 사용자의 여행 스타일과 현재 위치를 기준으로 바로 시작할 수 있는 미션만 추천한다. 홈은 size=2, + 전체보기는 size와 cursor를 사용한다. latitude/longitude는 함께 보내거나 모두 생략해야 하며, 좌표를 + 보내면 카테고리별 가까운 미션을 우선하고 생략하면 일일 고정 랜덤순이다. 선호 카테고리를 우선하면서 + 가능한 경우 카테고리를 번갈아 배치하고, 후보가 부족하면 다른 카테고리로 보충한다. + + 첫 요청은 cursor 없이 보내고 응답의 nextCursor를 다음 요청에 그대로 넣는다. 사용자 여행 스타일, + 좌표 또는 날짜(KST)가 바뀌면 기존 커서는 MISSION_400_002로 거부되므로 처음부터 다시 조회한다. + 완료·진행 중·코스에서 잠긴 미션은 제외되며, 결과가 없으면 missions는 빈 배열이다. + """ + ) + public ApiResponse getRecommendedMissions( + @RequestParam(required = false) BigDecimal latitude, + @RequestParam(required = false) BigDecimal longitude, + @RequestParam(required = false) @Size(max = 500) String cursor, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, + @Parameter(hidden = true) @AuthenticationPrincipal AuthenticatedUser principal + ) { + return ApiResponse.of( + MissionSuccessCode.RECOMMENDED_MISSIONS_FOUND, + missionRecommendationService.getRecommendations( + principal.userId(), latitude, longitude, cursor, size + ) + ); + } } diff --git a/src/main/java/com/quespot/domain/mission/converter/MissionConverter.java b/src/main/java/com/quespot/domain/mission/converter/MissionConverter.java index 977eedc..402df10 100644 --- a/src/main/java/com/quespot/domain/mission/converter/MissionConverter.java +++ b/src/main/java/com/quespot/domain/mission/converter/MissionConverter.java @@ -15,6 +15,7 @@ import com.quespot.domain.mission.dto.res.MissionDetailResponseDTO; import com.quespot.domain.mission.dto.res.MissionListItemResponseDTO; import com.quespot.domain.mission.dto.res.MissionPhotoResponseDTO; +import com.quespot.domain.mission.dto.res.RecommendedMissionItemResponseDTO; import com.quespot.domain.mission.dto.res.VerificationGuideResponseDTO; import com.quespot.domain.mission.entity.Mission; import com.quespot.domain.mission.entity.MissionAttempt; @@ -27,6 +28,7 @@ import com.quespot.domain.mission.repository.projection.ArchiveFeedRowProjection; import com.quespot.domain.mission.repository.projection.CompletedMissionArchiveProjection; import com.quespot.domain.mission.repository.projection.MissionListProjection; +import com.quespot.domain.mission.repository.projection.RecommendedMissionProjection; import com.quespot.domain.mission.service.MissionArrivalService; import com.quespot.domain.spot.entity.Spot; import org.springframework.data.domain.Page; @@ -96,6 +98,24 @@ public static MissionListItemResponseDTO toMissionListItem( ); } + public static RecommendedMissionItemResponseDTO toRecommendedMissionItem( + RecommendedMissionProjection projection, + boolean includeDistance, + boolean liked + ) { + return new RecommendedMissionItemResponseDTO( + projection.getMissionId(), + projection.getTitle(), + MissionCategory.valueOf(projection.getCategory()), + projection.getSpotName(), + projection.getImageUrl(), + includeDistance ? Math.round(projection.getSortValue()) : null, + projection.getRewardPoint(), + projection.getEstimatedMinutes(), + liked + ); + } + public static MissionDetailResponseDTO toMissionDetail( Mission mission, Long distanceMeters, diff --git a/src/main/java/com/quespot/domain/mission/dto/res/RecommendedMissionItemResponseDTO.java b/src/main/java/com/quespot/domain/mission/dto/res/RecommendedMissionItemResponseDTO.java new file mode 100644 index 0000000..d4530c8 --- /dev/null +++ b/src/main/java/com/quespot/domain/mission/dto/res/RecommendedMissionItemResponseDTO.java @@ -0,0 +1,19 @@ +package com.quespot.domain.mission.dto.res; + +import com.quespot.domain.mission.enums.MissionCategory; +import io.swagger.v3.oas.annotations.media.Schema; + +public record RecommendedMissionItemResponseDTO( + Long missionId, + String title, + MissionCategory category, + String spotName, + @Schema(description = "대표 이미지가 없으면 null", nullable = true) + String imageUrl, + @Schema(description = "요청에 latitude/longitude를 보내지 않으면 null", nullable = true) + Long distanceMeters, + Integer rewardPoint, + Integer estimatedMinutes, + boolean liked +) { +} diff --git a/src/main/java/com/quespot/domain/mission/dto/res/RecommendedMissionListResponseDTO.java b/src/main/java/com/quespot/domain/mission/dto/res/RecommendedMissionListResponseDTO.java new file mode 100644 index 0000000..61fe936 --- /dev/null +++ b/src/main/java/com/quespot/domain/mission/dto/res/RecommendedMissionListResponseDTO.java @@ -0,0 +1,14 @@ +package com.quespot.domain.mission.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record RecommendedMissionListResponseDTO( + @Schema(description = "바로 시작할 수 있는 추천 미션. 비면 빈 배열이다(null 아님)") + List missions, + @Schema(description = "다음 요청의 cursor에 그대로 넣는다. 마지막 페이지면 null", nullable = true) + String nextCursor, + boolean hasNext +) { +} diff --git a/src/main/java/com/quespot/domain/mission/exception/code/MissionSuccessCode.java b/src/main/java/com/quespot/domain/mission/exception/code/MissionSuccessCode.java index 419a088..b862d3e 100644 --- a/src/main/java/com/quespot/domain/mission/exception/code/MissionSuccessCode.java +++ b/src/main/java/com/quespot/domain/mission/exception/code/MissionSuccessCode.java @@ -118,6 +118,10 @@ public enum MissionSuccessCode implements BaseCode { "MISSION_200_028", "지도형 아카이브를 조회했습니다."), + RECOMMENDED_MISSIONS_FOUND(HttpStatus.OK, + "MISSION_200_029", + "추천 미션을 조회했습니다."), + MISSIONS_PUBLISHED(HttpStatus.CREATED, "MISSION_201_001", "미션을 일괄 발행했습니다."), diff --git a/src/main/java/com/quespot/domain/mission/repository/MissionRepository.java b/src/main/java/com/quespot/domain/mission/repository/MissionRepository.java index 1456f56..b4fa1e4 100644 --- a/src/main/java/com/quespot/domain/mission/repository/MissionRepository.java +++ b/src/main/java/com/quespot/domain/mission/repository/MissionRepository.java @@ -5,6 +5,7 @@ import com.quespot.domain.mission.repository.projection.MissionListProjection; import com.quespot.domain.mission.repository.projection.MissionSpotSummaryProjection; import com.quespot.domain.mission.repository.projection.NearbyMissionSpotProjection; +import com.quespot.domain.mission.repository.projection.RecommendedMissionProjection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -120,6 +121,109 @@ List findMissionListRandomly( @Param("limit") int limit ); + @Query(value = """ + with base as ( + select + m.id as mission_id, + m.title, + m.category, + m.snapshot_name as spot_name, + m.snapshot_address as address, + m.snapshot_image_url as image_url, + m.reward_point, + m.estimated_minutes, + case + when :preferredCategories = '' + or find_in_set(m.category, :preferredCategories) > 0 then 0 + else 1 + end as preference_rank, + CRC32(CONCAT(m.category, ':', CAST(:seed AS CHAR))) as category_order, + case + when :latitude is null + then CRC32(CONCAT(CAST(m.id AS CHAR), ':', CAST(:seed AS CHAR))) + else ST_Distance_Sphere( + POINT(m.snapshot_longitude, m.snapshot_latitude), + POINT(:longitude, :latitude) + ) + end as sort_value + from missions m + where m.status = 'ACTIVE' + and not exists ( + select 1 + from mission_attempts ma + where ma.user_id = :userId + and ma.mission_id = m.id + and ma.status in ('COMPLETED', 'IN_PROGRESS') + ) + ), ranked as ( + select + base.*, + row_number() over ( + partition by base.category + order by base.sort_value, base.mission_id + ) as category_rank + from base + ) + select + ranked.mission_id as missionId, + ranked.title as title, + ranked.category as category, + ranked.spot_name as spotName, + ranked.address as address, + ranked.image_url as imageUrl, + ranked.reward_point as rewardPoint, + ranked.estimated_minutes as estimatedMinutes, + ranked.sort_value as sortValue, + ranked.preference_rank as preferenceRank, + ranked.category_rank as categoryRank, + ranked.category_order as categoryOrder + from ranked + where :cursorPreferenceRank is null + or ranked.preference_rank > :cursorPreferenceRank + or ( + ranked.preference_rank = :cursorPreferenceRank + and ranked.category_rank > :cursorCategoryRank + ) + or ( + ranked.preference_rank = :cursorPreferenceRank + and ranked.category_rank = :cursorCategoryRank + and ranked.category_order > :cursorCategoryOrder + ) + or ( + ranked.preference_rank = :cursorPreferenceRank + and ranked.category_rank = :cursorCategoryRank + and ranked.category_order = :cursorCategoryOrder + and ranked.sort_value > :cursorSortValue + ) + or ( + ranked.preference_rank = :cursorPreferenceRank + and ranked.category_rank = :cursorCategoryRank + and ranked.category_order = :cursorCategoryOrder + and ranked.sort_value = :cursorSortValue + and ranked.mission_id > :cursorMissionId + ) + order by + ranked.preference_rank, + ranked.category_rank, + ranked.category_order, + ranked.sort_value, + ranked.mission_id + limit :limit + """, nativeQuery = true) + List findRecommendedMissions( + @Param("userId") Long userId, + @Param("preferredCategories") String preferredCategories, + @Param("latitude") BigDecimal latitude, + @Param("longitude") BigDecimal longitude, + @Param("seed") long seed, + @Param("cursorPreferenceRank") Integer cursorPreferenceRank, + @Param("cursorCategoryRank") Long cursorCategoryRank, + @Param("cursorCategoryOrder") Long cursorCategoryOrder, + @Param("cursorSortValue") Double cursorSortValue, + @Param("cursorMissionId") Long cursorMissionId, + @Param("limit") int limit + ); + @Query(value = """ select m.snapshot_district_code as districtCode, diff --git a/src/main/java/com/quespot/domain/mission/repository/projection/RecommendedMissionProjection.java b/src/main/java/com/quespot/domain/mission/repository/projection/RecommendedMissionProjection.java new file mode 100644 index 0000000..516fe16 --- /dev/null +++ b/src/main/java/com/quespot/domain/mission/repository/projection/RecommendedMissionProjection.java @@ -0,0 +1,10 @@ +package com.quespot.domain.mission.repository.projection; + +public interface RecommendedMissionProjection extends MissionListProjection { + + Integer getPreferenceRank(); + + Long getCategoryRank(); + + Long getCategoryOrder(); +} diff --git a/src/main/java/com/quespot/domain/mission/service/MissionArchiveQueryService.java b/src/main/java/com/quespot/domain/mission/service/MissionArchiveQueryService.java index 11a6236..85fb413 100644 --- a/src/main/java/com/quespot/domain/mission/service/MissionArchiveQueryService.java +++ b/src/main/java/com/quespot/domain/mission/service/MissionArchiveQueryService.java @@ -62,6 +62,7 @@ public MissionArchiveListResponseDTO getArchives(Long userId, String cursorValue return MissionConverter.toArchiveListResponse(items, nextCursor, hasNext); } + // 지도형 아카이브 조회 로직 @Transactional(readOnly = true) public MissionArchiveMapResponseDTO getMapArchive( Long userId, diff --git a/src/main/java/com/quespot/domain/mission/service/MissionRecommendationService.java b/src/main/java/com/quespot/domain/mission/service/MissionRecommendationService.java new file mode 100644 index 0000000..8bf4281 --- /dev/null +++ b/src/main/java/com/quespot/domain/mission/service/MissionRecommendationService.java @@ -0,0 +1,232 @@ +package com.quespot.domain.mission.service; + +import com.quespot.domain.like.enums.LikeTargetType; +import com.quespot.domain.like.repository.LikeRepository; +import com.quespot.domain.mission.converter.MissionConverter; +import com.quespot.domain.mission.dto.res.RecommendedMissionItemResponseDTO; +import com.quespot.domain.mission.dto.res.RecommendedMissionListResponseDTO; +import com.quespot.domain.mission.enums.UserMissionStatus; +import com.quespot.domain.mission.exception.MissionException; +import com.quespot.domain.mission.exception.code.MissionErrorCode; +import com.quespot.domain.mission.repository.MissionRepository; +import com.quespot.domain.mission.repository.projection.RecommendedMissionProjection; +import com.quespot.domain.user.repository.UserProfileRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MissionRecommendationService { + + private static final BigDecimal MIN_LATITUDE = new BigDecimal("-90"); + private static final BigDecimal MAX_LATITUDE = new BigDecimal("90"); + private static final BigDecimal MIN_LONGITUDE = new BigDecimal("-180"); + private static final BigDecimal MAX_LONGITUDE = new BigDecimal("180"); + private static final ZoneId SERVICE_ZONE_ID = ZoneId.of("Asia/Seoul"); + private static final int MIN_SCAN_BATCH_SIZE = 30; + private static final int MAX_SCAN_BATCH_SIZE = 300; + + private final MissionRepository missionRepository; + private final UserProfileRepository userProfileRepository; + private final MissionAttemptStatusResolver missionAttemptStatusResolver; + private final LikeRepository likeRepository; + private final RecommendationCursorCodec recommendationCursorCodec; + + // 추천 미션 목록 조회 로직 + @Transactional(readOnly = true) + public RecommendedMissionListResponseDTO getRecommendations( + Long userId, + BigDecimal latitude, + BigDecimal longitude, + String cursorValue, + int size + ) { + boolean hasLocation = validateLocation(latitude, longitude); + String preferredCategories = preferredCategories(userId); + long seed = dailySeed(userId); + RecommendationCursor.SortMode sortMode = hasLocation + ? RecommendationCursor.SortMode.DISTANCE + : RecommendationCursor.SortMode.RANDOM; + String querySignature = recommendationCursorCodec.querySignature( + userId, preferredCategories, latitude, longitude + ); + RecommendationCursor requestedCursor = resolveCursor( + cursorValue, sortMode, seed, querySignature + ); + + List availableRows = findAvailableRows( + userId, + preferredCategories, + latitude, + longitude, + sortMode, + seed, + querySignature, + requestedCursor, + size + 1 + ); + boolean hasNext = availableRows.size() > size; + List page = hasNext + ? availableRows.subList(0, size) + : availableRows; + Set likedMissionIds = findLikedMissionIds(userId, page); + List missions = page.stream() + .map(row -> MissionConverter.toRecommendedMissionItem( + row, hasLocation, likedMissionIds.contains(row.getMissionId()) + )) + .toList(); + String nextCursor = hasNext + ? recommendationCursorCodec.encode(toCursor( + page.get(page.size() - 1), sortMode, seed, querySignature + )) + : null; + + return new RecommendedMissionListResponseDTO(missions, nextCursor, hasNext); + } + + private List findAvailableRows( + Long userId, + String preferredCategories, + BigDecimal latitude, + BigDecimal longitude, + RecommendationCursor.SortMode sortMode, + long seed, + String querySignature, + RecommendationCursor requestedCursor, + int requiredCount + ) { + int batchSize = Math.min( + MAX_SCAN_BATCH_SIZE, + Math.max(MIN_SCAN_BATCH_SIZE, requiredCount * 3) + ); + List availableRows = new ArrayList<>(requiredCount); + RecommendationCursor scanCursor = requestedCursor; + + while (availableRows.size() < requiredCount) { + List rows = missionRepository.findRecommendedMissions( + userId, + preferredCategories, + latitude, + longitude, + seed, + scanCursor == null ? null : scanCursor.preferenceRank(), + scanCursor == null ? null : scanCursor.categoryRank(), + scanCursor == null ? null : scanCursor.categoryOrder(), + scanCursor == null ? null : scanCursor.sortValue(), + scanCursor == null ? null : scanCursor.missionId(), + batchSize + ); + if (rows.isEmpty()) { + break; + } + + var statuses = missionAttemptStatusResolver.resolveStatuses( + userId, rows.stream().map(RecommendedMissionProjection::getMissionId).toList() + ); + for (RecommendedMissionProjection row : rows) { + if (statuses.getOrDefault(row.getMissionId(), UserMissionStatus.AVAILABLE) + == UserMissionStatus.AVAILABLE) { + availableRows.add(row); + if (availableRows.size() == requiredCount) { + break; + } + } + } + + if (availableRows.size() == requiredCount || rows.size() < batchSize) { + break; + } + scanCursor = toCursor( + rows.get(rows.size() - 1), sortMode, seed, querySignature + ); + } + return availableRows; + } + + private Set findLikedMissionIds(Long userId, List page) { + if (page.isEmpty()) { + return Set.of(); + } + return new HashSet<>(likeRepository.findLikedTargetIds( + userId, + LikeTargetType.MISSION, + page.stream().map(RecommendedMissionProjection::getMissionId).toList() + )); + } + + private String preferredCategories(Long userId) { + return userProfileRepository.findByUserId(userId) + .map(profile -> profile.getTravelStyles().stream() + .map(style -> style.toMissionCategory().name()) + .distinct() + .sorted() + .collect(Collectors.joining(","))) + .orElse(""); + } + + private RecommendationCursor resolveCursor( + String cursorValue, + RecommendationCursor.SortMode sortMode, + long seed, + String querySignature + ) { + if (cursorValue == null || cursorValue.isBlank()) { + return null; + } + RecommendationCursor cursor = recommendationCursorCodec.decode(cursorValue); + if (cursor.sortMode() != sortMode + || cursor.seed() != seed + || !cursor.querySignature().equals(querySignature)) { + throw new MissionException(MissionErrorCode.INVALID_CURSOR); + } + return cursor; + } + + private RecommendationCursor toCursor( + RecommendedMissionProjection row, + RecommendationCursor.SortMode sortMode, + long seed, + String querySignature + ) { + return new RecommendationCursor( + sortMode, + seed, + row.getPreferenceRank(), + row.getCategoryRank(), + row.getCategoryOrder(), + row.getSortValue(), + row.getMissionId(), + querySignature + ); + } + + private boolean validateLocation(BigDecimal latitude, BigDecimal longitude) { + if ((latitude == null) != (longitude == null)) { + throw new MissionException(MissionErrorCode.INVALID_LOCATION); + } + if (latitude == null) { + return false; + } + if (latitude.compareTo(MIN_LATITUDE) < 0 + || latitude.compareTo(MAX_LATITUDE) > 0 + || longitude.compareTo(MIN_LONGITUDE) < 0 + || longitude.compareTo(MAX_LONGITUDE) > 0) { + throw new MissionException(MissionErrorCode.INVALID_LOCATION); + } + return true; + } + + private long dailySeed(Long userId) { + return 31 * userId + LocalDate.now(SERVICE_ZONE_ID).toEpochDay(); + } +} diff --git a/src/main/java/com/quespot/domain/mission/service/MissionSpotQueryService.java b/src/main/java/com/quespot/domain/mission/service/MissionSpotQueryService.java index eb09763..3223db9 100644 --- a/src/main/java/com/quespot/domain/mission/service/MissionSpotQueryService.java +++ b/src/main/java/com/quespot/domain/mission/service/MissionSpotQueryService.java @@ -42,6 +42,7 @@ public class MissionSpotQueryService { private final MissionCursorCodec missionCursorCodec; private final MissionAttemptStatusResolver missionAttemptStatusResolver; + // 행정구역 미션 스팟 조회 로직 @Transactional(readOnly = true) public MissionSpotMapResponseDTO getMissionSpots(Long userId, String regionCode) { List districts = administrativeDistrictResolver.findByRegionCode(regionCode); @@ -72,6 +73,7 @@ public MissionSpotMapResponseDTO getMissionSpots(Long userId, String regionCode) ); } + // 주변 미션 스팟 조회 로직 @Transactional(readOnly = true) public NearbyMissionSpotListResponseDTO getNearbyMissionSpots( Long userId, @@ -89,6 +91,7 @@ public NearbyMissionSpotListResponseDTO getNearbyMissionSpots( return new NearbyMissionSpotListResponseDTO(missionSpots); } + // 행정구역 미션 목록 조회 로직 @Transactional(readOnly = true) public MissionListResponseDTO getDistrictMissions( Long userId, diff --git a/src/main/java/com/quespot/domain/mission/service/RecommendationCursor.java b/src/main/java/com/quespot/domain/mission/service/RecommendationCursor.java new file mode 100644 index 0000000..e33380b --- /dev/null +++ b/src/main/java/com/quespot/domain/mission/service/RecommendationCursor.java @@ -0,0 +1,17 @@ +package com.quespot.domain.mission.service; + +record RecommendationCursor( + SortMode sortMode, + long seed, + int preferenceRank, + long categoryRank, + long categoryOrder, + double sortValue, + long missionId, + String querySignature +) { + enum SortMode { + DISTANCE, + RANDOM + } +} diff --git a/src/main/java/com/quespot/domain/mission/service/RecommendationCursorCodec.java b/src/main/java/com/quespot/domain/mission/service/RecommendationCursorCodec.java new file mode 100644 index 0000000..100fede --- /dev/null +++ b/src/main/java/com/quespot/domain/mission/service/RecommendationCursorCodec.java @@ -0,0 +1,135 @@ +package com.quespot.domain.mission.service; + +import com.quespot.domain.mission.exception.MissionException; +import com.quespot.domain.mission.exception.code.MissionErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +@Component +public class RecommendationCursorCodec { + + private static final String DELIMITER = "|"; + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String HMAC_CONTEXT = "mission-recommendation-cursor:v1|"; + + private final SecretKeySpec secretKey; + + public RecommendationCursorCodec(@Value("${app.jwt.secret}") String secret) { + this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM); + } + + public String encode(RecommendationCursor cursor) { + String payload = String.join( + DELIMITER, + cursor.sortMode().name(), + Long.toString(cursor.seed()), + Integer.toString(cursor.preferenceRank()), + Long.toString(cursor.categoryRank()), + Long.toString(cursor.categoryOrder()), + Double.toString(cursor.sortValue()), + Long.toString(cursor.missionId()), + cursor.querySignature() + ); + String value = payload + DELIMITER + encodeMac(payload); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + public RecommendationCursor decode(String value) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8); + String[] parts = decoded.split("\\|", -1); + if (parts.length != 9) { + throw invalidCursor(); + } + + String payload = String.join( + DELIMITER, + parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], parts[7] + ); + byte[] providedMac = Base64.getUrlDecoder().decode(parts[8]); + if (!MessageDigest.isEqual(createMac(payload), providedMac)) { + throw invalidCursor(); + } + + RecommendationCursor cursor = new RecommendationCursor( + RecommendationCursor.SortMode.valueOf(parts[0]), + Long.parseLong(parts[1]), + Integer.parseInt(parts[2]), + Long.parseLong(parts[3]), + Long.parseLong(parts[4]), + Double.parseDouble(parts[5]), + Long.parseLong(parts[6]), + parts[7] + ); + validate(cursor); + return cursor; + } catch (IllegalArgumentException exception) { + throw invalidCursor(); + } + } + + public String querySignature( + Long userId, + String preferredCategories, + BigDecimal latitude, + BigDecimal longitude + ) { + String value = String.join( + DELIMITER, + userId.toString(), + preferredCategories, + normalize(latitude), + normalize(longitude) + ); + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is unavailable", exception); + } + } + + private void validate(RecommendationCursor cursor) { + if (!Double.isFinite(cursor.sortValue()) + || cursor.sortValue() < 0 + || cursor.preferenceRank() < 0 + || cursor.categoryRank() <= 0 + || cursor.categoryOrder() < 0 + || cursor.missionId() <= 0) { + throw invalidCursor(); + } + } + + private String encodeMac(String payload) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(createMac(payload)); + } + + private byte[] createMac(String payload) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(secretKey); + return mac.doFinal((HMAC_CONTEXT + payload).getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("HMAC-SHA256 algorithm is unavailable", exception); + } + } + + private String normalize(BigDecimal value) { + return value == null ? "" : value.stripTrailingZeros().toPlainString(); + } + + private MissionException invalidCursor() { + return new MissionException(MissionErrorCode.INVALID_CURSOR); + } +} diff --git a/src/main/java/com/quespot/domain/user/service/EmailVerificationService.java b/src/main/java/com/quespot/domain/user/service/EmailVerificationService.java index 824f3e4..f9466c9 100644 --- a/src/main/java/com/quespot/domain/user/service/EmailVerificationService.java +++ b/src/main/java/com/quespot/domain/user/service/EmailVerificationService.java @@ -176,6 +176,7 @@ public VerifyEmailCodeResponseDTO verifyEmailCode(VerifyEmailCodeRequestDTO requ throw new AuthException(AuthErrorCode.INVALID_EMAIL_VERIFICATION_CODE); } + // 이메일 인증 완료 여부 조회 로직 public boolean isEmailVerified(String email, EmailVerificationPurpose purpose) { return Boolean.TRUE.equals(stringRedisTemplate.hasKey(verificationCompletedKey(email, purpose))); } diff --git a/src/main/java/com/quespot/domain/user/service/OAuth2LinkRequestService.java b/src/main/java/com/quespot/domain/user/service/OAuth2LinkRequestService.java index bb7b8ee..6cdb21d 100644 --- a/src/main/java/com/quespot/domain/user/service/OAuth2LinkRequestService.java +++ b/src/main/java/com/quespot/domain/user/service/OAuth2LinkRequestService.java @@ -87,6 +87,7 @@ public LinkRequest consume(String nonce) { return parse(value); } + // 소셜 로그인 제공자 확인 로직 public LoginProvider resolveSocialProvider(String providerValue) { if (providerValue == null || providerValue.isBlank()) { throw new AuthException(AuthErrorCode.UNSUPPORTED_LOGIN_PROVIDER); diff --git a/src/main/java/com/quespot/domain/user/service/OAuth2UnlinkTaskStateService.java b/src/main/java/com/quespot/domain/user/service/OAuth2UnlinkTaskStateService.java index 0fad99f..b202669 100644 --- a/src/main/java/com/quespot/domain/user/service/OAuth2UnlinkTaskStateService.java +++ b/src/main/java/com/quespot/domain/user/service/OAuth2UnlinkTaskStateService.java @@ -17,6 +17,7 @@ public class OAuth2UnlinkTaskStateService { private final OAuth2UnlinkTaskRepository oAuth2UnlinkTaskRepository; + // OAuth 연동 해제 작업 선점 로직 @Transactional public Optional claim(LoginProvider provider, String providerUserId) { return oAuth2UnlinkTaskRepository @@ -25,6 +26,7 @@ public Optional claim(LoginProvider provider, String provider .map(task -> new ClaimedUnlinkTask(task.getId(), task.toCommand())); } + // OAuth 연동 해제 작업 완료 처리 로직 @Transactional public void complete(Long taskId) { oAuth2UnlinkTaskRepository.findByIdForUpdate(taskId) @@ -32,6 +34,7 @@ public void complete(Long taskId) { .ifPresent(oAuth2UnlinkTaskRepository::delete); } + // OAuth 연동 해제 작업 실패 처리 로직 @Transactional public void fail(Long taskId, String errorMessage, boolean retryable) { oAuth2UnlinkTaskRepository.findByIdForUpdate(taskId) @@ -39,6 +42,7 @@ public void fail(Long taskId, String errorMessage, boolean retryable) { .ifPresent(task -> task.recordFailure(errorMessage, retryable)); } + // 소셜 계정 재연결 전 연동 해제 작업 정리 로직 @Transactional public void prepareForRelink(LoginProvider provider, String providerUserId) { oAuth2UnlinkTaskRepository diff --git a/src/main/java/com/quespot/domain/user/service/RefreshTokenService.java b/src/main/java/com/quespot/domain/user/service/RefreshTokenService.java index b29ece4..9144e67 100644 --- a/src/main/java/com/quespot/domain/user/service/RefreshTokenService.java +++ b/src/main/java/com/quespot/domain/user/service/RefreshTokenService.java @@ -74,6 +74,7 @@ public class RefreshTokenService { private final StringRedisTemplate stringRedisTemplate; + // 로그인 세션 유효성 조회 로직 public boolean isSessionActive(Long userId, String sessionId) { if (userId == null || sessionId == null || sessionId.isBlank()) { return false; @@ -85,6 +86,7 @@ public boolean isSessionActive(Long userId, String sessionId) { return String.valueOf(userId).equals(savedUserId); } + // 리프레시 토큰 저장 로직 public void saveRefreshToken(Long userId, JwtTokenPair tokenPair) { long currentTimeMillis = Instant.now().toEpochMilli(); long expirationTimeMillis = currentTimeMillis + tokenPair.refreshTokenExpiresInSeconds() * 1000; @@ -104,6 +106,7 @@ public void saveRefreshToken(Long userId, JwtTokenPair tokenPair) { ); } + // 리프레시 토큰 교체 로직 public void rotateRefreshToken( Long userId, String currentRefreshToken, @@ -131,6 +134,7 @@ public void rotateRefreshToken( } } + // 로그인 세션 삭제 로직 public void deleteSession(Long userId, String sessionId) { if (userId == null || sessionId == null || sessionId.isBlank()) { return; @@ -146,6 +150,7 @@ public void deleteSession(Long userId, String sessionId) { ); } + // 사용자의 전체 로그인 세션 삭제 로직 public void deleteAllSessions(Long userId) { if (userId == null) { return; diff --git a/src/main/java/com/quespot/global/file/service/FileService.java b/src/main/java/com/quespot/global/file/service/FileService.java index 20cb0c0..27ffe53 100644 --- a/src/main/java/com/quespot/global/file/service/FileService.java +++ b/src/main/java/com/quespot/global/file/service/FileService.java @@ -31,6 +31,7 @@ public class FileService { private final FileStorage fileStorage; + // 파일 업로드용 Presigned URL 생성 로직 public PresignedUploadResult createPresignedUploadUrl( Long userId, UploadPurpose purpose, @@ -51,11 +52,13 @@ public PresignedUploadResult createPresignedUploadUrl( ); } + // 파일 조회용 Presigned URL 생성 로직 public String createPresignedDownloadUrl(String objectKey) { validateObjectKey(objectKey); return fileStorage.createPresignedDownloadUrl(objectKey); } + // 파일 소유자 및 업로드 용도 검증 로직 public void validateOwnedObjectKey( Long userId, UploadPurpose purpose, @@ -78,6 +81,7 @@ public void validateOwnedObjectKey( } } + // 파일 삭제 로직 public void deleteObject(String objectKey) { validateObjectKey(objectKey); fileStorage.deleteObject(objectKey); diff --git a/src/test/java/com/quespot/domain/mission/repository/MissionRecommendationRepositoryTest.java b/src/test/java/com/quespot/domain/mission/repository/MissionRecommendationRepositoryTest.java new file mode 100644 index 0000000..77ac9c5 --- /dev/null +++ b/src/test/java/com/quespot/domain/mission/repository/MissionRecommendationRepositoryTest.java @@ -0,0 +1,146 @@ +package com.quespot.domain.mission.repository; + +import com.quespot.domain.mission.entity.Mission; +import com.quespot.domain.mission.entity.MissionAttempt; +import com.quespot.domain.mission.entity.MissionCandidate; +import com.quespot.domain.mission.enums.MissionCategory; +import com.quespot.domain.mission.enums.MissionTemplate; +import com.quespot.domain.mission.repository.projection.RecommendedMissionProjection; +import com.quespot.domain.spot.entity.Spot; +import com.quespot.domain.spot.enums.AppCategory; +import com.quespot.domain.spot.enums.SpotSource; +import com.quespot.domain.spot.repository.SpotRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +@SpringBootTest +@Testcontainers +class MissionRecommendationRepositoryTest { + + private static final long USER_ID = 9201L; + + @Container + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("quespot_test").withUsername("test").withPassword("test"); + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + registry.add("app.jwt.secret", () -> "dGVzdC1qd3Qtc2VjcmV0LWtleS1tdXN0LWJlLWF0LWxlYXN0LTMyLWJ5dGVz"); + registry.add("app.mail.verification-code-secret", () -> "integration-test-mail-secret"); + } + + @Autowired + private MissionRepository missionRepository; + + @Autowired + private MissionAttemptRepository missionAttemptRepository; + + @Autowired + private MissionCandidateRepository missionCandidateRepository; + + @Autowired + private SpotRepository spotRepository; + + @Autowired + private DataSource dataSource; + + @BeforeEach + void cleanDatabase() throws SQLException { + try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) { + statement.execute("DELETE FROM mission_attempts"); + statement.execute("DELETE FROM missions"); + statement.execute("DELETE FROM mission_candidates"); + statement.execute("DELETE FROM spots"); + } + } + + @Test + void recommendsPreferredCategoriesFirstAndExcludesCompletedAndInProgressMissions() { + Mission foodNear = createMission("food-near", AppCategory.FOOD, MissionTemplate.FOOD_LOCATION, "37.5700"); + Mission foodFar = createMission("food-far", AppCategory.FOOD, MissionTemplate.FOOD_LOCATION, "37.5710"); + Mission natureNear = createMission("nature-near", AppCategory.NATURE, MissionTemplate.NATURE_LOCATION, "37.5800"); + Mission natureFar = createMission("nature-far", AppCategory.NATURE, MissionTemplate.NATURE_LOCATION, "37.5810"); + Mission history = createMission("history", AppCategory.HISTORY, MissionTemplate.HISTORY_LOCATION, "37.5900"); + Mission completed = createMission("completed", AppCategory.FOOD, MissionTemplate.FOOD_LOCATION, "37.6000"); + Mission inProgress = createMission("progress", AppCategory.NATURE, MissionTemplate.NATURE_LOCATION, "37.6100"); + + MissionAttempt completedAttempt = MissionAttempt.start(USER_ID, completed); + completedAttempt.complete(completed.getSnapshotLatitude(), completed.getSnapshotLongitude(), 80); + missionAttemptRepository.save(completedAttempt); + missionAttemptRepository.save(MissionAttempt.start(USER_ID, inProgress)); + + List rows = missionRepository.findRecommendedMissions( + USER_ID, + "FOOD,NATURE", + new BigDecimal("37.5665"), + new BigDecimal("126.9780"), + 1234L, + null, null, null, null, null, + 20 + ); + + assertThat(rows).extracting(RecommendedMissionProjection::getMissionId) + .doesNotContain(completed.getId(), inProgress.getId()); + String firstCategory = rows.get(0).getCategory(); + String secondCategory = firstCategory.equals(MissionCategory.FOOD.name()) + ? MissionCategory.NATURE.name() + : MissionCategory.FOOD.name(); + assertThat(rows.subList(0, 4)).extracting( + RecommendedMissionProjection::getCategory, + RecommendedMissionProjection::getCategoryRank + ) + .containsExactly( + tuple(firstCategory, 1L), + tuple(secondCategory, 1L), + tuple(firstCategory, 2L), + tuple(secondCategory, 2L) + ); + assertThat(rows.subList(0, 4)).extracting(RecommendedMissionProjection::getPreferenceRank) + .containsOnly(0); + assertThat(rows.get(4).getMissionId()).isEqualTo(history.getId()); + assertThat(rows.get(4).getPreferenceRank()).isEqualTo(1); + assertThat(rows).extracting(RecommendedMissionProjection::getMissionId) + .contains(foodNear.getId(), foodFar.getId(), natureNear.getId(), natureFar.getId()); + } + + private Mission createMission( + String suffix, + AppCategory appCategory, + MissionTemplate template, + String latitude + ) { + Spot spot = spotRepository.save(Spot.builder() + .source(SpotSource.TOUR_API) + .sourceContentId("rec-" + suffix) + .name("추천 장소 " + suffix) + .latitude(new BigDecimal(latitude)) + .longitude(new BigDecimal("126.9780")) + .appCategory(appCategory) + .categoryMappingVersion(1) + .showFlag(true) + .build()); + MissionCandidate candidate = missionCandidateRepository.save( + MissionCandidate.generate(spot, template, 1) + ); + return missionRepository.save(Mission.publish(candidate)); + } +} diff --git a/src/test/java/com/quespot/domain/mission/service/MissionRecommendationServiceTest.java b/src/test/java/com/quespot/domain/mission/service/MissionRecommendationServiceTest.java new file mode 100644 index 0000000..3cba588 --- /dev/null +++ b/src/test/java/com/quespot/domain/mission/service/MissionRecommendationServiceTest.java @@ -0,0 +1,161 @@ +package com.quespot.domain.mission.service; + +import com.quespot.domain.like.enums.LikeTargetType; +import com.quespot.domain.like.repository.LikeRepository; +import com.quespot.domain.mission.enums.MissionCategory; +import com.quespot.domain.mission.enums.UserMissionStatus; +import com.quespot.domain.mission.exception.MissionException; +import com.quespot.domain.mission.repository.MissionRepository; +import com.quespot.domain.mission.repository.projection.RecommendedMissionProjection; +import com.quespot.domain.user.entity.UserProfile; +import com.quespot.domain.user.enums.TravelStyle; +import com.quespot.domain.user.repository.UserProfileRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MissionRecommendationServiceTest { + + private MissionRepository missionRepository; + private UserProfileRepository userProfileRepository; + private MissionAttemptStatusResolver missionAttemptStatusResolver; + private LikeRepository likeRepository; + private MissionRecommendationService service; + + @BeforeEach + void setUp() { + missionRepository = mock(MissionRepository.class); + userProfileRepository = mock(UserProfileRepository.class); + missionAttemptStatusResolver = mock(MissionAttemptStatusResolver.class); + likeRepository = mock(LikeRepository.class); + service = new MissionRecommendationService( + missionRepository, + userProfileRepository, + missionAttemptStatusResolver, + likeRepository, + new RecommendationCursorCodec("recommendation-service-test-secret") + ); + when(missionAttemptStatusResolver.resolveStatuses(anyLong(), any())).thenReturn(Map.of()); + } + + @Test + void recommendsPreferredAvailableMissionsWithDistanceAndLikedStatus() { + UserProfile profile = mock(UserProfile.class); + when(profile.getTravelStyles()).thenReturn(Set.of(TravelStyle.NATURE, TravelStyle.FOOD)); + when(userProfileRepository.findByUserId(10L)).thenReturn(Optional.of(profile)); + + BigDecimal latitude = new BigDecimal("37.5665"); + BigDecimal longitude = new BigDecimal("126.9780"); + RecommendedMissionProjection food = projection(1L, MissionCategory.FOOD, 0, 1, 10, 1200.4); + RecommendedMissionProjection nature = projection(2L, MissionCategory.NATURE, 0, 1, 20, 1800.0); + RecommendedMissionProjection fallback = projection(3L, MissionCategory.HISTORY, 1, 1, 30, 2300.0); + when(missionRepository.findRecommendedMissions( + eq(10L), + eq("FOOD,NATURE"), + eq(latitude), + eq(longitude), + anyLong(), + isNull(), isNull(), isNull(), isNull(), isNull(), + eq(30) + )).thenReturn(List.of(food, nature, fallback)); + when(likeRepository.findLikedTargetIds( + 10L, LikeTargetType.MISSION, List.of(1L, 2L) + )).thenReturn(List.of(2L)); + + var result = service.getRecommendations(10L, latitude, longitude, null, 2); + + assertThat(result.missions()).extracting("missionId").containsExactly(1L, 2L); + assertThat(result.missions()).extracting("distanceMeters").containsExactly(1200L, 1800L); + assertThat(result.missions()).extracting("liked").containsExactly(false, true); + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isNotBlank(); + } + + @Test + void excludesLockedMissionAndFallsBackWithoutProfileOrLocation() { + when(userProfileRepository.findByUserId(10L)).thenReturn(Optional.empty()); + RecommendedMissionProjection locked = projection(1L, MissionCategory.FOOD, 0, 1, 10, 100.0); + RecommendedMissionProjection available = projection(2L, MissionCategory.HISTORY, 0, 1, 20, 200.0); + when(missionRepository.findRecommendedMissions( + eq(10L), + eq(""), + isNull(), + isNull(), + anyLong(), + isNull(), isNull(), isNull(), isNull(), isNull(), + eq(30) + )).thenReturn(List.of(locked, available)); + when(missionAttemptStatusResolver.resolveStatuses(10L, List.of(1L, 2L))) + .thenReturn(Map.of(1L, UserMissionStatus.LOCKED)); + when(likeRepository.findLikedTargetIds(10L, LikeTargetType.MISSION, List.of(2L))) + .thenReturn(List.of()); + + var result = service.getRecommendations(10L, null, null, null, 2); + + assertThat(result.missions()).extracting("missionId").containsExactly(2L); + assertThat(result.missions().get(0).distanceMeters()).isNull(); + assertThat(result.hasNext()).isFalse(); + } + + @Test + void returnsEmptyResultWithoutQueryingLikesWhenNoCandidateExists() { + when(userProfileRepository.findByUserId(10L)).thenReturn(Optional.empty()); + when(missionRepository.findRecommendedMissions( + eq(10L), eq(""), isNull(), isNull(), anyLong(), + isNull(), isNull(), isNull(), isNull(), isNull(), anyInt() + )).thenReturn(List.of()); + + var result = service.getRecommendations(10L, null, null, null, 20); + + assertThat(result.missions()).isEmpty(); + assertThat(result.hasNext()).isFalse(); + assertThat(result.nextCursor()).isNull(); + verify(likeRepository, never()).findLikedTargetIds(anyLong(), any(), any()); + } + + @Test + void rejectsPartialLocation() { + assertThatThrownBy(() -> service.getRecommendations( + 10L, new BigDecimal("37.5"), null, null, 2 + )).isInstanceOf(MissionException.class); + } + + private RecommendedMissionProjection projection( + Long missionId, + MissionCategory category, + int preferenceRank, + long categoryRank, + long categoryOrder, + double sortValue + ) { + RecommendedMissionProjection projection = mock(RecommendedMissionProjection.class); + when(projection.getMissionId()).thenReturn(missionId); + when(projection.getTitle()).thenReturn("추천 미션 " + missionId); + when(projection.getCategory()).thenReturn(category.name()); + when(projection.getSpotName()).thenReturn("추천 장소 " + missionId); + when(projection.getRewardPoint()).thenReturn(100); + when(projection.getEstimatedMinutes()).thenReturn(30); + when(projection.getPreferenceRank()).thenReturn(preferenceRank); + when(projection.getCategoryRank()).thenReturn(categoryRank); + when(projection.getCategoryOrder()).thenReturn(categoryOrder); + when(projection.getSortValue()).thenReturn(sortValue); + return projection; + } +} diff --git a/src/test/java/com/quespot/domain/mission/service/RecommendationCursorCodecTest.java b/src/test/java/com/quespot/domain/mission/service/RecommendationCursorCodecTest.java new file mode 100644 index 0000000..2202df3 --- /dev/null +++ b/src/test/java/com/quespot/domain/mission/service/RecommendationCursorCodecTest.java @@ -0,0 +1,69 @@ +package com.quespot.domain.mission.service; + +import com.quespot.domain.mission.exception.MissionException; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RecommendationCursorCodecTest { + + private static final String SECRET = "recommendation-cursor-test-secret"; + + private final RecommendationCursorCodec codec = new RecommendationCursorCodec(SECRET); + + @Test + void roundTripsSignedCursor() { + RecommendationCursor cursor = new RecommendationCursor( + RecommendationCursor.SortMode.DISTANCE, + 123L, + 0, + 2L, + 100L, + 1250.5, + 7L, + "query-signature" + ); + + RecommendationCursor decoded = codec.decode(codec.encode(cursor)); + + assertThat(decoded).isEqualTo(cursor); + } + + @Test + void rejectsTamperedCursorPayload() { + RecommendationCursor cursor = new RecommendationCursor( + RecommendationCursor.SortMode.RANDOM, + 123L, + 0, + 1L, + 100L, + 200.0, + 7L, + "query-signature" + ); + String encoded = codec.encode(cursor); + String decoded = new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + String tampered = Base64.getUrlEncoder().withoutPadding().encodeToString( + decoded.replace("|200.0|", "|201.0|").getBytes(StandardCharsets.UTF_8) + ); + + assertThatThrownBy(() -> codec.decode(tampered)).isInstanceOf(MissionException.class); + } + + @Test + void querySignatureNormalizesEquivalentCoordinates() { + String first = codec.querySignature( + 1L, "FOOD,NATURE", new BigDecimal("37.500"), new BigDecimal("127.000") + ); + String second = codec.querySignature( + 1L, "FOOD,NATURE", new BigDecimal("37.5"), new BigDecimal("127") + ); + + assertThat(first).isEqualTo(second); + } +}