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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<KmaWeatherResponse.Item>,
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)
Expand Down Expand Up @@ -102,6 +106,24 @@ class WeatherServiceImpl(
)
}

private fun fetchForecastItems(location: WeatherLocation): List<KmaWeatherResponse.Item> {
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)
*/
Expand Down
1 change: 1 addition & 0 deletions pida-core/core-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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(
Expand Down Expand Up @@ -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,
)
}

Comment on lines +61 to +137
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

Restrict manual trigger endpoints to non-production/admin-only access.

Lines 61-137 add powerful POST endpoints that can trigger bulk notifications. If exposed in production without strict guardrails, this is abuse-prone and can cause user-impacting spam/cost incidents.

๐Ÿ”’ Suggested hardening
+import org.springframework.context.annotation.Profile
...
 `@RestController`
 `@RequestMapping`("/test")
+@Profile("local", "dev", "staging")
 class NotificationTestController(
+import org.springframework.security.access.prepost.PreAuthorize
...
+    `@PreAuthorize`("hasRole('ADMIN')")
     `@PostMapping`("/withered-notification")
     fun triggerWitheredNotification(): NotificationTriggerResponse { ... }

+    `@PreAuthorize`("hasRole('ADMIN')")
     `@PostMapping`("/bloomed-notification")
     fun triggerBloomedNotification(`@RequestParam` region: Region): NotificationTriggerResponse { ... }

+    `@PreAuthorize`("hasRole('ADMIN')")
     `@PostMapping`("/bloomed-spot-notification")
     fun triggerBloomedSpotNotification(`@RequestParam` spotId: Long): NotificationTriggerResponse { ... }

+    `@PreAuthorize`("hasRole('ADMIN')")
     `@PostMapping`("/rain-forecast-notification")
     fun triggerRainForecastNotification(): NotificationTriggerResponse { ... }
๐Ÿค– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt`
around lines 61 - 137, The new POST endpoints in NotificationTestController
(triggerWitheredNotification, triggerBloomedNotification,
triggerBloomedSpotNotification, triggerRainForecastNotification) must be locked
downโ€”add an environment and authorization guard so these can only be invoked in
non-production or by admin accounts: e.g., annotate the controller or each
method with a profile restriction (`@Profile` or conditional on an application
property) to disable them in prod AND enforce role-based access via Spring
Security (`@PreAuthorize`("hasRole('ADMIN')") or an explicit SecurityContext
check), or perform an env check at the start of each method that returns HTTP
403 when in production; include an audit/log entry for denied attempts.

data class NotificationTriggerResponse(
val message: String,
val triggeredAt: LocalDateTime,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
}
Loading
Loading