From f919fbfeebd09f840524d334e93d06ee25b5a785 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 20 May 2026 21:07:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=A7=80=EC=97=AD=EC=83=81?= =?UTF-8?q?=EC=83=9D=20=EC=9D=BC=EC=A0=95=20=EC=B6=94=EC=B2=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../application/consensus/ConsensusDtos.kt | 6 + .../application/consensus/ConsensusService.kt | 370 ++++++++++++++---- .../application/photo/PhotoService.kt | 4 +- .../ExternalPopularityBatchService.kt | 301 ++++++++++++++ .../popularity/GooglePlacePhotoService.kt | 28 ++ .../tripsync/application/room/RoomService.kt | 28 +- .../ScheduleGenerationPersistenceService.kt | 29 ++ .../schedule/ScheduleResponseMapper.kt | 172 ++++++-- .../application/schedule/ScheduleService.kt | 14 +- .../tripsync/common/config/SecurityConfig.kt | 1 + .../domain/entity/ExternalPopularityMetric.kt | 57 +++ .../com/tripsync/domain/entity/TripRoom.kt | 6 + .../ExternalPopularityMetricRepository.kt | 13 + .../repository/RoomMemberProfileRepository.kt | 1 + .../domain/repository/RoomMemberRepository.kt | 1 + .../domain/repository/ScheduleRepository.kt | 1 + .../ExternalPopularityProperties.kt | 31 ++ .../popularity/GooglePlacesClient.kt | 113 ++++++ .../popularity/NaverDataLabClient.kt | 81 ++++ .../com/tripsync/web/place/PlaceController.kt | 25 ++ .../com/tripsync/web/room/RoomController.kt | 4 +- .../tripsync/web/tourapi/TourApiController.kt | 10 + src/main/resources/application.yml | 18 + .../V8__add_external_popularity_metrics.sql | 17 + .../V9__add_trip_room_date_range.sql | 14 + .../consensus/ConsensusServiceTest.kt | 161 +++++++- .../schedule/ScheduleResponseMapperTest.kt | 76 +++- 28 files changed, 1445 insertions(+), 140 deletions(-) create mode 100644 src/main/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchService.kt create mode 100644 src/main/kotlin/com/tripsync/application/popularity/GooglePlacePhotoService.kt create mode 100644 src/main/kotlin/com/tripsync/domain/entity/ExternalPopularityMetric.kt create mode 100644 src/main/kotlin/com/tripsync/domain/repository/ExternalPopularityMetricRepository.kt create mode 100644 src/main/kotlin/com/tripsync/infrastructure/popularity/ExternalPopularityProperties.kt create mode 100644 src/main/kotlin/com/tripsync/infrastructure/popularity/GooglePlacesClient.kt create mode 100644 src/main/kotlin/com/tripsync/infrastructure/popularity/NaverDataLabClient.kt create mode 100644 src/main/kotlin/com/tripsync/web/place/PlaceController.kt create mode 100644 src/main/resources/db/migration/V8__add_external_popularity_metrics.sql create mode 100644 src/main/resources/db/migration/V9__add_trip_room_date_range.sql diff --git a/.env.example b/.env.example index 5b71332..c061c6c 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,12 @@ API_BASE_URL=http://localhost:8080/api GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CALLBACK_URL=http://localhost:8080/api/auth/google/callback +GOOGLE_PLACES_API_KEY= KAKAO_CLIENT_ID= KAKAO_CLIENT_SECRET= KAKAO_CALLBACK_URL=http://localhost:8080/api/auth/kakao/callback +NAVER_DATALAB_CLIENT_ID= +NAVER_DATALAB_CLIENT_SECRET= TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2 TOUR_API_SERVICE_KEY= diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusDtos.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusDtos.kt index 39665a3..e7334f9 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusDtos.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusDtos.kt @@ -19,6 +19,8 @@ data class PlaceCandidate( val id: Long, val name: String, val address: String, + val latitude: Double? = null, + val longitude: Double? = null, val category: String, val mobilityScore: Int, val photoScore: Int, @@ -26,6 +28,9 @@ data class PlaceCandidate( val themeScore: Int, val metadataTags: Map? = null, val operatingHours: Map? = null, + val externalPopularityScore: Int? = null, + val externalSignalConfidence: Int = 0, + val isRegionalBenefit: Boolean = false, ) data class ConflictAxisAnalysis( @@ -81,6 +86,7 @@ data class OptionContext( val roomId: Long, val destination: String, val tripDate: String, + val tripEndDate: String? = null, val startTime: String, val endTime: String, val members: List, diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt index 90d17ff..bef7af9 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt @@ -7,8 +7,6 @@ import com.tripsync.domain.enums.ReasonAxis import com.tripsync.domain.enums.ScheduleOptionType import com.tripsync.domain.enums.ScoreAxis import com.tripsync.domain.enums.SlotType -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import mu.KotlinLogging import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -18,8 +16,13 @@ import java.time.LocalTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt @Service class ConsensusService( @@ -30,6 +33,7 @@ class ConsensusService( companion object { private const val DETERMINISTIC_PROVIDER = "deterministic-consensus" private val SLOT_TEMPLATES = mapOf( + 3 to listOf(1, 1, 1), 5 to listOf(150, 120, 150, 120, 180), 6 to listOf(120, 120, 120, 120, 120, 120), 7 to listOf(90, 120, 90, 120, 90, 120, 90), @@ -80,85 +84,89 @@ class ConsensusService( throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "ROOM_NOT_READY", "일정 생성을 위해 최소 2명의 멤버가 필요합니다.") } - val window = parseScheduleWindow(context.tripDate, context.startTime, context.endTime) + val windows = parseScheduleWindows(context.tripDate, context.tripEndDate, context.startTime, context.endTime) val candidatePlaces = filterPlacesForDestination(context.destination, context.places) if (candidatePlaces.isEmpty()) { throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "PLACE_CANDIDATE_EMPTY", "목적지에 맞는 일정 생성 후보 장소가 부족합니다.") } val analysis = analyzeGroup(context.members) - val slotTemplate = buildSlotTemplate(window, analysis, context.members.size) + val slotTemplate = buildSlotTemplates(windows, analysis, context.members.size) val baseShapes = buildIndividualSlotShapes(slotTemplate, analysis, context.members) val averageVector = getAverageScores(context.members) - - return coroutineScope { - val individualDeferred = async { - materializeOption( - optionType = ScheduleOptionType.INDIVIDUAL, - label = "개성형", - summary = "각자의 취향이 살아있는 교대 배분 일정", - targets = baseShapes.map { buildIndividualTarget(it, analysis, context.members, averageVector) }, - places = candidatePlaces, - threshold = 60, - preferHiddenGem = false, - members = context.members, - tripDate = context.tripDate, - analysis = analysis, - context = context, - ) - } - val balancedDeferred = async { - materializeOption( - optionType = ScheduleOptionType.BALANCED, - label = "균형형", - summary = "모두가 조금씩 만족하는 안전한 선택", - targets = baseShapes.map { - TargetVector( - scores = averageVector, - targetUserId = null, - slotType = SlotType.COMMON, - reasonAxis = ReasonAxis.COMMON, - reasonText = "그룹 전원의 평균 취향 반영", - startTime = it.startTime, - endTime = it.endTime, - ) - }, - places = candidatePlaces, - threshold = 65, - preferHiddenGem = false, - members = context.members, - tripDate = context.tripDate, - analysis = analysis, - context = context, - ) - } - val discoveryDeferred = async { - materializeOption( - optionType = ScheduleOptionType.DISCOVERY, - label = "지역 발굴형", - summary = "${destinationLabel(context.destination)} 숨은 명소 중심 탐험 일정", - targets = baseShapes.map { buildIndividualTarget(it, analysis, context.members, averageVector) }, - places = candidatePlaces, - threshold = 55, - preferHiddenGem = true, - members = context.members, - tripDate = context.tripDate, - analysis = analysis, - context = context, + val optionPlaceScopes = selectOptionPlaceScopes(candidatePlaces, baseShapes.size) + + val usedPlaceIdsByOrder = mutableMapOf>() + val usedPlaceKeysByOrder = mutableMapOf>() + val balanced = materializeOption( + optionType = ScheduleOptionType.BALANCED, + label = "균형형", + summary = "모두가 조금씩 만족하는 안전한 선택", + targets = baseShapes.map { + TargetVector( + scores = averageVector, + targetUserId = null, + slotType = SlotType.COMMON, + reasonAxis = ReasonAxis.COMMON, + reasonText = "그룹 전원의 평균 취향 반영", + startTime = it.startTime, + endTime = it.endTime, ) - } - - val individual = individualDeferred.await() - val balanced = balancedDeferred.await() - val discovery = discoveryDeferred.await() + }, + places = optionPlaceScopes.getValue(ScheduleOptionType.BALANCED), + threshold = 65, + preferHiddenGem = false, + members = context.members, + tripDate = context.tripDate, + analysis = analysis, + context = context, + avoidPlaceIdsByOrder = usedPlaceIdsByOrder, + avoidPlaceKeysByOrder = usedPlaceKeysByOrder, + ).also { rememberUsedPlacesByOrder(usedPlaceIdsByOrder, usedPlaceKeysByOrder, it) } + + val individual = materializeOption( + optionType = ScheduleOptionType.INDIVIDUAL, + label = "개성형", + summary = "각자의 취향이 살아있는 교대 배분 일정", + targets = baseShapes.map { buildIndividualTarget(it, analysis, context.members, averageVector) }, + places = optionPlaceScopes.getValue(ScheduleOptionType.INDIVIDUAL), + threshold = 60, + preferHiddenGem = false, + members = context.members, + tripDate = context.tripDate, + analysis = analysis, + context = context, + avoidPlaceIdsByOrder = usedPlaceIdsByOrder, + avoidPlaceKeysByOrder = usedPlaceKeysByOrder, + ).also { rememberUsedPlacesByOrder(usedPlaceIdsByOrder, usedPlaceKeysByOrder, it) } + + val discovery = materializeOption( + optionType = ScheduleOptionType.DISCOVERY, + label = "지역 발굴형", + summary = "${destinationLabel(context.destination)} 숨은 명소 중심 탐험 일정", + targets = baseShapes.map { buildIndividualTarget(it, analysis, context.members, averageVector) }, + places = optionPlaceScopes.getValue(ScheduleOptionType.DISCOVERY), + threshold = 55, + preferHiddenGem = true, + members = context.members, + tripDate = context.tripDate, + analysis = analysis, + context = context, + avoidPlaceIdsByOrder = usedPlaceIdsByOrder, + avoidPlaceKeysByOrder = usedPlaceKeysByOrder, + ) - listOf(balanced, individual, discovery) - } + return listOf(balanced, individual, discovery) } - private fun parseScheduleWindow(tripDate: String, startTime: String, endTime: String): ScheduleWindow { - val date = runCatching { LocalDate.parse(tripDate) } + private fun parseScheduleWindows(tripStartDate: String, tripEndDate: String?, startTime: String, endTime: String): List { + val startDate = runCatching { LocalDate.parse(tripStartDate) } .getOrElse { throw DomainException(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "tripDate는 yyyy-MM-dd 형식이어야 합니다.") } + val endDate = runCatching { LocalDate.parse(tripEndDate ?: tripStartDate) } + .getOrElse { throw DomainException(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "tripEndDate는 yyyy-MM-dd 형식이어야 합니다.") } + if (endDate.isBefore(startDate)) { + throw DomainException(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "tripEndDate는 tripStartDate보다 빠를 수 없습니다.") + } val start = parseScheduleTime(startTime, "startTime") val end = parseScheduleTime(endTime, "endTime") val startMinutes = start.hour * 60 + start.minute @@ -169,7 +177,10 @@ class ConsensusService( if (endMinutes - startMinutes < 60) { throw DomainException(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "일정 생성 시간 범위는 최소 1시간 이상이어야 합니다.") } - return ScheduleWindow(date, startMinutes, endMinutes) + val dayCount = ChronoUnit.DAYS.between(startDate, endDate).toInt() + 1 + return (0 until dayCount).map { offset -> + ScheduleWindow(startDate.plusDays(offset.toLong()), startMinutes, endMinutes) + } } private fun parseScheduleTime(value: String, fieldName: String): LocalTime { @@ -225,6 +236,94 @@ class ConsensusService( return value.lowercase().replace(Regex("\\s+"), "") } + private fun selectOptionPlaceScopes( + places: List, + targetSlotCount: Int, + ): Map> { + val grouped = places.groupBy { extractPrimaryLocality(it.address) } + .filterKeys { it != null } + .mapKeys { it.key!! } + if (grouped.isEmpty()) { + return mapOf( + ScheduleOptionType.BALANCED to places, + ScheduleOptionType.INDIVIDUAL to places, + ScheduleOptionType.DISCOVERY to places, + ) + } + + val minimumUsefulSize = minOf(targetSlotCount, 5) + val candidates = grouped.filterValues { it.size >= minimumUsefulSize }.ifEmpty { grouped } + + val balancedLocality = pickLocality(candidates, ScheduleOptionType.BALANCED, targetSlotCount, emptySet()) + val individualLocality = pickLocality(candidates, ScheduleOptionType.INDIVIDUAL, targetSlotCount, setOfNotNull(balancedLocality)) + ?: balancedLocality + val discoveryLocality = pickLocality( + candidates, + ScheduleOptionType.DISCOVERY, + targetSlotCount, + setOfNotNull(balancedLocality, individualLocality), + ) ?: pickLocality(candidates, ScheduleOptionType.DISCOVERY, targetSlotCount, setOfNotNull(balancedLocality)) ?: balancedLocality + + return mapOf( + ScheduleOptionType.BALANCED to scopedPlacesForLocality(places, grouped, balancedLocality, targetSlotCount), + ScheduleOptionType.INDIVIDUAL to scopedPlacesForLocality(places, grouped, individualLocality, targetSlotCount), + ScheduleOptionType.DISCOVERY to scopedPlacesForLocality(places, grouped, discoveryLocality, targetSlotCount), + ) + } + + private fun pickLocality( + candidates: Map>, + optionType: ScheduleOptionType, + targetSlotCount: Int, + excludedLocalities: Set, + ): String? { + return candidates + .filterKeys { it !in excludedLocalities } + .maxByOrNull { (_, group) -> localityScore(group, optionType, targetSlotCount) } + ?.key + } + + private fun scopedPlacesForLocality( + places: List, + grouped: Map>, + selectedLocality: String?, + targetSlotCount: Int, + ): List { + if (selectedLocality == null) return places + val sameLocality = grouped.getValue(selectedLocality) + if (sameLocality.size >= targetSlotCount) return sameLocality + + val anchor = sameLocality.maxByOrNull { it.externalPopularityScore ?: 0 } ?: return sameLocality + val sameIds = sameLocality.map { place -> place.id }.toSet() + val nearby = places + .filter { it.id !in sameLocality.map { place -> place.id }.toSet() } + .filter { distanceKm(anchor, it)?.let { distance -> distance <= 12.0 } == true } + .sortedBy { distanceKm(anchor, it) ?: Double.MAX_VALUE } + + return (sameLocality + nearby.filter { it.id !in sameIds }).distinctBy { it.id } + } + + private fun localityScore(group: List, optionType: ScheduleOptionType, targetSlotCount: Int): Double { + val anchorCount = group.count { (it.externalPopularityScore ?: 0) >= 70 } + val regionalCount = group.count { it.isRegionalBenefit || isHiddenGem(it) } + val restaurantCount = group.count { isRestaurantPlace(it) } + val dayActivityCount = group.count { isDayActivityPlace(it) } + val sizeFit = minOf(group.size, targetSlotCount) / targetSlotCount.toDouble() + val mixScore = when (optionType) { + ScheduleOptionType.DISCOVERY -> regionalCount * 2.2 + anchorCount * 0.7 + ScheduleOptionType.BALANCED -> anchorCount * 1.6 + regionalCount * 1.2 + ScheduleOptionType.INDIVIDUAL -> anchorCount * 1.4 + regionalCount + else -> (anchorCount + regionalCount).toDouble() + } + return mixScore + restaurantCount * 0.35 + dayActivityCount * 0.25 + sizeFit + } + + private fun extractPrimaryLocality(address: String): String? { + val normalized = address.trim() + val provinceRemoved = normalized.replace(Regex("^(충청남도|충남|전라북도|전북|전라남도|전남|경상북도|경북|경상남도|경남|충청북도|충북)\\s*"), "") + return Regex("([가-힣]+(?:시|군))").find(provinceRemoved)?.value + } + private fun classifySeverity(gap: Int): ConflictSeverity { return when { gap <= 20 -> ConflictSeverity.COMMON @@ -234,16 +333,25 @@ class ConsensusService( } } - private fun buildSlotTemplate( - window: ScheduleWindow, + private fun buildSlotTemplates( + windows: List, analysis: GroupAnalysis, memberCount: Int, ): List { - val desiredSlotCount = when { - analysis.criticalAxes.isNotEmpty() || memberCount >= 4 -> 7 - analysis.conflictAxes.size >= 2 || analysis.conflictAxes.any { it.severity == ConflictSeverity.MODERATE } -> 6 - else -> 5 + var nextOrderIndex = 1 + return windows.flatMap { window -> + buildDaySlotTemplate(window, analysis, memberCount).map { slot -> + slot.copy(orderIndex = nextOrderIndex++) + } } + } + + private fun buildDaySlotTemplate( + window: ScheduleWindow, + analysis: GroupAnalysis, + memberCount: Int, + ): List { + val desiredSlotCount = 3 val maxSlotsByWindow = (window.totalMinutes / 60).coerceAtLeast(1) val slotCount = desiredSlotCount.coerceAtMost(maxSlotsByWindow) val weights = SLOT_TEMPLATES[slotCount] ?: List(slotCount) { 1 } @@ -436,6 +544,8 @@ class ConsensusService( tripDate: String, analysis: GroupAnalysis, context: OptionContext, + avoidPlaceIdsByOrder: Map>, + avoidPlaceKeysByOrder: Map>, ): ScheduleOptionDraft { val chosenPlaces = mutableListOf() val forcedHiddenGemIndex = if (preferHiddenGem) pickForcedHiddenGemSlot(targets) else -1 @@ -443,10 +553,14 @@ class ConsensusService( val slots = targets.mapIndexed { index, target -> val profile = buildSlotSelectionProfile(target.startTime, target.endTime, index + 1, targets.size) + val orderIndex = index + 1 + val blockedPlaceIds = chosenPlaces.map { it.id }.toSet() + avoidPlaceIdsByOrder[orderIndex].orEmpty() + val blockedPlaceKeys = chosenPlaces.flatMap { placeCandidateKeys(it) }.toSet() + avoidPlaceKeysByOrder[orderIndex].orEmpty() val rankedPlaces = rankPlaces( targetVector = target.scores, places = places, - usedPlaceIds = chosenPlaces.map { it.id }.toSet(), + usedPlaceIds = blockedPlaceIds, + usedPlaceKeys = blockedPlaceKeys, previousPlace = chosenPlaces.lastOrNull(), preferHiddenGem = preferHiddenGem, mustBeHiddenGem = index == forcedHiddenGemIndex, @@ -465,7 +579,7 @@ class ConsensusService( slotType = target.slotType, targetUserId = target.targetUserId, reasonAxis = target.reasonAxis, - candidatePlaces = rankedPlaces.take(5).map { + candidatePlaces = rankedPlaces.distinctBy { normalizedPlaceName(it) }.take(5).map { CandidatePlace(it.id, it.name, it.category, it.address) }, deterministicPlaceId = place.id, @@ -550,6 +664,17 @@ class ConsensusService( ) } + private fun rememberUsedPlacesByOrder( + usedPlaceIdsByOrder: MutableMap>, + usedPlaceKeysByOrder: MutableMap>, + option: ScheduleOptionDraft, + ) { + option.slots.forEach { slot -> + usedPlaceIdsByOrder.getOrPut(slot.orderIndex) { mutableSetOf() }.add(slot.placeId) + usedPlaceKeysByOrder.getOrPut(slot.orderIndex) { mutableSetOf() }.addAll(scheduleSlotPlaceKeys(slot)) + } + } + private fun buildSatisfaction( optionType: ScheduleOptionType, slots: List, @@ -595,6 +720,7 @@ class ConsensusService( targetVector: AxisScores, places: List, usedPlaceIds: Set, + usedPlaceKeys: Set, previousPlace: PlaceCandidate?, preferHiddenGem: Boolean, mustBeHiddenGem: Boolean, @@ -620,11 +746,42 @@ class ConsensusService( if (restaurants.isNotEmpty()) pool = restaurants } - return pool.sortedByDescending { + val hardExcludedPlaceKeys = usedPlaceKeys + places + .filter { it.id in usedPlaceIds } + .flatMap { placeCandidateKeys(it) } + .toSet() + val unusedPool = pool.filter { it.id !in usedPlaceIds && placeCandidateKeys(it).none { key -> key in hardExcludedPlaceKeys } } + pool = if (unusedPool.isNotEmpty()) { + unusedPool + } else { + places.filter { it.id !in usedPlaceIds && placeCandidateKeys(it).none { key -> key in hardExcludedPlaceKeys } } + } + + return pool.distinctBy { normalizedPlaceName(it) }.sortedByDescending { placeRankingScore(it, targetVector, previousPlace, preferHiddenGem, usedPlaceIds, tripDate, profile) } } + private fun placeCandidateKeys(place: PlaceCandidate): Set { + val name = normalizedPlaceName(place) + val address = normalizedPlaceAddress(place.address) + return setOf(name, "$name|$address").filter { it.isNotBlank() }.toSet() + } + + private fun scheduleSlotPlaceKeys(slot: ScheduleSlotDraft): Set { + val name = normalizePlaceText(slot.placeName) + val address = normalizedPlaceAddress(slot.placeAddress) + return setOf(name, "$name|$address").filter { it.isNotBlank() }.toSet() + } + + private fun normalizedPlaceName(place: PlaceCandidate): String = normalizePlaceText(place.name) + + private fun normalizedPlaceAddress(address: String): String = normalizePlaceText(address) + + private fun normalizePlaceText(value: String): String { + return value.trim().lowercase().replace(Regex("[\\s\\p{Punct}]+"), "") + } + private fun placeRankingScore( place: PlaceCandidate, targetVector: AxisScores, @@ -637,8 +794,18 @@ class ConsensusService( var score = calculateVectorMatch(targetVector, placeScores(place)) if (usedPlaceIds.contains(place.id)) score -= 0.2 if (previousPlace != null && previousPlace.category == place.category) score -= 0.08 + if (previousPlace != null) { + val distance = distanceKm(previousPlace, place) + when { + distance == null -> score -= 0.03 + distance <= 3.0 -> score += 0.08 + distance <= 8.0 -> score += 0.03 + distance > 20.0 -> score -= 0.35 + distance > 12.0 -> score -= 0.18 + } + } if (hasUnknownHours(place)) score -= 0.08 - if (preferHiddenGem && isHiddenGem(place)) score += 0.2 + score += regionalCoexistenceModifier(place, preferHiddenGem, profile) val operatingAvailability = isPlaceOpenDuringSlot(place, profile) when (operatingAvailability) { @@ -651,6 +818,43 @@ class ConsensusService( return score } + private fun distanceKm(from: PlaceCandidate, to: PlaceCandidate): Double? { + val fromLat = from.latitude ?: return null + val fromLon = from.longitude ?: return null + val toLat = to.latitude ?: return null + val toLon = to.longitude ?: return null + val earthRadiusKm = 6371.0 + val dLat = Math.toRadians(toLat - fromLat) + val dLon = Math.toRadians(toLon - fromLon) + val lat1 = Math.toRadians(fromLat) + val lat2 = Math.toRadians(toLat) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1) * cos(lat2) * sin(dLon / 2) * sin(dLon / 2) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return earthRadiusKm * c + } + + private fun regionalCoexistenceModifier( + place: PlaceCandidate, + preferRegionalBenefit: Boolean, + profile: SlotSelectionProfile, + ): Double { + val popularity = place.externalPopularityScore + val confidenceBonus = (place.externalSignalConfidence / 100.0) * 0.04 + val regionalBenefitBonus = if (place.isRegionalBenefit || isHiddenGem(place)) { + if (preferRegionalBenefit) 0.22 else 0.08 + } else { + 0.0 + } + val anchorBridgeBonus = if (!preferRegionalBenefit && popularity != null && popularity >= 70 && (profile.isEarlySlot || profile.isFinalSlot)) { + 0.10 + } else { + 0.0 + } + val overPopularPenalty = if (preferRegionalBenefit && popularity != null && popularity >= 80) -0.06 else 0.0 + return confidenceBonus + regionalBenefitBonus + anchorBridgeBonus + overPopularPenalty + } + private fun placeCategoryModifier(place: PlaceCandidate, tripDate: String, profile: SlotSelectionProfile): Double { var modifier = 0.0 @@ -698,12 +902,16 @@ class ConsensusService( } private fun isHiddenGem(place: PlaceCandidate): Boolean { + if (place.isRegionalBenefit) return true val tags = place.metadataTags ?: return false val tagList = tags["tags"] as? List<*> if (tagList != null) { - return tagList.any { it in listOf("hidden_gem", "population_decline") } + return tagList.any { it in listOf("hidden_gem", "population_decline", "regional_benefit") } } - return tags["hiddenGem"] == true || tags["populationDeclineArea"] == true || tags["regionType"] == "population_decline" + return tags["hiddenGem"] == true || + tags["populationDeclineArea"] == true || + tags["regionalBenefit"] == true || + tags["regionType"] == "population_decline" } private fun buildSlotSelectionProfile( diff --git a/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt b/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt index 3062416..27e7562 100644 --- a/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt +++ b/src/main/kotlin/com/tripsync/application/photo/PhotoService.kt @@ -47,7 +47,9 @@ class PhotoService( "scheduleId" to schedule.id, "roomId" to schedule.room.id, "destination" to schedule.room.destination, - "tripDate" to schedule.room.tripDate.toString(), + "tripDate" to schedule.room.tripStartDate.toString(), + "tripStartDate" to schedule.room.tripStartDate.toString(), + "tripEndDate" to schedule.room.tripEndDate.toString(), "isConfirmed" to schedule.isConfirmed, "totalPhotoCount" to photosBySlotId.values.sumOf { it.size }, "slots" to slots.map { slot -> formatSlot(slot, photosBySlotId[slot.id].orEmpty()) }, diff --git a/src/main/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchService.kt b/src/main/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchService.kt new file mode 100644 index 0000000..767080d --- /dev/null +++ b/src/main/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchService.kt @@ -0,0 +1,301 @@ +package com.tripsync.application.popularity + +import com.tripsync.common.dto.ApiResponse +import com.tripsync.common.exception.DomainException +import com.tripsync.domain.entity.ExternalPopularityMetric +import com.tripsync.domain.entity.Place +import com.tripsync.domain.entity.User +import com.tripsync.domain.enums.YnFlag +import com.tripsync.domain.repository.ExternalPopularityMetricRepository +import com.tripsync.domain.repository.PlaceRepository +import com.tripsync.infrastructure.popularity.ExternalPopularityProperties +import com.tripsync.infrastructure.popularity.GooglePlaceCandidate +import com.tripsync.infrastructure.popularity.GooglePlacesClient +import com.tripsync.infrastructure.popularity.NaverDataLabClient +import kotlinx.coroutines.runBlocking +import mu.KotlinLogging +import org.springframework.http.HttpStatus +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Instant +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.ln +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +@Service +class ExternalPopularityBatchService( + private val placeRepository: PlaceRepository, + private val metricRepository: ExternalPopularityMetricRepository, + private val naverDataLabClient: NaverDataLabClient, + private val googlePlacesClient: GooglePlacesClient, + private val properties: ExternalPopularityProperties, +) { + private val logger = KotlinLogging.logger {} + + @Scheduled(cron = "\${external-popularity.sync.cron:0 30 4 * * MON}") + fun syncScheduled() { + if (!properties.sync.enabled) { + logger.info { "External popularity scheduled sync skipped: disabled" } + return + } + + runCatching { syncInternal(triggeredBy = "scheduled", operatorUserId = null) } + .onSuccess { report -> logger.info { "External popularity scheduled sync completed: ${report.toMap()}" } } + .onFailure { logger.warn(it) { "External popularity scheduled sync failed" } } + } + + fun syncManually(user: User, limit: Int? = null): ApiResponse> { + assertAdminUser(user) + val boundedLimit = limit?.coerceIn(1, 1000) + val report = syncInternal(triggeredBy = "manual", operatorUserId = user.id, limitOverride = boundedLimit) + return ApiResponse.ok(report.toMap()) + } + + private fun syncInternal(triggeredBy: String, operatorUserId: Long?, limitOverride: Int? = null): ExternalPopularitySyncReport { + val startedAt = Instant.now() + val limit = (limitOverride ?: properties.sync.batchLimit).coerceAtLeast(1) + val places = placeRepository.findByDelYn(YnFlag.N).take(limit) + val rawResults = mutableListOf() + + places.forEachIndexed { index, place -> + val raw = runCatching { collectPlaceSignals(place) } + .getOrElse { error -> + logger.warn(error) { "External popularity collect failed placeId=${place.id}" } + recordFailure(place, error) + ExternalPopularityRawResult(place = place, error = error.message ?: error.javaClass.simpleName) + } + rawResults += raw + throttleIfNeeded(index, places.lastIndex) + } + + val successful = rawResults.filter { it.error == null } + val maxReviewLog = successful.mapNotNull { it.googleUserRatingCount } + .maxOfOrNull { ln(1.0 + it.toDouble()) } + ?.takeIf { it > 0.0 } + ?: 1.0 + + var updated = 0 + successful.forEach { raw -> + val normalized = normalizePopularity(raw, maxReviewLog) + upsertMetric(raw, normalized) + updated += 1 + } + + val report = ExternalPopularitySyncReport( + triggeredBy = triggeredBy, + operatorUserId = operatorUserId, + startedAt = startedAt.toString(), + finishedAt = Instant.now().toString(), + scanned = places.size, + updated = updated, + failed = rawResults.count { it.error != null }, + failures = rawResults.filter { it.error != null }.take(20).map { + mapOf( + "placeId" to it.place.id, + "placeName" to it.place.name, + "message" to it.error, + ) + }, + ) + logger.info { "External popularity sync summary: ${report.toMap()}" } + return report + } + + private fun collectPlaceSignals(place: Place): ExternalPopularityRawResult { + val keywords = buildNaverKeywords(place) + val naverScore = runBlocking { + naverDataLabClient.fetchSearchTrendScore(place.name, keywords) + } + val matched = runBlocking { + val query = listOf(place.name, place.address).filter { it.isNotBlank() }.joinToString(" ") + val candidates = googlePlacesClient.searchPlace(query, place.latitude, place.longitude) + selectGoogleMatch(place, candidates) + } + + return ExternalPopularityRawResult( + place = place, + naverSearchTrendScore = naverScore, + googlePlaceId = matched?.place_id, + googleRating = matched?.rating, + googleUserRatingCount = matched?.user_ratings_total, + googlePhotoReference = matched?.photos?.firstOrNull()?.photo_reference, + error = null, + ) + } + + private fun normalizePopularity(raw: ExternalPopularityRawResult, maxReviewLog: Double): Int? { + val hasAnySignal = raw.naverSearchTrendScore != null || raw.googleUserRatingCount != null || raw.googleRating != null + if (!hasAnySignal) return null + + val naver = raw.naverSearchTrendScore ?: 0 + val reviewVolume = raw.googleUserRatingCount + ?.let { (ln(1.0 + it.toDouble()) / maxReviewLog * 100.0).roundToInt().coerceIn(0, 100) } + ?: 0 + val ratingConfidence = if (raw.googleRating != null && raw.googleUserRatingCount != null) { + (raw.googleRating.toDouble() / 5.0 * min(raw.googleUserRatingCount / 100.0, 1.0) * 100.0) + .roundToInt() + .coerceIn(0, 100) + } else { + 0 + } + + return (naver * 0.60 + reviewVolume * 0.30 + ratingConfidence * 0.10) + .roundToInt() + .coerceIn(0, 100) + } + + private fun upsertMetric(raw: ExternalPopularityRawResult, normalizedPopularityScore: Int?) { + val now = Instant.now() + val metric = metricRepository.findByPlaceId(raw.place.id) + ?: ExternalPopularityMetric(place = raw.place, collectedAt = now) + metric.naverSearchTrendScore = raw.naverSearchTrendScore + metric.googlePlaceId = raw.googlePlaceId + metric.googleRating = raw.googleRating?.setScale(1, RoundingMode.HALF_UP) + metric.googleUserRatingCount = raw.googleUserRatingCount + metric.googlePhotoReference = raw.googlePhotoReference + metric.normalizedPopularityScore = normalizedPopularityScore + metric.collectedAt = now + metric.expiresAt = now.plusSeconds(properties.sync.expiresAfterDays * 24 * 60 * 60) + metric.lastError = null + metricRepository.save(metric) + } + + private fun recordFailure(place: Place, error: Throwable) { + val now = Instant.now() + val metric = metricRepository.findByPlaceId(place.id) + ?: ExternalPopularityMetric(place = place, collectedAt = now) + metric.lastError = error.message ?: error.javaClass.simpleName + metricRepository.save(metric) + } + + private fun buildNaverKeywords(place: Place): List { + val regionTerms = buildList { + metadataText(place, "region")?.let { add(it) } + metadataText(place, "area")?.let { add(it) } + extractRegionFromAddress(place.address)?.let { add(it) } + extractSigunguFromAddress(place.address)?.let { add(it) } + } + + return buildList { + add(place.name) + regionTerms.forEach { add("$it ${place.name}") } + }.map { it.trim() }.filter { it.isNotBlank() }.distinct() + } + + private fun selectGoogleMatch(place: Place, candidates: List): GooglePlaceCandidate? { + return candidates + .mapNotNull { candidate -> + val location = candidate.geometry?.location ?: return@mapNotNull null + val lat = location.lat ?: return@mapNotNull null + val lng = location.lng ?: return@mapNotNull null + val distanceMeters = haversineMeters(place.latitude.toDouble(), place.longitude.toDouble(), lat.toDouble(), lng.toDouble()) + if (distanceMeters > properties.google.matchRadiusMeters) return@mapNotNull null + val nameSimilarity = nameSimilarity(place.name, candidate.name ?: "") + if (nameSimilarity < properties.google.minNameSimilarity) return@mapNotNull null + GoogleMatch(candidate, nameSimilarity, distanceMeters) + } + .sortedWith( + compareByDescending { it.nameSimilarity } + .thenByDescending { it.candidate.user_ratings_total ?: 0 } + .thenBy { it.distanceMeters } + ) + .firstOrNull() + ?.candidate + } + + private fun nameSimilarity(left: String, right: String): Double { + val leftTokens = bigrams(normalizeName(left)) + val rightTokens = bigrams(normalizeName(right)) + if (leftTokens.isEmpty() || rightTokens.isEmpty()) return 0.0 + val intersection = leftTokens.intersect(rightTokens).size + return (2.0 * intersection) / (leftTokens.size + rightTokens.size) + } + + private fun bigrams(value: String): Set { + if (value.length <= 1) return if (value.isBlank()) emptySet() else setOf(value) + return value.windowed(2).toSet() + } + + private fun normalizeName(value: String): String { + return value.lowercase().replace(Regex("[^0-9a-z가-힣]"), "") + } + + private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val earthRadiusMeters = 6371000.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).pow(2.0) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2.0) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return earthRadiusMeters * c + } + + private fun metadataText(place: Place, key: String): String? { + return place.metadataTags?.get(key)?.toString()?.trim()?.takeIf { it.isNotBlank() } + } + + private fun extractRegionFromAddress(address: String): String? { + return address.trim().split(Regex("\\s+")).firstOrNull()?.takeIf { it.isNotBlank() } + } + + private fun extractSigunguFromAddress(address: String): String? { + return address.trim().split(Regex("\\s+")).drop(1).firstOrNull()?.takeIf { it.isNotBlank() } + } + + private fun throttleIfNeeded(index: Int, lastIndex: Int) { + if (index >= lastIndex || properties.sync.requestIntervalMillis <= 0) return + runCatching { Thread.sleep(properties.sync.requestIntervalMillis) } + .onFailure { Thread.currentThread().interrupt() } + } + + private fun assertAdminUser(user: User) { + if (user.isGuest || user.adminYn != YnFlag.Y) { + throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "관리자 권한이 필요합니다.") + } + } + + private data class GoogleMatch( + val candidate: GooglePlaceCandidate, + val nameSimilarity: Double, + val distanceMeters: Double, + ) +} + +data class ExternalPopularityRawResult( + val place: Place, + val naverSearchTrendScore: Int? = null, + val googlePlaceId: String? = null, + val googleRating: BigDecimal? = null, + val googleUserRatingCount: Int? = null, + val googlePhotoReference: String? = null, + val error: String? = null, +) + +data class ExternalPopularitySyncReport( + val triggeredBy: String, + val operatorUserId: Long?, + val startedAt: String, + val finishedAt: String, + val scanned: Int, + val updated: Int, + val failed: Int, + val failures: List>, +) { + fun toMap(): Map = mapOf( + "triggeredBy" to triggeredBy, + "operatorUserId" to operatorUserId, + "startedAt" to startedAt, + "finishedAt" to finishedAt, + "scanned" to scanned, + "updated" to updated, + "failed" to failed, + "failures" to failures, + ) +} diff --git a/src/main/kotlin/com/tripsync/application/popularity/GooglePlacePhotoService.kt b/src/main/kotlin/com/tripsync/application/popularity/GooglePlacePhotoService.kt new file mode 100644 index 0000000..cfebf64 --- /dev/null +++ b/src/main/kotlin/com/tripsync/application/popularity/GooglePlacePhotoService.kt @@ -0,0 +1,28 @@ +package com.tripsync.application.popularity + +import com.tripsync.common.exception.DomainException +import com.tripsync.domain.enums.YnFlag +import com.tripsync.domain.repository.ExternalPopularityMetricRepository +import com.tripsync.infrastructure.popularity.GooglePlacePhoto +import com.tripsync.infrastructure.popularity.GooglePlacesClient +import kotlinx.coroutines.runBlocking +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service + +@Service +class GooglePlacePhotoService( + private val metricRepository: ExternalPopularityMetricRepository, + private val googlePlacesClient: GooglePlacesClient, +) { + fun fetchPlacePhoto(placeId: Long): GooglePlacePhoto { + val metric = metricRepository.findByPlaceId(placeId) + ?: throw DomainException(HttpStatus.NOT_FOUND, "PHOTO_NOT_FOUND", "장소 사진을 찾을 수 없습니다.") + if (metric.place.delYn != YnFlag.N) { + throw DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.") + } + val reference = metric.googlePhotoReference?.takeIf { it.isNotBlank() } + ?: throw DomainException(HttpStatus.NOT_FOUND, "PHOTO_NOT_FOUND", "장소 사진을 찾을 수 없습니다.") + + return runBlocking { googlePlacesClient.fetchPhoto(reference) } + } +} diff --git a/src/main/kotlin/com/tripsync/application/room/RoomService.kt b/src/main/kotlin/com/tripsync/application/room/RoomService.kt index 1aa3b4b..ad3b699 100644 --- a/src/main/kotlin/com/tripsync/application/room/RoomService.kt +++ b/src/main/kotlin/com/tripsync/application/room/RoomService.kt @@ -33,12 +33,21 @@ class RoomService( ) { @Transactional - fun createRoom(host: User, destination: String, tripDate: LocalDate, roomName: String? = null): ApiResponse> { + fun createRoom( + host: User, + destination: String, + tripStartDate: LocalDate, + tripEndDate: LocalDate, + roomName: String? = null, + ): ApiResponse> { if (host.isGuest) { throw DomainException(HttpStatus.FORBIDDEN, "FORBIDDEN", "방장 권한이 필요합니다.") } - if (!tripDate.isAfter(LocalDate.now())) { - throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "INVALID_REQUEST", "tripDate는 오늘 이후여야 합니다.") + if (!tripStartDate.isAfter(LocalDate.now())) { + throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "INVALID_REQUEST", "tripStartDate는 오늘 이후여야 합니다.") + } + if (tripEndDate.isBefore(tripStartDate)) { + throw DomainException(HttpStatus.UNPROCESSABLE_ENTITY, "INVALID_REQUEST", "tripEndDate는 tripStartDate보다 빠를 수 없습니다.") } val normalizedRoomName = normalizeRoomName(roomName, destination) @@ -48,7 +57,9 @@ class RoomService( shareCode = generateShareCode(), destination = destination, roomName = normalizedRoomName, - tripDate = tripDate, + tripDate = tripStartDate, + tripStartDate = tripStartDate, + tripEndDate = tripEndDate, status = TripRoomStatus.WAITING, ) ) @@ -69,6 +80,9 @@ class RoomService( "roomId" to room.id, "roomName" to room.roomName, "shareCode" to room.shareCode, + "tripDate" to room.tripDate.toString(), + "tripStartDate" to room.tripStartDate.toString(), + "tripEndDate" to room.tripEndDate.toString(), "status" to room.status.name.lowercase(), ) ) @@ -294,9 +308,9 @@ class RoomService( "roomId" to room.id, "roomName" to room.roomName, "destination" to room.destination, - "tripDate" to room.tripDate.toString(), - "tripStartDate" to room.tripDate.toString(), - "tripEndDate" to room.tripDate.toString(), + "tripDate" to room.tripStartDate.toString(), + "tripStartDate" to room.tripStartDate.toString(), + "tripEndDate" to room.tripEndDate.toString(), "shareCode" to room.shareCode, "status" to room.status.name.lowercase(), "hostUserId" to room.hostUser.id, diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt index adefed6..3c8d2a1 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt @@ -14,6 +14,7 @@ import com.tripsync.domain.enums.TripRoomStatus import com.tripsync.domain.enums.YnFlag import com.tripsync.domain.repository.PlaceQueryRepository import com.tripsync.domain.repository.PlaceRepository +import com.tripsync.domain.repository.ExternalPopularityMetricRepository import com.tripsync.domain.repository.RoomMemberProfileRepository import com.tripsync.domain.repository.SatisfactionScoreRepository import com.tripsync.domain.repository.ScheduleRepository @@ -32,6 +33,7 @@ class ScheduleGenerationPersistenceService( private val roomMemberProfileRepository: RoomMemberProfileRepository, private val placeRepository: PlaceRepository, private val placeQueryRepository: PlaceQueryRepository, + private val externalPopularityMetricRepository: ExternalPopularityMetricRepository, private val userRepository: UserRepository, private val accessPolicy: ScheduleAccessPolicy, ) { @@ -60,11 +62,16 @@ class ScheduleGenerationPersistenceService( } val places = placeQueryRepository.findScheduleCandidates(destination) + val metricsByPlaceId = externalPopularityMetricRepository.findByPlaceIdIn(places.map { it.id }) + .associateBy { it.place.id } val placeCandidates = places.map { + val metric = metricsByPlaceId[it.id] PlaceCandidate( id = it.id, name = it.name, address = it.address, + latitude = it.latitude.toDouble(), + longitude = it.longitude.toDouble(), category = it.category, mobilityScore = it.mobilityScore, photoScore = it.photoScore, @@ -72,6 +79,9 @@ class ScheduleGenerationPersistenceService( themeScore = it.themeScore, metadataTags = it.metadataTags, operatingHours = it.operatingHours, + externalPopularityScore = metric?.normalizedPopularityScore, + externalSignalConfidence = externalSignalConfidence(metric), + isRegionalBenefit = isRegionalBenefit(it.metadataTags, metric?.normalizedPopularityScore), ) } @@ -175,6 +185,25 @@ class ScheduleGenerationPersistenceService( accessPolicy.validateHost(roomId, userId) return roomId } + + private fun externalSignalConfidence(metric: com.tripsync.domain.entity.ExternalPopularityMetric?): Int { + if (metric == null || metric.normalizedPopularityScore == null) return 0 + var confidence = 40 + if (metric.naverSearchTrendScore != null) confidence += 25 + if ((metric.googleUserRatingCount ?: 0) > 0) confidence += 25 + if (metric.googleRating != null) confidence += 10 + return confidence.coerceIn(0, 100) + } + + private fun isRegionalBenefit(metadataTags: Map?, externalPopularityScore: Int?): Boolean { + val tagList = metadataTags?.get("tags") as? List<*> + val tagged = tagList?.any { it in listOf("hidden_gem", "population_decline", "regional_benefit") } == true || + metadataTags?.get("hiddenGem") == true || + metadataTags?.get("populationDeclineArea") == true || + metadataTags?.get("regionalBenefit") == true || + metadataTags?.get("regionType") == "population_decline" + return tagged || (externalPopularityScore != null && externalPopularityScore <= 35) + } } data class ScheduleGenerationContext( diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt index 3a5ee4a..85df105 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt @@ -1,15 +1,21 @@ package com.tripsync.application.schedule import com.tripsync.application.consensus.ScheduleOptionDraft +import com.tripsync.domain.entity.ExternalPopularityMetric import com.tripsync.domain.entity.Place import com.tripsync.domain.entity.Schedule import com.tripsync.domain.enums.YnFlag +import com.tripsync.domain.repository.ExternalPopularityMetricRepository import com.tripsync.domain.repository.RoomMemberProfileRepository +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component @Component class ScheduleResponseMapper( private val roomMemberProfileRepository: RoomMemberProfileRepository, + private val externalPopularityMetricRepository: ExternalPopularityMetricRepository, + @Value("\${api.base-url:http://localhost:8080/api}") + private val apiBaseUrl: String, ) { fun formatGeneratedOption( scheduleId: Long, @@ -17,52 +23,59 @@ class ScheduleResponseMapper( personaValidation: Map?, memberNicknames: Map, placesById: Map, - ): Map = mapOf( - "scheduleId" to scheduleId, - "optionType" to option.optionType.name.lowercase(), - "label" to option.label, - "summary" to option.summary, - "groupSatisfaction" to option.groupSatisfaction, - "personaValidation" to personaValidation, - "llmProvider" to option.llmProvider, - "llmAttemptedProvider" to option.llmAttemptedProvider, - "llmLatencyMs" to option.llmLatencyMs, - "fallbackUsed" to option.fallbackUsed, - "llmFallbackReason" to option.llmFallbackReason, - "slots" to option.slots.sortedBy { it.orderIndex }.map { slot -> - val place = placesById[slot.placeId] - mapOf( - "slotId" to null, - "orderIndex" to slot.orderIndex, - "startTime" to slot.startTime.toString(), - "endTime" to slot.endTime.toString(), - "slotType" to slot.slotType.name.lowercase(), - "targetUserId" to slot.targetUserId, - "targetNickname" to slot.targetUserId?.let { memberNicknames[it] }, - "reasonAxis" to slot.reasonAxis.name.lowercase(), - "reasonText" to slot.reasonText, - "reason" to slot.reasonText, - "place" to formatPlace(place, slot.placeId, slot.placeName, slot.placeAddress), - ) - }, - "satisfactionByUser" to option.satisfactionByUser.map { - mapOf( - "userId" to it.userId, - "nickname" to memberNicknames[it.userId], - "score" to it.score, - ) - }, - ) + ): Map { + val metricsByPlaceId = loadMetricsByPlaceId(option.slots.map { it.placeId }) + return mapOf( + "scheduleId" to scheduleId, + "optionType" to option.optionType.name.lowercase(), + "label" to option.label, + "summary" to option.summary, + "groupSatisfaction" to option.groupSatisfaction, + "personaValidation" to personaValidation, + "llmProvider" to option.llmProvider, + "llmAttemptedProvider" to option.llmAttemptedProvider, + "llmLatencyMs" to option.llmLatencyMs, + "fallbackUsed" to option.fallbackUsed, + "llmFallbackReason" to option.llmFallbackReason, + "slots" to option.slots.sortedBy { it.orderIndex }.map { slot -> + val place = placesById[slot.placeId] + mapOf( + "slotId" to null, + "orderIndex" to slot.orderIndex, + "startTime" to slot.startTime.toString(), + "endTime" to slot.endTime.toString(), + "slotType" to slot.slotType.name.lowercase(), + "targetUserId" to slot.targetUserId, + "targetNickname" to slot.targetUserId?.let { memberNicknames[it] }, + "reasonAxis" to slot.reasonAxis.name.lowercase(), + "reasonText" to slot.reasonText, + "reason" to slot.reasonText, + "place" to formatPlace(place, slot.placeId, slot.placeName, slot.placeAddress, metricsByPlaceId[slot.placeId]), + ) + }, + "satisfactionByUser" to option.satisfactionByUser.map { + mapOf( + "userId" to it.userId, + "nickname" to memberNicknames[it.userId], + "score" to it.score, + ) + }, + ) + } fun formatStoredSchedule(schedule: Schedule): Map { val memberNicknames = roomMemberProfileRepository.findAllByRoomIdAndDelYn(schedule.room.id, YnFlag.N) .associate { it.user.id to it.user.nickname } val llmMetadata = formatLlmMetadata(schedule) + val activeSlots = schedule.slots.filter { it.delYn == YnFlag.N }.sortedBy { slot -> slot.orderIndex } + val metricsByPlaceId = loadMetricsByPlaceId(activeSlots.map { it.place.id }) return mapOf( "id" to schedule.id, "roomId" to schedule.room.id, "destination" to schedule.room.destination, - "tripDate" to schedule.room.tripDate.toString(), + "tripDate" to schedule.room.tripStartDate.toString(), + "tripStartDate" to schedule.room.tripStartDate.toString(), + "tripEndDate" to schedule.room.tripEndDate.toString(), "version" to schedule.version, "optionType" to schedule.optionType.name.lowercase(), "isConfirmed" to schedule.isConfirmed, @@ -74,7 +87,7 @@ class ScheduleResponseMapper( "llmLatencyMs" to llmMetadata["latencyMs"], "fallbackUsed" to llmMetadata["fallbackUsed"], "llmFallbackReason" to llmMetadata["fallbackReason"], - "slots" to schedule.slots.filter { it.delYn == YnFlag.N }.sortedBy { slot -> slot.orderIndex }.map { slot -> + "slots" to activeSlots.map { slot -> mapOf( "slotId" to slot.id, "orderIndex" to slot.orderIndex, @@ -86,7 +99,7 @@ class ScheduleResponseMapper( "reasonAxis" to slot.reasonAxis.name.lowercase(), "reasonText" to slot.reasonText, "reason" to slot.reasonText, - "place" to formatPlace(slot.place, slot.place.id, slot.place.name, slot.place.address), + "place" to formatPlace(slot.place, slot.place.id, slot.place.name, slot.place.address, metricsByPlaceId[slot.place.id]), ) }, "satisfactionByUser" to schedule.satisfactionScores.filter { it.delYn == YnFlag.N }.map { score -> @@ -99,12 +112,21 @@ class ScheduleResponseMapper( ) } + fun formatPlaces(places: List): List> { + val metricsByPlaceId = loadMetricsByPlaceId(places.map { it.id }) + return places.map { place -> + formatPlace(place, place.id, place.name, place.address, metricsByPlaceId[place.id]) + } + } + fun formatPublicShareSchedule(schedule: Schedule): Map { val publicKeys = setOf( "id", "roomId", "destination", "tripDate", + "tripStartDate", + "tripEndDate", "version", "optionType", "isConfirmed", @@ -130,7 +152,12 @@ class ScheduleResponseMapper( ) } - fun formatPlace(place: Place?, id: Long, name: String, address: String): Map = mapOf( + fun formatPlace(place: Place?, id: Long, name: String, address: String): Map { + val metric = externalPopularityMetricRepository.findByPlaceId(id) + return formatPlace(place, id, name, address, metric) + } + + private fun formatPlace(place: Place?, id: Long, name: String, address: String, metric: ExternalPopularityMetric?): Map = mapOf( "id" to id, "name" to name, "address" to address, @@ -138,9 +165,72 @@ class ScheduleResponseMapper( "latitude" to place?.latitude?.toDouble(), "longitude" to place?.longitude?.toDouble(), "isDepopulationArea" to isDepopulationArea(place?.metadataTags), - ) + ) + formatExternalSignals(place, id, metric) fun isDepopulationArea(metadataTags: Map?): Boolean { return metadataTags?.get("populationDeclineArea") == true || metadataTags?.get("regionType") == "population_decline" } + + private fun formatExternalSignals(place: Place?, placeId: Long, metric: ExternalPopularityMetric?): Map { + val image = formatImage(place, metric, placeId) + return mapOf( + "imageUrl" to image.url, + "imageSource" to image.source, + "isRegionalBenefit" to isRegionalBenefit(place?.metadataTags, metric), + "popularity" to formatPopularity(metric, place?.metadataTags), + ) + } + + private fun loadMetricsByPlaceId(placeIds: Collection): Map { + val ids = placeIds.distinct() + if (ids.isEmpty()) return emptyMap() + return externalPopularityMetricRepository.findByPlaceIdIn(ids).associateBy { it.place.id } + } + + private fun formatImage(place: Place?, metric: ExternalPopularityMetric?, placeId: Long): PlaceImage { + val tourApiImage = place?.imageUrl?.trim()?.takeIf { it.isNotBlank() } + if (tourApiImage != null) return PlaceImage(tourApiImage, "tourapi") + val hasGooglePhoto = metric?.googlePhotoReference?.isNotBlank() == true + return if (hasGooglePhoto) { + PlaceImage("${apiBaseUrl.trimEnd('/')}/places/$placeId/photo", "google_places") + } else { + PlaceImage(null, null) + } + } + + private fun formatPopularity(metric: ExternalPopularityMetric?, metadataTags: Map?): Map { + val score = metric?.normalizedPopularityScore + val role = when { + score != null && score >= 70 -> "popular_anchor" + isRegionalBenefit(metadataTags, metric) -> "regional_benefit" + score == null -> "unverified" + else -> "balanced" + } + val label = when (role) { + "popular_anchor" -> "많이 찾는 대표 장소" + "regional_benefit" -> "지역상생 추천 장소" + "unverified" -> "외부 신호 미확인" + else -> "취향 균형 장소" + } + return mapOf( + "role" to role, + "label" to label, + "hasExternalSignal" to (score != null), + ) + } + + private fun isRegionalBenefit(metadataTags: Map?, metric: ExternalPopularityMetric?): Boolean { + val tagList = metadataTags?.get("tags") as? List<*> + return tagList?.any { it in listOf("hidden_gem", "population_decline", "regional_benefit") } == true || + metadataTags?.get("hiddenGem") == true || + metadataTags?.get("populationDeclineArea") == true || + metadataTags?.get("regionalBenefit") == true || + metadataTags?.get("regionType") == "population_decline" || + ((metric?.normalizedPopularityScore ?: 101) <= 35) + } + + private data class PlaceImage( + val url: String?, + val source: String?, + ) } diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt index ce70ea9..5a293ef 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt @@ -41,7 +41,8 @@ class ScheduleService( val context = OptionContext( roomId = roomId, destination = dto.destination, - tripDate = dto.tripDate, + tripDate = dto.tripStartDate ?: dto.tripDate, + tripEndDate = dto.tripEndDate ?: dto.tripStartDate ?: dto.tripDate, startTime = dto.startTime, endTime = dto.endTime, members = members, @@ -105,16 +106,15 @@ class ScheduleService( .thenBy { it.name } ) .take(30) + .toList() + val formattedPlaces = responseMapper.formatPlaces(places) .map { place -> - responseMapper.formatPlace(place, place.id, place.name, place.address) + mapOf( - "alreadyAdded" to usedPlaceIds.contains(place.id), - ) + place + mapOf("alreadyAdded" to usedPlaceIds.contains(place["id"])) } - .toList() return ApiResponse.ok( mapOf( - "places" to places, + "places" to formattedPlaces, "query" to query.trim(), ) ) @@ -335,7 +335,7 @@ class ScheduleService( val input = schedule.generationInput val startText = input["startTime"]?.toString()?.takeIf { it.isNotBlank() } ?: "09:00" val endText = input["endTime"]?.toString()?.takeIf { it.isNotBlank() } ?: "21:00" - val tripDate = LocalDate.parse(schedule.room.tripDate.toString()) + val tripDate = LocalDate.parse(schedule.room.tripStartDate.toString()) val zone = ZoneId.of("Asia/Seoul") val startParts = startText.split(":") val endParts = endText.split(":") diff --git a/src/main/kotlin/com/tripsync/common/config/SecurityConfig.kt b/src/main/kotlin/com/tripsync/common/config/SecurityConfig.kt index 757f1cc..77c4e5e 100644 --- a/src/main/kotlin/com/tripsync/common/config/SecurityConfig.kt +++ b/src/main/kotlin/com/tripsync/common/config/SecurityConfig.kt @@ -50,6 +50,7 @@ class SecurityConfig( "/tpti/questions", "/share/**", "/rooms/share/**", + "/places/*/photo", "/actuator/health", ).permitAll() it.anyRequest().authenticated() diff --git a/src/main/kotlin/com/tripsync/domain/entity/ExternalPopularityMetric.kt b/src/main/kotlin/com/tripsync/domain/entity/ExternalPopularityMetric.kt new file mode 100644 index 0000000..e2bfda5 --- /dev/null +++ b/src/main/kotlin/com/tripsync/domain/entity/ExternalPopularityMetric.kt @@ -0,0 +1,57 @@ +package com.tripsync.domain.entity + +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.JoinColumn +import jakarta.persistence.OneToOne +import jakarta.persistence.Table +import org.hibernate.annotations.UpdateTimestamp +import java.math.BigDecimal +import java.time.Instant + +@Entity +@Table(name = "external_popularity_metrics") +class ExternalPopularityMetric( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + var place: Place, + + @Column(name = "naver_search_trend_score") + var naverSearchTrendScore: Int? = null, + + @Column(name = "google_place_id", length = 255) + var googlePlaceId: String? = null, + + @Column(name = "google_rating", precision = 2, scale = 1) + var googleRating: BigDecimal? = null, + + @Column(name = "google_user_rating_count") + var googleUserRatingCount: Int? = null, + + @Column(name = "google_photo_reference", columnDefinition = "TEXT") + var googlePhotoReference: String? = null, + + @Column(name = "normalized_popularity_score") + var normalizedPopularityScore: Int? = null, + + @Column(name = "collected_at", nullable = false) + var collectedAt: Instant, + + @Column(name = "expires_at") + var expiresAt: Instant? = null, + + @Column(name = "last_error", columnDefinition = "TEXT") + var lastError: String? = null, +) { + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = Instant.now() +} diff --git a/src/main/kotlin/com/tripsync/domain/entity/TripRoom.kt b/src/main/kotlin/com/tripsync/domain/entity/TripRoom.kt index 57812ae..6a2cffc 100644 --- a/src/main/kotlin/com/tripsync/domain/entity/TripRoom.kt +++ b/src/main/kotlin/com/tripsync/domain/entity/TripRoom.kt @@ -27,6 +27,12 @@ class TripRoom( @Column(name = "trip_date", nullable = false) var tripDate: LocalDate, + @Column(name = "trip_start_date", nullable = false) + var tripStartDate: LocalDate = tripDate, + + @Column(name = "trip_end_date", nullable = false) + var tripEndDate: LocalDate = tripDate, + @Enumerated(EnumType.STRING) @Column(nullable = false) var status: TripRoomStatus = TripRoomStatus.WAITING, diff --git a/src/main/kotlin/com/tripsync/domain/repository/ExternalPopularityMetricRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/ExternalPopularityMetricRepository.kt new file mode 100644 index 0000000..7dbee57 --- /dev/null +++ b/src/main/kotlin/com/tripsync/domain/repository/ExternalPopularityMetricRepository.kt @@ -0,0 +1,13 @@ +package com.tripsync.domain.repository + +import com.tripsync.domain.entity.ExternalPopularityMetric +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ExternalPopularityMetricRepository : JpaRepository { + @EntityGraph(attributePaths = ["place"]) + fun findByPlaceId(placeId: Long): ExternalPopularityMetric? + fun findByPlaceIdIn(placeIds: Collection): List +} diff --git a/src/main/kotlin/com/tripsync/domain/repository/RoomMemberProfileRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/RoomMemberProfileRepository.kt index 028f4cd..0c47a17 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/RoomMemberProfileRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/RoomMemberProfileRepository.kt @@ -9,4 +9,5 @@ import org.springframework.stereotype.Repository interface RoomMemberProfileRepository : JpaRepository { fun findAllByRoomIdAndDelYn(roomId: Long, delYn: YnFlag): List fun findByRoomIdAndUserId(roomId: Long, userId: Long): RoomMemberProfile? + fun findAllByRoomId(roomId: Long): List } diff --git a/src/main/kotlin/com/tripsync/domain/repository/RoomMemberRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/RoomMemberRepository.kt index 1930e04..10fa38e 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/RoomMemberRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/RoomMemberRepository.kt @@ -11,5 +11,6 @@ interface RoomMemberRepository : JpaRepository { fun findByRoomIdAndUserIdAndDelYn(roomId: Long, userId: Long, delYn: YnFlag): RoomMember? fun existsByRoomIdAndUserIdAndDelYn(roomId: Long, userId: Long, delYn: YnFlag): Boolean fun findAllByRoomIdAndDelYn(roomId: Long, delYn: YnFlag): List + fun findAllByRoomId(roomId: Long): List fun findAllByUserIdAndDelYn(userId: Long, delYn: YnFlag): List } diff --git a/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt index de4ba4e..3519db2 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/ScheduleRepository.kt @@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository @Repository interface ScheduleRepository : JpaRepository { fun findByRoomIdAndDelYn(roomId: Long, delYn: YnFlag): List + fun findByRoomId(roomId: Long): List fun findTopByRoomIdAndDelYnOrderByVersionDesc(roomId: Long, delYn: YnFlag): Schedule? @Query( diff --git a/src/main/kotlin/com/tripsync/infrastructure/popularity/ExternalPopularityProperties.kt b/src/main/kotlin/com/tripsync/infrastructure/popularity/ExternalPopularityProperties.kt new file mode 100644 index 0000000..fee7d15 --- /dev/null +++ b/src/main/kotlin/com/tripsync/infrastructure/popularity/ExternalPopularityProperties.kt @@ -0,0 +1,31 @@ +package com.tripsync.infrastructure.popularity + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "external-popularity") +data class ExternalPopularityProperties( + val sync: Sync = Sync(), + val naver: Naver = Naver(), + val google: Google = Google(), +) { + data class Sync( + val enabled: Boolean = true, + val batchLimit: Int = 500, + val requestIntervalMillis: Long = 0, + val expiresAfterDays: Long = 14, + ) + + data class Naver( + val clientId: String = "", + val clientSecret: String = "", + val searchUrl: String = "https://openapi.naver.com/v1/datalab/search", + val lookbackDays: Int = 30, + ) + + data class Google( + val apiKey: String = "", + val matchRadiusMeters: Int = 500, + val minNameSimilarity: Double = 0.45, + val photoMaxWidth: Int = 900, + ) +} diff --git a/src/main/kotlin/com/tripsync/infrastructure/popularity/GooglePlacesClient.kt b/src/main/kotlin/com/tripsync/infrastructure/popularity/GooglePlacesClient.kt new file mode 100644 index 0000000..54190a7 --- /dev/null +++ b/src/main/kotlin/com/tripsync/infrastructure/popularity/GooglePlacesClient.kt @@ -0,0 +1,113 @@ +package com.tripsync.infrastructure.popularity + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import kotlinx.coroutines.reactive.awaitSingle +import mu.KotlinLogging +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono +import java.math.BigDecimal + +@Component +class GooglePlacesClient( + private val webClient: WebClient, + private val properties: ExternalPopularityProperties, +) { + private val logger = KotlinLogging.logger {} + + suspend fun searchPlace(query: String, latitude: BigDecimal, longitude: BigDecimal): List { + val apiKey = properties.google.apiKey + if (apiKey.isBlank()) { + logger.warn { "Google Places API key not configured" } + return emptyList() + } + + val response = webClient.get() + .uri { builder -> + builder + .scheme("https") + .host("maps.googleapis.com") + .path("/maps/api/place/textsearch/json") + .queryParam("query", query) + .queryParam("location", "${latitude.toPlainString()},${longitude.toPlainString()}") + .queryParam("radius", properties.google.matchRadiusMeters) + .queryParam("language", "ko") + .queryParam("key", apiKey) + .build() + } + .retrieve() + .bodyToMono() + .awaitSingle() + + return response.results + } + + suspend fun fetchPhoto(photoReference: String): GooglePlacePhoto { + val apiKey = properties.google.apiKey + if (apiKey.isBlank()) { + throw IllegalStateException("Google Places API key not configured") + } + + val entity = webClient.get() + .uri { builder -> + builder + .scheme("https") + .host("maps.googleapis.com") + .path("/maps/api/place/photo") + .queryParam("maxwidth", properties.google.photoMaxWidth) + .queryParam("photo_reference", photoReference) + .queryParam("key", apiKey) + .build() + } + .retrieve() + .toEntity(ByteArray::class.java) + .awaitSingle() + + val contentType = entity.headers.contentType ?: MediaType.IMAGE_JPEG + return GooglePlacePhoto( + bytes = entity.body ?: ByteArray(0), + contentType = contentType, + cacheControl = entity.headers[HttpHeaders.CACHE_CONTROL]?.firstOrNull(), + ) + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GoogleTextSearchResponse( + val results: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GooglePlaceCandidate( + val place_id: String? = null, + val name: String? = null, + val formatted_address: String? = null, + val geometry: GoogleGeometry? = null, + val rating: BigDecimal? = null, + val user_ratings_total: Int? = null, + val photos: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GoogleGeometry( + val location: GoogleLocation? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GoogleLocation( + val lat: BigDecimal? = null, + val lng: BigDecimal? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GooglePlacePhotoReference( + val photo_reference: String? = null, +) + +data class GooglePlacePhoto( + val bytes: ByteArray, + val contentType: MediaType, + val cacheControl: String?, +) diff --git a/src/main/kotlin/com/tripsync/infrastructure/popularity/NaverDataLabClient.kt b/src/main/kotlin/com/tripsync/infrastructure/popularity/NaverDataLabClient.kt new file mode 100644 index 0000000..b607f94 --- /dev/null +++ b/src/main/kotlin/com/tripsync/infrastructure/popularity/NaverDataLabClient.kt @@ -0,0 +1,81 @@ +package com.tripsync.infrastructure.popularity + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import kotlinx.coroutines.reactive.awaitSingle +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono +import java.time.LocalDate + +@Component +class NaverDataLabClient( + private val webClient: WebClient, + private val properties: ExternalPopularityProperties, +) { + private val logger = KotlinLogging.logger {} + + suspend fun fetchSearchTrendScore(groupName: String, keywords: List): Int? { + val clientId = properties.naver.clientId + val clientSecret = properties.naver.clientSecret + if (clientId.isBlank() || clientSecret.isBlank()) { + logger.warn { "Naver DataLab credentials not configured" } + return null + } + if (keywords.isEmpty()) return null + + val endDate = LocalDate.now().minusDays(1) + val startDate = endDate.minusDays(properties.naver.lookbackDays.toLong()) + val request = NaverDataLabSearchRequest( + startDate = startDate.toString(), + endDate = endDate.toString(), + timeUnit = "date", + keywordGroups = listOf( + NaverKeywordGroup( + groupName = groupName.take(20), + keywords = keywords.distinct().take(20), + ) + ), + ) + + val response = webClient.post() + .uri(properties.naver.searchUrl) + .header("X-Naver-Client-Id", clientId) + .header("X-Naver-Client-Secret", clientSecret) + .bodyValue(request) + .retrieve() + .bodyToMono() + .awaitSingle() + + val ratios = response.results.flatMap { it.data }.map { it.ratio } + if (ratios.isEmpty()) return null + return ratios.average().toInt().coerceIn(0, 100) + } +} + +data class NaverDataLabSearchRequest( + val startDate: String, + val endDate: String, + val timeUnit: String, + val keywordGroups: List, +) + +data class NaverKeywordGroup( + val groupName: String, + val keywords: List, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class NaverDataLabSearchResponse( + val results: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class NaverDataLabResult( + val data: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class NaverDataLabPoint( + val ratio: Double = 0.0, +) diff --git a/src/main/kotlin/com/tripsync/web/place/PlaceController.kt b/src/main/kotlin/com/tripsync/web/place/PlaceController.kt new file mode 100644 index 0000000..495fa49 --- /dev/null +++ b/src/main/kotlin/com/tripsync/web/place/PlaceController.kt @@ -0,0 +1,25 @@ +package com.tripsync.web.place + +import com.tripsync.application.popularity.GooglePlacePhotoService +import org.springframework.http.CacheControl +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import java.util.concurrent.TimeUnit + +@RestController +class PlaceController( + private val googlePlacePhotoService: GooglePlacePhotoService, +) { + @GetMapping("/places/{placeId}/photo") + fun getPlacePhoto(@PathVariable placeId: Long): ResponseEntity { + val photo = googlePlacePhotoService.fetchPlacePhoto(placeId) + val builder = ResponseEntity.ok() + .contentType(photo.contentType) + .cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS).cachePublic()) + photo.cacheControl?.let { builder.header(HttpHeaders.CACHE_CONTROL, it) } + return builder.body(photo.bytes) + } +} diff --git a/src/main/kotlin/com/tripsync/web/room/RoomController.kt b/src/main/kotlin/com/tripsync/web/room/RoomController.kt index fee9967..b48bd73 100644 --- a/src/main/kotlin/com/tripsync/web/room/RoomController.kt +++ b/src/main/kotlin/com/tripsync/web/room/RoomController.kt @@ -20,7 +20,9 @@ class RoomController( @PostMapping @ResponseStatus(HttpStatus.CREATED) fun createRoom(@Valid @RequestBody dto: CreateRoomDto, @CurrentUser user: User): ApiResponse> { - return roomService.createRoom(user, dto.destination, LocalDate.parse(dto.tripDate), dto.roomName) + val startDate = LocalDate.parse(dto.tripStartDate ?: dto.tripDate) + val endDate = LocalDate.parse(dto.tripEndDate ?: dto.tripStartDate ?: dto.tripDate) + return roomService.createRoom(user, dto.destination, startDate, endDate, dto.roomName) } @GetMapping("/my") diff --git a/src/main/kotlin/com/tripsync/web/tourapi/TourApiController.kt b/src/main/kotlin/com/tripsync/web/tourapi/TourApiController.kt index e0cde9e..9999a08 100644 --- a/src/main/kotlin/com/tripsync/web/tourapi/TourApiController.kt +++ b/src/main/kotlin/com/tripsync/web/tourapi/TourApiController.kt @@ -1,5 +1,6 @@ package com.tripsync.web.tourapi +import com.tripsync.application.popularity.ExternalPopularityBatchService import com.tripsync.application.tourapi.TourApiBatchService import com.tripsync.common.dto.ApiResponse import com.tripsync.common.security.CurrentUser @@ -15,6 +16,7 @@ data class EnrichPlacesDto(val limit: Int? = null) @RequestMapping("/tour-api") class TourApiController( private val tourApiBatchService: TourApiBatchService, + private val externalPopularityBatchService: ExternalPopularityBatchService, ) { @PostMapping("/sync/chungnam") fun syncChungnam(@CurrentUser user: User): ApiResponse> { @@ -28,4 +30,12 @@ class TourApiController( ): ApiResponse> { return tourApiBatchService.enrichChungnamPlaces(user, dto?.limit ?: 50) } + + @PostMapping("/external-popularity/sync") + fun syncExternalPopularity( + @RequestBody(required = false) dto: EnrichPlacesDto?, + @CurrentUser user: User, + ): ApiResponse> { + return externalPopularityBatchService.syncManually(user, dto?.limit) + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7f88add..2510211 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -86,6 +86,24 @@ tourapi: request-interval-millis: ${TOUR_API_SYNC_REQUEST_INTERVAL_MILLIS:0} enrich-limit: ${TOUR_API_ENRICH_LIMIT:50} +external-popularity: + sync: + enabled: ${EXTERNAL_POPULARITY_SYNC_ENABLED:true} + cron: ${EXTERNAL_POPULARITY_SYNC_CRON:0 30 4 * * MON} + batch-limit: ${EXTERNAL_POPULARITY_BATCH_LIMIT:500} + request-interval-millis: ${EXTERNAL_POPULARITY_REQUEST_INTERVAL_MILLIS:0} + expires-after-days: ${EXTERNAL_POPULARITY_EXPIRES_AFTER_DAYS:14} + naver: + client-id: ${NAVER_DATALAB_CLIENT_ID:} + client-secret: ${NAVER_DATALAB_CLIENT_SECRET:} + search-url: ${NAVER_DATALAB_SEARCH_URL:https://openapi.naver.com/v1/datalab/search} + lookback-days: ${NAVER_DATALAB_LOOKBACK_DAYS:30} + google: + api-key: ${GOOGLE_PLACES_API_KEY:} + match-radius-meters: ${GOOGLE_PLACES_MATCH_RADIUS_METERS:500} + min-name-similarity: ${GOOGLE_PLACES_MIN_NAME_SIMILARITY:0.45} + photo-max-width: ${GOOGLE_PLACES_PHOTO_MAX_WIDTH:900} + api: base-url: ${API_BASE_URL:http://localhost:8080/api} diff --git a/src/main/resources/db/migration/V8__add_external_popularity_metrics.sql b/src/main/resources/db/migration/V8__add_external_popularity_metrics.sql new file mode 100644 index 0000000..9d54f9f --- /dev/null +++ b/src/main/resources/db/migration/V8__add_external_popularity_metrics.sql @@ -0,0 +1,17 @@ +CREATE TABLE external_popularity_metrics ( + id BIGSERIAL PRIMARY KEY, + place_id BIGINT NOT NULL REFERENCES places(id) ON DELETE CASCADE, + naver_search_trend_score SMALLINT CHECK (naver_search_trend_score BETWEEN 0 AND 100), + google_place_id VARCHAR(255), + google_rating NUMERIC(2, 1), + google_user_rating_count INTEGER, + google_photo_reference TEXT, + normalized_popularity_score SMALLINT CHECK (normalized_popularity_score BETWEEN 0 AND 100), + collected_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + last_error TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX uq_external_popularity_place ON external_popularity_metrics(place_id); +CREATE INDEX idx_external_popularity_score ON external_popularity_metrics(normalized_popularity_score); diff --git a/src/main/resources/db/migration/V9__add_trip_room_date_range.sql b/src/main/resources/db/migration/V9__add_trip_room_date_range.sql new file mode 100644 index 0000000..2d4ff0e --- /dev/null +++ b/src/main/resources/db/migration/V9__add_trip_room_date_range.sql @@ -0,0 +1,14 @@ +ALTER TABLE trip_rooms + ADD COLUMN trip_start_date DATE, + ADD COLUMN trip_end_date DATE; + +UPDATE trip_rooms +SET trip_start_date = trip_date, + trip_end_date = trip_date +WHERE trip_start_date IS NULL + OR trip_end_date IS NULL; + +ALTER TABLE trip_rooms + ALTER COLUMN trip_start_date SET NOT NULL, + ALTER COLUMN trip_end_date SET NOT NULL; + diff --git a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt index ce80df0..74d2dab 100644 --- a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt @@ -69,6 +69,98 @@ class ConsensusServiceTest { options.forEach { option -> assertEquals("08:00", option.slots.first().startTime.toSeoulTime()) assertEquals("12:00", option.slots.last().endTime.toSeoulTime()) + assertEquals(3, option.slots.size) + } + } + + @Test + fun `broad destination keeps each option inside one primary locality`() = runBlocking { + val options = consensusService.buildScheduleOptions( + context( + destination = "충남", + startTime = "09:00", + endTime = "18:00", + members = members(3), + places = mixedLocalityPlaces(), + ) + ) + + options.forEach { option -> + val localities = option.slots.map { primaryLocality(it.placeAddress) }.toSet() + assertEquals(1, localities.size, "option ${option.optionType} crossed localities: $localities") + } + val optionLocalities = options.map { option -> primaryLocality(option.slots.first().placeAddress) }.toSet() + assertEquals(3, optionLocalities.size, "three recommendation options must not collapse into the same locality") + val optionPlaceSets = options.map { option -> option.slots.map { it.placeId }.toSet() } + assertEquals(3, optionPlaceSets.toSet().size, "three recommendation options must not return the same place set") + } + + @Test + fun `same time slot does not repeat the same place across recommendation options`() = runBlocking { + val options = consensusService.buildScheduleOptions( + context( + destination = "공주시", + startTime = "09:00", + endTime = "18:00", + members = members(3), + places = places("충청남도 공주시"), + ) + ) + + val slotsByOrder = options.flatMap { option -> option.slots }.groupBy { it.orderIndex } + slotsByOrder.forEach { (orderIndex, slots) -> + assertEquals( + slots.size, + slots.map { it.placeId }.toSet().size, + "slot $orderIndex repeated the same place across recommendation options", + ) + assertEquals( + slots.size, + slots.map { "${it.placeName}|${it.placeAddress}" }.toSet().size, + "slot $orderIndex repeated a semantically identical place across recommendation options", + ) + } + } + + @Test + fun `same time slot does not repeat semantically duplicated places with different ids`() = runBlocking { + val options = consensusService.buildScheduleOptions( + context( + destination = "공주시", + startTime = "09:00", + endTime = "18:00", + members = members(3), + places = placesWithDuplicatedNames("충청남도 공주시"), + ) + ) + + val slotsByOrder = options.flatMap { option -> option.slots }.groupBy { it.orderIndex } + slotsByOrder.forEach { (orderIndex, slots) -> + assertEquals( + slots.size, + slots.map { normalizePlaceName(it.placeName) }.toSet().size, + "slot $orderIndex repeated the same visible place name across recommendation options", + ) + } + } + + @Test + fun `multi day schedule creates separate slots for each requested date`() = runBlocking { + val options = consensusService.buildScheduleOptions( + context( + destination = "충남", + startTime = "09:00", + endTime = "12:00", + members = members(2), + places = places("충청남도 공주시"), + tripDate = "2026-06-01", + tripEndDate = "2026-06-02", + ) + ) + + options.forEach { option -> + val dates = option.slots.map { it.startTime.atZone(ZoneId.of("Asia/Seoul")).toLocalDate() }.toSet() + assertEquals(setOf(java.time.LocalDate.parse("2026-06-01"), java.time.LocalDate.parse("2026-06-02")), dates) } } @@ -97,10 +189,13 @@ class ConsensusServiceTest { endTime: String, members: List, places: List, + tripDate: String = "2026-06-01", + tripEndDate: String? = null, ) = OptionContext( roomId = 1L, destination = destination, - tripDate = "2026-06-01", + tripDate = tripDate, + tripEndDate = tripEndDate, startTime = startTime, endTime = endTime, members = members, @@ -130,6 +225,8 @@ class ConsensusServiceTest { id = index.toLong(), name = "place-$index", address = "$addressPrefix $index", + latitude = 36.0 + index * 0.001, + longitude = 127.0 + index * 0.001, category = categories[(index - 1) % categories.size], mobilityScore = 30 + index * 3, photoScore = 80 - index, @@ -141,6 +238,68 @@ class ConsensusServiceTest { } } + private fun placesWithDuplicatedNames(addressPrefix: String): List { + val base = places(addressPrefix).toMutableList() + base.addAll( + listOf( + base[0].copyCandidate(id = 101, address = "$addressPrefix 상세주소-중복-1"), + base[1].copyCandidate(id = 102, address = "$addressPrefix 상세주소-중복-2"), + base[2].copyCandidate(id = 103, address = "$addressPrefix 상세주소-중복-3"), + ) + ) + return base + } + + private fun PlaceCandidate.copyCandidate(id: Long, address: String): PlaceCandidate { + return copy( + id = id, + address = address, + latitude = (latitude ?: 36.0) + id * 0.00001, + longitude = (longitude ?: 127.0) + id * 0.00001, + ) + } + + private fun mixedLocalityPlaces(): List { + val localities = listOf( + "충청남도 서천군 장항읍", + "충청남도 금산군 금산읍", + "충청남도 예산군 덕산면", + ) + return localities.flatMapIndexed { localityIndex, locality -> + (1..8).map { index -> + PlaceCandidate( + id = (localityIndex * 100 + index).toLong(), + name = "place-$localityIndex-$index", + address = "$locality 테스트로 $index", + latitude = 36.0 + localityIndex * 0.4 + index * 0.001, + longitude = 126.5 + localityIndex * 0.4 + index * 0.001, + category = if (index % 4 == 0) "restaurant" else "tourist_attraction", + mobilityScore = 55, + photoScore = 55, + budgetScore = 55, + themeScore = 55, + metadataTags = mapOf("hiddenGem" to (index % 3 == 0)), + operatingHours = mapOf("status" to "always"), + externalPopularityScore = when (index) { + 1 -> 85 + 2, 3 -> 30 + else -> 50 + }, + externalSignalConfidence = 90, + isRegionalBenefit = index in listOf(2, 3), + ) + } + } + } + + private fun primaryLocality(address: String): String { + return Regex("([가-힣]+(?:시|군))").find(address)?.value ?: address + } + + private fun normalizePlaceName(name: String): String { + return name.trim().lowercase().replace(Regex("[\\s\\p{Punct}]+"), "") + } + private fun java.time.Instant.toSeoulTime(): String { return atZone(ZoneId.of("Asia/Seoul")).toLocalTime().toString().take(5) } diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt index a0f809e..43510ff 100644 --- a/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleResponseMapperTest.kt @@ -1,5 +1,7 @@ package com.tripsync.application.schedule +import com.tripsync.domain.entity.ExternalPopularityMetric +import com.tripsync.domain.entity.Place import com.tripsync.domain.entity.Schedule import com.tripsync.domain.entity.TripRoom import com.tripsync.domain.entity.User @@ -7,17 +9,22 @@ import com.tripsync.domain.enums.AuthProvider import com.tripsync.domain.enums.ScheduleOptionType import com.tripsync.domain.enums.TripRoomStatus import com.tripsync.domain.enums.YnFlag +import com.tripsync.domain.repository.ExternalPopularityMetricRepository import com.tripsync.domain.repository.RoomMemberProfileRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test import org.mockito.Mockito.`when` import org.mockito.Mockito.mock +import java.math.BigDecimal +import java.time.Instant import java.time.LocalDate class ScheduleResponseMapperTest { private val roomMemberProfileRepository = mock(RoomMemberProfileRepository::class.java) - private val mapper = ScheduleResponseMapper(roomMemberProfileRepository) + private val externalPopularityMetricRepository = mock(ExternalPopularityMetricRepository::class.java) + private val mapper = ScheduleResponseMapper(roomMemberProfileRepository, externalPopularityMetricRepository, "http://localhost:8080/api") @Test fun `stored schedule response includes persisted llm metadata`() { @@ -47,6 +54,55 @@ class ScheduleResponseMapperTest { assertFalse(response.containsKey("llmFallbackReason")) } + @Test + fun `place response prefers TourAPI image and hides raw popularity score`() { + val place = place(imageUrl = "https://tour.example/image.jpg") + val metric = ExternalPopularityMetric( + place = place, + normalizedPopularityScore = 82, + collectedAt = Instant.parse("2026-05-20T00:00:00Z"), + ) + `when`(externalPopularityMetricRepository.findByPlaceId(place.id)).thenReturn(metric) + + val response = mapper.formatPlace(place, place.id, place.name, place.address) + val popularity = response["popularity"] as Map<*, *> + + assertEquals("https://tour.example/image.jpg", response["imageUrl"]) + assertEquals("tourapi", response["imageSource"]) + assertEquals("popular_anchor", popularity["role"]) + assertFalse(popularity.containsKey("score")) + } + + @Test + fun `place response falls back to proxied Google Places photo`() { + val place = place(imageUrl = null) + val metric = ExternalPopularityMetric( + place = place, + googlePhotoReference = "photo-ref", + collectedAt = Instant.parse("2026-05-20T00:00:00Z"), + ) + `when`(externalPopularityMetricRepository.findByPlaceId(place.id)).thenReturn(metric) + + val response = mapper.formatPlace(place, place.id, place.name, place.address) + + assertEquals("http://localhost:8080/api/places/${place.id}/photo", response["imageUrl"]) + assertEquals("google_places", response["imageSource"]) + } + + @Test + fun `place response marks external signal missing without excluding place`() { + val place = place(imageUrl = null) + `when`(externalPopularityMetricRepository.findByPlaceId(place.id)).thenReturn(null) + + val response = mapper.formatPlace(place, place.id, place.name, place.address) + val popularity = response["popularity"] as Map<*, *> + + assertNull(response["imageUrl"]) + assertNull(response["imageSource"]) + assertEquals("unverified", popularity["role"]) + assertEquals(false, popularity["hasExternalSignal"]) + } + private fun scheduleWithLlmMetadata(): Schedule { val host = User( id = 10L, @@ -83,4 +139,22 @@ class ScheduleResponseMapperTest { llmProvider = "deterministic-consensus", ) } + + private fun place(imageUrl: String?): Place { + return Place( + id = 200L, + tourApiId = "tour-200", + name = "공산성", + address = "충청남도 공주시", + latitude = BigDecimal("36.4625000"), + longitude = BigDecimal("127.1249000"), + category = "tourist_attraction", + imageUrl = imageUrl, + mobilityScore = 60, + photoScore = 70, + budgetScore = 50, + themeScore = 55, + metadataTags = emptyMap(), + ) + } } From 35300f59a83956c35aa6fc695d866a556772d0dc Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 20 May 2026 21:16:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=B6=80=EC=A1=B1=ED=95=9C=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=ED=9B=84=EB=B3=B4=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/consensus/ConsensusService.kt | 42 ++++++++++++++----- .../consensus/ConsensusServiceTest.kt | 19 +++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt index bef7af9..330dcc2 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt @@ -554,13 +554,15 @@ class ConsensusService( val slots = targets.mapIndexed { index, target -> val profile = buildSlotSelectionProfile(target.startTime, target.endTime, index + 1, targets.size) val orderIndex = index + 1 - val blockedPlaceIds = chosenPlaces.map { it.id }.toSet() + avoidPlaceIdsByOrder[orderIndex].orEmpty() - val blockedPlaceKeys = chosenPlaces.flatMap { placeCandidateKeys(it) }.toSet() + avoidPlaceKeysByOrder[orderIndex].orEmpty() + val currentOptionPlaceIds = chosenPlaces.map { it.id }.toSet() + val currentOptionPlaceKeys = chosenPlaces.flatMap { placeCandidateKeys(it) }.toSet() val rankedPlaces = rankPlaces( targetVector = target.scores, places = places, - usedPlaceIds = blockedPlaceIds, - usedPlaceKeys = blockedPlaceKeys, + usedPlaceIds = currentOptionPlaceIds, + usedPlaceKeys = currentOptionPlaceKeys, + avoidPlaceIds = avoidPlaceIdsByOrder[orderIndex].orEmpty(), + avoidPlaceKeys = avoidPlaceKeysByOrder[orderIndex].orEmpty(), previousPlace = chosenPlaces.lastOrNull(), preferHiddenGem = preferHiddenGem, mustBeHiddenGem = index == forcedHiddenGemIndex, @@ -721,6 +723,8 @@ class ConsensusService( places: List, usedPlaceIds: Set, usedPlaceKeys: Set, + avoidPlaceIds: Set, + avoidPlaceKeys: Set, previousPlace: PlaceCandidate?, preferHiddenGem: Boolean, mustBeHiddenGem: Boolean, @@ -746,19 +750,35 @@ class ConsensusService( if (restaurants.isNotEmpty()) pool = restaurants } - val hardExcludedPlaceKeys = usedPlaceKeys + places + val currentOptionPlaceKeys = usedPlaceKeys + places .filter { it.id in usedPlaceIds } .flatMap { placeCandidateKeys(it) } .toSet() - val unusedPool = pool.filter { it.id !in usedPlaceIds && placeCandidateKeys(it).none { key -> key in hardExcludedPlaceKeys } } - pool = if (unusedPool.isNotEmpty()) { - unusedPool - } else { - places.filter { it.id !in usedPlaceIds && placeCandidateKeys(it).none { key -> key in hardExcludedPlaceKeys } } + val softAvoidPlaceKeys = avoidPlaceKeys + places + .filter { it.id in avoidPlaceIds } + .flatMap { placeCandidateKeys(it) } + .toSet() + fun List.withoutCurrentOptionPlaces(): List = filter { + it.id !in usedPlaceIds && placeCandidateKeys(it).none { key -> key in currentOptionPlaceKeys } + } + fun List.withoutSoftAvoidPlaces(): List = filter { + it.id !in avoidPlaceIds && placeCandidateKeys(it).none { key -> key in softAvoidPlaceKeys } + } + + val currentOptionUnused = pool.withoutCurrentOptionPlaces() + val preferredUnused = currentOptionUnused.withoutSoftAvoidPlaces() + val broadlyPreferredUnused = places.withoutCurrentOptionPlaces().withoutSoftAvoidPlaces() + val broadlyCurrentOptionUnused = places.withoutCurrentOptionPlaces() + pool = when { + preferredUnused.isNotEmpty() -> preferredUnused + broadlyPreferredUnused.isNotEmpty() -> broadlyPreferredUnused + currentOptionUnused.isNotEmpty() -> currentOptionUnused + broadlyCurrentOptionUnused.isNotEmpty() -> broadlyCurrentOptionUnused + else -> pool } return pool.distinctBy { normalizedPlaceName(it) }.sortedByDescending { - placeRankingScore(it, targetVector, previousPlace, preferHiddenGem, usedPlaceIds, tripDate, profile) + placeRankingScore(it, targetVector, previousPlace, preferHiddenGem, usedPlaceIds + avoidPlaceIds, tripDate, profile) } } diff --git a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt index 74d2dab..a11a6d5 100644 --- a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt @@ -144,6 +144,25 @@ class ConsensusServiceTest { } } + @Test + fun `schedule generation reuses cross option candidates instead of failing when candidates are scarce`() = runBlocking { + val options = consensusService.buildScheduleOptions( + context( + destination = "공주시", + startTime = "09:00", + endTime = "10:00", + members = members(2), + places = places("충청남도 공주시").take(2), + ) + ) + + assertEquals(3, options.size) + options.forEach { option -> + assertEquals(1, option.slots.size) + assertTrue(option.slots.first().placeAddress.contains("공주시")) + } + } + @Test fun `multi day schedule creates separate slots for each requested date`() = runBlocking { val options = consensusService.buildScheduleOptions( From 42b52487bc56da8e0c0253d37071035f62aa47e0 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 20 May 2026 21:19:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20=EC=A4=91=EB=B3=B5=20=EC=9E=A5=EC=86=8C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/consensus/ConsensusService.kt | 2 +- .../infrastructure/llm/OpenAiClient.kt | 5 ++++ .../consensus/ConsensusServiceTest.kt | 19 +++++++++++++++ .../infrastructure/llm/OpenAiClientTest.kt | 23 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt index 330dcc2..9cc1da8 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt @@ -774,7 +774,7 @@ class ConsensusService( broadlyPreferredUnused.isNotEmpty() -> broadlyPreferredUnused currentOptionUnused.isNotEmpty() -> currentOptionUnused broadlyCurrentOptionUnused.isNotEmpty() -> broadlyCurrentOptionUnused - else -> pool + else -> emptyList() } return pool.distinctBy { normalizedPlaceName(it) }.sortedByDescending { diff --git a/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt b/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt index 0a3aba8..248afdc 100644 --- a/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt +++ b/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt @@ -187,6 +187,7 @@ class OpenAiClient( $slotDescriptions 각 슬롯의 후보 장소 중에서 가장 적합한 장소를 선택하고, 일정 전체 요약을 50자 이내로 개선해주세요. + 같은 일정 안에서는 동일한 장소 ID를 두 번 이상 선택하지 마세요. 응답 형식: { @@ -260,6 +261,10 @@ class OpenAiClient( if (hasInvalidSelection) { throw LlmParseException(LlmService.FallbackReason.RESPONSE_SCHEMA_INVALID, "LLM slots contain place selection outside shortlist") } + + if (result.slots.map { it.placeId }.toSet().size != result.slots.size) { + throw LlmParseException(LlmService.FallbackReason.RESPONSE_SCHEMA_INVALID, "LLM slots contain duplicated place selection") + } } private fun readJson(value: String, reason: LlmService.FallbackReason): JsonNode { diff --git a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt index a11a6d5..db0e5f3 100644 --- a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt @@ -163,6 +163,25 @@ class ConsensusServiceTest { } } + @Test + fun `schedule generation rejects a single option when unique places are fewer than slots`() { + val error = assertThrows(DomainException::class.java) { + runBlocking { + consensusService.buildScheduleOptions( + context( + destination = "공주시", + startTime = "09:00", + endTime = "12:00", + members = members(2), + places = places("충청남도 공주시").take(2), + ) + ) + } + } + + assertEquals("PLACE_CANDIDATE_EMPTY", error.code) + } + @Test fun `multi day schedule creates separate slots for each requested date`() = runBlocking { val options = consensusService.buildScheduleOptions( diff --git a/src/test/kotlin/com/tripsync/infrastructure/llm/OpenAiClientTest.kt b/src/test/kotlin/com/tripsync/infrastructure/llm/OpenAiClientTest.kt index 4e9c1f4..e731394 100644 --- a/src/test/kotlin/com/tripsync/infrastructure/llm/OpenAiClientTest.kt +++ b/src/test/kotlin/com/tripsync/infrastructure/llm/OpenAiClientTest.kt @@ -114,6 +114,29 @@ class OpenAiClientTest { assertEquals(LlmService.FallbackReason.RESPONSE_SCHEMA_INVALID, error.reason) } + @Test + fun `validate refinement rejects duplicated place selections in one schedule`() { + val result = LlmService.RefinementResult( + summary = "요약", + provider = "openai/gpt-test", + latencyMs = 10, + slots = listOf( + LlmService.RefinedSlot(orderIndex = 1, placeId = 10, reason = "첫 슬롯"), + LlmService.RefinedSlot(orderIndex = 2, placeId = 10, reason = "중복 슬롯"), + ), + ) + val slotPlan = listOf( + shortlist(orderIndex = 1, placeId = 10), + shortlist(orderIndex = 2, placeId = 10), + ) + + val error = assertThrows(OpenAiClient.LlmParseException::class.java) { + client.validateRefinement(result, slotPlan) + } + + assertEquals(LlmService.FallbackReason.RESPONSE_SCHEMA_INVALID, error.reason) + } + @Test fun `parse response classifies malformed json as parse failure`() { val error = assertThrows(OpenAiClient.LlmParseException::class.java) {