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/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/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/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 + ) + } }