Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ services:
restart: unless-stopped
env_file:
- .env
secrets:
- firebase-service-account
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
Expand Down Expand Up @@ -164,6 +166,10 @@ configs:
}
}

secrets:
firebase-service-account:
file: /opt/quespot/secrets/firebase-service-account.json

volumes:
redis-data:
certbot-webroot:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> findLikedTargetIds(
@Param("userId") Long userId,
@Param("targetType") LikeTargetType targetType,
@Param("targetIds") List<Long> targetIds
);

// likes엔 FK가 없어 연관관계 대신 `join ... on`으로 붙인다. 비활성 미션은
// 목록에서 빠진다(좋아요 행은 남겨둔다 — 해제는 사용자가 한다).
@Query("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -105,4 +108,33 @@ public ApiResponse<MissionDetailResponseDTO> getMission(
)
);
}

@GetMapping("/recommendations")
@Operation(
summary = "추천 미션 조회",
description = """
사용자의 여행 스타일과 현재 위치를 기준으로 바로 시작할 수 있는 미션만 추천한다. 홈은 size=2,
전체보기는 size와 cursor를 사용한다. latitude/longitude는 함께 보내거나 모두 생략해야 하며, 좌표를
보내면 카테고리별 가까운 미션을 우선하고 생략하면 일일 고정 랜덤순이다. 선호 카테고리를 우선하면서
가능한 경우 카테고리를 번갈아 배치하고, 후보가 부족하면 다른 카테고리로 보충한다.

첫 요청은 cursor 없이 보내고 응답의 nextCursor를 다음 요청에 그대로 넣는다. 사용자 여행 스타일,
좌표 또는 날짜(KST)가 바뀌면 기존 커서는 MISSION_400_002로 거부되므로 처음부터 다시 조회한다.
완료·진행 중·코스에서 잠긴 미션은 제외되며, 결과가 없으면 missions는 빈 배열이다.
"""
)
public ApiResponse<RecommendedMissionListResponseDTO> 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
)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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<RecommendedMissionItemResponseDTO> missions,
@Schema(description = "다음 요청의 cursor에 그대로 넣는다. 마지막 페이지면 null", nullable = true)
String nextCursor,
boolean hasNext
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"미션을 일괄 발행했습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -120,6 +121,109 @@ List<MissionListProjection> 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<RecommendedMissionProjection> 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.quespot.domain.mission.repository.projection;

public interface RecommendedMissionProjection extends MissionListProjection {

Integer getPreferenceRank();

Long getCategoryRank();

Long getCategoryOrder();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading