Skip to content

Commit 385be48

Browse files
committed
[YS-570] feature: AI 공고 기능 RateLimiter 카운팅 API 작업 (#170)
* feature: add domain layer to view limit logics for daily snapshot * feature: add application layer usecase to execute logic * feature: add infra layer to implement gateway logic * feature: add presentation layer logic to controller
1 parent 39825b9 commit 385be48

9 files changed

Lines changed: 140 additions & 1 deletion

File tree

application/src/main/kotlin/com/dobby/service/ExperimentPostService.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.dobby.usecase.experiment.CreateExperimentPostUseCase
1010
import com.dobby.usecase.experiment.DeleteExperimentPostUseCase
1111
import com.dobby.usecase.experiment.ExtractExperimentPostKeywordsUseCase
1212
import com.dobby.usecase.experiment.GenerateExperimentPostPreSignedUrlUseCase
13+
import com.dobby.usecase.experiment.GetDailyLimitForExtractUseCase
1314
import com.dobby.usecase.experiment.GetExperimentPostApplyMethodUseCase
1415
import com.dobby.usecase.experiment.GetExperimentPostCountsByAreaUseCase
1516
import com.dobby.usecase.experiment.GetExperimentPostCountsByRegionUseCase
@@ -43,6 +44,7 @@ class ExperimentPostService(
4344
private val getMyExperimentPostsUseCase: GetMyExperimentPostsUseCase,
4445
private val getMyExperimentPostTotalCountUseCase: GetMyExperimentPostTotalCountUseCase,
4546
private val extractExperimentPostKeywordsUseCase: ExtractExperimentPostKeywordsUseCase,
47+
private val getDailyLimitForExtractUseCase: GetDailyLimitForExtractUseCase,
4648
private val cacheGateway: CacheGateway
4749
) {
4850
@Transactional
@@ -154,6 +156,11 @@ class ExperimentPostService(
154156
return getMyExperimentPostTotalCountUseCase.execute(GetMyExperimentPostTotalCountUseCase.Input(input.memberId))
155157
}
156158

159+
@Transactional
160+
fun getMyDailyUsageLimit(input: GetDailyLimitForExtractUseCase.Input): GetDailyLimitForExtractUseCase.Output {
161+
return getDailyLimitForExtractUseCase.execute(input)
162+
}
163+
157164
private fun validateSortOrder(sortOrder: String): String {
158165
return when (sortOrder) {
159166
"ASC", "DESC" -> sortOrder

application/src/main/kotlin/com/dobby/usecase/experiment/ExtractExperimentPostKeywordsUseCase.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class ExtractExperimentPostKeywordsUseCase(
3333

3434
override fun execute(input: Input): Output {
3535
val member = memberGateway.getById(input.memberId)
36-
// validateDailyUsageLimit(input.memberId)
36+
validateDailyUsageLimit(input.memberId)
3737

3838
val experimentPostKeyword = openAiGateway.extractKeywords(input.text)
3939
val log = ExperimentPostKeywordsLog.newExperimentPostKeywordsLog(
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.dobby.usecase.experiment
2+
3+
import com.dobby.gateway.UsageLimitGateway
4+
import com.dobby.usecase.UseCase
5+
import java.time.LocalDateTime
6+
7+
class GetDailyLimitForExtractUseCase(
8+
private val usageLimitGateway: UsageLimitGateway
9+
) : UseCase<GetDailyLimitForExtractUseCase.Input, GetDailyLimitForExtractUseCase.Output> {
10+
11+
companion object {
12+
private const val DAILY_USAGE_LIMIT = 2
13+
}
14+
15+
data class Input(
16+
val memberId: String
17+
)
18+
19+
data class Output(
20+
val count: Long,
21+
val limit: Int,
22+
val remainingCount: Long,
23+
val resetsAt: LocalDateTime
24+
)
25+
26+
override fun execute(input: Input): Output {
27+
val snapshot = usageLimitGateway.getCurrentUsage(
28+
memberId = input.memberId,
29+
dailyLimit = DAILY_USAGE_LIMIT
30+
)
31+
32+
return Output(
33+
count = snapshot.count,
34+
limit = snapshot.limit,
35+
remainingCount = snapshot.remainingCount,
36+
resetsAt = snapshot.resetsAt
37+
)
38+
}
39+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.dobby.gateway
22

3+
import com.dobby.model.UsageSnapshot
4+
35
interface UsageLimitGateway {
46
fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean
7+
fun getCurrentUsage(memberId: String, dailyLimit: Int): UsageSnapshot
58
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.dobby.model
2+
3+
import java.time.LocalDateTime
4+
5+
data class UsageSnapshot(
6+
val count: Long,
7+
val limit: Int,
8+
val remainingCount: Long,
9+
val resetsAt: LocalDateTime
10+
)

infrastructure/src/main/kotlin/com/dobby/external/gateway/cache/RedisUsageLimitGatewayImpl.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package com.dobby.external.gateway.cache
22

33
import com.dobby.gateway.UsageLimitGateway
4+
import com.dobby.model.UsageSnapshot
45
import org.slf4j.LoggerFactory
56
import org.springframework.core.env.Environment
67
import org.springframework.data.redis.core.RedisTemplate
78
import org.springframework.stereotype.Component
89
import java.time.Duration
910
import java.time.LocalDate
1011
import java.time.LocalDateTime
12+
import java.time.ZoneId
13+
import java.time.ZonedDateTime
1114
import java.util.concurrent.TimeUnit
1215

1316
@Component
@@ -17,6 +20,7 @@ class RedisUsageLimitGatewayImpl(
1720
) : UsageLimitGateway {
1821

1922
private val logger = LoggerFactory.getLogger(this::class.java)
23+
private val zone = ZoneId.of("Asia/Seoul")
2024

2125
override fun incrementAndCheckLimit(memberId: String, dailyLimit: Int): Boolean {
2226
val key = getCacheKey(memberId)
@@ -32,8 +36,34 @@ class RedisUsageLimitGatewayImpl(
3236
return count <= dailyLimit
3337
}
3438

39+
override fun getCurrentUsage(memberId: String, dailyLimit: Int): UsageSnapshot {
40+
val key = getCacheKey(memberId)
41+
val count = (redisTemplate.opsForValue().get(key)?.toLong()) ?: 0L
42+
43+
val ttlSeconds = redisTemplate.getExpire(key, TimeUnit.SECONDS)
44+
val resetsAt: LocalDateTime = when {
45+
ttlSeconds != null && ttlSeconds > 0L ->
46+
ZonedDateTime.now(zone).plusSeconds(ttlSeconds).toLocalDateTime()
47+
else ->
48+
nextMidnightKST().toLocalDateTime()
49+
}
50+
51+
val remainingCount = maxOf(0L, dailyLimit.toLong() - count)
52+
return UsageSnapshot(
53+
count = count,
54+
limit = dailyLimit,
55+
remainingCount = remainingCount,
56+
resetsAt = resetsAt
57+
)
58+
}
59+
3560
private fun getCacheKey(memberId: String): String {
3661
val activeProfile = environment.activeProfiles.firstOrNull() ?: "local"
3762
return "$activeProfile:usage:$memberId:${LocalDate.now()}"
3863
}
64+
65+
private fun nextMidnightKST(): ZonedDateTime {
66+
val today = ZonedDateTime.now(zone).toLocalDate()
67+
return today.plusDays(1).atStartOfDay(zone)
68+
}
3969
}

presentation/src/main/kotlin/com/dobby/api/controller/ExperimentPostController.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.dobby.api.dto.request.experiment.UpdateExperimentPostRequest
77
import com.dobby.api.dto.response.PaginatedResponse
88
import com.dobby.api.dto.response.PreSignedUrlResponse
99
import com.dobby.api.dto.response.experiment.CreateExperimentPostResponse
10+
import com.dobby.api.dto.response.experiment.DailyUsageSnapshotResponse
1011
import com.dobby.api.dto.response.experiment.ExperimentPostApplyMethodResponse
1112
import com.dobby.api.dto.response.experiment.ExperimentPostCountsResponse
1213
import com.dobby.api.dto.response.experiment.ExperimentPostDetailResponse
@@ -233,4 +234,16 @@ class ExperimentPostController(
233234
val output = experimentPostService.extractExperimentPostKeywords(input)
234235
return ExperimentPostMapper.toExtractKeywordResponse(output)
235236
}
237+
238+
@PreAuthorize("hasRole('RESEARCHER')")
239+
@GetMapping("/usage-limit")
240+
@Operation(
241+
summary = "실험 공고 키워드 추출 일일 사용량 조회 API",
242+
description = "실험 공고 키워드 추출의 남은 일일 사용량 및 정보를 조회합니다."
243+
)
244+
fun getDailyUsageForExtract(): DailyUsageSnapshotResponse {
245+
val input = ExperimentPostMapper.toDailyUsageSnapshotInput()
246+
val output = experimentPostService.getMyDailyUsageLimit(input)
247+
return ExperimentPostMapper.toDailyUsageSnapshotResponse(output)
248+
}
236249
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.dobby.api.dto.response.experiment
2+
3+
import io.swagger.v3.oas.annotations.media.Schema
4+
import java.time.LocalDateTime
5+
6+
@Schema(description = "AI 공고 등록 일일 사용 횟수 관련 응답")
7+
data class DailyUsageSnapshotResponse(
8+
9+
@Schema(description = "현재 사용한 횟수", example = "1")
10+
val count: Long,
11+
12+
@Schema(description = "현재 사용한 횟수", example = "3")
13+
val limit: Int,
14+
15+
@Schema(description = "현재 남은 사용 횟수", example = "2")
16+
val remainingCount: Long,
17+
18+
@Schema(description = "초기화 시각", example = "2025-11-01T05:59:28.565Z")
19+
val resetsAt: LocalDateTime
20+
)

presentation/src/main/kotlin/com/dobby/api/mapper/ExperimentPostMapper.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.dobby.api.dto.request.experiment.UpdateExperimentPostRequest
1010
import com.dobby.api.dto.response.PaginatedResponse
1111
import com.dobby.api.dto.response.PreSignedUrlResponse
1212
import com.dobby.api.dto.response.experiment.CreateExperimentPostResponse
13+
import com.dobby.api.dto.response.experiment.DailyUsageSnapshotResponse
1314
import com.dobby.api.dto.response.experiment.DataCount
1415
import com.dobby.api.dto.response.experiment.DurationInfo
1516
import com.dobby.api.dto.response.experiment.ExperimentPostApplyMethodResponse
@@ -29,6 +30,7 @@ import com.dobby.usecase.experiment.CreateExperimentPostUseCase
2930
import com.dobby.usecase.experiment.DeleteExperimentPostUseCase
3031
import com.dobby.usecase.experiment.ExtractExperimentPostKeywordsUseCase
3132
import com.dobby.usecase.experiment.GenerateExperimentPostPreSignedUrlUseCase
33+
import com.dobby.usecase.experiment.GetDailyLimitForExtractUseCase
3234
import com.dobby.usecase.experiment.GetExperimentPostApplyMethodUseCase
3335
import com.dobby.usecase.experiment.GetExperimentPostCountsByAreaUseCase
3436
import com.dobby.usecase.experiment.GetExperimentPostCountsByRegionUseCase
@@ -540,4 +542,19 @@ object ExperimentPostMapper {
540542
experimentPostKeywords = output.experimentPostKeywords
541543
)
542544
}
545+
546+
fun toDailyUsageSnapshotInput(): GetDailyLimitForExtractUseCase.Input {
547+
return GetDailyLimitForExtractUseCase.Input(
548+
memberId = getCurrentMemberId()
549+
)
550+
}
551+
552+
fun toDailyUsageSnapshotResponse(output: GetDailyLimitForExtractUseCase.Output): DailyUsageSnapshotResponse {
553+
return DailyUsageSnapshotResponse(
554+
count = output.count,
555+
limit = output.limit,
556+
remainingCount = output.remainingCount,
557+
resetsAt = output.resetsAt
558+
)
559+
}
543560
}

0 commit comments

Comments
 (0)