diff --git a/application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt b/application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt index 0bfd4163..fca6de34 100644 --- a/application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt +++ b/application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt @@ -10,6 +10,7 @@ import com.dobby.usecase.experiment.CreateExperimentPostUseCase import com.dobby.usecase.experiment.DeleteExperimentPostUseCase import com.dobby.usecase.experiment.ExtractExperimentPostKeywordsUseCase import com.dobby.usecase.experiment.GenerateExperimentPostPreSignedUrlUseCase +import com.dobby.usecase.experiment.GetDailyLimitForExtractUseCase import com.dobby.usecase.experiment.GetExperimentPostApplyMethodUseCase import com.dobby.usecase.experiment.GetExperimentPostCountsByAreaUseCase import com.dobby.usecase.experiment.GetExperimentPostCountsByRegionUseCase @@ -43,6 +44,7 @@ class ExperimentPostService( private val getMyExperimentPostsUseCase: GetMyExperimentPostsUseCase, private val getMyExperimentPostTotalCountUseCase: GetMyExperimentPostTotalCountUseCase, private val extractExperimentPostKeywordsUseCase: ExtractExperimentPostKeywordsUseCase, + private val getDailyLimitForExtractUseCase: GetDailyLimitForExtractUseCase, private val cacheGateway: CacheGateway ) { @Transactional @@ -154,6 +156,11 @@ class ExperimentPostService( return getMyExperimentPostTotalCountUseCase.execute(GetMyExperimentPostTotalCountUseCase.Input(input.memberId)) } + @Transactional + fun getMyDailyUsageLimit(input: GetDailyLimitForExtractUseCase.Input): GetDailyLimitForExtractUseCase.Output { + return getDailyLimitForExtractUseCase.execute(input) + } + private fun validateSortOrder(sortOrder: String): String { return when (sortOrder) { "ASC", "DESC" -> sortOrder diff --git a/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt b/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt index 307153cc..733acdf0 100644 --- a/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt +++ b/application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt @@ -33,7 +33,7 @@ class ExtractExperimentPostKeywordsUseCase( override fun execute(input: Input): Output { val member = memberGateway.getById(input.memberId) - // validateDailyUsageLimit(input.memberId) + validateDailyUsageLimit(input.memberId) val experimentPostKeyword = openAiGateway.extractKeywords(input.text) val log = ExperimentPostKeywordsLog.newExperimentPostKeywordsLog( diff --git a/application/src/main/kotlin/com/dobby/usecase/experiment/GetDailyLimitForExtractUseCase.kt b/application/src/main/kotlin/com/dobby/usecase/experiment/GetDailyLimitForExtractUseCase.kt new file mode 100644 index 00000000..fc45e8c4 --- /dev/null +++ b/application/src/main/kotlin/com/dobby/usecase/experiment/GetDailyLimitForExtractUseCase.kt @@ -0,0 +1,39 @@ +package com.dobby.usecase.experiment + +import com.dobby.gateway.UsageLimitGateway +import com.dobby.usecase.UseCase +import java.time.LocalDateTime + +class GetDailyLimitForExtractUseCase( + private val usageLimitGateway: UsageLimitGateway +) : UseCase { + + companion object { + private const val DAILY_USAGE_LIMIT = 2 + } + + data class Input( + val memberId: String + ) + + data class Output( + val count: Long, + val limit: Int, + val remainingCount: Long, + val resetsAt: LocalDateTime + ) + + override fun execute(input: Input): Output { + val snapshot = usageLimitGateway.getCurrentUsage( + memberId = input.memberId, + dailyLimit = DAILY_USAGE_LIMIT + ) + + return Output( + count = snapshot.count, + limit = snapshot.limit, + remainingCount = snapshot.remainingCount, + resetsAt = snapshot.resetsAt + ) + } +} 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/domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt b/domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt index f4bb2d88..d370d5df 100644 --- a/domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt +++ b/domain/src/main/kotlin/com/dobby/gateway/UsageLimitGateway.kt @@ -1,5 +1,8 @@ package com.dobby.gateway +import com.dobby.model.UsageSnapshot + interface UsageLimitGateway { fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean + fun getCurrentUsage(memberId: String, dailyLimit: Int): UsageSnapshot } diff --git a/domain/src/main/kotlin/com/dobby/model/UsageSnapshot.kt b/domain/src/main/kotlin/com/dobby/model/UsageSnapshot.kt new file mode 100644 index 00000000..622c8376 --- /dev/null +++ b/domain/src/main/kotlin/com/dobby/model/UsageSnapshot.kt @@ -0,0 +1,10 @@ +package com.dobby.model + +import java.time.LocalDateTime + +data class UsageSnapshot( + val count: Long, + val limit: Int, + val remainingCount: Long, + val resetsAt: LocalDateTime +) 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/cache/RedisUsageLimitGatewayImpl.kt b/infrastructure/src/main/kotlin/com/dobby/external/gateway/cache/RedisUsageLimitGatewayImpl.kt index 50b66a30..0d527764 100644 --- a/infrastructure/src/main/kotlin/com/dobby/external/gateway/cache/RedisUsageLimitGatewayImpl.kt +++ b/infrastructure/src/main/kotlin/com/dobby/external/gateway/cache/RedisUsageLimitGatewayImpl.kt @@ -1,6 +1,7 @@ package com.dobby.external.gateway.cache import com.dobby.gateway.UsageLimitGateway +import com.dobby.model.UsageSnapshot import org.slf4j.LoggerFactory import org.springframework.core.env.Environment import org.springframework.data.redis.core.RedisTemplate @@ -8,6 +9,8 @@ import org.springframework.stereotype.Component import java.time.Duration import java.time.LocalDate import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime import java.util.concurrent.TimeUnit @Component @@ -17,6 +20,7 @@ class RedisUsageLimitGatewayImpl( ) : UsageLimitGateway { private val logger = LoggerFactory.getLogger(this::class.java) + private val zone = ZoneId.of("Asia/Seoul") override fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean { val key = getCacheKey(memberId) @@ -32,8 +36,34 @@ class RedisUsageLimitGatewayImpl( return count <= dailyLimit } + override fun getCurrentUsage(memberId: String, dailyLimit: Int): UsageSnapshot { + val key = getCacheKey(memberId) + val count = (redisTemplate.opsForValue().get(key)?.toLong()) ?: 0L + + val ttlSeconds = redisTemplate.getExpire(key, TimeUnit.SECONDS) + val resetsAt: LocalDateTime = when { + ttlSeconds != null && ttlSeconds > 0L -> + ZonedDateTime.now(zone).plusSeconds(ttlSeconds).toLocalDateTime() + else -> + nextMidnightKST().toLocalDateTime() + } + + val remainingCount = maxOf(0L, dailyLimit.toLong() - count) + return UsageSnapshot( + count = count, + limit = dailyLimit, + remainingCount = remainingCount, + resetsAt = resetsAt + ) + } + private fun getCacheKey(memberId: String): String { val activeProfile = environment.activeProfiles.firstOrNull() ?: "local" return "$activeProfile:usage:$memberId:${LocalDate.now()}" } + + private fun nextMidnightKST(): ZonedDateTime { + val today = ZonedDateTime.now(zone).toLocalDate() + return today.plusDays(1).atStartOfDay(zone) + } } 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: diff --git a/infrastructure/src/main/resources/templates/matching-template.html b/infrastructure/src/main/resources/templates/matching-template.html index 8170ed9d..f031bd06 100644 --- a/infrastructure/src/main/resources/templates/matching-template.html +++ b/infrastructure/src/main/resources/templates/matching-template.html @@ -73,7 +73,7 @@

- diff --git a/presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt b/presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt index b3cc573f..c5ca3527 100644 --- a/presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt +++ b/presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt @@ -7,6 +7,7 @@ import com.dobby.api.dto.request.experiment.UpdateExperimentPostRequest import com.dobby.api.dto.response.PaginatedResponse import com.dobby.api.dto.response.PreSignedUrlResponse import com.dobby.api.dto.response.experiment.CreateExperimentPostResponse +import com.dobby.api.dto.response.experiment.DailyUsageSnapshotResponse import com.dobby.api.dto.response.experiment.ExperimentPostApplyMethodResponse import com.dobby.api.dto.response.experiment.ExperimentPostCountsResponse import com.dobby.api.dto.response.experiment.ExperimentPostDetailResponse @@ -233,4 +234,16 @@ class ExperimentPostController( val output = experimentPostService.extractExperimentPostKeywords(input) return ExperimentPostMapper.toExtractKeywordResponse(output) } + + @PreAuthorize("hasRole('RESEARCHER')") + @GetMapping("/usage-limit") + @Operation( + summary = "μ‹€ν—˜ 곡고 ν‚€μ›Œλ“œ μΆ”μΆœ 일일 μ‚¬μš©λŸ‰ 쑰회 API", + description = "μ‹€ν—˜ 곡고 ν‚€μ›Œλ“œ μΆ”μΆœμ˜ 남은 일일 μ‚¬μš©λŸ‰ 및 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€." + ) + fun getDailyUsageForExtract(): DailyUsageSnapshotResponse { + val input = ExperimentPostMapper.toDailyUsageSnapshotInput() + val output = experimentPostService.getMyDailyUsageLimit(input) + return ExperimentPostMapper.toDailyUsageSnapshotResponse(output) + } } diff --git a/presentation/src/main/kotlin/com/dobby/api/dto/response/experiment/DailyUsageSnapshotResponse.kt b/presentation/src/main/kotlin/com/dobby/api/dto/response/experiment/DailyUsageSnapshotResponse.kt new file mode 100644 index 00000000..7e27c9a5 --- /dev/null +++ b/presentation/src/main/kotlin/com/dobby/api/dto/response/experiment/DailyUsageSnapshotResponse.kt @@ -0,0 +1,20 @@ +package com.dobby.api.dto.response.experiment + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@Schema(description = "AI 곡고 등둝 일일 μ‚¬μš© 횟수 κ΄€λ ¨ 응닡") +data class DailyUsageSnapshotResponse( + + @Schema(description = "ν˜„μž¬ μ‚¬μš©ν•œ 횟수", example = "1") + val count: Long, + + @Schema(description = "ν˜„μž¬ μ‚¬μš©ν•œ 횟수", example = "3") + val limit: Int, + + @Schema(description = "ν˜„μž¬ 남은 μ‚¬μš© 횟수", example = "2") + val remainingCount: Long, + + @Schema(description = "μ΄ˆκΈ°ν™” μ‹œκ°", example = "2025-11-01T05:59:28.565Z") + val resetsAt: LocalDateTime +) diff --git a/presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt b/presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt index 8121a33d..de734092 100644 --- a/presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt +++ b/presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt @@ -10,6 +10,7 @@ import com.dobby.api.dto.request.experiment.UpdateExperimentPostRequest import com.dobby.api.dto.response.PaginatedResponse import com.dobby.api.dto.response.PreSignedUrlResponse import com.dobby.api.dto.response.experiment.CreateExperimentPostResponse +import com.dobby.api.dto.response.experiment.DailyUsageSnapshotResponse import com.dobby.api.dto.response.experiment.DataCount import com.dobby.api.dto.response.experiment.DurationInfo import com.dobby.api.dto.response.experiment.ExperimentPostApplyMethodResponse @@ -29,6 +30,7 @@ import com.dobby.usecase.experiment.CreateExperimentPostUseCase import com.dobby.usecase.experiment.DeleteExperimentPostUseCase import com.dobby.usecase.experiment.ExtractExperimentPostKeywordsUseCase import com.dobby.usecase.experiment.GenerateExperimentPostPreSignedUrlUseCase +import com.dobby.usecase.experiment.GetDailyLimitForExtractUseCase import com.dobby.usecase.experiment.GetExperimentPostApplyMethodUseCase import com.dobby.usecase.experiment.GetExperimentPostCountsByAreaUseCase import com.dobby.usecase.experiment.GetExperimentPostCountsByRegionUseCase @@ -540,4 +542,19 @@ object ExperimentPostMapper { experimentPostKeywords = output.experimentPostKeywords ) } + + fun toDailyUsageSnapshotInput(): GetDailyLimitForExtractUseCase.Input { + return GetDailyLimitForExtractUseCase.Input( + memberId = getCurrentMemberId() + ) + } + + fun toDailyUsageSnapshotResponse(output: GetDailyLimitForExtractUseCase.Output): DailyUsageSnapshotResponse { + return DailyUsageSnapshotResponse( + count = output.count, + limit = output.limit, + remainingCount = output.remainingCount, + resetsAt = output.resetsAt + ) + } }