diff --git a/domain/src/main/kotlin/com/dobby/gateway/AlertChannel.kt b/domain/src/main/kotlin/com/dobby/gateway/AlertChannel.kt new file mode 100644 index 00000000..568b3547 --- /dev/null +++ b/domain/src/main/kotlin/com/dobby/gateway/AlertChannel.kt @@ -0,0 +1,6 @@ +package com.dobby.gateway + +enum class AlertChannel { + ERRORS, + NOTIFY +} diff --git a/domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt b/domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt index 4c504309..b2975125 100644 --- a/domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt +++ b/domain/src/main/kotlin/com/dobby/gateway/AlertGateway.kt @@ -2,4 +2,5 @@ package com.dobby.gateway interface AlertGateway { fun sendError(e: Exception, requestUrl: String, clientIp: String?) + fun sendNotify(title: String, description: String, content: String?) } diff --git a/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt b/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt new file mode 100644 index 00000000..93f288ee --- /dev/null +++ b/infrastructure/src/main/kotlin/com/dobby/config/properties/DiscordProperties.kt @@ -0,0 +1,13 @@ +package com.dobby.config.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("discord") +data class DiscordProperties( + val webhooks: Map = emptyMap() +) { + data class Webhook( + val id: String, + val token: String + ) +} diff --git a/infrastructure/src/main/kotlin/com/dobby/external/feign/discord/DiscordFeignClient.kt b/infrastructure/src/main/kotlin/com/dobby/external/feign/discord/DiscordFeignClient.kt index 32b56801..5176de4d 100644 --- a/infrastructure/src/main/kotlin/com/dobby/external/feign/discord/DiscordFeignClient.kt +++ b/infrastructure/src/main/kotlin/com/dobby/external/feign/discord/DiscordFeignClient.kt @@ -3,15 +3,23 @@ package com.dobby.external.feign.discord import com.dobby.api.dto.request.DiscordMessageRequest import org.springframework.cloud.openfeign.FeignClient import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @FeignClient( name = "discord-alarm-feign-client", - url = "\${discord.webhook-url}" + url = "https://discord.com" ) interface DiscordFeignClient { - @PostMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) - fun sendMessage(@RequestBody discordMessageRequest: DiscordMessageRequest) + @PostMapping( + value = ["/api/webhooks/{id}/{token}"], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + fun sendMessage( + @PathVariable id: String, + @PathVariable token: String, + @RequestBody discordMessageRequest: DiscordMessageRequest + ) } diff --git a/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt b/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt index 91bd95af..427c3678 100644 --- a/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt +++ b/infrastructure/src/main/kotlin/com/dobby/external/gateway/discord/AlertGatewayImpl.kt @@ -1,7 +1,9 @@ package com.dobby.external.gateway.discord import com.dobby.api.dto.request.DiscordMessageRequest +import com.dobby.config.properties.DiscordProperties import com.dobby.external.feign.discord.DiscordFeignClient +import com.dobby.gateway.AlertChannel import com.dobby.gateway.AlertGateway import org.springframework.stereotype.Component import java.io.PrintWriter @@ -10,14 +12,35 @@ import java.time.LocalDateTime @Component class AlertGatewayImpl( + private val discordProperties: DiscordProperties, private val discordFeignClient: DiscordFeignClient ) : AlertGateway { + fun send(channel: AlertChannel, body: DiscordMessageRequest) { + val key = when (channel) { + AlertChannel.ERRORS -> "errors" + AlertChannel.NOTIFY -> "notify" + } + val hook = discordProperties.webhooks[key] ?: return + runCatching { + discordFeignClient.sendMessage(hook.id, hook.token, body) + }.onFailure { + } + } + override fun sendError(e: Exception, requestUrl: String, clientIp: String?) { - sendMessage(createMessage(e, requestUrl, clientIp)) + send(AlertChannel.ERRORS, createErrorMessage(e, requestUrl, clientIp)) + } + + override fun sendNotify( + title: String, + description: String, + content: String? + ) { + send(AlertChannel.NOTIFY, createNotifyMessage(title, description, content)) } - private fun createMessage(e: Exception, requestUrl: String, clientIp: String?): DiscordMessageRequest { + private fun createErrorMessage(e: Exception, requestUrl: String, clientIp: String?): DiscordMessageRequest { return DiscordMessageRequest( content = "# 🚨 μ—λŸ¬ λ°œμƒ 비이이이이사아아아앙", embeds = listOf( @@ -26,13 +49,13 @@ class AlertGatewayImpl( description = """ ### πŸ•– λ°œμƒ μ‹œκ°„ ${LocalDateTime.now()} - + ### πŸ”— μš”μ²­ URL $requestUrl - + ### πŸ–₯️ ν΄λΌμ΄μ–ΈνŠΈ IP ${clientIp ?: "μ•Œ 수 μ—†μŒ"} - + ### πŸ“„ Stack Trace ``` ${getStackTrace(e).substring(0, 1000)} @@ -42,14 +65,26 @@ class AlertGatewayImpl( ) ) } + fun createNotifyMessage( + title: String, + description: String, + content: String? = null + ): DiscordMessageRequest { + val embeds = listOf( + DiscordMessageRequest.Embed( + title = "πŸ“£ $title", + description = description + ) + ) + return DiscordMessageRequest( + content = content, + embeds = embeds + ) + } private fun getStackTrace(e: Exception): String { val stringWriter = StringWriter() e.printStackTrace(PrintWriter(stringWriter)) return stringWriter.toString() } - - private fun sendMessage(request: DiscordMessageRequest) { - discordFeignClient.sendMessage(request) - } } diff --git a/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostCustomRepositoryImpl.kt b/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostCustomRepositoryImpl.kt index 7c080c53..50ac625d 100644 --- a/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostCustomRepositoryImpl.kt +++ b/infrastructure/src/main/kotlin/com/dobby/persistence/repository/ExperimentPostCustomRepositoryImpl.kt @@ -30,10 +30,12 @@ import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext import org.slf4j.Logger import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.stereotype.Repository import java.time.LocalDate import java.time.LocalDateTime import java.time.Period +import java.time.format.DateTimeFormatter @Repository class ExperimentPostCustomRepositoryImpl( @@ -43,6 +45,9 @@ class ExperimentPostCustomRepositoryImpl( private lateinit var entityManager: EntityManager private val logger: Logger = LoggerFactory.getLogger(this::class.java) + companion object { + private val LOG_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + } override fun findExperimentPostsByCustomFilter( customFilter: CustomFilter, @@ -263,7 +268,7 @@ class ExperimentPostCustomRepositoryImpl( ) .fetch() - logger.info("[쿼리 κ²°κ³Ό] participants count: {}", participants.size) + logger.info("[쿼리 κ²°κ³Ό] μ•Œλ¦Ό μˆ˜μ‹ μ— λ™μ˜ν•œ participants count: {}", participants.size) participants.take(10).forEachIndexed { index, tuple -> val participantEntity = tuple.get(participant) @@ -307,6 +312,12 @@ class ExperimentPostCustomRepositoryImpl( } }.toMap() + MDC.put("match.windowStart", lastProcessedTime.format(LOG_TIME_FORMATTER)) + MDC.put("match.windowEnd", currentTime.format(LOG_TIME_FORMATTER)) + MDC.put("match.todayPosts", todayPosts.size.toString()) + MDC.put("match.consentParticipants", participants.size.toString()) + MDC.put("match.matchedRecipients", resultMap.size.toString()) + logger.info("[μ΅œμ’… κ²°κ³Ό] 이메일을 받을 λŒ€μƒμž 수: {}", resultMap.size) return resultMap diff --git a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt index d8150934..72ecca83 100644 --- a/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt +++ b/infrastructure/src/main/kotlin/com/dobby/scheduler/SendMatchingEmailJob.kt @@ -1,5 +1,6 @@ package com.dobby.scheduler +import com.dobby.gateway.AlertGateway import com.dobby.service.EmailService import com.dobby.usecase.member.email.GetMatchingExperimentPostsUseCase import com.dobby.usecase.member.email.SendMatchingEmailUseCase @@ -8,21 +9,27 @@ import org.quartz.Job import org.quartz.JobExecutionContext import org.slf4j.Logger import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.stereotype.Component +import java.time.Duration +import java.time.LocalDate +import java.time.format.DateTimeFormatter @Component class SendMatchingEmailJob( - private val emailService: EmailService + private val emailService: EmailService, + private val alertGateway: AlertGateway ) : Job { companion object { private val logger: Logger = LoggerFactory.getLogger(SendMatchingEmailJob::class.java) } override fun execute(context: JobExecutionContext) { - logger.info("BulkSendMatchingEmailJob started at ${TimeProvider.currentDateTime()}") + val start = TimeProvider.currentDateTime() + logger.info("BulkSendMatchingEmailJob started at $start") val input = GetMatchingExperimentPostsUseCase.Input( - requestTime = TimeProvider.currentDateTime() + requestTime = start ) val output = emailService.getMatchingInfo(input) val matchingExperimentPosts = output.matchingPosts @@ -32,25 +39,67 @@ class SendMatchingEmailJob( for ((contactEmail, jobList) in matchingExperimentPosts) { if (jobList.isEmpty()) continue + val emailInput = SendMatchingEmailUseCase.Input( contactEmail = contactEmail, experimentPosts = jobList, currentDateTime = TimeProvider.currentDateTime() ) - try { - val emailOutput = emailService.sendMatchingEmail(emailInput) - if (emailOutput.isSuccess) { + + runCatching { + emailService.sendMatchingEmail(emailInput) + }.onSuccess { result -> + if (result.isSuccess) { successCount++ logger.info("Email sent successfully to $contactEmail") } else { failureCount++ - logger.error("Failed to send email to $contactEmail: ${emailOutput.message}") + logger.error("Failed to send email to $contactEmail: ${result.message}") } - } catch (e: Exception) { + }.onFailure { e -> failureCount++ - logger.error("Exception occurred while sending email to $contactEmail", e) + logger.error("Exception while sending email to $contactEmail", e) } } - logger.info("SendMatchingEmailJob completed. Success: $successCount, Failures: $failureCount") + + val rawEnd = TimeProvider.currentDateTime() + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val end = rawEnd.format(formatter) + val took = Duration.between(start, rawEnd).toSeconds() + + val windowStart = MDC.get("match.windowStart") ?: start.toString() + val windowEnd = MDC.get("match.windowEnd") ?: rawEnd.toString() + val todayCount = MDC.get("match.todayPosts")?.toIntOrNull() ?: 0 + val consentParticipants = MDC.get("match.consentParticipants")?.toIntOrNull() ?: 0 + val matchedRecipients = MDC.get("match.matchedRecipients")?.toIntOrNull() ?: matchingExperimentPosts.size + + listOf( + "match.windowStart", + "match.windowEnd", + "match.todayPosts", + "match.consentParticipants", + "match.matchedRecipients" + ).forEach { MDC.remove(it) } + + logger.info("SendMatchingEmailJob completed. Success=$successCount, Failures=$failureCount, Took=${took}s") + + val today = LocalDate.now() + + alertGateway.sendNotify( + title = "# πŸ“€ $today λ§€μΉ­ 메일 λ°œμ†‘ κ²°κ³Ό", + description = """ + **집계 ꡬ간**: $windowStart ~ $windowEnd (KST) + πŸ—“οΈ **였늘 올라온 곡고 수**: **$todayCount** 건 + πŸ‘€ **μ•Œλ¦Ό λ™μ˜ν•œ 전체 수**: **$consentParticipants** λͺ… + βœ‰οΈ **λ§€μΉ­ λ°œμ†‘λœ λŒ€μƒμž(이메일)**: **$matchedRecipients** λͺ… + + --- + βœ… **λ°œμ†‘ 성곡**: **$successCount** 건 + ❌ **λ°œμ†‘ μ‹€νŒ¨**: **$failureCount** 건 + ⏰ **μ‹€ν–‰ μ‹œκ°„**: $took 초 + πŸ•’ **μ™„λ£Œ μ‹œκ°**: $end + """.trimIndent(), + content = "@here" + ) } } diff --git a/infrastructure/src/main/resources/application.yml b/infrastructure/src/main/resources/application.yml index a710bfe4..3cd43703 100644 --- a/infrastructure/src/main/resources/application.yml +++ b/infrastructure/src/main/resources/application.yml @@ -78,7 +78,13 @@ cloud: secret-key: ${S3_SECRET_KEY} discord: - webhook-url: ${DISCORD_WEBHOOK_URL} + webhooks: + errors: + id: ${DISCORD_ERRORS_ID} + token: ${DISCORD_ERRORS_TOKEN} + notify: + id: ${DISCORD_NOTIFY_ID} + token: ${DISCORD_NOTIFY_TOKEN} cors: allowed-origins: diff --git a/infrastructure/src/main/resources/template-application-local.yml b/infrastructure/src/main/resources/template-application-local.yml index 4673a0b6..b0d7d417 100644 --- a/infrastructure/src/main/resources/template-application-local.yml +++ b/infrastructure/src/main/resources/template-application-local.yml @@ -81,7 +81,13 @@ cloud: secret-key: ${S3_SECRET_KEY} discord: - webhook-url: ${DISCORD_WEBHOOK_URL} + webhooks: + errors: + id: ${DISCORD_ERRORS_ID} + token: ${DISCORD_ERRORS_TOKEN} + notify: + id: ${DISCORD_NOTIFY_ID} + token: ${DISCORD_NOTIFY_TOKEN} cors: allowed-origins: