Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pom.xml.releaseBackup
*.7z

.claude
.codex

# Raw external data (station pipeline source files)
src/main/resources/data/raw/
Expand All @@ -76,4 +77,4 @@ scripts/GenerateSignupToken.java
scripts/cleanup-retired-place-tags.sql

# Google Sheets API 서비스 계정 키 (장소 데이터 시트 쓰기 배치용)
credentials/
credentials/
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
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.security.SecurityRequirement;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -29,26 +28,26 @@ public class CustomRecommendationController {
summary = "맞춤추천",
description = """
출발역·이동 가능 시간·여행 스타일(최소 1개, 최대 3개)을 받아 조건에 맞는 역 하나를 추천한다.
- 출발역에서 이동 가능 시간 내에 닿는 뽑기 대상 역만 후보로 둔다(상관없음이면 시간 제한 없음).
- 여행 스타일 태그로 역 점수(장소 수 합 + 충족 태그 수 × 10)를 매기되, 가본 역(스탬프 있는 역)은 4점 감점한 뒤 상위 5개 역을 후보군으로 묶는다.
- 직전 추천 역은 제외한다. 제외 후 후보가 없으면 이 조건도 적용하지 않는다.
- 남은 후보 중 무작위로 하나를 고른다.
- 출발역에서 이동 가능 시간 내에 닿는 뽑기 대상 역 전체를 후보로 둔다(컷 없음, 상관없음이면 시간 제한 없음).
- 여행 스타일 태그로 역 점수(장소 수 합 + 충족 태그 수 × 10)를 매기되, 가본 역(스탬프 있는 역)은 4점 감점한다. 동점은 역 ID 오름차순으로 정렬한다.
- 같은 추천 세션·같은 선택 조건에서는 아직 보여주지 않은 역을 등수 순서대로 하나씩 제공한다.
- 결과 화면의 다시 뽑기는 같은 recommendationSessionId를 사용하며, 새 조건 선택 흐름에서는 새 UUID를 사용한다.
- 해당 세션·조건의 역을 전부 제공하면 후보 전체 중 직전 맞춤추천 1건만 제외한 무작위로 전환한다.
- 결과 역에서 코스를 만들려면 역별 장소 목록 조회로 이어진다.

accessToken은 **선택**이다. 없으면 비로그인 추천으로 동작한다(가본 역 감점·직전 추천 제외 미적용).
accessToken은 **선택**이다. 비로그인도 세션별 순차 추천을 적용하며 가본 역 감점만 적용하지 않는다.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
보냈는데 만료·위조면 401이다.
"""
)
@SecurityRequirement(name = "accessTokenAuth")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "추천 성공"),
@ApiResponse(responseCode = "400", description = "요청 값 검증 실패 — 여행 스타일이 1~3개가 아니거나, 존재하지 않거나, 중복됨 (`GlobalErrorCode.VALIDATION_ERROR`)"),
@ApiResponse(responseCode = "400", description = "요청 값 검증 실패 — 추천 세션 ID 누락·UUID 형식 오류, 필수 조건 누락, 여행 스타일 개수·존재 여부·중복 오류 (`GlobalErrorCode.VALIDATION_ERROR`)"),
@ApiResponse(responseCode = "401", description = "accessToken을 보냈으나 위변조 또는 만료 (`GlobalErrorCode.INVALID_TOKEN`, `GlobalErrorCode.EXPIRED_TOKEN`)"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 출발역 또는 조건에 맞게 갈 수 있는 역이 없음 (`RecommendationErrorCode.DEPARTURE_STATION_NOT_FOUND`, `NO_REACHABLE_STATION`)"),
})
@PostMapping("/custom")
public CommonResponse<CustomRecommendationResponse> recommendCustom(
// 비로그인도 추천받을 수 있어야 해서 required = false다. 로그인 시에만 가본 역 감점·직전 추천 제외가 적용된다.
// 비로그인도 추천받을 수 있어 required = false다. 로그인 시에만 가본 역 감점이 적용된다.
@Parameter(hidden = true) @AuthenticationPrincipal(required = false) JwtPrincipal principal,
@Valid @RequestBody CustomRecommendationRequest request) {
Long memberId = (principal != null) ? principal.memberId() : null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.cotato.nextstation.domain.recommendation.controller;

import com.cotato.nextstation.domain.recommendation.dto.request.RandomRecommendationRequest;
import com.cotato.nextstation.domain.recommendation.dto.response.CoursePreviewResponse;
import com.cotato.nextstation.domain.recommendation.dto.response.RandomRecommendationResponse;
import com.cotato.nextstation.domain.recommendation.service.command.RecommendationCommandService;
Expand All @@ -10,10 +11,11 @@
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.security.SecurityRequirement;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -28,46 +30,46 @@ public class RandomController {
summary = "랜덤뽑기",
description = """
뽑기 대상 역 중 무작위로 1개를 뽑아 역 정보와 코스 미리보기를 반환한다.
- 로그인 시 직전 추천 1건을 제외한다. 제외 후 후보가 없으면 전체에서 다시 뽑는다.
- 환승역이면 소속 노선을 모두 반환하며, 대표 노선이 목록 맨 앞에 온다.
- 코스 미리보기는 카테고리별 장소 1개씩으로 구성되며, 장소가 없는 카테고리는 제외된다.
- 코스 미리보기는 저장되지 않는다. 저장은 코스 저장 API에서만 일어난다.
- 같은 추천 세션에서는 이미 보여준 역을 제외하고 무작위로 추천한다.
- 결과 화면에서 다시 뽑을 때는 같은 recommendationSessionId를 사용한다.
- 새 결과 화면에 진입할 때는 새로운 UUID를 사용한다.
- 해당 세션에서 모든 역을 추천하면 직전 추천역만 제외하고 무작위로 추천한다.
- 환승역이면 소속 노선을 모두 반환한다.
- 코스 미리보기는 카테고리별 장소 1개씩으로 구성하며 장소가 없는 카테고리는 제외한다.

accessToken은 **선택**이다. 없으면 비로그인 뽑기로 동작하고,
보냈는데 만료·위조면 401이다.
accessToken은 선택이다. 비로그인도 세션별 중복 방지를 적용한다.
보냈는데 만료·위조된 토큰이면 401이다.
"""
)
@SecurityRequirement(name = "accessTokenAuth")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "뽑기 성공"),
@ApiResponse(responseCode = "401", description = "accessToken을 보냈으나 위변조 또는 만료 (`GlobalErrorCode.INVALID_TOKEN`, `GlobalErrorCode.EXPIRED_TOKEN`)"),
@ApiResponse(responseCode = "400", description = "추천 세션 ID 누락 또는 UUID 형식 오류"),
@ApiResponse(responseCode = "401", description = "accessToken 위변조 또는 만료"),
@ApiResponse(responseCode = "404", description = "뽑기 대상 역이 없음"),
})
@PostMapping
public CommonResponse<RandomRecommendationResponse> drawRandom(
// 비로그인도 뽑을 수 있어야 해서 required = false다. 로그인 시에만 직전 추천을 제외한다.
@Parameter(hidden = true) @AuthenticationPrincipal(required = false) JwtPrincipal principal) {
@Parameter(hidden = true) @AuthenticationPrincipal(required = false) JwtPrincipal principal,
@Valid @RequestBody RandomRecommendationRequest request) {
Long memberId = (principal != null) ? principal.memberId() : null;
return CommonResponse.success(recommendationCommandService.drawRandom(memberId));
return CommonResponse.success(recommendationCommandService.drawRandom(memberId, request));
}

@Operation(
summary = "코스만 다시 뽑기",
description = """
역은 그대로 두고 코스 미리보기만 다시 무작위로 뽑는다. 랜덤뽑기 결과 화면에서 "다시 뽑기"를 눌렀을 때 쓴다.
- 직전 결과와 관계없이 완전 무작위다.
- 카테고리별 장소 1개씩으로 구성되며, 장소가 없는 카테고리는 제외된다.
- 코스 미리보기는 저장되지 않는다. 저장은 코스 저장 API에서만 일어난다.
- 역을 새로 추천한 것이 아니므로 recommendation_log에 기록되지 않는다.
역은 그대로 두고 코스 미리보기만 다시 무작위로 뽑는다.
- 카테고리별 장소 1개씩으로 구성하며 장소가 없는 카테고리는 제외한다.
- 새로운 역 추천이 아니므로 recommendation_log에는 기록하지 않는다.
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "다시 뽑기 성공"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 역 (`StationErrorCode.STATION_NOT_FOUND`)"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 역"),
})
@PostMapping("/{stationId}/course")
public CommonResponse<CoursePreviewResponse> redrawCourse(
@Parameter(description = "코스를 다시 뽑을 역 ID (직전 뽑기 결과의 역)", example = "10")
@Parameter(description = "코스를 다시 뽑을 역 ID", example = "10")
@PathVariable Long stationId) {
return CommonResponse.success(recommendationCommandService.redrawCourse(stationId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,25 @@
import com.cotato.nextstation.domain.recommendation.enums.TravelTime;
import com.cotato.nextstation.global.validation.EnumNames;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

import java.util.List;

@Schema(description = "맞춤추천 요청")
public record CustomRecommendationRequest(

@Schema(description = "맞춤추천 결과 화면 세션 ID. 같은 결과 화면에서 다시 뽑을 때 동일한 값을 사용한다.",
example = "550e8400-e29b-41d4-a716-446655440000")
@NotBlank(message = "추천 세션 ID는 필수입니다.")
@Pattern(
regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
message = "추천 세션 ID는 UUID 형식이어야 합니다."
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
String recommendationSessionId,

@Schema(description = "출발역 ID (저장된 출발역 또는 검색으로 고른 역)", example = "1")
@NotNull(message = "출발역은 필수입니다.")
Long departureStationId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.cotato.nextstation.domain.recommendation.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

@Schema(description = "랜덤추천 요청")
public record RandomRecommendationRequest(

@Schema(description = "랜덤추천 결과 화면 세션 ID. 같은 결과 화면에서 다시 뽑을 때 동일한 값을 사용한다.",
example = "550e8400-e29b-41d4-a716-446655440000")
@NotBlank(message = "추천 세션 ID는 필수입니다.")
@Pattern(
regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
message = "추천 세션 ID는 UUID 형식이어야 합니다."
)
String recommendationSessionId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
@Getter
@Table(
name = "recommendation_log",
// 직전 추천 1건 조회(member_id 필터 + 최신순)를 위한 복합 인덱스
indexes = @Index(name = "idx_recommendation_log_member_created", columnList = "member_id, created_at")
indexes = {
@Index(name = "idx_recommendation_log_member_created", columnList = "member_id, created_at"),
@Index(name = "idx_recommendation_log_session_condition",
columnList = "session_id, is_random, departure_station_id, travel_time, travel_styles")
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
Expand All @@ -44,6 +47,9 @@ public class RecommendationLog extends BaseEntity {
@Column(name = "is_random", nullable = false)
private boolean isRandom;

@Column(name = "session_id", length = 36)
private String recommendationSessionId;

@Column(name = "departure_station_id")
private Long departureStationId;

Expand All @@ -60,16 +66,18 @@ public class RecommendationLog extends BaseEntity {

@Builder
private RecommendationLog(Long memberId, Long resultStationId, boolean isRandom,
Long departureStationId, TravelTime travelTime, List<String> travelStyles) {
String recommendationSessionId, Long departureStationId,
TravelTime travelTime, List<String> travelStyles) {
this.memberId = memberId;
this.resultStationId = resultStationId;
this.isRandom = isRandom;
this.recommendationSessionId = recommendationSessionId;
this.departureStationId = departureStationId;
this.travelTime = travelTime;
this.travelStyles = joinSorted(travelStyles);
this.travelStyles = canonicalizeTravelStyles(travelStyles);
}

private static String joinSorted(List<String> travelStyles) {
public static String canonicalizeTravelStyles(List<String> travelStyles) {
if (travelStyles == null || travelStyles.isEmpty()) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,27 @@

public interface RecommendationLogRepository extends JpaRepository<RecommendationLog, Long> {

// 직전 추천 1건. 로그인 사용자에게만 조회한다.
// created_at이 같은 순간에 겹칠 때를 대비해 id로 tie-break 한다.
Optional<RecommendationLog> findTopByMemberIdOrderByCreatedAtDescIdDesc(Long memberId);
// 같은 추천 세션에서 랜덤추천으로 제공한 역 ID 전체를 조회한다.
@Query("SELECT DISTINCT rl.resultStationId FROM RecommendationLog rl " +
"WHERE rl.recommendationSessionId = :sessionId AND rl.isRandom = true")
List<Long> findRandomRecommendedStationIds(@Param("sessionId") String sessionId);

// 같은 추천 세션의 직전 랜덤추천 1건을 조회한다. created_at 동점은 id로 결정한다.
Optional<RecommendationLog> findTopByRecommendationSessionIdAndIsRandomTrueOrderByCreatedAtDescIdDesc(
String recommendationSessionId);

// 같은 추천 세션과 선택 조건에서 이미 제공한 역을 조회한다.
@Query("SELECT DISTINCT rl.resultStationId FROM RecommendationLog rl " +
"WHERE rl.recommendationSessionId = :sessionId AND rl.isRandom = false " +
"AND rl.departureStationId = :departureStationId AND rl.travelTime = :travelTime " +
"AND rl.travelStyles = :travelStyles")
List<Long> findCustomRecommendedStationIds(@Param("sessionId") String sessionId,
@Param("departureStationId") Long departureStationId,
@Param("travelTime") TravelTime travelTime,
@Param("travelStyles") String travelStyles);

Optional<RecommendationLog> findTopByRecommendationSessionIdAndIsRandomFalseAndDepartureStationIdAndTravelTimeAndTravelStylesOrderByCreatedAtDescIdDesc(
String recommendationSessionId, Long departureStationId, TravelTime travelTime, String travelStyles);

// 리포트 집계 구간은 모두 [from, to), 일간·주간 리포트가 같은 건을 중복 집계하지 않도록 끝을 배제한다.
@Query("SELECT COUNT(l) FROM RecommendationLog l "
Expand Down
Loading
Loading