From f6cfeb40553f14535e610cb678bde730ea03e181 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 8 Jul 2026 11:53:32 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Refactor:=20=EC=9D=BC=EC=A0=95=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EC=B2=9C=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/consensus/ConsensusService.kt | 46 +++++-- .../application/consensus/LlmService.kt | 3 + .../ScheduleGenerationPersistenceService.kt | 68 +++++++---- .../application/schedule/ScheduleService.kt | 113 +++++++++++------- .../infrastructure/llm/OpenAiClient.kt | 2 +- src/main/resources/application.yml | 1 + 6 files changed, 162 insertions(+), 71 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt index 767212a..6eba68e 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/ConsensusService.kt @@ -7,9 +7,12 @@ 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.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeout import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import java.time.Instant @@ -19,6 +22,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.cos @@ -29,6 +33,8 @@ import kotlin.math.sqrt @Service class ConsensusService( private val llmService: LlmService, + @Value("\${openai.refinement-timeout-seconds:3}") + private val refinementTimeoutSeconds: Long = 3, ) { private val logger = KotlinLogging.logger {} @@ -627,16 +633,34 @@ class ConsensusService( analysis: GroupAnalysis, context: OptionContext, ): ScheduleOptionDraft { - val llmAttempt = llmService.refineScheduleOption( - optionType = prepared.optionType, - label = prepared.label, - summary = prepared.summary, - room = RoomRef(roomId = context.roomId, destination = context.destination, tripDate = context.tripDate), - commonAxes = analysis.commonAxes, - priorityAxes = analysis.priorityAxes, - members = members.map { MemberRef(it.userId, it.nickname) }, - slotPlan = prepared.shortlistedPerSlot, - ) + val startNanos = System.nanoTime() + val llmAttempt = try { + withTimeout(refinementTimeoutMillis()) { + llmService.refineScheduleOption( + optionType = prepared.optionType, + label = prepared.label, + summary = prepared.summary, + room = RoomRef(roomId = context.roomId, destination = context.destination, tripDate = context.tripDate), + commonAxes = analysis.commonAxes, + priorityAxes = analysis.priorityAxes, + members = members.map { MemberRef(it.userId, it.nickname) }, + slotPlan = prepared.shortlistedPerSlot, + ) + } + } catch (e: TimeoutCancellationException) { + val latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos).coerceAtLeast(0) + logger.warn(e) { + "schedule_option_refinement_timeout roomId=${context.roomId} optionType=${prepared.optionType} budgetMs=${refinementTimeoutMillis()} latencyMs=$latencyMs fallbackReason=${LlmService.FallbackReason.API_CALL_FAILED.code}" + } + LlmService.RefinementAttempt( + result = null, + attemptedProvider = llmService.attemptedProvider, + latencyMs = latencyMs.toLong(), + fallbackUsed = true, + fallbackReason = LlmService.FallbackReason.API_CALL_FAILED, + failureDetail = "refinement budget exceeded (${refinementTimeoutMillis()}ms)", + ) + } val llmRefined = llmAttempt.result val finalSummary = llmRefined?.summary ?: prepared.summary @@ -689,6 +713,8 @@ class ConsensusService( ) } + private fun refinementTimeoutMillis(): Long = refinementTimeoutSeconds.coerceAtLeast(1) * 1_000 + private fun resolveCrossOptionPlaceCollisions( options: List, preparedOptions: List, diff --git a/src/main/kotlin/com/tripsync/application/consensus/LlmService.kt b/src/main/kotlin/com/tripsync/application/consensus/LlmService.kt index db1430f..b9e7416 100644 --- a/src/main/kotlin/com/tripsync/application/consensus/LlmService.kt +++ b/src/main/kotlin/com/tripsync/application/consensus/LlmService.kt @@ -8,6 +8,9 @@ import org.springframework.stereotype.Service open class LlmService( private val openAiClient: OpenAiClient, ) { + open val attemptedProvider: String + get() = openAiClient.providerName + data class RefinedSlot( val orderIndex: Int, diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt index d03830d..56bb68b 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt @@ -10,6 +10,7 @@ import com.tripsync.domain.entity.Place import com.tripsync.domain.entity.SatisfactionScore import com.tripsync.domain.entity.Schedule import com.tripsync.domain.entity.ScheduleSlot +import com.tripsync.domain.entity.User import com.tripsync.domain.enums.ScheduleOptionType import com.tripsync.domain.enums.TripRoomStatus import com.tripsync.domain.enums.YnFlag @@ -107,8 +108,17 @@ class ScheduleGenerationPersistenceService( ?: throw DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.") val version = (scheduleRepository.findTopByRoomIdAndDelYnOrderByVersionDescIdDesc(room.id, YnFlag.N)?.version ?: 0) + 1 val replacementCandidates = placeQueryRepository.findScheduleCandidates(dto.destination) - val saved = options.map { rawOption -> - val option = rawOption.copy(slots = ensureUniqueSlots(rawOption.slots, replacementCandidates)) + val repairedOptions = options.map { rawOption -> + rawOption.copy(slots = ensureUniqueSlots(rawOption.slots, replacementCandidates)) + } + val placesById = loadActivePlacesById(repairedOptions.flatMap { option -> option.slots.map { it.placeId } }) + val usersById = loadUsersById( + repairedOptions.flatMap { option -> + option.slots.mapNotNull { it.targetUserId } + option.satisfactionByUser.map { it.userId } + } + ) + + val saved = repairedOptions.map { option -> val personaValidation = personaValidationByType[option.optionType] val schedule = scheduleRepository.save( Schedule( @@ -137,32 +147,28 @@ class ScheduleGenerationPersistenceService( ) ) - option.slots.forEach { slot -> - val place = placeRepository.findById(slot.placeId) - .orElseThrow { DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.") } - if (place.delYn != YnFlag.N) { - throw DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.") - } - val targetUser = slot.targetUserId?.let { userRepository.findById(it).orElse(null) } - scheduleSlotRepository.save( + scheduleSlotRepository.saveAll( + option.slots.map { slot -> + val place = placesById[slot.placeId] + ?: throw DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.") ScheduleSlot( schedule = schedule, startTime = slot.startTime, endTime = slot.endTime, place = place, slotType = slot.slotType, - targetUser = targetUser, + targetUser = slot.targetUserId?.let { usersById[it] }, reasonAxis = slot.reasonAxis, reasonText = slot.reasonText, orderIndex = slot.orderIndex, ) - ) - } + } + ) - option.satisfactionByUser.forEach { sat -> - val user = userRepository.findById(sat.userId) - .orElseThrow { DomainException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다: ${sat.userId}") } - satisfactionScoreRepository.save( + satisfactionScoreRepository.saveAll( + option.satisfactionByUser.map { sat -> + val user = usersById[sat.userId] + ?: throw DomainException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다: ${sat.userId}") SatisfactionScore( schedule = schedule, user = user, @@ -172,8 +178,8 @@ class ScheduleGenerationPersistenceService( "byAxis" to sat.breakdown.byAxis.mapKeys { it.key.name.lowercase() }, ), ) - ) - } + } + ) SavedScheduleOption( scheduleId = schedule.id, @@ -183,6 +189,24 @@ class ScheduleGenerationPersistenceService( } return SavedScheduleGeneration(version = version, options = saved) } + private fun loadActivePlacesById(placeIds: List): Map { + val uniqueIds = placeIds.distinct() + if (uniqueIds.isEmpty()) return emptyMap() + val placesById = placeRepository.findAllById(uniqueIds) + .filter { it.delYn == YnFlag.N } + .associateBy { it.id } + if (placesById.size != uniqueIds.size) { + throw DomainException(HttpStatus.NOT_FOUND, "PLACE_NOT_FOUND", "장소를 찾을 수 없습니다.") + } + return placesById + } + + private fun loadUsersById(userIds: List): Map { + val uniqueIds = userIds.distinct() + if (uniqueIds.isEmpty()) return emptyMap() + return userRepository.findAllById(uniqueIds).associateBy { it.id } + } + private fun ensureUniqueSlots(slots: List, replacementCandidates: List): List { if (slots.isEmpty()) return slots @@ -205,7 +229,11 @@ class ScheduleGenerationPersistenceService( placeAddress = replacement.address, isHiddenGem = isHiddenGem(replacement), ) - } ?: slot + } ?: throw DomainException( + HttpStatus.UNPROCESSABLE_ENTITY, + "INSUFFICIENT_UNIQUE_PLACES", + "중복 장소를 대체할 수 있는 후보가 부족합니다.", + ) } else { slot } diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt index 33627a1..6e15384 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleService.kt @@ -18,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional import java.time.Duration import java.time.LocalDate import java.time.ZoneId +import java.util.concurrent.TimeUnit @Service class ScheduleService( @@ -35,49 +36,81 @@ class ScheduleService( private val logger = KotlinLogging.logger {} fun generateSchedule(roomId: Long, hostId: Long, dto: GenerateScheduleDto): ApiResponse> { - val generationContext = generationPersistenceService.loadGenerationContext(roomId, hostId, dto.destination) - val members = generationContext.members - val placesById = generationContext.placesById - - val recentPlaceIds = latestGeneratedPlaceIds(roomId) - val context = OptionContext( - roomId = roomId, - destination = dto.destination, - tripDate = dto.tripStartDate ?: dto.tripDate, - tripEndDate = dto.tripEndDate ?: dto.tripStartDate ?: dto.tripDate, - startTime = dto.startTime, - endTime = dto.endTime, - members = members, - places = generationContext.places, - recentPlaceIds = recentPlaceIds, - diversitySalt = System.nanoTime(), - ) + val totalStartNanos = System.nanoTime() + var contextLoadMs = 0L + var consensusMs = 0L + var validationMs = 0L + var persistenceMs = 0L + var optionCount = 0 + var status = "success" + + try { + val contextStartNanos = System.nanoTime() + val generationContext = generationPersistenceService.loadGenerationContext(roomId, hostId, dto.destination) + contextLoadMs = elapsedMillis(contextStartNanos) + val members = generationContext.members + val placesById = generationContext.placesById + + val recentPlaceIds = latestGeneratedPlaceIds(roomId) + val context = OptionContext( + roomId = roomId, + destination = dto.destination, + tripDate = dto.tripStartDate ?: dto.tripDate, + tripEndDate = dto.tripEndDate ?: dto.tripStartDate ?: dto.tripDate, + startTime = dto.startTime, + endTime = dto.endTime, + members = members, + places = generationContext.places, + recentPlaceIds = recentPlaceIds, + diversitySalt = System.nanoTime(), + ) - val options = runBlocking { consensusService.buildScheduleOptions(context) } - val personaValidationByType = safePersonaValidationByType(options, members, placesById) - val savedGeneration = generationPersistenceService.saveGeneratedOptions( - roomId = roomId, - dto = dto, - options = options, - personaValidationByType = personaValidationByType, - ) + val consensusStartNanos = System.nanoTime() + val options = runBlocking { consensusService.buildScheduleOptions(context) } + consensusMs = elapsedMillis(consensusStartNanos) + optionCount = options.size + + val validationStartNanos = System.nanoTime() + val personaValidationByType = safePersonaValidationByType(options, members, placesById) + validationMs = elapsedMillis(validationStartNanos) + + val persistenceStartNanos = System.nanoTime() + val savedGeneration = generationPersistenceService.saveGeneratedOptions( + roomId = roomId, + dto = dto, + options = options, + personaValidationByType = personaValidationByType, + ) + persistenceMs = elapsedMillis(persistenceStartNanos) - val memberNicknames = members.associate { it.userId to it.nickname } - return ApiResponse.ok( - mapOf( - "roomId" to roomId, - "version" to savedGeneration.version, - "options" to savedGeneration.options.map { saved -> - responseMapper.formatGeneratedOption( - scheduleId = saved.scheduleId, - option = saved.option, - personaValidation = saved.personaValidation, - memberNicknames = memberNicknames, - placesById = placesById, - ) - }, + val memberNicknames = members.associate { it.userId to it.nickname } + return ApiResponse.ok( + mapOf( + "roomId" to roomId, + "version" to savedGeneration.version, + "options" to savedGeneration.options.map { saved -> + responseMapper.formatGeneratedOption( + scheduleId = saved.scheduleId, + option = saved.option, + personaValidation = saved.personaValidation, + memberNicknames = memberNicknames, + placesById = placesById, + ) + }, + ) ) - ) + } catch (error: Exception) { + status = "failed" + throw error + } finally { + logger.info { + "schedule_generation_timing status=$status optionCount=$optionCount totalMs=${elapsedMillis(totalStartNanos)} contextLoadMs=$contextLoadMs consensusMs=$consensusMs validationMs=$validationMs persistenceMs=$persistenceMs" + } + } + } + + private fun elapsedMillis(startNanos: Long): Long { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos).coerceAtLeast(0) } diff --git a/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt b/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt index 044eb83..4a4300f 100644 --- a/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt +++ b/src/main/kotlin/com/tripsync/infrastructure/llm/OpenAiClient.kt @@ -32,7 +32,7 @@ class OpenAiClient( private val meterRegistry: MeterRegistry, ) { private val logger = KotlinLogging.logger {} - private val providerName: String + val providerName: String get() = "openai/$model" suspend fun refineSchedule( diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fbd83af..2daf697 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -72,6 +72,7 @@ openai: api-key: ${OPENAI_API_KEY:} model: ${OPENAI_MODEL:gpt-4o-mini} timeout-seconds: ${OPENAI_TIMEOUT_SECONDS:10} + refinement-timeout-seconds: ${OPENAI_REFINEMENT_TIMEOUT_SECONDS:3} tourapi: key: ${TOURAPI_KEY:${TOUR_API_SERVICE_KEY:}} From 7aa1e99c7c9a2b2e24da51e4d3142dfa3598ea4f Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 8 Jul 2026 11:53:32 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Refactor:=20=EB=B0=A9=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=20=EC=83=81=ED=83=9C=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tripsync/application/room/RoomService.kt | 13 +++--- .../schedule/ScheduleReadAssembler.kt | 29 +++++++++++++ .../schedule/ScheduleResponseMapper.kt | 42 ++++++++++++++++++- .../repository/SatisfactionScoreRepository.kt | 4 ++ .../repository/ScheduleSlotRepository.kt | 6 +++ 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/com/tripsync/application/room/RoomService.kt b/src/main/kotlin/com/tripsync/application/room/RoomService.kt index 1b24383..2c17106 100644 --- a/src/main/kotlin/com/tripsync/application/room/RoomService.kt +++ b/src/main/kotlin/com/tripsync/application/room/RoomService.kt @@ -5,7 +5,6 @@ import com.tripsync.common.dto.ApiResponse import com.tripsync.common.exception.DomainException import com.tripsync.domain.entity.RoomMember import com.tripsync.domain.entity.RoomMemberProfile -import com.tripsync.domain.entity.Schedule import com.tripsync.domain.entity.TripRoom import com.tripsync.domain.entity.User import com.tripsync.domain.enums.RoomMemberRole @@ -345,16 +344,18 @@ class RoomService( ?.let { version -> scheduleRepository.findAllByRoomIdAndDelYnAndVersion(room.id, YnFlag.N, version) } ?.sortedBy { it.optionType.ordinal } .orEmpty() + val schedulesToFormat = (listOfNotNull(confirmed) + latestOptions).distinctBy { it.id } + val formattedSchedules = scheduleReadAssembler.formatStoredSchedules(schedulesToFormat) val scheduleState = when { confirmed != null -> mapOf( "status" to "confirmed", - "confirmedSchedule" to formatSchedule(confirmed), - "options" to latestOptions.map { formatSchedule(it) }, + "confirmedSchedule" to formattedSchedules.getValue(confirmed.id), + "options" to latestOptions.map { formattedSchedules.getValue(it.id) }, ) latestOptions.isNotEmpty() -> mapOf( "status" to "generated", "confirmedSchedule" to null, - "options" to latestOptions.map { formatSchedule(it) }, + "options" to latestOptions.map { formattedSchedules.getValue(it.id) }, ) else -> mapOf( "status" to "empty", @@ -384,10 +385,6 @@ class RoomService( } } - private fun formatSchedule(schedule: Schedule): Map { - return scheduleReadAssembler.formatStoredSchedule(schedule) - } - private fun normalizeRoomName(roomName: String?, destination: String): String { val normalized = roomName?.trim()?.takeIf { it.isNotBlank() } ?: defaultRoomName(destination) if (normalized.length > ROOM_NAME_MAX_LENGTH) { diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleReadAssembler.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleReadAssembler.kt index bff145f..8817d98 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleReadAssembler.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleReadAssembler.kt @@ -24,6 +24,35 @@ class ScheduleReadAssembler( ) } + fun formatStoredSchedules(schedules: List): Map> { + if (schedules.isEmpty()) return emptyMap() + + val memberNicknamesByRoomId = schedules + .map { it.room.id } + .distinct() + .associateWith { roomId -> + roomMemberProfileRepository.findMemberNicknamesByRoomId(roomId, YnFlag.N) + .associate { it.getUserId() to it.getNickname() } + } + val scheduleIds = schedules.map { it.id } + val slotsByScheduleId = scheduleSlotRepository + .findAllByScheduleIdInAndDelYnOrderByScheduleIdAscOrderIndexAsc(scheduleIds, YnFlag.N) + .groupBy { it.schedule.id } + val scoresByScheduleId = satisfactionScoreRepository + .findAllByScheduleIdInAndDelYn(scheduleIds, YnFlag.N) + .groupBy { it.schedule.id } + + val memberNicknamesByScheduleId = schedules.associate { schedule -> + schedule.id to memberNicknamesByRoomId[schedule.room.id].orEmpty() + } + return responseMapper.formatStoredSchedules( + schedules = schedules, + memberNicknamesByScheduleId = memberNicknamesByScheduleId, + slotsByScheduleId = slotsByScheduleId, + satisfactionScoresByScheduleId = scoresByScheduleId, + ) + } + fun formatPublicShareSchedule(schedule: Schedule): Map { val details = loadDetails(schedule) return responseMapper.formatPublicShareSchedule( diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt index 9cf45d1..1304101 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleResponseMapper.kt @@ -69,9 +69,47 @@ class ScheduleResponseMapper( slots: List, satisfactionScores: List, ): Map { - val llmMetadata = formatLlmMetadata(schedule) val activeSlots = slots.filter { it.delYn == YnFlag.N }.sortedBy { slot -> slot.orderIndex } - val metricsByPlaceId = loadMetricsByPlaceId(activeSlots.map { it.place.id }) + return formatStoredSchedule( + schedule = schedule, + memberNicknames = memberNicknames, + activeSlots = activeSlots, + satisfactionScores = satisfactionScores, + metricsByPlaceId = loadMetricsByPlaceId(activeSlots.map { it.place.id }), + ) + } + + fun formatStoredSchedules( + schedules: List, + memberNicknamesByScheduleId: Map>, + slotsByScheduleId: Map>, + satisfactionScoresByScheduleId: Map>, + ): Map> { + val activeSlotsByScheduleId = schedules.associate { schedule -> + schedule.id to slotsByScheduleId[schedule.id].orEmpty() + .filter { it.delYn == YnFlag.N } + .sortedBy { slot -> slot.orderIndex } + } + val metricsByPlaceId = loadMetricsByPlaceId(activeSlotsByScheduleId.values.flatten().map { it.place.id }) + return schedules.associate { schedule -> + schedule.id to formatStoredSchedule( + schedule = schedule, + memberNicknames = memberNicknamesByScheduleId[schedule.id].orEmpty(), + activeSlots = activeSlotsByScheduleId[schedule.id].orEmpty(), + satisfactionScores = satisfactionScoresByScheduleId[schedule.id].orEmpty(), + metricsByPlaceId = metricsByPlaceId, + ) + } + } + + private fun formatStoredSchedule( + schedule: Schedule, + memberNicknames: Map, + activeSlots: List, + satisfactionScores: List, + metricsByPlaceId: Map, + ): Map { + val llmMetadata = formatLlmMetadata(schedule) return mapOf( "id" to schedule.id, "shareToken" to schedule.shareToken, diff --git a/src/main/kotlin/com/tripsync/domain/repository/SatisfactionScoreRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/SatisfactionScoreRepository.kt index 503261e..935fa23 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/SatisfactionScoreRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/SatisfactionScoreRepository.kt @@ -10,5 +10,9 @@ import org.springframework.stereotype.Repository interface SatisfactionScoreRepository : JpaRepository { @EntityGraph(attributePaths = ["user"]) fun findAllByScheduleIdAndDelYn(scheduleId: Long, delYn: YnFlag): List + + @EntityGraph(attributePaths = ["user"]) + fun findAllByScheduleIdInAndDelYn(scheduleIds: Collection, delYn: YnFlag): List + fun findAllByScheduleRoomIdAndUserIdAndDelYn(roomId: Long, userId: Long, delYn: YnFlag): List } diff --git a/src/main/kotlin/com/tripsync/domain/repository/ScheduleSlotRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/ScheduleSlotRepository.kt index 0bfdccb..a027ca4 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/ScheduleSlotRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/ScheduleSlotRepository.kt @@ -15,6 +15,12 @@ interface ScheduleSlotRepository : JpaRepository { @EntityGraph(attributePaths = ["place", "targetUser"]) fun findAllByScheduleIdAndDelYnOrderByOrderIndexAsc(scheduleId: Long, delYn: YnFlag): List + @EntityGraph(attributePaths = ["place", "targetUser"]) + fun findAllByScheduleIdInAndDelYnOrderByScheduleIdAscOrderIndexAsc( + scheduleIds: Collection, + delYn: YnFlag, + ): List + @Query("select slot.place.id from ScheduleSlot slot where slot.schedule.id = :scheduleId and slot.delYn = :delYn") fun findActivePlaceIdsByScheduleId(scheduleId: Long, delYn: YnFlag = YnFlag.N): List } From 0e159f0eeb01254eca38f451548ae45ff9388e07 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 8 Jul 2026 11:53:32 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Refactor:=20=EC=99=B8=EB=B6=80=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=EB=8F=84=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=9C=ED=95=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/popularity/ExternalPopularityBatchService.kt | 3 ++- .../kotlin/com/tripsync/domain/repository/PlaceRepository.kt | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchService.kt b/src/main/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchService.kt index 767080d..e4a0437 100644 --- a/src/main/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchService.kt +++ b/src/main/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchService.kt @@ -14,6 +14,7 @@ import com.tripsync.infrastructure.popularity.GooglePlacesClient import com.tripsync.infrastructure.popularity.NaverDataLabClient import kotlinx.coroutines.runBlocking import mu.KotlinLogging +import org.springframework.data.domain.PageRequest import org.springframework.http.HttpStatus import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service @@ -61,7 +62,7 @@ class ExternalPopularityBatchService( 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 places = placeRepository.findByDelYn(YnFlag.N, PageRequest.of(0, limit)) val rawResults = mutableListOf() places.forEachIndexed { index, place -> diff --git a/src/main/kotlin/com/tripsync/domain/repository/PlaceRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/PlaceRepository.kt index 2a874a1..b5f9ac6 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/PlaceRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/PlaceRepository.kt @@ -2,6 +2,7 @@ package com.tripsync.domain.repository import com.tripsync.domain.entity.Place import com.tripsync.domain.enums.YnFlag +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -11,6 +12,7 @@ import org.springframework.stereotype.Repository interface PlaceRepository : JpaRepository { fun findByCategoryAndDelYn(category: String, delYn: YnFlag): List fun findByDelYn(delYn: YnFlag): List + fun findByDelYn(delYn: YnFlag, pageable: Pageable): List fun findByTourApiIdIn(tourApiIds: Collection): List @Query( From f71559303a8631582ec6ea7f2d174d3dea24006c Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 8 Jul 2026 11:53:32 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Test:=20=EC=B6=94=EC=B2=9C=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consensus/ConsensusServiceTest.kt | 80 ++++++++ .../ExternalPopularityBatchServiceTest.kt | 56 ++++++ .../room/RoomScheduleQueryCountTest.kt | 109 ++++++---- .../ScheduleGenerationPerformanceTest.kt | 186 ++++++++++++++++++ 4 files changed, 397 insertions(+), 34 deletions(-) create mode 100644 src/test/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchServiceTest.kt create mode 100644 src/test/kotlin/com/tripsync/application/schedule/ScheduleGenerationPerformanceTest.kt diff --git a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt index 17c659e..a64e773 100644 --- a/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/consensus/ConsensusServiceTest.kt @@ -7,6 +7,7 @@ import com.tripsync.domain.entity.AxisScores import com.tripsync.domain.enums.ScheduleOptionType import com.tripsync.infrastructure.llm.OpenAiClient import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -98,6 +99,37 @@ class ConsensusServiceTest { assertTrue(elapsedMillis < 2_500, "three LLM refinements should wait once in parallel instead of timing out sequentially") } + @Test + fun `one slow llm refinement falls back while sibling options remain successful`() = runBlocking { + val partialTimeoutConsensusService = ConsensusService(partialTimeoutLlmService(), refinementTimeoutSeconds = 1) + + val options = partialTimeoutConsensusService.buildScheduleOptions( + context( + destination = "공주시", + startTime = "09:00", + endTime = "12:00", + members = members(2), + places = places("충청남도 공주시"), + ) + ) + + assertEquals(3, options.size) + val fallbackOption = options.single { it.optionType == ScheduleOptionType.DISCOVERY } + val successfulOptions = options.filter { it.optionType != ScheduleOptionType.DISCOVERY } + + assertTrue(fallbackOption.fallbackUsed) + assertEquals("api_call_failed", fallbackOption.llmFallbackReason) + assertEquals("deterministic-consensus", fallbackOption.llmProvider) + assertEquals(2, successfulOptions.size) + successfulOptions.forEach { option -> + assertEquals("test/partial-timeout-llm", option.llmProvider) + assertEquals("test/partial-timeout-llm", option.llmAttemptedProvider) + assertEquals(false, option.fallbackUsed) + assertEquals(null, option.llmFallbackReason) + assertTrue(option.slots.all { it.reasonText == "LLM 성공 보정" }) + } + } + @Test fun `llm refined places are deduplicated across recommendation options by same order`() = runBlocking { val llmConsensusService = ConsensusService(coordinatedDuplicateLlmService()) @@ -384,6 +416,54 @@ class ConsensusServiceTest { } } + private fun partialTimeoutLlmService(): LlmService { + val client = OpenAiClient( + webClient = WebClient.create(), + objectMapper = ObjectMapper(), + apiKey = "", + model = "gpt-test", + meterRegistry = SimpleMeterRegistry(), + ) + return object : LlmService(client) { + override val attemptedProvider: String + get() = "test/partial-timeout-llm" + + override suspend fun refineScheduleOption( + optionType: ScheduleOptionType, + label: String, + summary: String, + room: ConsensusService.RoomRef, + commonAxes: List, + priorityAxes: List, + members: List, + slotPlan: List, + ): RefinementAttempt { + if (optionType == ScheduleOptionType.DISCOVERY) { + delay(2_000) + } + + return RefinementAttempt( + result = RefinementResult( + summary = "$label LLM 보정 요약", + provider = "test/partial-timeout-llm", + latencyMs = 1, + slots = slotPlan.map { slot -> + RefinedSlot( + orderIndex = slot.orderIndex, + placeId = slot.candidatePlaces.first().id, + reason = "LLM 성공 보정", + ) + }, + ), + attemptedProvider = "test/partial-timeout-llm", + latencyMs = 1, + fallbackUsed = false, + fallbackReason = null, + ) + } + } + } + private fun context( destination: String, startTime: String, diff --git a/src/test/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchServiceTest.kt b/src/test/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchServiceTest.kt new file mode 100644 index 0000000..cb947bb --- /dev/null +++ b/src/test/kotlin/com/tripsync/application/popularity/ExternalPopularityBatchServiceTest.kt @@ -0,0 +1,56 @@ +package com.tripsync.application.popularity + +import com.tripsync.domain.entity.User +import com.tripsync.domain.enums.AuthProvider +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.GooglePlacesClient +import com.tripsync.infrastructure.popularity.NaverDataLabClient +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.springframework.data.domain.PageRequest +import org.springframework.web.reactive.function.client.WebClient + +class ExternalPopularityBatchServiceTest { + private val placeRepository = mock(PlaceRepository::class.java) + private val metricRepository = mock(ExternalPopularityMetricRepository::class.java) + private val properties = ExternalPopularityProperties( + sync = ExternalPopularityProperties.Sync(batchLimit = 500), + ) + private val service = ExternalPopularityBatchService( + placeRepository = placeRepository, + metricRepository = metricRepository, + naverDataLabClient = NaverDataLabClient(WebClient.builder().build(), properties), + googlePlacesClient = GooglePlacesClient(WebClient.builder().build(), properties), + properties = properties, + ) + + @Test + fun `manual sync passes bounded limit to repository query`() { + val expectedPage = PageRequest.of(0, 7) + `when`(placeRepository.findByDelYn(YnFlag.N, expectedPage)).thenReturn(emptyList()) + + val response = service.syncManually(adminUser(), limit = 7).data!! + + assertEquals(0, response["scanned"]) + verify(placeRepository).findByDelYn(YnFlag.N, expectedPage) + verify(placeRepository, never()).findByDelYn(YnFlag.N) + } + + private fun adminUser(): User { + return User( + id = 1, + nickname = "admin", + email = "admin@example.com", + authProvider = AuthProvider.LOCAL, + passwordHash = "password", + adminYn = YnFlag.Y, + ) + } +} diff --git a/src/test/kotlin/com/tripsync/application/room/RoomScheduleQueryCountTest.kt b/src/test/kotlin/com/tripsync/application/room/RoomScheduleQueryCountTest.kt index e97dec0..fe9f21e 100644 --- a/src/test/kotlin/com/tripsync/application/room/RoomScheduleQueryCountTest.kt +++ b/src/test/kotlin/com/tripsync/application/room/RoomScheduleQueryCountTest.kt @@ -96,12 +96,49 @@ class RoomScheduleQueryCountTest( ) } - private fun createFixture(roomCount: Int, slotCount: Int, scoreCount: Int): QueryFixture { + @Test + fun `room detail batches generated schedule state detail reads`() { + val fixture = createFixture( + roomCount = 1, + slotCount = 3, + scoreCount = 3, + optionTypes = listOf( + ScheduleOptionType.BALANCED, + ScheduleOptionType.INDIVIDUAL, + ScheduleOptionType.DISCOVERY, + ), + confirmed = false, + ) + val room = fixture.rooms.first() + flushAndClear() + + val measured = queryCounter.count { + roomService.getRoom(room.id, fixture.host).data!! + } + val scheduleState = measured.result["scheduleState"] as Map<*, *> + val options = scheduleState["options"] as List<*> + + assertEquals("generated", scheduleState["status"]) + assertEquals(3, options.size) + assertTrue( + measured.prepareStatementCount <= 11, + "expected room detail scheduleState to stay within 11 statements, got ${measured.prepareStatementCount}", + ) + } + + private fun createFixture( + roomCount: Int, + slotCount: Int, + scoreCount: Int, + optionTypes: List = listOf(ScheduleOptionType.BALANCED), + confirmed: Boolean = true, + ): QueryFixture { val suffix = System.nanoTime() val host = user("host-$suffix", "host-$suffix@example.com") val members = (1..scoreCount).map { user("member-$it-$suffix", "member-$it-$suffix@example.com") } val place = placeRepository.save(place("place-$suffix")) val schedules = mutableListOf() + val rooms = mutableListOf() repeat(roomCount) { roomIndex -> val room = tripRoomRepository.save( @@ -114,6 +151,7 @@ class RoomScheduleQueryCountTest( status = TripRoomStatus.COMPLETED, ) ) + rooms += room roomMemberRepository.save(RoomMember(room = room, user = host, role = RoomMemberRole.HOST)) (members.take(scoreCount - 1)).forEach { roomMemberRepository.save(RoomMember(room = room, user = it, role = RoomMemberRole.MEMBER)) @@ -134,44 +172,46 @@ class RoomScheduleQueryCountTest( ) } - val schedule = scheduleRepository.save( - Schedule( - room = room, - version = 1, - optionType = ScheduleOptionType.BALANCED, - isConfirmed = true, - generationInput = mapOf("destination" to "충남", "startTime" to "09:00", "endTime" to "21:00"), - summary = "query-count schedule", - groupSatisfaction = 80, - ) - ) - schedules += schedule - repeat(slotCount) { slotIndex -> - scheduleSlotRepository.save( - ScheduleSlot( - schedule = schedule, - startTime = Instant.parse("2026-06-01T0${slotIndex % 9}:00:00Z"), - endTime = Instant.parse("2026-06-01T0${slotIndex % 9}:30:00Z"), - place = place, - slotType = SlotType.COMMON, - reasonAxis = ReasonAxis.COMMON, - reasonText = "slot-$slotIndex", - orderIndex = slotIndex + 1, + optionTypes.forEachIndexed { optionIndex, optionType -> + val schedule = scheduleRepository.save( + Schedule( + room = room, + version = 1, + optionType = optionType, + isConfirmed = confirmed && optionIndex == 0, + generationInput = mapOf("destination" to "충남", "startTime" to "09:00", "endTime" to "21:00"), + summary = "query-count schedule", + groupSatisfaction = 80, ) ) - } - (listOf(host) + members.take(scoreCount - 1)).forEach { user -> - satisfactionScoreRepository.save( - SatisfactionScore( - schedule = schedule, - user = user, - score = 80, - breakdown = mapOf("overall" to 80), + schedules += schedule + repeat(slotCount) { slotIndex -> + scheduleSlotRepository.save( + ScheduleSlot( + schedule = schedule, + startTime = Instant.parse("2026-06-01T0${slotIndex % 9}:00:00Z"), + endTime = Instant.parse("2026-06-01T0${slotIndex % 9}:30:00Z"), + place = place, + slotType = SlotType.COMMON, + reasonAxis = ReasonAxis.COMMON, + reasonText = "slot-$slotIndex", + orderIndex = slotIndex + 1, + ) ) - ) + } + (listOf(host) + members.take(scoreCount - 1)).forEach { user -> + satisfactionScoreRepository.save( + SatisfactionScore( + schedule = schedule, + user = user, + score = 80, + breakdown = mapOf("overall" to 80), + ) + ) + } } } - return QueryFixture(host = host, schedules = schedules) + return QueryFixture(host = host, rooms = rooms, schedules = schedules) } private fun user(nickname: String, email: String): User { @@ -220,6 +260,7 @@ class RoomScheduleQueryCountTest( private data class QueryFixture( val host: User, + val rooms: List, val schedules: List, ) } diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleGenerationPerformanceTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleGenerationPerformanceTest.kt new file mode 100644 index 0000000..81e9b07 --- /dev/null +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleGenerationPerformanceTest.kt @@ -0,0 +1,186 @@ +package com.tripsync.application.schedule + +import com.tripsync.application.consensus.ScheduleOptionDraft +import com.tripsync.application.consensus.ScheduleSlotDraft +import com.tripsync.application.consensus.SatisfactionDraft +import com.tripsync.domain.entity.Place +import com.tripsync.domain.entity.Schedule +import com.tripsync.domain.entity.TripRoom +import com.tripsync.domain.entity.User +import com.tripsync.domain.enums.AuthProvider +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 com.tripsync.domain.enums.TripRoomStatus +import com.tripsync.domain.enums.YnFlag +import com.tripsync.domain.repository.ExternalPopularityMetricRepository +import com.tripsync.domain.repository.PlaceQueryRepository +import com.tripsync.domain.repository.PlaceRepository +import com.tripsync.domain.repository.RoomMemberProfileRepository +import com.tripsync.domain.repository.SatisfactionScoreRepository +import com.tripsync.domain.repository.ScheduleRepository +import com.tripsync.domain.repository.ScheduleSlotRepository +import com.tripsync.domain.repository.TripRoomRepository +import com.tripsync.domain.repository.UserRepository +import com.tripsync.web.dto.GenerateScheduleDto +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDate + +class ScheduleGenerationPerformanceTest { + private val scheduleRepository = mock(ScheduleRepository::class.java) + private val scheduleSlotRepository = mock(ScheduleSlotRepository::class.java) + private val satisfactionScoreRepository = mock(SatisfactionScoreRepository::class.java) + private val roomMemberProfileRepository = mock(RoomMemberProfileRepository::class.java) + private val placeRepository = mock(PlaceRepository::class.java) + private val placeQueryRepository = mock(PlaceQueryRepository::class.java) + private val externalPopularityMetricRepository = mock(ExternalPopularityMetricRepository::class.java) + private val userRepository = mock(UserRepository::class.java) + private val tripRoomRepository = mock(TripRoomRepository::class.java) + private val accessPolicy = mock(ScheduleAccessPolicy::class.java) + + private val service = ScheduleGenerationPersistenceService( + scheduleRepository = scheduleRepository, + scheduleSlotRepository = scheduleSlotRepository, + satisfactionScoreRepository = satisfactionScoreRepository, + roomMemberProfileRepository = roomMemberProfileRepository, + placeRepository = placeRepository, + placeQueryRepository = placeQueryRepository, + externalPopularityMetricRepository = externalPopularityMetricRepository, + userRepository = userRepository, + tripRoomRepository = tripRoomRepository, + accessPolicy = accessPolicy, + ) + + @Test + fun `generated schedule persistence batches place and user lookup and child saves`() { + val host = user(101) + val companion = user(102) + val room = TripRoom( + id = 10, + hostUser = host, + shareCode = "ROOM00000010", + destination = "충남", + roomName = "충남 여행", + tripDate = LocalDate.parse("2026-06-01"), + status = TripRoomStatus.WAITING, + ) + val places = (1L..4L).map { id -> place(id) } + val options = listOf( + generatedOption(ScheduleOptionType.BALANCED, listOf(1L, 2L), listOf(host.id, companion.id)), + generatedOption(ScheduleOptionType.INDIVIDUAL, listOf(2L, 3L), listOf(host.id, companion.id)), + generatedOption(ScheduleOptionType.DISCOVERY, listOf(3L, 4L), listOf(host.id, companion.id)), + ) + + `when`(tripRoomRepository.findActiveByIdForUpdate(room.id, YnFlag.N)).thenReturn(room) + `when`(scheduleRepository.findTopByRoomIdAndDelYnOrderByVersionDescIdDesc(room.id, YnFlag.N)).thenReturn(null) + `when`(placeQueryRepository.findScheduleCandidates("충남")).thenReturn(places) + `when`(placeRepository.findAllById(listOf(1L, 2L, 3L, 4L))).thenReturn(places) + `when`(userRepository.findAllById(listOf(host.id, companion.id))).thenReturn(listOf(host, companion)) + `when`(scheduleRepository.save(any(Schedule::class.java))).thenAnswer { invocation -> invocation.arguments[0] } + + service.saveGeneratedOptions( + roomId = room.id, + dto = GenerateScheduleDto( + destination = "충남", + tripDate = "2026-06-01", + startTime = "09:00", + endTime = "12:00", + ), + options = options, + personaValidationByType = emptyMap(), + ) + + verify(placeRepository).findAllById(listOf(1L, 2L, 3L, 4L)) + verify(userRepository).findAllById(listOf(host.id, companion.id)) + verify(scheduleSlotRepository, times(3)).saveAll(anyIterable()) + verify(satisfactionScoreRepository, times(3)).saveAll(anyIterable()) + verify(placeRepository, never()).findById(anyLong()) + verify(userRepository, never()).findById(anyLong()) + } + + private fun generatedOption( + optionType: ScheduleOptionType, + placeIds: List, + userIds: List, + ): ScheduleOptionDraft { + val start = Instant.parse("2026-06-01T00:00:00Z") + return ScheduleOptionDraft( + optionType = optionType, + label = optionType.name.lowercase(), + summary = "성능 테스트", + groupSatisfaction = 80, + slots = placeIds.mapIndexed { index, placeId -> + ScheduleSlotDraft( + orderIndex = index + 1, + slotType = SlotType.COMMON, + targetUserId = userIds.getOrNull(index), + reasonAxis = ReasonAxis.COMMON, + reasonText = "공통 선호 장소", + startTime = start.plusSeconds(index * 3600L), + endTime = start.plusSeconds((index + 1) * 3600L), + placeId = placeId, + placeName = "place-$placeId", + placeAddress = "충청남도 공주시 $placeId", + isHiddenGem = false, + ) + }, + satisfactionByUser = userIds.map { userId -> + SatisfactionDraft( + userId = userId, + score = 80, + breakdown = SatisfactionDraft.Breakdown( + overall = 80, + byAxis = mapOf(ScoreAxis.MOBILITY to 80.0), + ), + ) + }, + llmProvider = "deterministic-consensus", + llmAttemptedProvider = "test", + llmLatencyMs = null, + fallbackUsed = true, + llmFallbackReason = "test", + ) + } + + private fun user(id: Long): User { + return User( + id = id, + nickname = "user-$id", + email = "user-$id@example.com", + authProvider = AuthProvider.LOCAL, + passwordHash = "password", + ) + } + + private fun place(id: Long): Place { + return Place( + id = id, + tourApiId = "place-$id", + name = "place-$id", + address = "충청남도 공주시 $id", + latitude = BigDecimal("36.5000000"), + longitude = BigDecimal("126.5000000"), + category = "관광지", + mobilityScore = 50, + photoScore = 60, + budgetScore = 70, + themeScore = 80, + ) + } + + @Suppress("UNCHECKED_CAST") + private fun anyIterable(): Iterable { + any(Iterable::class.java) + return emptyList() + } +} From a1ca347f55d268a80b567d4e622f6b60d5e66c55 Mon Sep 17 00:00:00 2001 From: Heeyaa Date: Wed, 8 Jul 2026 11:53:32 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Chore:=20Kotlin=20compiler=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=95=84=ED=8B=B0=ED=8C=A9=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4bc1ac1..3a7772e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ application-prod.yml *~ node_modules/ + +.kotlin/