diff --git a/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/WeatherServiceImpl.kt b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/WeatherServiceImpl.kt index 3d5c6a6..24a7628 100644 --- a/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/WeatherServiceImpl.kt +++ b/pida-clients/weather-client/src/main/kotlin/com/pida/client/weather/WeatherServiceImpl.kt @@ -9,6 +9,7 @@ import com.pida.weather.Weather import com.pida.weather.WeatherLocation import com.pida.weather.WeatherService import org.springframework.stereotype.Service +import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter import kotlin.math.abs @@ -21,34 +22,37 @@ class WeatherServiceImpl( private val kmaWeatherClient: KmaWeatherClient, ) : WeatherService { private val logger by logger() + private val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") override fun getWeather(location: WeatherLocation): Weather { - val (baseDate, baseTime) = kmaWeatherClient.getLatestBaseTime() + val items = fetchForecastItems(location) + return parseWeatherResponse(items, location) + } - logger.info( - "Fetching weather for location: lat=${location.latitude}, lon=${location.longitude}, nx=${location.nx}, ny=${location.ny}", - ) + override fun getTomorrowMaxPrecipitationProbability(location: WeatherLocation): Int { + val items = fetchForecastItems(location) - val response = - try { - kmaWeatherClient.getVilageForecast(baseDate, baseTime, location.nx, location.ny) - } catch (error: Exception) { - logger.error("Failed to fetch weather forecast", error) - throw ErrorException(ErrorType.WEATHER_API_CALL_FAILED) - } + if (items.isEmpty()) { + logger.warn("KMA response contains no forecast items for nx=${location.nx}, ny=${location.ny}") + throw ErrorException(ErrorType.WEATHER_DATA_NOT_AVAILABLE) + } + + val tomorrowDate = LocalDate.now().plusDays(1).format(dateFormatter) - return parseWeatherResponse(response, location) + return items + .asSequence() + .filter { it.category == "POP" && it.fcstDate == tomorrowDate } + .mapNotNull { it.fcstValue.toIntOrNull() } + .maxOrNull() ?: 0 } /** * 기상청 API 응답을 Weather 도메인 모델로 변환 */ private fun parseWeatherResponse( - response: KmaWeatherResponse, + items: List, location: WeatherLocation, ): Weather { - val items = response.response.body.items.item - if (items.isEmpty()) { logger.warn("KMA response contains no forecast items for nx=${location.nx}, ny=${location.ny}") throw ErrorException(ErrorType.WEATHER_DATA_NOT_AVAILABLE) @@ -102,6 +106,24 @@ class WeatherServiceImpl( ) } + private fun fetchForecastItems(location: WeatherLocation): List { + val (baseDate, baseTime) = kmaWeatherClient.getLatestBaseTime() + + logger.info( + "Fetching weather for location: lat=${location.latitude}, lon=${location.longitude}, nx=${location.nx}, ny=${location.ny}", + ) + + val response = + try { + kmaWeatherClient.getVilageForecast(baseDate, baseTime, location.nx, location.ny) + } catch (error: Exception) { + logger.error("Failed to fetch weather forecast", error) + throw ErrorException(ErrorType.WEATHER_API_CALL_FAILED) + } + + return response.response.body.items.item + } + /** * 예보 일시 파싱 (yyyyMMddHHmm -> LocalDateTime) */ diff --git a/pida-core/core-api/build.gradle.kts b/pida-core/core-api/build.gradle.kts index c493761..b2f976d 100644 --- a/pida-core/core-api/build.gradle.kts +++ b/pida-core/core-api/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.aop) implementation(libs.spring.boot.starter.validation) + compileOnly(libs.redisson) // Security implementation(libs.spring.boot.starter.security) diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt index c37ce3c..137cd72 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt @@ -1,11 +1,17 @@ package com.pida.presentation.v1.notification +import com.pida.notification.bloomed.BloomedNotificationService +import com.pida.notification.bloomedspot.BloomedSpotNotificationService +import com.pida.notification.rain.RainForecastNotificationService import com.pida.notification.weekday.WeekdayNotificationService import com.pida.notification.weekend.WeekendNotificationService +import com.pida.notification.withered.WitheredNotificationService +import com.pida.support.geo.Region import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import java.time.LocalDateTime @@ -15,6 +21,10 @@ import java.time.LocalDateTime class NotificationTestController( private val weekendNotificationService: WeekendNotificationService, private val weekdayNotificationService: WeekdayNotificationService, + private val witheredNotificationService: WitheredNotificationService, + private val bloomedNotificationService: BloomedNotificationService, + private val bloomedSpotNotificationService: BloomedSpotNotificationService, + private val rainForecastNotificationService: RainForecastNotificationService, ) { @PostMapping("/weekend-notification") @Operation( @@ -48,6 +58,83 @@ class NotificationTestController( ) } + @PostMapping("/withered-notification") + @Operation( + summary = "저물었어요 알림 수동 트리거", + description = + "WITHERED 상태 푸시 알림을 수동으로 발송합니다. (테스트용)\n\n" + + "각 지역별 WITHERED 투표 비율을 확인하여 30% 이상인 지역의 사용자들에게 알림을 발송합니다.", + ) + fun triggerWitheredNotification(): NotificationTriggerResponse { + val startTime = LocalDateTime.now() + + witheredNotificationService.sendWitheredNotifications() + + return NotificationTriggerResponse( + message = "Withered notification triggered successfully", + triggeredAt = startTime, + ) + } + + @PostMapping("/bloomed-notification") + @Operation( + summary = "만개했어요 알림 수동 트리거", + description = + "BLOOMED 상태 푸시 알림을 수동으로 발송합니다. (테스트용)\n\n" + + "입력한 지역의 사용자들에게 BLOOMED 알림을 수동 발송합니다.", + ) + fun triggerBloomedNotification( + @RequestParam region: Region, + ): NotificationTriggerResponse { + val startTime = LocalDateTime.now() + + bloomedNotificationService.sendBloomedNotificationForRegion(region) + + return NotificationTriggerResponse( + message = "Bloomed notification triggered successfully", + triggeredAt = startTime, + ) + } + + @PostMapping("/bloomed-spot-notification") + @Operation( + summary = "벚꽃길 만개 이벤트 알림 수동 트리거", + description = + "특정 벚꽃길의 BLOOMED 투표 이벤트 알림을 수동 발송합니다. (테스트용)\n\n" + + "조건: 반경 3km 내 위치 권한 허용 사용자\n" + + "제외: 당일 이미 푸시 수신 사용자, 해당 벚꽃길 시즌 내 알림 기수신 사용자", + ) + fun triggerBloomedSpotNotification( + @RequestParam spotId: Long, + ): NotificationTriggerResponse { + val startTime = LocalDateTime.now() + + bloomedSpotNotificationService.sendBloomedSpotNotification(spotId) + + return NotificationTriggerResponse( + message = "Bloomed spot notification triggered successfully", + triggeredAt = startTime, + ) + } + + @PostMapping("/rain-forecast-notification") + @Operation( + summary = "비 예보 알림 수동 트리거", + description = + "내일 POP(강수확률) 최대값이 60 이상인 위치의 사용자에게 비 예보 알림을 수동 발송합니다. (테스트용)\n\n" + + "제외 조건: 당일 다른 푸시 수신 사용자, 당주 이미 비 예보 알림 수신 사용자", + ) + fun triggerRainForecastNotification(): NotificationTriggerResponse { + val startTime = LocalDateTime.now() + + rainForecastNotificationService.sendRainForecastNotifications() + + return NotificationTriggerResponse( + message = "Rain forecast notification triggered successfully", + triggeredAt = startTime, + ) + } + data class NotificationTriggerResponse( val message: String, val triggeredAt: LocalDateTime, diff --git a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/BloomedNotificationScheduler.kt b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/BloomedNotificationScheduler.kt new file mode 100644 index 0000000..cd119e4 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/BloomedNotificationScheduler.kt @@ -0,0 +1,28 @@ +package com.pida.scheduler + +import com.pida.notification.bloomed.BloomedNotificationService +import com.pida.support.extension.logger +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +/** + * 만개했어요 알림 스케줄러 + * + * 매일 오전 9시에 실행하여 BLOOMED 비율 80% 이상 지역 알림을 발송합니다. + * 하루 1회만 실행되도록 중복 실행을 방지합니다. + */ +@Component +class BloomedNotificationScheduler( + private val bloomedNotificationService: BloomedNotificationService, +) : DailyNotificationScheduler() { + private val logger by logger() + + @Scheduled(cron = "0 0 9 * * *") + fun executeBloomedNotification() = + executeOncePerDay( + logger = logger, + failureMessage = "Failed to execute bloomed notification scheduler", + ) { + bloomedNotificationService.sendBloomedNotifications() + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/EveningNotificationScheduler.kt b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/EveningNotificationScheduler.kt new file mode 100644 index 0000000..8a8e0b3 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/EveningNotificationScheduler.kt @@ -0,0 +1,70 @@ +package com.pida.scheduler + +import com.pida.notification.rain.RainForecastNotificationService +import com.pida.support.extension.logger +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDate + +/** + * 저녁 푸시 알림 오케스트레이터 + * + * 매일 18:00에 실행되며 분산 락 아래에서 실행 순서를 고정합니다. + */ +@Component +class EveningNotificationScheduler( + private val notificationExecutionLock: NotificationExecutionLock, + private val weekdayNotificationScheduler: WeekdayNotificationScheduler, + private val rainForecastNotificationService: RainForecastNotificationService, +) { + private val logger by logger() + + @Scheduled(cron = "0 0 18 * * *") + fun executeEveningNotifications() { + val today = LocalDate.now() + val result = + notificationExecutionLock.runWithEveningLock(date = today) { + executeInOrder(today) + } + + when (result) { + NotificationExecutionLock.ExecutionResult.ACQUIRED -> { + logger.info("Evening notification orchestration completed with distributed lock: date=$today") + } + + NotificationExecutionLock.ExecutionResult.SKIPPED_BY_CONTENTION -> { + logger.info("Skipped evening notification orchestration due to lock contention: date=$today") + } + + NotificationExecutionLock.ExecutionResult.FAIL_OPEN -> { + logger.warn("Evening notification orchestration executed in fail-open mode: date=$today") + } + } + } + + private fun executeInOrder(today: LocalDate) { + if (isWeekday(today.dayOfWeek)) { + weekdayNotificationScheduler.executeWeekdayNotification(today) + } else { + logger.info("Skipping weekday notification flow on weekend: ${today.dayOfWeek}") + } + + runSchedulerSafely( + logger = logger, + failureMessage = "Failed to execute rain forecast notification scheduler", + ) { + rainForecastNotificationService.sendRainForecastNotifications() + } + } + + private fun isWeekday(dayOfWeek: DayOfWeek): Boolean = + dayOfWeek in + setOf( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ) +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/NotificationExecutionLock.kt b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/NotificationExecutionLock.kt new file mode 100644 index 0000000..d2efb85 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/NotificationExecutionLock.kt @@ -0,0 +1,89 @@ +package com.pida.scheduler + +import com.pida.support.extension.logger +import org.redisson.api.RedissonClient +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit + +/** + * 저녁 알림 스케줄러 실행 경합 방지를 위한 분산 락 + */ +@Component +class NotificationExecutionLock( + @param:Qualifier("coreRedissonClient") + private val redissonClient: RedissonClient, +) { + private val logger by logger() + + companion object { + private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE + private const val LOCK_KEY_PREFIX = "notification:evening" + private const val WAIT_SECONDS = 1L + private const val LEASE_SECONDS = 3600L + } + + enum class ExecutionResult { + ACQUIRED, + SKIPPED_BY_CONTENTION, + FAIL_OPEN, + } + + fun runWithEveningLock( + date: LocalDate = LocalDate.now(), + action: () -> Unit, + ): ExecutionResult { + val lockKey = "$LOCK_KEY_PREFIX:${date.format(DATE_FORMATTER)}" + + val lock = + try { + redissonClient.getLock(lockKey) + } catch (error: RuntimeException) { + logger.error( + "Failed to create evening notification lock. Executing action with fail-open: key=$lockKey, date=$date, errorType=${error::class.simpleName}", + error, + ) + action() + return ExecutionResult.FAIL_OPEN + } + + val acquired = + try { + lock.tryLock(WAIT_SECONDS, LEASE_SECONDS, TimeUnit.SECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + logger.warn("Interrupted while acquiring evening notification lock: $lockKey", error) + return ExecutionResult.SKIPPED_BY_CONTENTION + } catch (error: RuntimeException) { + logger.error( + "Failed to acquire evening notification lock. Executing action with fail-open: key=$lockKey, date=$date, errorType=${error::class.simpleName}", + error, + ) + action() + return ExecutionResult.FAIL_OPEN + } + + if (!acquired) { + logger.info("Skipped evening notifications due to lock contention: $lockKey") + return ExecutionResult.SKIPPED_BY_CONTENTION + } + + return try { + action() + ExecutionResult.ACQUIRED + } finally { + runCatching { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + }.onFailure { error -> + logger.warn( + "Failed to release evening notification lock: $lockKey, errorType=${error::class.simpleName}", + error, + ) + } + } + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/NotificationSchedulerSupport.kt b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/NotificationSchedulerSupport.kt new file mode 100644 index 0000000..4573a2c --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/NotificationSchedulerSupport.kt @@ -0,0 +1,37 @@ +package com.pida.scheduler + +import org.slf4j.Logger +import java.time.LocalDate +import java.time.temporal.ChronoField + +internal fun LocalDate.toWeekKey(): Int = (year * 100) + get(ChronoField.ALIGNED_WEEK_OF_YEAR) + +internal inline fun runSchedulerSafely( + logger: Logger, + failureMessage: String, + block: () -> Unit, +): Boolean = + runCatching { block() } + .onFailure { error -> logger.error(failureMessage, error) } + .isSuccess + +abstract class DailyNotificationScheduler { + private var lastExecutedDate: LocalDate? = null + + protected fun executeOncePerDay( + logger: Logger, + failureMessage: String, + action: () -> Unit, + ) { + val today = LocalDate.now() + + if (lastExecutedDate == today) { + return + } + + val executed = runSchedulerSafely(logger, failureMessage, action) + if (executed) { + lastExecutedDate = today + } + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WeekdayNotificationScheduler.kt b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WeekdayNotificationScheduler.kt index 1194e19..78a0b5b 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WeekdayNotificationScheduler.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WeekdayNotificationScheduler.kt @@ -2,17 +2,16 @@ package com.pida.scheduler import com.pida.notification.weekday.WeekdayNotificationService import com.pida.support.extension.logger -import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import java.time.DayOfWeek import java.time.LocalDate -import java.time.temporal.ChronoField import kotlin.random.Random /** * 평일 힐링 푸시 알림 스케줄러 * - * 평일(월~금) 중 주당 랜덤으로 2회, 오후 6시에 실행 + * 평일(월~금) 중 주당 랜덤으로 2회 실행 여부를 결정합니다. + * 실행 트리거는 EveningNotificationScheduler(매일 18:00)에서 호출합니다. */ @Component class WeekdayNotificationScheduler( @@ -20,59 +19,32 @@ class WeekdayNotificationScheduler( ) { private val logger by logger() private var executionCountThisWeek = 0 - private var lastExecutedWeek: Int? = null + private var lastExecutedWeekKey: Int? = null companion object { private const val MAX_EXECUTIONS_PER_WEEK = 2 - private val EXECUTION_PROBABILITY = 0.4 // 월~금 5일 중 2회 실행 확률 (2/5 = 0.4) + private const val EXECUTION_PROBABILITY = 0.4 + private const val THURSDAY_BOOSTED_PROBABILITY = 0.6 } - // 월요일 오후 6시 - @Scheduled(cron = "0 0 18 ? * MON") - fun mondayCheck() { - executeIfEligible(DayOfWeek.MONDAY) - } - - // 화요일 오후 6시 - @Scheduled(cron = "0 0 18 ? * TUE") - fun tuesdayCheck() { - executeIfEligible(DayOfWeek.TUESDAY) - } - - // 수요일 오후 6시 - @Scheduled(cron = "0 0 18 ? * WED") - fun wednesdayCheck() { - executeIfEligible(DayOfWeek.WEDNESDAY) - } - - // 목요일 오후 6시 - @Scheduled(cron = "0 0 18 ? * THU") - fun thursdayCheck() { - executeIfEligible(DayOfWeek.THURSDAY) - } - - // 금요일 오후 6시 - @Scheduled(cron = "0 0 18 ? * FRI") - fun fridayCheck() { - executeIfEligible(DayOfWeek.FRIDAY) - } + fun executeWeekdayNotification(today: LocalDate = LocalDate.now()) = executeIfEligible(today) /** * 평일 알림 실행 여부 결정 * * - 이번 주에 이미 2회 실행했으면 스킵 * - 아직 2회 미만이면 확률적으로 실행 - * - 금요일인 경우 아직 2회 실행하지 않았다면 무조건 실행 + * - 금요일인 경우 남은 횟수만큼 연속 실행 * - * @param day 요일 + * @param today 실행 날짜 */ - private fun executeIfEligible(day: DayOfWeek) { - val currentWeek = LocalDate.now().get(ChronoField.ALIGNED_WEEK_OF_YEAR) + private fun executeIfEligible(today: LocalDate) { + val currentWeekKey = today.toWeekKey() // 새로운 주가 시작되면 카운터 리셋 - if (lastExecutedWeek != currentWeek) { + if (lastExecutedWeekKey != currentWeekKey) { executionCountThisWeek = 0 - lastExecutedWeek = currentWeek + lastExecutedWeekKey = currentWeekKey } // 이미 주당 2회 실행했으면 스킵 @@ -80,30 +52,54 @@ class WeekdayNotificationScheduler( return } - val shouldExecute = - when { - // 금요일인데 아직 2회 실행 안 했으면 무조건 실행 - day == DayOfWeek.FRIDAY && executionCountThisWeek < MAX_EXECUTIONS_PER_WEEK -> { - true - } - // 목요일인데 아직 1회도 실행 안 했으면 확률 높여서 실행 - day == DayOfWeek.THURSDAY && executionCountThisWeek == 0 -> { - Random.nextDouble() < 0.6 // 목요일까지 1회도 안 했으면 60% 확률 - } - // 그 외의 경우 기본 확률로 실행 - else -> Random.nextDouble() < EXECUTION_PROBABILITY + val day = today.dayOfWeek + + if (day == DayOfWeek.FRIDAY) { + executeRemainingOnFriday() + return + } + + if (!shouldExecute(day)) { + logger.info("Skipped weekday notification on $day (random)") + return + } + + val executed = + runSchedulerSafely( + logger = logger, + failureMessage = "Failed to execute weekday notification scheduler", + ) { + weekdayNotificationService.sendWeekdayNotificationsSync() } - if (shouldExecute) { - weekdayNotificationService.sendWeekdayNotifications() + if (executed) { executionCountThisWeek++ + } + } + + private fun executeRemainingOnFriday() { + while (executionCountThisWeek < MAX_EXECUTIONS_PER_WEEK) { + val executed = + runSchedulerSafely( + logger = logger, + failureMessage = "Failed to execute weekday notification scheduler", + ) { + weekdayNotificationService.sendWeekdayNotificationsSync() + } - // 금요일이고 아직 2회 실행 안 했으면 반복 실행 - if (day == DayOfWeek.FRIDAY && executionCountThisWeek < MAX_EXECUTIONS_PER_WEEK) { - executeIfEligible(day) + if (!executed) { + return } - } else { - logger.info("Skipped weekday notification on $day (random)") + + executionCountThisWeek++ } } + + private fun shouldExecute(day: DayOfWeek): Boolean = + when { + day == DayOfWeek.THURSDAY && executionCountThisWeek == 0 -> { + Random.nextDouble() < THURSDAY_BOOSTED_PROBABILITY + } + else -> Random.nextDouble() < EXECUTION_PROBABILITY + } } diff --git a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WeekendNotificationScheduler.kt b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WeekendNotificationScheduler.kt index fe516f3..1832150 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WeekendNotificationScheduler.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WeekendNotificationScheduler.kt @@ -6,12 +6,11 @@ import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import java.time.DayOfWeek import java.time.LocalDate -import java.time.temporal.ChronoField /** * 주말 힐링 푸시 알림 스케줄러 * - * 매주 토요일 또는 일요일 중 랜덤으로 1회 오전 10시에 실행 + * 매주 토요일 또는 일요일 중 1회, 오전 10시에 실행 */ @Component class WeekendNotificationScheduler( @@ -20,48 +19,51 @@ class WeekendNotificationScheduler( private val logger by logger() // 마지막 실행 주차 (중복 실행 방지) - private var lastExecutedWeek: Int? = null + private var lastExecutedWeekKey: Int? = null /** - * 토요일 오전 10시 실행 + * 토요일/일요일 오전 10시 실행 */ - @Scheduled(cron = "0 0 10 ? * SAT") - fun saturdayCheck() { - executeIfNotThisWeek(DayOfWeek.SATURDAY) - } - - /** - * 일요일 오전 10시 실행 - */ - @Scheduled(cron = "0 0 10 ? * SUN") - fun sundayCheck() { - executeIfNotThisWeek(DayOfWeek.SUNDAY) - } + @Scheduled(cron = "0 0 10 ? * SAT,SUN") + fun executeWeekendNotification() = executeIfNotThisWeek(LocalDate.now()) /** - * 이번 주에 아직 실행되지 않았다면 50% 확률로 실행 + * 이번 주에 아직 실행되지 않았다면 주차 키를 기준으로 실행 요일을 결정해 1회 실행 * - * @param day 실행 요일 + * @param today 실행 날짜 */ - private fun executeIfNotThisWeek(day: DayOfWeek) { - val today = LocalDate.now() - val currentWeek = today.get(ChronoField.ALIGNED_WEEK_OF_YEAR) - val currentYear = today.year - val weekKey = currentYear * 100 + currentWeek + private fun executeIfNotThisWeek(today: LocalDate) { + val currentWeekKey = today.toWeekKey() // 이번 주에 이미 실행되었는지 확인 - if (lastExecutedWeek == weekKey) { + if (lastExecutedWeekKey == currentWeekKey) { return } - // Deterministically choose Saturday or Sunday based on week - val chosenDay = if (weekKey % 2 == 0) DayOfWeek.SATURDAY else DayOfWeek.SUNDAY + val day = today.dayOfWeek + val chosenDay = chooseExecutionDay(currentWeekKey) if (day == chosenDay) { - weekendNotificationService.sendWeekendNotifications() - lastExecutedWeek = weekKey + val executed = + runSchedulerSafely( + logger = logger, + failureMessage = "Failed to execute weekend notification scheduler", + ) { + weekendNotificationService.sendWeekendNotifications() + } + + if (executed) { + lastExecutedWeekKey = currentWeekKey + } } else { - logger.info("Skipped weekend notification on $day (random selection, week: $currentWeek)") + logger.info("Skipped weekend notification on $day (chosen day: $chosenDay)") } } + + private fun chooseExecutionDay(weekKey: Int): DayOfWeek = + if (weekKey % 2 == 0) { + DayOfWeek.SATURDAY + } else { + DayOfWeek.SUNDAY + } } diff --git a/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WitheredNotificationScheduler.kt b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WitheredNotificationScheduler.kt new file mode 100644 index 0000000..6bda445 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/scheduler/WitheredNotificationScheduler.kt @@ -0,0 +1,33 @@ +package com.pida.scheduler + +import com.pida.notification.withered.WitheredNotificationService +import com.pida.support.extension.logger +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +/** + * 저물었어요 알림 스케줄러 + * + * 매일 오전 9시에 실행하여 WITHERED 상태 알림을 발송합니다. + * 하루 1회만 실행되도록 중복 실행을 방지합니다. + */ +@Component +class WitheredNotificationScheduler( + private val witheredNotificationService: WitheredNotificationService, +) : DailyNotificationScheduler() { + private val logger by logger() + + /** + * 매일 오전 9시 실행 + * + * WITHERED 비율이 30% 이상인 지역의 사용자들에게 알림 발송 + */ + @Scheduled(cron = "0 0 9 * * *", zone = "Asia/Seoul") + fun executeWitheredNotification() = + executeOncePerDay( + logger = logger, + failureMessage = "Failed to execute withered notification scheduler", + ) { + witheredNotificationService.sendWitheredNotifications() + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingAddedEvent.kt b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingAddedEvent.kt new file mode 100644 index 0000000..c3178bf --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingAddedEvent.kt @@ -0,0 +1,5 @@ +package com.pida.blooming + +data class BloomingAddedEvent( + val newBlooming: NewBlooming, +) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFacade.kt index 636e46d..fa99d3d 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFacade.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFacade.kt @@ -6,6 +6,7 @@ import com.pida.support.aws.ImageS3Caller import com.pida.user.UserService import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import java.time.LocalDate import kotlin.math.roundToInt @@ -16,6 +17,7 @@ class BloomingFacade( private val recentReporterService: RecentReporterService, private val userService: UserService, private val imageS3Caller: ImageS3Caller, + private val eventPublisher: ApplicationEventPublisher, ) { suspend fun readBloomingDetailsBySpotId(flowerSpotId: Long): BloomingDetails = coroutineScope { @@ -69,6 +71,7 @@ class BloomingFacade( suspend fun uploadBloomingStatus(newBlooming: NewBlooming): BloomingImageUploadUrl { bloomingService.add(newBlooming) + eventPublisher.publishEvent(BloomingAddedEvent(newBlooming)) return BloomingImageUploadUrl.from( imageS3Caller.createUploadUrl(newBlooming.userId, ImagePrefix.FLOWERSPOT.value, newBlooming.flowerSpotId), diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFinder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFinder.kt index 92a4279..74edc80 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFinder.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingFinder.kt @@ -9,7 +9,7 @@ class BloomingFinder( suspend fun readTopByUserIdAndFlowerSpotIdDesc( userId: Long, flowerSpotId: Long, - ): Blooming? = bloomingRepository.findTopByUserIdAndSpotIdDecs(userId, flowerSpotId) + ): Blooming? = bloomingRepository.findTopByUserIdAndSpotIdDesc(userId, flowerSpotId) suspend fun readAllByUserId(userId: Long): List = bloomingRepository.findAllByUserId(userId) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingRepository.kt index 9e64157..e60eaec 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/BloomingRepository.kt @@ -1,9 +1,12 @@ package com.pida.blooming +import com.pida.support.geo.Region +import java.time.LocalDateTime + interface BloomingRepository { fun add(newBlooming: NewBlooming): Blooming - suspend fun findTopByUserIdAndSpotIdDecs( + suspend fun findTopByUserIdAndSpotIdDesc( userId: Long, flowerSpotId: Long, ): Blooming? @@ -22,4 +25,19 @@ interface BloomingRepository { ): Blooming? fun findBloomedSpotIdsByFlowerSpotIds(spotIds: List): List + + /** + * 지역별, 상태별 최근 5일간 투표 수를 집계합니다. + * + * @return 지역별 상태별 투표 수 리스트 + */ + fun countByRegionAndStatus(): List + + /** + * 특정 지역에서 특정 시점 이후 BLOOMED 투표 수를 조회합니다. + */ + fun countBloomedVotesByRegionAndCreatedAtAfter( + region: Region, + createdAtAfter: LocalDateTime, + ): Long } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/blooming/RegionStatusCount.kt b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/RegionStatusCount.kt new file mode 100644 index 0000000..bc9a54d --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/blooming/RegionStatusCount.kt @@ -0,0 +1,16 @@ +package com.pida.blooming + +import com.pida.support.geo.Region + +/** + * 지역별 BloomingStatus 투표 집계 결과 + * + * @property region 지역 + * @property status 개화 상태 + * @property count 투표 수 + */ +data class RegionStatusCount( + val region: Region, + val status: BloomingStatus, + val count: Long, +) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/EligibleUser.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/EligibleUser.kt similarity index 73% rename from pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/EligibleUser.kt rename to pida-core/core-domain/src/main/kotlin/com/pida/notification/EligibleUser.kt index debce29..4a8ebd1 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/EligibleUser.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/EligibleUser.kt @@ -1,4 +1,4 @@ -package com.pida.notification.weekend +package com.pida.notification data class EligibleUser( val userId: Long, diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/EligibleUserWithRegion.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/EligibleUserWithRegion.kt new file mode 100644 index 0000000..56461cf --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/EligibleUserWithRegion.kt @@ -0,0 +1,18 @@ +package com.pida.notification + +import com.pida.support.geo.Region + +/** + * 알림 발송 대상 사용자 정보 (지역 포함) + * + * @property userId 사용자 ID + * @property latitude 위도 + * @property longitude 경도 + * @property region 사용자가 속한 지역 + */ +data class EligibleUserWithRegion( + val userId: Long, + val latitude: Double, + val longitude: Double, + val region: Region, +) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/NotificationStoredRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/NotificationStoredRepository.kt index 07225bf..8348bd6 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/NotificationStoredRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/NotificationStoredRepository.kt @@ -32,5 +32,23 @@ interface NotificationStoredRepository { createdAtAfter: LocalDateTime, ): Map + fun countByUserIdsAndCreatedAtAfter( + userIds: List, + createdAtAfter: LocalDateTime, + ): Map + + fun countByUserIdsAndTypeNotAndCreatedAtAfter( + userIds: List, + excludedType: NotificationType, + createdAtAfter: LocalDateTime, + ): Map + + fun countByUserIdsAndTypeAndParameterValueAndCreatedAtAfter( + userIds: List, + type: NotificationType, + parameterValue: String, + createdAtAfter: LocalDateTime, + ): Map + fun markAsRead(notificationId: Long) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/NotificationType.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/NotificationType.kt index 9db93ad..25655f7 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/NotificationType.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/NotificationType.kt @@ -5,6 +5,10 @@ enum class NotificationType { REGULAR, // 정기 푸시 알림 (화요일 오전 9시) WEEKEND_HEALING, // 주말 힐링 푸시 알림 WEEKDAY_HEALING, // 평일 힐링 푸시 알림 + WITHERED_ALERT, // 저물었어요 알림 (지역별 WITHERED 30% 이상 시) + BLOOMED_ALERT, // 만개했어요 알림 (지역별 BLOOMED 80% 이상 또는 첫 BLOOMED 투표 시) + BLOOMED_SPOT_ALERT, // 만개 벚꽃길 알림 (반경 3km 내 spot BLOOMED 투표 발생 시) + RAIN_FORECAST_ALERT, // 비 예보 알림 (내일 POP 60% 이상 시) ; companion object { @@ -14,6 +18,10 @@ enum class NotificationType { "REGULAR" -> REGULAR "WEEKEND_HEALING" -> WEEKEND_HEALING "WEEKDAY_HEALING" -> WEEKDAY_HEALING + "WITHERED_ALERT" -> WITHERED_ALERT + "BLOOMED_ALERT" -> BLOOMED_ALERT + "BLOOMED_SPOT_ALERT" -> BLOOMED_SPOT_ALERT + "RAIN_FORECAST_ALERT" -> RAIN_FORECAST_ALERT else -> throw IllegalArgumentException("Unknown NotificationType: $type") } } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/RegionStatusCount.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/RegionStatusCount.kt new file mode 100644 index 0000000..12e4db3 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/RegionStatusCount.kt @@ -0,0 +1,13 @@ +package com.pida.notification + +import com.pida.blooming.BloomingStatus +import com.pida.support.geo.Region + +/** + * Region과 BloomingStatus별 투표 수 + */ +data class RegionStatusCount( + val region: Region, + val status: BloomingStatus, + val count: Long, +) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteChecker.kt new file mode 100644 index 0000000..4d7b752 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteChecker.kt @@ -0,0 +1,31 @@ +package com.pida.notification.bloomed + +import com.pida.blooming.BloomingRepository +import com.pida.support.geo.Region +import org.springframework.stereotype.Component +import java.time.LocalDate + +/** + * 해당 연도 기준 지역별 첫 BLOOMED 투표 여부를 판별합니다. + */ +@Component +class BloomedFirstVoteChecker( + private val bloomingRepository: BloomingRepository, +) { + fun isFirstBloomedVoteOfYear(region: Region): Boolean { + val yearStartDateTime = getYearStartDateTime() + val bloomedVoteCount = + bloomingRepository.countBloomedVotesByRegionAndCreatedAtAfter( + region = region, + createdAtAfter = yearStartDateTime, + ) + + return bloomedVoteCount == 1L + } + + private fun getYearStartDateTime() = + LocalDate + .now() + .withDayOfYear(1) + .atStartOfDay() +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteNotificationEventListener.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteNotificationEventListener.kt new file mode 100644 index 0000000..41f733b --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteNotificationEventListener.kt @@ -0,0 +1,46 @@ +package com.pida.notification.bloomed + +import com.pida.blooming.BloomingAddedEvent +import com.pida.blooming.BloomingStatus +import com.pida.support.extension.logger +import com.pida.support.geo.GeoJson +import com.pida.user.location.UserLocationReader +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service + +/** + * BLOOMED 추가 이벤트를 수신하여 지역 첫 투표 조건에 맞으면 알림을 발송합니다. + */ +@Service +class BloomedFirstVoteNotificationEventListener( + private val userLocationReader: UserLocationReader, + private val regionResolver: BloomedRegionResolver, + private val firstVoteChecker: BloomedFirstVoteChecker, + private val bloomedNotificationService: BloomedNotificationService, +) { + private val logger by logger() + + @Async + @EventListener + fun handleBloomingAddedEvent(event: BloomingAddedEvent) { + if (event.newBlooming.status != BloomingStatus.BLOOMED) { + return + } + + val userLocation = userLocationReader.readUserLocationByUserId(event.newBlooming.userId) ?: return + val point = userLocation.location as? GeoJson.Point ?: return + + val userRegion = + regionResolver.resolveRegion( + latitude = point.coordinates[1], + longitude = point.coordinates[0], + ) ?: return + + if (!firstVoteChecker.isFirstBloomedVoteOfYear(userRegion)) { + return + } + + bloomedNotificationService.sendBloomedNotificationForRegion(userRegion) + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationEligibilityChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationEligibilityChecker.kt new file mode 100644 index 0000000..bf0f9e4 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationEligibilityChecker.kt @@ -0,0 +1,102 @@ +package com.pida.notification.bloomed + +import com.pida.notification.EligibleUserWithRegion +import com.pida.notification.NotificationStoredRepository +import com.pida.notification.NotificationType +import com.pida.support.extension.logger +import com.pida.support.geo.Region +import org.springframework.stereotype.Component +import java.time.LocalDate + +/** + * BLOOMED 알림 적격성 검증 컴포넌트 + * + * 특정 지역의 사용자 중 알림을 받을 수 있는 사용자를 필터링합니다. + */ +@Component +class BloomedNotificationEligibilityChecker( + private val userReader: BloomedNotificationUserReader, + private val regionResolver: BloomedRegionResolver, + private val notificationStoredRepository: NotificationStoredRepository, +) { + private val logger by logger() + + /** + * 특정 지역의 사용자 중 알림 적격 사용자 조회 + * + * 조건: + * - 최근 7일 내 활성 사용자 + * - 위치 정보가 있는 사용자 + * - 현재 위치가 대상 지역에 속한 사용자 + * - 올해 BLOOMED_ALERT 알림을 받지 않은 사용자 + * + * @param region 대상 지역 + * @return 적격 사용자 목록 + */ + fun findEligibleUsersForRegion(region: Region): List { + // 1. 활성 사용자 조회 + val activeUsers = userReader.findActiveUsersWithLocation() + + if (activeUsers.isEmpty()) { + logger.info("No active users found") + return emptyList() + } + + logger.info("Found ${activeUsers.size} active users with location") + + // 2. 각 사용자의 Region 해석 및 필터링 + val usersInTargetRegion = + activeUsers.mapNotNull { user -> + val userRegion = regionResolver.resolveRegion(user.latitude, user.longitude) + + if (userRegion == region) { + EligibleUserWithRegion( + userId = user.userId, + latitude = user.latitude, + longitude = user.longitude, + region = userRegion, + ) + } else { + null + } + } + + logger.info("${usersInTargetRegion.size} users are in target region: $region") + + if (usersInTargetRegion.isEmpty()) { + return emptyList() + } + + // 3. 올해 BLOOMED_ALERT 알림 수신 여부 확인 + val yearStartDateTime = getYearStartDateTime() + val userIds = usersInTargetRegion.map { it.userId } + val notificationCountMap = + notificationStoredRepository.countByUserIdsAndTypeAndCreatedAtAfter( + userIds = userIds, + type = NotificationType.BLOOMED_ALERT, + createdAtAfter = yearStartDateTime, + ) + + logger.info("Yearly notification counts retrieved for ${notificationCountMap.size} users") + + // 4. 필터링: 올해 알림을 받지 않은 사용자만 + val eligibleUsers = + usersInTargetRegion.filter { user -> + val count = notificationCountMap[user.userId] ?: 0 + count == 0L + } + + logger.info("${eligibleUsers.size} users have not received BLOOMED_ALERT this year") + + return eligibleUsers + } + + /** + * 올해 1월 1일 00:00:00 반환 + */ + private fun getYearStartDateTime() = + LocalDate + .now() + .withDayOfYear(1) + .atStartOfDay() +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationMessageBuilder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationMessageBuilder.kt new file mode 100644 index 0000000..b830338 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationMessageBuilder.kt @@ -0,0 +1,46 @@ +package com.pida.notification.bloomed + +import com.pida.notification.NewFirebaseCloudMessage +import com.pida.support.geo.Region +import org.springframework.stereotype.Component + +/** + * BLOOMED 알림 메시지 빌더 + * + * 만개했어요 상태 알림의 FCM 메시지를 생성합니다. + */ +@Component +class BloomedNotificationMessageBuilder { + companion object { + private const val DESTINATION = "home" + } + + /** + * FCM 메시지 생성 + * + * @param fcmToken FCM 토큰 + * @param region 지역 + * @return FCM 메시지 + */ + fun buildMessage( + fcmToken: String, + region: Region, + ): NewFirebaseCloudMessage { + val messageContent = getMessageContent(region) + + return NewFirebaseCloudMessage( + fcmToken = fcmToken, + title = messageContent.lines()[0], // 첫 번째 줄을 제목으로 + body = messageContent.lines().drop(1).joinToString("\n"), // 나머지를 본문으로 + destination = DESTINATION, + ) + } + + /** + * 메시지 내용 반환 (NotificationStored 저장용) + * + * @param region 지역 + * @return 메시지 전체 내용 + */ + fun getMessageContent(region: Region): String = "${region.toKoreanName()}에도 벚꽃이 눈을 떴어요! 🌸\n우리 동네 가장 빠른 봄을 만나보세요." +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationService.kt new file mode 100644 index 0000000..64f863e --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationService.kt @@ -0,0 +1,133 @@ +package com.pida.notification.bloomed + +import com.pida.notification.CreateNotificationStoredCommand +import com.pida.notification.EligibleUserWithRegion +import com.pida.notification.FcmSender +import com.pida.notification.NewFirebaseCloudMessage +import com.pida.notification.NotificationService +import com.pida.notification.NotificationType +import com.pida.notification.ReadStatus +import com.pida.support.extension.logger +import com.pida.support.geo.Region +import com.pida.user.device.UserDeviceReader +import org.springframework.stereotype.Service + +/** + * BLOOMED 상태 푸시 알림 서비스 + * + * 특정 지역에 대해 BLOOMED 알림을 발송합니다. + */ +@Service +class BloomedNotificationService( + private val bloomedThresholdChecker: BloomedThresholdChecker, + private val bloomedNotificationEligibilityChecker: BloomedNotificationEligibilityChecker, + private val bloomedNotificationMessageBuilder: BloomedNotificationMessageBuilder, + private val userDeviceReader: UserDeviceReader, + private val fcmSender: FcmSender, + private val notificationService: NotificationService, +) { + private val logger by logger() + + /** + * BLOOMED 비율이 80% 이상인 지역들에 대해 알림을 발송합니다. + */ + fun sendBloomedNotifications() { + try { + val regionsExceedingThreshold = bloomedThresholdChecker.findRegionsExceedingThreshold() + + if (regionsExceedingThreshold.isEmpty()) { + return + } + + var totalSentCount = 0 + regionsExceedingThreshold.forEach { bloomedRegion -> + val sentCount = sendBloomedNotificationForRegion(bloomedRegion.region) + totalSentCount += sentCount + } + + logger.info("Bloomed threshold notification execution completed. Total $totalSentCount users notified.") + } catch (e: Exception) { + logger.error("Failed to send bloomed threshold notifications", e) + } + } + + /** + * 특정 지역에 대해 BLOOMED 알림을 발송합니다. + */ + fun sendBloomedNotificationForRegion(region: Region): Int { + try { + logger.info("Processing region: $region") + + // 1. 지역별 적격 사용자 조회 + val eligibleUsers = bloomedNotificationEligibilityChecker.findEligibleUsersForRegion(region) + + if (eligibleUsers.isEmpty()) { + logger.info("Region $region: No eligible users found") + return 0 + } + + // 2. FCM 메시지 생성 + val messages = buildNotificationMessages(eligibleUsers, region) + + if (messages.isEmpty()) { + return 0 + } + // 3. FCM 발송 + fcmSender.sendAllAsync(messages) + + // 4. 알림 이력 저장 + storeNotificationRecords(eligibleUsers, region) + + return messages.size + } catch (e: Exception) { + logger.error("Failed to process region: $region", e) + return 0 + } + } + + /** + * FCM 메시지 빌드 + * + * @param users 대상 사용자 목록 + * @param region 대상 지역 + * @return FCM 메시지 집합 + */ + private fun buildNotificationMessages( + users: List, + region: Region, + ): Set = + userDeviceReader.readLastByUserIds(users.map { it.userId }).let { latestDevicesByUserId -> + users + .mapNotNull { user -> + latestDevicesByUserId[user.userId]?.let { device -> + bloomedNotificationMessageBuilder.buildMessage(device.fcmToken, region) + } + }.toSet() + } + + /** + * 알림 이력 저장 + * + * @param users 대상 사용자 목록 + * @param region 대상 지역 + */ + private fun storeNotificationRecords( + users: List, + region: Region, + ) { + val commands = + users.map { user -> + CreateNotificationStoredCommand( + notificationStoredKey = "", // 서비스에서 재생성됨 + userId = user.userId, + type = NotificationType.BLOOMED_ALERT, + parameterValue = region.name, + topic = "피다", + contents = bloomedNotificationMessageBuilder.getMessageContent(region), + readStatus = ReadStatus.UNREAD, + ) + } + + notificationService.appendAll(commands) + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationUserReader.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationUserReader.kt new file mode 100644 index 0000000..ffb95cd --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationUserReader.kt @@ -0,0 +1,69 @@ +package com.pida.notification.bloomed + +import com.pida.auth.AuthenticationHistoryRepository +import com.pida.notification.EligibleUser +import com.pida.support.extension.logger +import com.pida.support.geo.GeoJson +import com.pida.user.location.UserLocation +import com.pida.user.location.UserLocationReader +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +/** + * BLOOMED 알림 대상 사용자 조회 컴포넌트 + * + * 최근 7일 내 활성 사용자 중 위치 정보가 있는 사용자 목록을 조회합니다. + */ +@Component +class BloomedNotificationUserReader( + private val authenticationHistoryRepository: AuthenticationHistoryRepository, + private val userLocationReader: UserLocationReader, +) { + private val logger by logger() + + companion object { + private const val ACTIVE_USER_DAYS = 7L + } + + /** + * 최근 7일 내 활성 사용자 중 위치 정보가 있는 사용자 목록 조회 + * + * @return 대상 사용자 목록 + */ + fun findActiveUsersWithLocation(): List { + val sevenDaysAgo = LocalDateTime.now().minusDays(ACTIVE_USER_DAYS) + + // 1. 최근 7일 내 로그인한 활성 사용자 ID 조회 + val activeUserIds = authenticationHistoryRepository.findActiveUsersSince(sevenDaysAgo) + + logger.info("Found ${activeUserIds.size} active users in last $ACTIVE_USER_DAYS days") + + if (activeUserIds.isEmpty()) { + return emptyList() + } + + // 2. 활성 사용자들의 위치 정보 일괄 조회 (N+1 문제 해결) + val userLocations: List = + userLocationReader.readUserLocationsByUserIds(activeUserIds).distinctBy { it.userId } + + logger.info("Found ${userLocations.size} user locations") + + // 3. UserLocation을 EligibleUser로 변환 + val eligibleUsers = + userLocations.mapNotNull { userLocation: UserLocation.Info -> + // GeoJson.Point로 캐스팅하여 coordinates 접근 + val point = userLocation.location as? GeoJson.Point + point?.let { + EligibleUser( + userId = userLocation.userId, + latitude = it.coordinates[1], // GeoJson Point: [longitude, latitude] + longitude = it.coordinates[0], + ) + } + } + + logger.info("${eligibleUsers.size} users have location information") + + return eligibleUsers + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedRegion.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedRegion.kt new file mode 100644 index 0000000..f6e79a6 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedRegion.kt @@ -0,0 +1,18 @@ +package com.pida.notification.bloomed + +import com.pida.support.geo.Region + +/** + * BLOOMED 상태가 임계값을 초과한 지역 정보 + * + * @property region 지역 + * @property bloomedPercentage BLOOMED 투표 비율 (%) + * @property totalVotes 총 투표 수 + * @property bloomedVotes BLOOMED 투표 수 + */ +data class BloomedRegion( + val region: Region, + val bloomedPercentage: Double, + val totalVotes: Int, + val bloomedVotes: Int, +) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedRegionResolver.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedRegionResolver.kt new file mode 100644 index 0000000..74ef4c5 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedRegionResolver.kt @@ -0,0 +1,39 @@ +package com.pida.notification.bloomed + +import com.pida.place.DistrictRepository +import com.pida.support.extension.logger +import com.pida.support.geo.Region +import org.springframework.stereotype.Component + +/** + * 사용자 좌표를 Region(광역 자치단체)으로 변환하는 컴포넌트 + * + * 가장 가까운 District를 찾아 해당 District의 region을 반환합니다. + */ +@Component +class BloomedRegionResolver( + private val districtRepository: DistrictRepository, +) { + private val logger by logger() + + /** + * 주어진 좌표를 Region으로 변환합니다. + * + * @param latitude 위도 + * @param longitude 경도 + * @return 해당 좌표가 속한 Region, 찾을 수 없으면 null + */ + fun resolveRegion( + latitude: Double, + longitude: Double, + ): Region? { + val district = districtRepository.findNearestDistrict(latitude, longitude) + + if (district == null) { + logger.warn("No district found for coordinates: ($latitude, $longitude)") + return null + } + + return district.region + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedThresholdChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedThresholdChecker.kt new file mode 100644 index 0000000..eefe8a9 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedThresholdChecker.kt @@ -0,0 +1,60 @@ +package com.pida.notification.bloomed + +import com.pida.blooming.BloomingRepository +import com.pida.blooming.BloomingStatus +import org.springframework.stereotype.Component + +/** + * 지역별 BLOOMED 상태 투표 비율을 확인하여 임계값을 초과한 지역을 찾는 컴포넌트 + * + * 최근 5일간의 투표 데이터를 기준으로 BLOOMED 비율이 80% 이상인 지역을 반환합니다. + */ +@Component +class BloomedThresholdChecker( + private val bloomingRepository: BloomingRepository, +) { + companion object { + private const val THRESHOLD_PERCENTAGE = 80.0 + } + + /** + * BLOOMED 투표 비율이 80% 이상인 지역을 찾습니다. + * + * @return 임계값을 초과한 지역 리스트 + */ + fun findRegionsExceedingThreshold(): List { + val regionStats = bloomingRepository.countByRegionAndStatus() + + if (regionStats.isEmpty()) { + return emptyList() + } + + return regionStats + .groupBy { it.region } + .mapNotNull { (region, counts) -> + val totalVotes = counts.sumOf { it.count } + + if (totalVotes == 0L) { + return@mapNotNull null + } + + val bloomedVotes = + counts + .filter { it.status == BloomingStatus.BLOOMED } + .sumOf { it.count } + + val percentage = (bloomedVotes.toDouble() / totalVotes) * 100 + + if (percentage >= THRESHOLD_PERCENTAGE) { + BloomedRegion( + region = region, + bloomedPercentage = percentage, + totalVotes = totalVotes.toInt(), + bloomedVotes = bloomedVotes.toInt(), + ) + } else { + null + } + } + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationEligibilityChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationEligibilityChecker.kt new file mode 100644 index 0000000..8302bbb --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationEligibilityChecker.kt @@ -0,0 +1,102 @@ +package com.pida.notification.bloomedspot + +import com.pida.flowerspot.FlowerSpot +import com.pida.notification.NotificationStoredRepository +import com.pida.notification.NotificationType +import com.pida.support.extension.logger +import com.pida.support.geo.GeoJson +import com.pida.user.location.UserLocationReader +import org.springframework.stereotype.Component +import java.time.LocalDate + +/** + * 벚꽃길 만개 이벤트 알림 적격성 검증 컴포넌트 + */ +@Component +class BloomedSpotNotificationEligibilityChecker( + private val userLocationReader: UserLocationReader, + private val notificationStoredRepository: NotificationStoredRepository, +) { + private val logger by logger() + + companion object { + private const val RADIUS_METERS = 3000.0 + } + + /** + * 특정 벚꽃길 만개 이벤트에 대해 푸시 발송 가능한 사용자 ID 목록 조회 + * + * 조건: + * - 위치 권한 허용 사용자(위치 저장 이력 존재) + * - 벚꽃길 반경 3km 이내 + * - 당일 이미 푸시를 받은 사용자는 제외 + * - 해당 벚꽃길 만개 알림을 같은 시즌(연도)에 받은 사용자는 제외 + */ + fun findEligibleUserIds(flowerSpot: FlowerSpot): List { + val pinPoint = flowerSpot.pinPoint as? GeoJson.Point + + if (pinPoint == null || pinPoint.coordinates.size < 2) { + logger.warn("Flower spot ${flowerSpot.id} has invalid pinPoint. Skip bloomed spot notification") + return emptyList() + } + + val longitude = pinPoint.coordinates[0] + val latitude = pinPoint.coordinates[1] + + val nearbyUserIds = + userLocationReader + .readUserLocationsWithinRadius( + latitude = latitude, + longitude = longitude, + radiusMeters = RADIUS_METERS, + ).map { it.userId } + .distinct() + + if (nearbyUserIds.isEmpty()) { + logger.info("No users found within ${RADIUS_METERS}m for flowerSpotId=${flowerSpot.id}") + return emptyList() + } + + val todayStart = LocalDate.now().atStartOfDay() + val todayNotificationCountMap = + notificationStoredRepository.countByUserIdsAndCreatedAtAfter( + userIds = nearbyUserIds, + createdAtAfter = todayStart, + ) + + val eligibleByToday = + nearbyUserIds.filter { userId -> + (todayNotificationCountMap[userId] ?: 0L) == 0L + } + + if (eligibleByToday.isEmpty()) { + logger.info("All nearby users were excluded by same-day push condition for flowerSpotId=${flowerSpot.id}") + return emptyList() + } + + val seasonStart = + LocalDate + .now() + .withDayOfYear(1) + .atStartOfDay() + + val spotNotificationCountMap = + notificationStoredRepository.countByUserIdsAndTypeAndParameterValueAndCreatedAtAfter( + userIds = eligibleByToday, + type = NotificationType.BLOOMED_SPOT_ALERT, + parameterValue = flowerSpot.id.toString(), + createdAtAfter = seasonStart, + ) + + val eligibleUsers = + eligibleByToday.filter { userId -> + (spotNotificationCountMap[userId] ?: 0L) == 0L + } + + logger.info( + "Bloomed spot notification eligible users=${eligibleUsers.size}, flowerSpotId=${flowerSpot.id}, streetName=${flowerSpot.streetName}", + ) + + return eligibleUsers + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationEventListener.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationEventListener.kt new file mode 100644 index 0000000..bd6e9bf --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationEventListener.kt @@ -0,0 +1,25 @@ +package com.pida.notification.bloomedspot + +import com.pida.blooming.BloomingAddedEvent +import com.pida.blooming.BloomingStatus +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component + +/** + * BLOOMED 투표 이벤트를 수신하여 벚꽃길 반경 알림을 발송합니다. + */ +@Component +class BloomedSpotNotificationEventListener( + private val bloomedSpotNotificationService: BloomedSpotNotificationService, +) { + @Async + @EventListener + fun handleBloomingAddedEvent(event: BloomingAddedEvent) { + if (event.newBlooming.status != BloomingStatus.BLOOMED) { + return + } + + bloomedSpotNotificationService.sendBloomedSpotNotification(event.newBlooming.flowerSpotId) + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationMessageBuilder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationMessageBuilder.kt new file mode 100644 index 0000000..8457722 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationMessageBuilder.kt @@ -0,0 +1,30 @@ +package com.pida.notification.bloomedspot + +import com.pida.notification.NewFirebaseCloudMessage +import org.springframework.stereotype.Component + +/** + * 벚꽃길 만개 이벤트 알림 메시지 빌더 + */ +@Component +class BloomedSpotNotificationMessageBuilder { + companion object { + private const val DESTINATION = "home" + } + + fun buildMessage( + fcmToken: String, + streetName: String, + ): NewFirebaseCloudMessage { + val messageContent = getMessageContent(streetName) + + return NewFirebaseCloudMessage( + fcmToken = fcmToken, + title = messageContent.lines().firstOrNull().orEmpty(), + body = messageContent.lines().drop(1).joinToString("\n"), + destination = DESTINATION, + ) + } + + fun getMessageContent(streetName: String): String = "우리 동네에 만개한 벚꽃길이 생겼어요 🌸\n지금 제일 예쁜 $streetName 확인해보세요." +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationService.kt new file mode 100644 index 0000000..950b966 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomedspot/BloomedSpotNotificationService.kt @@ -0,0 +1,101 @@ +package com.pida.notification.bloomedspot + +import com.pida.flowerspot.FlowerSpot +import com.pida.flowerspot.FlowerSpotRepository +import com.pida.notification.CreateNotificationStoredCommand +import com.pida.notification.FcmSender +import com.pida.notification.NotificationService +import com.pida.notification.NotificationType +import com.pida.notification.ReadStatus +import com.pida.support.extension.logger +import com.pida.user.device.UserDeviceReader +import kotlinx.coroutines.runBlocking +import org.springframework.stereotype.Service + +/** + * 벚꽃길 만개 이벤트 푸시 알림 서비스 + */ +@Service +class BloomedSpotNotificationService( + private val flowerSpotRepository: FlowerSpotRepository, + private val bloomedSpotNotificationEligibilityChecker: BloomedSpotNotificationEligibilityChecker, + private val bloomedSpotNotificationMessageBuilder: BloomedSpotNotificationMessageBuilder, + private val userDeviceReader: UserDeviceReader, + private val fcmSender: FcmSender, + private val notificationService: NotificationService, +) { + private val logger by logger() + + /** + * 특정 벚꽃길(spot)의 만개 이벤트 알림 발송 + */ + fun sendBloomedSpotNotification(flowerSpotId: Long) { + try { + val flowerSpot = readFlowerSpot(flowerSpotId) ?: return + val eligibleUserIds = bloomedSpotNotificationEligibilityChecker.findEligibleUserIds(flowerSpot) + + if (eligibleUserIds.isEmpty()) { + return + } + + val latestDevicesByUserId = userDeviceReader.readLastByUserIds(eligibleUserIds) + val targets = + eligibleUserIds + .mapNotNull { userId -> + latestDevicesByUserId[userId]?.let { device -> + PushTarget( + userId = userId, + fcmToken = device.fcmToken, + ) + } + }.distinctBy { it.userId } + + if (targets.isEmpty()) { + logger.info("No push target with valid FCM token for flowerSpotId=$flowerSpotId") + return + } + + val messages = + targets + .map { target -> + bloomedSpotNotificationMessageBuilder.buildMessage(target.fcmToken, flowerSpot.streetName) + }.toSet() + + fcmSender.sendAllAsync(messages) + + val messageContent = bloomedSpotNotificationMessageBuilder.getMessageContent(flowerSpot.streetName) + val commands = + targets.map { target -> + CreateNotificationStoredCommand( + notificationStoredKey = "", + userId = target.userId, + type = NotificationType.BLOOMED_SPOT_ALERT, + parameterValue = flowerSpot.id.toString(), + topic = "피다", + contents = messageContent, + readStatus = ReadStatus.UNREAD, + ) + } + + notificationService.appendAll(commands) + + logger.info( + "Bloomed spot notifications sent. flowerSpotId=$flowerSpotId, streetName=${flowerSpot.streetName}, sent=${messages.size}", + ) + } catch (e: Exception) { + logger.error("Failed to send bloomed spot notifications for flowerSpotId=$flowerSpotId", e) + } + } + + private fun readFlowerSpot(flowerSpotId: Long): FlowerSpot? = + runCatching { + runBlocking { flowerSpotRepository.findBy(flowerSpotId) } + }.onFailure { error -> + logger.error("Failed to read flower spot for bloomed notification. flowerSpotId=$flowerSpotId", error) + }.getOrNull() + + private data class PushTarget( + val userId: Long, + val fcmToken: String, + ) +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationEligibilityChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationEligibilityChecker.kt new file mode 100644 index 0000000..db9b341 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationEligibilityChecker.kt @@ -0,0 +1,169 @@ +package com.pida.notification.rain + +import com.pida.notification.EligibleUser +import com.pida.notification.NotificationStoredRepository +import com.pida.notification.NotificationType +import com.pida.notification.weekend.WeekendNotificationUserReader +import com.pida.support.extension.logger +import com.pida.weather.WeatherLocation +import com.pida.weather.WeatherService +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.temporal.TemporalAdjusters + +/** + * 비 예보 알림 대상 사용자 적격성 검증 컴포넌트 + */ +@Component +class RainForecastNotificationEligibilityChecker( + private val userReader: WeekendNotificationUserReader, + private val weatherService: WeatherService, + private val notificationStoredRepository: NotificationStoredRepository, +) { + private val logger by logger() + + companion object { + private const val RAIN_PROBABILITY_THRESHOLD = 60 + } + + /** + * 조건을 모두 만족하는 사용자 조회 + * + * 조건: + * - 최근 30일 내 활성 사용자 + * - 위치 정보가 있는 사용자 + * - 내일 기준 사용자 위치 격자(nx, ny)의 최대 POP가 60 이상 + * - 당일 RAIN_FORECAST_ALERT 외 다른 푸시를 받지 않은 사용자 + * - 당주(월요일 시작) RAIN_FORECAST_ALERT를 받지 않은 사용자 + */ + fun findEligibleUsers(): List { + val activeUsers = userReader.findActiveUsersWithLocation() + + if (activeUsers.isEmpty()) { + logger.info("No active users found for rain forecast notification") + return emptyList() + } + + val usersWithWeatherLocation = + activeUsers.map { user -> + UserWithWeatherLocation( + user = user, + weatherLocation = WeatherLocation.fromCoordinates(user.latitude, user.longitude), + ) + } + + val usersByGrid = + usersWithWeatherLocation + .groupBy { userWithLocation -> + val location = userWithLocation.weatherLocation + GridKey(location.nx, location.ny) + } + + logger.info("Found ${usersByGrid.size} unique weather grids from ${activeUsers.size} active users") + + val rainForecastGridKeys = + usersByGrid.keys + .filter { key -> + val weatherLocation = usersByGrid.getValue(key).first().weatherLocation + runCatching { + weatherService.willRainTomorrow(weatherLocation, RAIN_PROBABILITY_THRESHOLD) + }.onFailure { error -> + logger.warn( + "Failed to check rain forecast for nx=${key.nx}, ny=${key.ny}. Users in this grid will be excluded.", + error, + ) + }.getOrDefault(false) + }.toSet() + + if (rainForecastGridKeys.isEmpty()) { + logger.info("No grids satisfy tomorrow rain forecast threshold") + return emptyList() + } + + val rainForecastUsers = + usersByGrid + .filterKeys { it in rainForecastGridKeys } + .values + .flatten() + .map { it.user } + .distinctBy { it.userId } + + if (rainForecastUsers.isEmpty()) { + return emptyList() + } + + val usersExcludedByOtherNotificationsToday = findUsersWithOtherNotificationsToday(rainForecastUsers) + + val eligibleByTodayCondition = + rainForecastUsers.filter { user -> + user.userId !in usersExcludedByOtherNotificationsToday + } + + if (eligibleByTodayCondition.isEmpty()) { + logger.info("All users were excluded by daily other-notification condition") + return emptyList() + } + + val usersExcludedByWeeklyRainNotification = findUsersWithWeeklyRainNotification(eligibleByTodayCondition) + + val eligibleUsers = + eligibleByTodayCondition.filter { user -> + user.userId !in usersExcludedByWeeklyRainNotification + } + + logger.info("${eligibleUsers.size} users are eligible for rain forecast notification") + + return eligibleUsers + } + + private fun findUsersWithOtherNotificationsToday(users: List): Set { + val todayStart = LocalDate.now().atStartOfDay() + val userIds = users.map { it.userId } + + val otherNotificationCountMap = + notificationStoredRepository.countByUserIdsAndTypeNotAndCreatedAtAfter( + userIds = userIds, + excludedType = NotificationType.RAIN_FORECAST_ALERT, + createdAtAfter = todayStart, + ) + + return otherNotificationCountMap + .filterValues { count -> count > 0L } + .keys + } + + private fun findUsersWithWeeklyRainNotification(users: List): Set { + val weekStartDateTime = getWeekStartDateTime() + val userIds = users.map { it.userId } + + val rainNotificationCountMap = + notificationStoredRepository.countByUserIdsAndTypeAndCreatedAtAfter( + userIds = userIds, + type = NotificationType.RAIN_FORECAST_ALERT, + createdAtAfter = weekStartDateTime, + ) + + return rainNotificationCountMap + .filterValues { count -> count > 0L } + .keys + } + + private fun getWeekStartDateTime(): LocalDateTime = + LocalDateTime + .now() + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .toLocalDate() + .atStartOfDay() + + private data class GridKey( + val nx: Int, + val ny: Int, + ) + + private data class UserWithWeatherLocation( + val user: EligibleUser, + val weatherLocation: WeatherLocation, + ) +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationMessageBuilder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationMessageBuilder.kt new file mode 100644 index 0000000..b83b0f5 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationMessageBuilder.kt @@ -0,0 +1,26 @@ +package com.pida.notification.rain + +import com.pida.notification.NewFirebaseCloudMessage +import org.springframework.stereotype.Component + +/** + * 비 예보 알림 메시지 빌더 + */ +@Component +class RainForecastNotificationMessageBuilder { + companion object { + private const val DESTINATION = "home" + private const val TITLE = "내일 비 소식이 있어요 ☔️" + private const val BODY = "마지막 꽃구경 찬스! 오늘 밤 산책을 놓치지 마세요." + } + + fun buildMessage(fcmToken: String): NewFirebaseCloudMessage = + NewFirebaseCloudMessage( + fcmToken = fcmToken, + title = TITLE, + body = BODY, + destination = DESTINATION, + ) + + fun getMessageContent(): String = "$TITLE\n$BODY" +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationService.kt new file mode 100644 index 0000000..33d719b --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/rain/RainForecastNotificationService.kt @@ -0,0 +1,103 @@ +package com.pida.notification.rain + +import com.pida.notification.CreateNotificationStoredCommand +import com.pida.notification.EligibleUser +import com.pida.notification.FcmSender +import com.pida.notification.NewFirebaseCloudMessage +import com.pida.notification.NotificationService +import com.pida.notification.NotificationType +import com.pida.notification.ReadStatus +import com.pida.support.extension.logger +import com.pida.user.device.UserDeviceReader +import org.springframework.stereotype.Service + +/** + * 내일 비 예보 푸시 알림 서비스 + */ +@Service +class RainForecastNotificationService( + private val rainForecastNotificationEligibilityChecker: RainForecastNotificationEligibilityChecker, + private val rainForecastNotificationMessageBuilder: RainForecastNotificationMessageBuilder, + private val userDeviceReader: UserDeviceReader, + private val fcmSender: FcmSender, + private val notificationService: NotificationService, +) { + private val logger by logger() + + /** + * 내일 비 예보 알림 발송 + */ + fun sendRainForecastNotifications() { + try { + val eligibleUsers = rainForecastNotificationEligibilityChecker.findEligibleUsers() + + if (eligibleUsers.isEmpty()) { + return + } + + val pushTargets = resolvePushTargets(eligibleUsers) + + if (pushTargets.isEmpty()) { + logger.info("No users with FCM token for rain forecast notification") + return + } + + val messages = + pushTargets + .map { target -> + rainForecastNotificationMessageBuilder.buildMessage(target.fcmToken) + }.toSet() + + sendMessagesAndStoreRecords(messages, pushTargets.map { it.userId }) + + logger.info("Rain forecast notification process completed successfully. sent=${messages.size}") + } catch (e: Exception) { + logger.error("Failed to send rain forecast notifications", e) + } + } + + private fun resolvePushTargets(users: List): List = + userDeviceReader.readLastByUserIds(users.map { it.userId }).let { latestDevicesByUserId -> + users + .mapNotNull { user -> + latestDevicesByUserId[user.userId]?.let { device -> + PushTarget( + userId = user.userId, + fcmToken = device.fcmToken, + ) + } + }.distinctBy { it.userId } + } + + private fun sendMessagesAndStoreRecords( + messages: Set, + userIds: List, + ) { + if (messages.isEmpty() || userIds.isEmpty()) { + return + } + + fcmSender.sendAllAsync(messages) + + val messageContent = rainForecastNotificationMessageBuilder.getMessageContent() + val commands = + userIds.distinct().map { userId -> + CreateNotificationStoredCommand( + notificationStoredKey = "", + userId = userId, + type = NotificationType.RAIN_FORECAST_ALERT, + parameterValue = "", + topic = "피다", + contents = messageContent, + readStatus = ReadStatus.UNREAD, + ) + } + + notificationService.appendAll(commands) + } + + private data class PushTarget( + val userId: Long, + val fcmToken: String, + ) +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationEligibilityChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationEligibilityChecker.kt index 8ef9094..72607ad 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationEligibilityChecker.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationEligibilityChecker.kt @@ -1,8 +1,8 @@ package com.pida.notification.weekday +import com.pida.notification.EligibleUser import com.pida.notification.NotificationStoredRepository import com.pida.notification.NotificationType -import com.pida.notification.weekend.EligibleUser import com.pida.notification.weekend.WeekendNotificationAirQualityChecker import com.pida.notification.weekend.WeekendNotificationLocationChecker import com.pida.notification.weekend.WeekendNotificationUserReader diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationService.kt index db9f8cc..b62c431 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationService.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationService.kt @@ -1,12 +1,12 @@ package com.pida.notification.weekday import com.pida.notification.CreateNotificationStoredCommand +import com.pida.notification.EligibleUser import com.pida.notification.FcmSender import com.pida.notification.NewFirebaseCloudMessage import com.pida.notification.NotificationService import com.pida.notification.NotificationType import com.pida.notification.ReadStatus -import com.pida.notification.weekend.EligibleUser import com.pida.support.extension.logger import com.pida.user.device.UserDeviceReader import org.springframework.scheduling.annotation.Async @@ -30,7 +30,14 @@ class WeekdayNotificationService( * 3. 알림 이력 저장 */ @Async - fun sendWeekdayNotifications() { + fun sendWeekdayNotifications() = sendWeekdayNotificationsSync() + + /** + * 평일 알림 동기 실행 + * + * evening 오케스트레이터에서 실행 순서를 보장하기 위해 사용됩니다. + */ + fun sendWeekdayNotificationsSync() { try { // Step 1: 대상 사용자 조회 val eligibleUsers = weekdayNotificationEligibilityChecker.findEligibleUsers() @@ -58,12 +65,14 @@ class WeekdayNotificationService( } private fun buildNotificationMessages(users: List): Set = - users - .mapNotNull { user -> - userDeviceReader.readLastByUserId(user.userId)?.let { device -> - weekdayNotificationMessageBuilder.buildMessage(device.fcmToken) - } - }.toSet() + userDeviceReader.readLastByUserIds(users.map { it.userId }).let { latestDevicesByUserId -> + users + .mapNotNull { user -> + latestDevicesByUserId[user.userId]?.let { device -> + weekdayNotificationMessageBuilder.buildMessage(device.fcmToken) + } + }.toSet() + } private fun storeNotificationRecords(users: List) { val commands = diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationEligibilityChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationEligibilityChecker.kt index b083d7a..52b8ee8 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationEligibilityChecker.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationEligibilityChecker.kt @@ -1,5 +1,6 @@ package com.pida.notification.weekend +import com.pida.notification.EligibleUser import com.pida.support.extension.logger import org.springframework.stereotype.Component diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationService.kt index 3ad6ac5..fd48ec1 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationService.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationService.kt @@ -1,6 +1,7 @@ package com.pida.notification.weekend import com.pida.notification.CreateNotificationStoredCommand +import com.pida.notification.EligibleUser import com.pida.notification.FcmSender import com.pida.notification.NewFirebaseCloudMessage import com.pida.notification.NotificationService @@ -67,12 +68,14 @@ class WeekendNotificationService( * @return FCM 메시지 집합 */ private fun buildNotificationMessages(users: List): Set = - users - .mapNotNull { user -> - userDeviceReader.readLastByUserId(user.userId)?.let { device -> - weekendNotificationMessageBuilder.buildMessage(device.fcmToken) - } - }.toSet() + userDeviceReader.readLastByUserIds(users.map { it.userId }).let { latestDevicesByUserId -> + users + .mapNotNull { user -> + latestDevicesByUserId[user.userId]?.let { device -> + weekendNotificationMessageBuilder.buildMessage(device.fcmToken) + } + }.toSet() + } /** * 알림 이력 저장 diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationUserReader.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationUserReader.kt index 2f9cc93..bdd5859 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationUserReader.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekend/WeekendNotificationUserReader.kt @@ -1,6 +1,7 @@ package com.pida.notification.weekend import com.pida.auth.AuthenticationHistoryRepository +import com.pida.notification.EligibleUser import com.pida.support.extension.logger import com.pida.support.geo.GeoJson import com.pida.user.location.UserLocation diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationEligibilityChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationEligibilityChecker.kt new file mode 100644 index 0000000..285c9db --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationEligibilityChecker.kt @@ -0,0 +1,102 @@ +package com.pida.notification.withered + +import com.pida.notification.EligibleUserWithRegion +import com.pida.notification.NotificationStoredRepository +import com.pida.notification.NotificationType +import com.pida.support.extension.logger +import com.pida.support.geo.Region +import org.springframework.stereotype.Component +import java.time.LocalDate + +/** + * WITHERED 알림 적격성 검증 컴포넌트 + * + * 특정 지역의 사용자 중 알림을 받을 수 있는 사용자를 필터링합니다. + */ +@Component +class WitheredNotificationEligibilityChecker( + private val userReader: WitheredNotificationUserReader, + private val regionResolver: WitheredRegionResolver, + private val notificationStoredRepository: NotificationStoredRepository, +) { + private val logger by logger() + + /** + * 특정 지역의 사용자 중 알림 적격 사용자 조회 + * + * 조건: + * - 최근 7일 내 활성 사용자 + * - 위치 정보가 있는 사용자 + * - 현재 위치가 대상 지역에 속한 사용자 + * - 올해 WITHERED_ALERT 알림을 받지 않은 사용자 + * + * @param region 대상 지역 + * @return 적격 사용자 목록 + */ + fun findEligibleUsersForRegion(region: Region): List { + // 1. 활성 사용자 조회 + val activeUsers = userReader.findActiveUsersWithLocation() + + if (activeUsers.isEmpty()) { + logger.info("No active users found") + return emptyList() + } + + logger.info("Found ${activeUsers.size} active users with location") + + // 2. 각 사용자의 Region 해석 및 필터링 + val usersInTargetRegion = + activeUsers.mapNotNull { user -> + val userRegion = regionResolver.resolveRegion(user.latitude, user.longitude) + + if (userRegion == region) { + EligibleUserWithRegion( + userId = user.userId, + latitude = user.latitude, + longitude = user.longitude, + region = userRegion, + ) + } else { + null + } + } + + logger.info("${usersInTargetRegion.size} users are in target region: $region") + + if (usersInTargetRegion.isEmpty()) { + return emptyList() + } + + // 3. 올해 WITHERED_ALERT 알림 수신 여부 확인 + val yearStartDateTime = getYearStartDateTime() + val userIds = usersInTargetRegion.map { it.userId } + val notificationCountMap = + notificationStoredRepository.countByUserIdsAndTypeAndCreatedAtAfter( + userIds = userIds, + type = NotificationType.WITHERED_ALERT, + createdAtAfter = yearStartDateTime, + ) + + logger.info("Yearly notification counts retrieved for ${notificationCountMap.size} users") + + // 4. 필터링: 올해 알림을 받지 않은 사용자만 + val eligibleUsers = + usersInTargetRegion.filter { user -> + val count = notificationCountMap[user.userId] ?: 0 + count == 0L + } + + logger.info("${eligibleUsers.size} users have not received WITHERED_ALERT this year") + + return eligibleUsers + } + + /** + * 올해 1월 1일 00:00:00 반환 + */ + private fun getYearStartDateTime() = + LocalDate + .now() + .withDayOfYear(1) + .atStartOfDay() +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationMessageBuilder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationMessageBuilder.kt new file mode 100644 index 0000000..4390018 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationMessageBuilder.kt @@ -0,0 +1,40 @@ +package com.pida.notification.withered + +import com.pida.notification.NewFirebaseCloudMessage +import org.springframework.stereotype.Component + +/** + * WITHERED 알림 메시지 빌더 + * + * 저물었어요 상태 알림의 FCM 메시지를 생성합니다. + */ +@Component +class WitheredNotificationMessageBuilder { + companion object { + private const val MESSAGE_CONTENT = + "이번 주말이 지나면 늦을 지도 몰라요. 🌸\n엔딩 크레딧 올라가기 전, 마지막 벚꽃 산책 어때요?" + + private const val DESTINATION = "home" + } + + /** + * FCM 메시지 생성 + * + * @param fcmToken FCM 토큰 + * @return FCM 메시지 + */ + fun buildMessage(fcmToken: String): NewFirebaseCloudMessage = + NewFirebaseCloudMessage( + fcmToken = fcmToken, + title = MESSAGE_CONTENT.lines()[0], // 첫 번째 줄을 제목으로 + body = MESSAGE_CONTENT.lines().drop(1).joinToString("\n"), // 나머지를 본문으로 + destination = DESTINATION, + ) + + /** + * 메시지 내용 반환 (NotificationStored 저장용) + * + * @return 메시지 전체 내용 + */ + fun getMessageContent(): String = MESSAGE_CONTENT +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationService.kt new file mode 100644 index 0000000..b068255 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationService.kt @@ -0,0 +1,145 @@ +package com.pida.notification.withered + +import com.pida.notification.CreateNotificationStoredCommand +import com.pida.notification.EligibleUserWithRegion +import com.pida.notification.FcmSender +import com.pida.notification.NewFirebaseCloudMessage +import com.pida.notification.NotificationService +import com.pida.notification.NotificationType +import com.pida.notification.ReadStatus +import com.pida.support.extension.logger +import com.pida.support.geo.Region +import com.pida.user.device.UserDeviceReader +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service + +/** + * WITHERED 상태 푸시 알림 서비스 + * + * 각 지역별 WITHERED 투표 비율을 확인하여 30% 이상인 지역의 사용자들에게 알림을 발송합니다. + */ +@Service +class WitheredNotificationService( + private val witheredThresholdChecker: WitheredThresholdChecker, + private val witheredNotificationEligibilityChecker: WitheredNotificationEligibilityChecker, + private val witheredNotificationMessageBuilder: WitheredNotificationMessageBuilder, + private val userDeviceReader: UserDeviceReader, + private val fcmSender: FcmSender, + private val notificationService: NotificationService, +) { + private val logger by logger() + + /** + * WITHERED 알림 발송 + * + * 1. WITHERED 임계값을 초과한 지역 조회 + * 2. 각 지역별로 적격 사용자 조회 + * 3. FCM 메시지 생성 및 발송 + * 4. 알림 이력 저장 + */ + @Async + fun sendWitheredNotifications() { + try { + // Step 1: WITHERED 비율이 30% 이상인 지역 조회 + val regionsExceedingThreshold = witheredThresholdChecker.findRegionsExceedingThreshold() + + if (regionsExceedingThreshold.isEmpty()) { + return + } + + // Step 2: 각 지역별로 알림 처리 + var totalSentCount = 0 + regionsExceedingThreshold.forEach { witheredRegion -> + val sentCount = processRegion(witheredRegion.region) + totalSentCount += sentCount + } + + logger.info("Withered notification execution completed. Total $totalSentCount users notified.") + } catch (e: Exception) { + logger.error("Failed to send withered notifications", e) + } + } + + /** + * 특정 지역에 대한 알림 처리 + * + * @param region 대상 지역 + * @return 발송된 사용자 수 + */ + private fun processRegion(region: Region): Int { + try { + logger.info("Processing region: $region") + + // 1. 지역별 적격 사용자 조회 + val eligibleUsers = witheredNotificationEligibilityChecker.findEligibleUsersForRegion(region) + + if (eligibleUsers.isEmpty()) { + logger.info("Region $region: No eligible users found") + return 0 + } + + logger.info("Region $region: ${eligibleUsers.size} eligible users found") + + // 2. FCM 메시지 생성 + val messages = buildNotificationMessages(eligibleUsers) + + if (messages.isEmpty()) { + logger.info("Region $region: No FCM messages to send (no valid FCM tokens)") + return 0 + } + + logger.info("Region $region: ${messages.size} FCM messages prepared") + + // 3. FCM 발송 + fcmSender.sendAllAsync(messages) + + // 4. 알림 이력 저장 + storeNotificationRecords(eligibleUsers) + + logger.info("Region $region: Notifications sent successfully to ${messages.size} users") + + return messages.size + } catch (e: Exception) { + logger.error("Failed to process region: $region", e) + return 0 + } + } + + /** + * FCM 메시지 빌드 + * + * @param users 대상 사용자 목록 + * @return FCM 메시지 집합 + */ + private fun buildNotificationMessages(users: List): Set = + userDeviceReader.readLastByUserIds(users.map { it.userId }).let { latestDevicesByUserId -> + users + .mapNotNull { user -> + latestDevicesByUserId[user.userId]?.let { device -> + witheredNotificationMessageBuilder.buildMessage(device.fcmToken) + } + }.toSet() + } + + /** + * 알림 이력 저장 + * + * @param users 대상 사용자 목록 + */ + private fun storeNotificationRecords(users: List) { + val commands = + users.map { user -> + CreateNotificationStoredCommand( + notificationStoredKey = "", // 서비스에서 재생성됨 + userId = user.userId, + type = NotificationType.WITHERED_ALERT, + parameterValue = "", + topic = "피다", + contents = witheredNotificationMessageBuilder.getMessageContent(), + readStatus = ReadStatus.UNREAD, + ) + } + + notificationService.appendAll(commands) + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationUserReader.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationUserReader.kt new file mode 100644 index 0000000..6b16403 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredNotificationUserReader.kt @@ -0,0 +1,69 @@ +package com.pida.notification.withered + +import com.pida.auth.AuthenticationHistoryRepository +import com.pida.notification.EligibleUser +import com.pida.support.extension.logger +import com.pida.support.geo.GeoJson +import com.pida.user.location.UserLocation +import com.pida.user.location.UserLocationReader +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +/** + * WITHERED 알림 대상 사용자 조회 컴포넌트 + * + * 최근 7일 내 활성 사용자 중 위치 정보가 있는 사용자 목록을 조회합니다. + */ +@Component +class WitheredNotificationUserReader( + private val authenticationHistoryRepository: AuthenticationHistoryRepository, + private val userLocationReader: UserLocationReader, +) { + private val logger by logger() + + companion object { + private const val ACTIVE_USER_DAYS = 7L + } + + /** + * 최근 7일 내 활성 사용자 중 위치 정보가 있는 사용자 목록 조회 + * + * @return 대상 사용자 목록 + */ + fun findActiveUsersWithLocation(): List { + val sevenDaysAgo = LocalDateTime.now().minusDays(ACTIVE_USER_DAYS) + + // 1. 최근 7일 내 로그인한 활성 사용자 ID 조회 + val activeUserIds = authenticationHistoryRepository.findActiveUsersSince(sevenDaysAgo) + + logger.info("Found ${activeUserIds.size} active users in last $ACTIVE_USER_DAYS days") + + if (activeUserIds.isEmpty()) { + return emptyList() + } + + // 2. 활성 사용자들의 위치 정보 일괄 조회 (N+1 문제 해결) + val userLocations: List = + userLocationReader.readUserLocationsByUserIds(activeUserIds).distinctBy { it.userId } + + logger.info("Found ${userLocations.size} user locations") + + // 3. UserLocation을 EligibleUser로 변환 + val eligibleUsers = + userLocations.mapNotNull { userLocation: UserLocation.Info -> + // GeoJson.Point로 캐스팅하여 coordinates 접근 + val point = userLocation.location as? GeoJson.Point + point?.let { + EligibleUser( + userId = userLocation.userId, + latitude = it.coordinates[1], // GeoJson Point: [longitude, latitude] + longitude = it.coordinates[0], + ) + } + } + + logger.info("${eligibleUsers.size} users have location information") + + return eligibleUsers + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredRegion.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredRegion.kt new file mode 100644 index 0000000..bec81d0 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredRegion.kt @@ -0,0 +1,18 @@ +package com.pida.notification.withered + +import com.pida.support.geo.Region + +/** + * WITHERED 상태가 임계값을 초과한 지역 정보 + * + * @property region 지역 + * @property witheredPercentage WITHERED 투표 비율 (%) + * @property totalVotes 총 투표 수 + * @property witheredVotes WITHERED 투표 수 + */ +data class WitheredRegion( + val region: Region, + val witheredPercentage: Double, + val totalVotes: Int, + val witheredVotes: Int, +) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredRegionResolver.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredRegionResolver.kt new file mode 100644 index 0000000..5f47b17 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredRegionResolver.kt @@ -0,0 +1,39 @@ +package com.pida.notification.withered + +import com.pida.place.DistrictRepository +import com.pida.support.extension.logger +import com.pida.support.geo.Region +import org.springframework.stereotype.Component + +/** + * 사용자 좌표를 Region(광역 자치단체)으로 변환하는 컴포넌트 + * + * 가장 가까운 District를 찾아 해당 District의 region을 반환합니다. + */ +@Component +class WitheredRegionResolver( + private val districtRepository: DistrictRepository, +) { + private val logger by logger() + + /** + * 주어진 좌표를 Region으로 변환합니다. + * + * @param latitude 위도 + * @param longitude 경도 + * @return 해당 좌표가 속한 Region, 찾을 수 없으면 null + */ + fun resolveRegion( + latitude: Double, + longitude: Double, + ): Region? { + val district = districtRepository.findNearestDistrict(latitude, longitude) + + if (district == null) { + logger.warn("No district found for coordinates: ($latitude, $longitude)") + return null + } + + return district.region + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredThresholdChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredThresholdChecker.kt new file mode 100644 index 0000000..f480f18 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/withered/WitheredThresholdChecker.kt @@ -0,0 +1,63 @@ +package com.pida.notification.withered + +import com.pida.blooming.BloomingRepository +import com.pida.blooming.BloomingStatus +import org.springframework.stereotype.Component + +/** + * 지역별 WITHERED 상태 투표 비율을 확인하여 임계값을 초과한 지역을 찾는 컴포넌트 + * + * 최근 5일간의 투표 데이터를 기준으로 WITHERED 비율이 30% 이상인 지역을 반환합니다. + */ +@Component +class WitheredThresholdChecker( + private val bloomingRepository: BloomingRepository, +) { + companion object { + private const val THRESHOLD_PERCENTAGE = 30.0 + } + + /** + * WITHERED 투표 비율이 30% 이상인 지역을 찾습니다. + * + * @return 임계값을 초과한 지역 리스트 + */ + fun findRegionsExceedingThreshold(): List { + val regionStats = bloomingRepository.countByRegionAndStatus() + + if (regionStats.isEmpty()) { + return emptyList() + } + + val result = + regionStats + .groupBy { it.region } + .mapNotNull { (region, counts) -> + val totalVotes = counts.sumOf { it.count } + + if (totalVotes == 0L) { + return@mapNotNull null + } + + val witheredVotes = + counts + .filter { it.status == BloomingStatus.WITHERED } + .sumOf { it.count } + + val percentage = (witheredVotes.toDouble() / totalVotes) * 100 + + if (percentage >= THRESHOLD_PERCENTAGE) { + WitheredRegion( + region = region, + witheredPercentage = percentage, + totalVotes = totalVotes.toInt(), + witheredVotes = witheredVotes.toInt(), + ) + } else { + null + } + } + + return result + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/place/DistrictRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/place/DistrictRepository.kt index 9d4a8d4..a7e9a76 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/place/DistrictRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/place/DistrictRepository.kt @@ -4,4 +4,16 @@ interface DistrictRepository { fun saveAll(districts: List) fun searchByKeyword(keyword: String): List + + /** + * 주어진 좌표에서 가장 가까운 District를 찾습니다. + * + * @param latitude 위도 + * @param longitude 경도 + * @return 가장 가까운 District, 없으면 null + */ + fun findNearestDistrict( + latitude: Double, + longitude: Double, + ): District? } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/geo/Region.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/geo/Region.kt index d8a09a7..2d164a6 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/support/geo/Region.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/geo/Region.kt @@ -18,6 +18,31 @@ enum class Region { GYEONGBUK, GYEONGNAM, JEJU, + ; + + /** + * Region을 한글 이름으로 변환합니다. + */ + fun toKoreanName(): String = + when (this) { + SEOUL -> "서울" + GYEONGGI -> "경기" + BUSAN -> "부산" + DAEGU -> "대구" + INCHEON -> "인천" + GWANGJU -> "광주" + DAEJEON -> "대전" + ULSAN -> "울산" + SEJONG -> "세종" + GANGWON -> "강원" + CHUNGBUK -> "충북" + CHUNGNAM -> "충남" + JEONBUK -> "전북" + JEONNAM -> "전남" + GYEONGBUK -> "경북" + GYEONGNAM -> "경남" + JEJU -> "제주" + } } fun String.toRegion(): Region = diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/user/device/UserDeviceReader.kt b/pida-core/core-domain/src/main/kotlin/com/pida/user/device/UserDeviceReader.kt index 1d57897..11912ca 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/user/device/UserDeviceReader.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/user/device/UserDeviceReader.kt @@ -8,6 +8,8 @@ class UserDeviceReader( ) { fun readLastByUserId(userId: Long): UserDevice.Info? = userDeviceRepository.findLastByUserId(userId) + fun readLastByUserIds(userIds: List): Map = userDeviceRepository.findLastByUserIds(userIds) + fun readAllByUserKey(userKey: String): List = userDeviceRepository.findAllByUserKey(userKey) fun readAllByUserId(userId: Long): List = userDeviceRepository.findAllByUserId(userId) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/user/device/UserDeviceRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/user/device/UserDeviceRepository.kt index 521cf3b..a0ef96e 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/user/device/UserDeviceRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/user/device/UserDeviceRepository.kt @@ -7,6 +7,8 @@ interface UserDeviceRepository { fun findLastByUserId(userId: Long): UserDevice.Info? + fun findLastByUserIds(userIds: List): Map + fun findAllByUserKey(userKey: String): List fun findAllByUserId(userId: Long): List diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/user/location/UserLocationReader.kt b/pida-core/core-domain/src/main/kotlin/com/pida/user/location/UserLocationReader.kt index dfc4236..c6b9695 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/user/location/UserLocationReader.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/user/location/UserLocationReader.kt @@ -9,4 +9,10 @@ class UserLocationReader( fun readUserLocationByUserId(userId: Long) = userLocationRepository.findByUserId(userId) fun readUserLocationsByUserIds(userIds: List) = userLocationRepository.findByUserIds(userIds) + + fun readUserLocationsWithinRadius( + latitude: Double, + longitude: Double, + radiusMeters: Double, + ) = userLocationRepository.findWithinRadius(latitude, longitude, radiusMeters) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/user/location/UserLocationRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/user/location/UserLocationRepository.kt index 6878e0c..d5b4533 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/user/location/UserLocationRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/user/location/UserLocationRepository.kt @@ -5,6 +5,12 @@ interface UserLocationRepository { fun findByUserIds(userIds: List): List + fun findWithinRadius( + latitude: Double, + longitude: Double, + radiusMeters: Double, + ): List + fun saveOrUpdate( userId: Long, latitude: Double, diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/weather/WeatherService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/weather/WeatherService.kt index e760707..cd686d7 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/weather/WeatherService.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/weather/WeatherService.kt @@ -30,4 +30,17 @@ interface WeatherService { val weather = getWeather(location) return weather.hasRainForecast(probabilityThreshold) || weather.isRaining() } + + /** + * 내일 시간대 예보 중 최대 강수확률(POP) 조회 + */ + fun getTomorrowMaxPrecipitationProbability(location: WeatherLocation): Int + + /** + * 내일 비 예보 여부 확인 + */ + fun willRainTomorrow( + location: WeatherLocation, + probabilityThreshold: Int = 60, + ): Boolean = getTomorrowMaxPrecipitationProbability(location) >= probabilityThreshold } diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCoreRepository.kt index 1ce3655..09e9627 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCoreRepository.kt @@ -3,8 +3,11 @@ package com.pida.storage.db.core.blooming import com.pida.blooming.Blooming import com.pida.blooming.BloomingRepository import com.pida.blooming.NewBlooming +import com.pida.blooming.RegionStatusCount +import com.pida.support.geo.Region import com.pida.support.tx.Tx import org.springframework.stereotype.Repository +import java.time.LocalDateTime @Repository class BloomingCoreRepository( @@ -22,7 +25,7 @@ class BloomingCoreRepository( bloomingJpaRepository.save(bloomingEntity).toBlooming() } - override suspend fun findTopByUserIdAndSpotIdDecs( + override suspend fun findTopByUserIdAndSpotIdDesc( userId: Long, flowerSpotId: Long, ): Blooming? = @@ -62,4 +65,20 @@ class BloomingCoreRepository( Tx.readable { bloomingCustomRepository.findBloomedSpotIdsByFlowerSpotIds(spotIds) } + + override fun countByRegionAndStatus(): List = + Tx.readable { + bloomingCustomRepository.countByRegionAndStatus() + } + + override fun countBloomedVotesByRegionAndCreatedAtAfter( + region: Region, + createdAtAfter: LocalDateTime, + ): Long = + Tx.readable { + bloomingCustomRepository.countBloomedVotesByRegionAndCreatedAtAfter( + region = region, + createdAtAfter = createdAtAfter, + ) + } } diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCustomRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCustomRepository.kt index 9361ab6..daf3d62 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCustomRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/blooming/BloomingCustomRepository.kt @@ -4,6 +4,10 @@ import com.linecorp.kotlinjdsl.dsl.jpql.jpql import com.linecorp.kotlinjdsl.render.RenderContext import com.linecorp.kotlinjdsl.support.spring.data.jpa.extension.createQuery import com.pida.blooming.BloomingStatus +import com.pida.blooming.RegionStatusCount +import com.pida.storage.db.core.flowerspot.FlowerSpotEntity +import com.pida.storage.db.core.support.JDSLExtensions +import com.pida.support.geo.Region import jakarta.persistence.EntityManager import org.springframework.stereotype.Repository import java.time.LocalDate @@ -90,4 +94,61 @@ class BloomingCustomRepository( return entityManager.createQuery(query, jdslRenderContext).resultList } + + /** + * 지역별, 상태별 최근 5일간 투표 수를 집계합니다. + * BloomingEntity와 FlowerSpotEntity를 JOIN하여 지역 정보를 가져옵니다. + * + * @return 지역별 상태별 투표 수 리스트 + */ + fun countByRegionAndStatus(): List { + val threshold = LocalDateTime.now().minusDays(DATE_THRESHOLD) + + val query = + jpql(JDSLExtensions) { + selectNew( + path(FlowerSpotEntity::region), + path(BloomingEntity::status), + count(path(BloomingEntity::id)), + ).from( + entity(BloomingEntity::class), + join(FlowerSpotEntity::class) + .on(path(BloomingEntity::flowerSpotId).eq(path(FlowerSpotEntity::id))), + ).whereAnd( + path(BloomingEntity::createdAt).greaterThan(threshold), + path(FlowerSpotEntity::deletedAt).isNull(), + ).groupBy( + path(FlowerSpotEntity::region), + path(BloomingEntity::status), + ) + } + + return entityManager.createQuery(query, jdslRenderContext).resultList + } + + fun countBloomedVotesByRegionAndCreatedAtAfter( + region: Region, + createdAtAfter: LocalDateTime, + ): Long { + val query = + jpql(JDSLExtensions) { + select( + count(path(BloomingEntity::id)), + ).from( + entity(BloomingEntity::class), + join(FlowerSpotEntity::class) + .on(path(BloomingEntity::flowerSpotId).eq(path(FlowerSpotEntity::id))), + ).whereAnd( + path(BloomingEntity::status).eq(BloomingStatus.BLOOMED), + path(FlowerSpotEntity::region).eq(region), + path(BloomingEntity::createdAt).greaterThanOrEqualTo(createdAtAfter), + path(FlowerSpotEntity::deletedAt).isNull(), + ) + } + + return entityManager + .createQuery(query, jdslRenderContext) + .resultList + .firstOrNull() ?: 0L + } } diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCoreRepository.kt index e13ceaa..8067fe2 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCoreRepository.kt @@ -38,4 +38,12 @@ class DistrictCoreRepository( districtCustomRepository .searchByKeyword(keyword) .map { it.toDistrict() } + + override fun findNearestDistrict( + latitude: Double, + longitude: Double, + ): District? = + districtCustomRepository + .findNearestDistrict(latitude, longitude) + ?.toDistrict() } diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCustomRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCustomRepository.kt index 561f5e1..61a202f 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCustomRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCustomRepository.kt @@ -11,6 +11,41 @@ class DistrictCustomRepository( private val entityManager: EntityManager, private val jdslRenderContext: RenderContext, ) { + /** + * 주어진 좌표에서 가장 가까운 District를 PostGIS KNN 정렬로 찾습니다. + * + * @param latitude 위도 + * @param longitude 경도 + * @return 가장 가까운 DistrictEntity, 없으면 null + */ + fun findNearestDistrict( + latitude: Double, + longitude: Double, + ): DistrictEntity? { + require(latitude in -90.0..90.0) { "latitude must be between -90 and 90" } + require(longitude in -180.0..180.0) { "longitude must be between -180 and 180" } + + val query = + entityManager.createNativeQuery( + """ + SELECT * + FROM t_district + WHERE deleted_at IS NULL + ORDER BY pin_point <-> ST_SetSRID(ST_MakePoint(:lng, :lat), 4326) + LIMIT 1 + """, + DistrictEntity::class.java, + ) + + query.setParameter("lat", latitude) + query.setParameter("lng", longitude) + + @Suppress("UNCHECKED_CAST") + val results = query.resultList as List + + return results.firstOrNull() + } + fun searchByKeyword(keyword: String): List { val pattern = "$keyword%" // B-tree index 활용을 위한 접두사 일치 패턴 diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/notification/NotificationStoredCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/notification/NotificationStoredCoreRepository.kt index d83355b..a0670b5 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/notification/NotificationStoredCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/notification/NotificationStoredCoreRepository.kt @@ -79,6 +79,45 @@ class NotificationStoredCoreRepository( ) } + override fun countByUserIdsAndCreatedAtAfter( + userIds: List, + createdAtAfter: LocalDateTime, + ): Map = + Tx.readable { + notificationStoredCustomRepository.countByUserIdsAndCreatedAtAfter( + userIds = userIds, + createdAtAfter = createdAtAfter, + ) + } + + override fun countByUserIdsAndTypeNotAndCreatedAtAfter( + userIds: List, + excludedType: NotificationType, + createdAtAfter: LocalDateTime, + ): Map = + Tx.readable { + notificationStoredCustomRepository.countByUserIdsAndTypeNotAndCreatedAtAfter( + userIds = userIds, + excludedType = excludedType, + createdAtAfter = createdAtAfter, + ) + } + + override fun countByUserIdsAndTypeAndParameterValueAndCreatedAtAfter( + userIds: List, + type: NotificationType, + parameterValue: String, + createdAtAfter: LocalDateTime, + ): Map = + Tx.readable { + notificationStoredCustomRepository.countByUserIdsAndTypeAndParameterValueAndCreatedAtAfter( + userIds = userIds, + type = type, + parameterValue = parameterValue, + createdAtAfter = createdAtAfter, + ) + } + override fun markAsRead(notificationId: Long) = Tx.writeable { val notificationStored = diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/notification/NotificationStoredCustomRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/notification/NotificationStoredCustomRepository.kt index 4203ba9..4abdd2e 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/notification/NotificationStoredCustomRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/notification/NotificationStoredCustomRepository.kt @@ -104,4 +104,121 @@ class NotificationStoredCustomRepository( .resultList .associate { it.userId to it.count } } + + /** + * 특정 기간 이후 알림을 받은 사용자별 알림 횟수 조회 + * + * @param userIds 조회할 사용자 ID 목록 + * @param createdAtAfter 조회 시작 시간 + * @return 사용자 ID별 알림 횟수 Map + */ + fun countByUserIdsAndCreatedAtAfter( + userIds: List, + createdAtAfter: LocalDateTime, + ): Map { + if (userIds.isEmpty()) { + return emptyMap() + } + + val query = + jpql(JDSLExtensions) { + selectNew( + path(NotificationStoredEntity::userId), + count(NotificationStoredEntity::id), + ).from( + entity(NotificationStoredEntity::class), + ).whereAnd( + path(NotificationStoredEntity::userId).`in`(userIds), + path(NotificationStoredEntity::createdAt).greaterThanOrEqualTo(createdAtAfter), + ).groupBy( + path(NotificationStoredEntity::userId), + ) + } + + return entityManager + .createQuery(query, jdslRenderContext) + .resultList + .associate { it.userId to it.count } + } + + /** + * 특정 기간 이후 특정 타입을 제외한 알림을 받은 사용자별 알림 횟수 조회 + * + * @param userIds 조회할 사용자 ID 목록 + * @param excludedType 제외할 알림 타입 + * @param createdAtAfter 조회 시작 시간 + * @return 사용자 ID별 알림 횟수 Map + */ + fun countByUserIdsAndTypeNotAndCreatedAtAfter( + userIds: List, + excludedType: NotificationType, + createdAtAfter: LocalDateTime, + ): Map { + if (userIds.isEmpty()) { + return emptyMap() + } + + val query = + jpql(JDSLExtensions) { + selectNew( + path(NotificationStoredEntity::userId), + count(NotificationStoredEntity::id), + ).from( + entity(NotificationStoredEntity::class), + ).whereAnd( + path(NotificationStoredEntity::userId).`in`(userIds), + path(NotificationStoredEntity::type).notIn(listOf(excludedType)), + path(NotificationStoredEntity::createdAt).greaterThanOrEqualTo(createdAtAfter), + ).groupBy( + path(NotificationStoredEntity::userId), + ) + } + + return entityManager + .createQuery(query, jdslRenderContext) + .resultList + .associate { it.userId to it.count } + } + + /** + * 특정 기간 이후 특정 타입/파라미터의 알림을 받은 사용자별 알림 횟수 조회 + * + * @param userIds 조회할 사용자 ID 목록 + * @param type 알림 타입 + * @param parameterValue 알림 파라미터 값 + * @param createdAtAfter 조회 시작 시간 + * @return 사용자 ID별 알림 횟수 Map + */ + fun countByUserIdsAndTypeAndParameterValueAndCreatedAtAfter( + userIds: List, + type: NotificationType, + parameterValue: String, + createdAtAfter: LocalDateTime, + ): Map { + if (userIds.isEmpty()) { + return emptyMap() + } + + val query = + jpql(JDSLExtensions) { + selectNew( + path(NotificationStoredEntity::userId), + count(NotificationStoredEntity::id), + ).from( + entity(NotificationStoredEntity::class), + ).whereAnd( + path(NotificationStoredEntity::userId).`in`(userIds), + path(NotificationStoredEntity::type).eq(type), + path(NotificationStoredEntity::parameterValue).eq(parameterValue), + path(NotificationStoredEntity::createdAt).greaterThanOrEqualTo(createdAtAfter), + ).groupBy( + path(NotificationStoredEntity::userId), + ) + } + + return entityManager + .createQuery(query, jdslRenderContext) + .resultList + .associate { it.userId to it.count } + } } diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/device/UserDeviceCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/device/UserDeviceCoreRepository.kt index 4549831..65e81b6 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/device/UserDeviceCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/device/UserDeviceCoreRepository.kt @@ -29,11 +29,22 @@ class UserDeviceCoreRepository( override fun findLastByUserId(userId: Long): UserDevice.Info? = Tx.readable { userDeviceJpaRepository - .findAllByUserIdAndDeletedAtIsNull(userId) - .lastOrNull() + .findFirstByUserIdAndDeletedAtIsNullOrderByCreatedAtDescIdDesc(userId) ?.toUserDevice() } + override fun findLastByUserIds(userIds: List): Map = + Tx.readable { + if (userIds.isEmpty()) { + emptyMap() + } else { + userDeviceJpaRepository + .findLastByUserIds(userIds.distinct()) + .map { it.toUserDevice() } + .associateBy { it.userId } + } + } + override fun findAllByUserKey(userKey: String): List = Tx.readable { userDeviceJpaRepository diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/device/UserDeviceJpaRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/device/UserDeviceJpaRepository.kt index 5c77a5c..48d40dd 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/device/UserDeviceJpaRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/device/UserDeviceJpaRepository.kt @@ -2,12 +2,30 @@ package com.pida.storage.db.core.user.device import com.linecorp.kotlinjdsl.support.spring.data.jpa.repository.KotlinJdslJpqlExecutor import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface UserDeviceJpaRepository : JpaRepository, KotlinJdslJpqlExecutor { fun findAllByDeletedAtIsNull(): List + fun findFirstByUserIdAndDeletedAtIsNullOrderByCreatedAtDescIdDesc(userId: Long): UserDeviceEntity? + + @Query( + """ + SELECT DISTINCT ON (user_id) * + FROM t_user_device + WHERE deleted_at IS NULL + AND user_id IN (:userIds) + ORDER BY user_id, created_at DESC, id DESC + """, + nativeQuery = true, + ) + fun findLastByUserIds( + @Param("userIds") userIds: List, + ): List + fun findAllByUserKeyAndDeletedAtIsNull(userKey: String): List fun findAllByUserIdAndDeletedAtIsNull(userId: Long): List diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/location/UserLocationCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/location/UserLocationCoreRepository.kt index 8762260..b6f853c 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/location/UserLocationCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/location/UserLocationCoreRepository.kt @@ -34,6 +34,20 @@ class UserLocationCoreRepository( } } + override fun findWithinRadius( + latitude: Double, + longitude: Double, + radiusMeters: Double, + ): List = + Tx.readable { + userLocationJpaRepository + .findWithinRadius( + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + ).map { it.toUserLocation() } + } + override fun saveOrUpdate( userId: Long, latitude: Double, diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/location/UserLocationJpaRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/location/UserLocationJpaRepository.kt index 4c57a45..3da722a 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/location/UserLocationJpaRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/user/location/UserLocationJpaRepository.kt @@ -2,6 +2,8 @@ package com.pida.storage.db.core.user.location import com.linecorp.kotlinjdsl.support.spring.data.jpa.repository.KotlinJdslJpqlExecutor import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface UserLocationJpaRepository : JpaRepository, @@ -9,4 +11,26 @@ interface UserLocationJpaRepository : fun findByUserId(userId: Long): UserLocationEntity? fun findByUserIdIn(userIds: List): List + + @Query( + """ + SELECT * + FROM t_user_location + WHERE ST_DWithin( + location::geography, + ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, + :radiusMeters + ) + ORDER BY ST_Distance( + location::geography, + ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography + ) + """, + nativeQuery = true, + ) + fun findWithinRadius( + @Param("latitude") latitude: Double, + @Param("longitude") longitude: Double, + @Param("radiusMeters") radiusMeters: Double, + ): List }