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 @@ -14,6 +14,10 @@ enum class AdminAuditAction {
// 모델 교체는 추출 품질·비용에 직결되므로 행위자가 남아야 한다.
EXTRACTION_MODEL_UPDATE,

// 아이템 등록 한도(#934) — 누가 어느 노브를 무슨 값으로 바꿨는지. 한도는 비용·사용자 차단에 직결되고
// 배포 없이 바뀌므로, 값이 왜 이렇게 되어 있는지를 되짚을 유일한 기록이 이 로그다.
ITEM_QUOTA_UPDATE,

// 공지 행위자 추적(#558) — 등록·예약·예약취소·발송을 각각 다른 코드로 남겨 audit 에서 action 별로 가른다.
// (이전엔 예약·취소·발송이 ANNOUNCEMENT_SEND 한 코드로 뭉쳐 detail 문자열로만 구분됐다.)
ANNOUNCEMENT_REGISTER,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.depromeet.piki.admin.quota

import com.depromeet.piki.admin.access.AdminSession
import com.depromeet.piki.admin.config.ClientIp
import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled
import io.swagger.v3.oas.annotations.Hidden
import jakarta.servlet.http.HttpServletRequest
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam

// 아이템 등록 한도 화면(#934). 지금 얼마나 쓰고 있는지 보고, 한도를 배포 없이 조절한다 —
// AdminExtractionModelController 와 같은 토대(게이트는 슬랙-세션 #526, actor 는 AdminSession.actorName).
//
// 목록 하나로 끝난다(상세 화면 없음). 설정이 한 벌뿐이라 상세로 들어갈 것이 없고, 계정 사용량 조회도
// 같은 화면의 폼 하나로 처리해 "현황을 보다가 바로 조인다" 는 흐름이 끊기지 않게 한다.
@Hidden
@Controller
@ConditionalOnAdminEnabled
@RequestMapping("/admin/item-quota")
class AdminItemQuotaController(
private val adminItemQuotaService: AdminItemQuotaService,
) {
@GetMapping
fun board(view: Model): String = boardView(view)

// 계정 사용량 조회 — 조회일 뿐이라 GET 이고, 결과를 같은 화면에 얹는다.
@GetMapping("/usage")
fun usage(
@RequestParam("userId") rawUserId: String,
view: Model,
): String =
try {
view.addAttribute("userUsage", adminItemQuotaService.usageOf(rawUserId))
view.addAttribute("draftUserId", rawUserId)
boardView(view)
} catch (e: IllegalArgumentException) {
view.addAttribute("draftUserId", rawUserId)
errorView(e, view)
}

// 빈 칸은 "그 노브를 기본값으로" 다. 네 칸을 한 번에 받으므로 제출된 화면이 곧 최종 상태다.
@PostMapping
fun save(
@RequestParam("enabled", required = false) enabled: Boolean?,
@RequestParam("userLimit", required = false) userLimit: Int?,
@RequestParam("capacityLimit", required = false) capacityLimit: Int?,
@RequestParam("capacityAlertPercent", required = false) capacityAlertPercent: Int?,
request: HttpServletRequest,
view: Model,
): String =
try {
adminItemQuotaService.save(
ItemQuotaSettingsForm(enabled, userLimit, capacityLimit, capacityAlertPercent),
actor = AdminSession.actorName(request),
clientIp = ClientIp.of(request),
)
"redirect:/admin/item-quota?updated"
} catch (e: IllegalArgumentException) {
// 제출값을 유지한 채 목록에 에러를 표시한다(AdminExtractionModelController.save 와 같은 결).
view.addAttribute("draftEnabled", enabled)
view.addAttribute("draftUserLimit", userLimit)
view.addAttribute("draftCapacityLimit", capacityLimit)
view.addAttribute("draftCapacityAlertPercent", capacityAlertPercent)
errorView(e, view)
}

@PostMapping("/reset")
fun reset(
request: HttpServletRequest,
view: Model,
): String =
try {
adminItemQuotaService.reset(
actor = AdminSession.actorName(request),
clientIp = ClientIp.of(request),
)
"redirect:/admin/item-quota?reset"
} catch (e: IllegalArgumentException) {
errorView(e, view)
}

// 목록 모델 채우기 단일 지점 — 정상 진입과 모든 에러 재표시가 공유한다(한쪽만 갱신돼 에러 화면에서
// 현황이 비는 함정 방지).
private fun boardView(view: Model): String {
view.addAttribute("board", adminItemQuotaService.board())
return "admin/item-quota"
}

private fun errorView(
e: IllegalArgumentException,
view: Model,
): String {
view.addAttribute("error", e.message)
return boardView(view)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package com.depromeet.piki.admin.quota

import com.depromeet.piki.admin.audit.AdminAuditAction
import com.depromeet.piki.admin.audit.AdminAuditService
import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled
import com.depromeet.piki.common.ratelimit.DbItemQuotaSettings
import com.depromeet.piki.common.ratelimit.ItemQuotaProperties
import com.depromeet.piki.common.ratelimit.ItemQuotaSettingsEntity
import com.depromeet.piki.common.ratelimit.ItemQuotaSettingsJpaRepository
import com.depromeet.piki.common.ratelimit.ItemQuotaSnapshot
import com.depromeet.piki.common.ratelimit.ItemQuotaUsage
import com.depromeet.piki.common.ratelimit.ItemQuotaUsageReader
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime
import java.util.UUID

// 백오피스 아이템 등록 한도 관리(#934). 배포 없이 한도를 조이거나 푼다.
//
// **왜 필요한가**: 값이 env 에만 있으면 비용이 튀어 급히 조여야 할 때도, 한도가 낮아 정상 사용자가 막힐 때도
// 배포나 재시작을 기다려야 한다. 둘 다 분 단위가 아까운 상황이다.
@Service
@ConditionalOnAdminEnabled
class AdminItemQuotaService(
private val settings: DbItemQuotaSettings,
private val properties: ItemQuotaProperties,
private val usageReader: ItemQuotaUsageReader,
private val writer: ItemQuotaSettingsWriter,
) {
// 화면은 저장소를 직접 읽는다(캐시가 아니라) — 캐시는 afterCommit 갱신이라 방금 저장한 값이 아직 안 보인다.
@Transactional(readOnly = true)
fun board(): AdminItemQuotaView {
val override = settings.findOverride()
return AdminItemQuotaView(
effective = ItemQuotaSnapshot.of(properties, override),
defaults = ItemQuotaSnapshot.of(properties),
override = override,
capacityUsage = usageReader.capacity(),
)
}

@Transactional(readOnly = true)
fun usageOf(rawUserId: String): AdminItemQuotaUserUsage {
val userId =
try {
UUID.fromString(rawUserId.trim())
} catch (e: IllegalArgumentException) {
// 형식 오류는 화면에서 되돌려 줄 계약 위반이다. 예외 메시지에 입력값을 싣지 않는다.
throw IllegalArgumentException("userId 형식이 올바르지 않습니다 (UUID).", e)
}
return AdminItemQuotaUserUsage(userId = userId, usage = usageReader.user(userId))
}

// 빈 칸은 "그 노브를 기본값으로 되돌린다" 는 뜻이다(null 저장). 네 칸을 한 번에 저장하므로 화면이 곧 최종 상태다.
fun save(
form: ItemQuotaSettingsForm,
actor: String,
clientIp: String?,
) {
val previous = ItemQuotaSnapshot.of(properties, settings.findOverride())
// 엔티티 생성자의 require 가 불변식 층이지만, 그 메시지는 개발자용이라 화면에 그대로 내보내지 않는다.
// 사용자 대면 문구는 이 경계가 소유한다(CLAUDE.md "검증은 입력 경계와 엔티티 양쪽에").
form.userLimit?.let { require(it > 0) { "계정 한도는 1 이상이어야 합니다 (0 이면 등록이 통째로 막힙니다)." } }
form.capacityLimit?.let { require(it > 0) { "전역 상한은 1 이상이어야 합니다 (0 이면 모든 사용자가 막힙니다)." } }
form.capacityAlertPercent?.let { require(it in 1..100) { "경고선은 1 에서 100 사이여야 합니다." } }
val entity =
ItemQuotaSettingsEntity(
enabled = form.enabled,
userLimit = form.userLimit,
capacityLimit = form.capacityLimit,
capacityAlertPercent = form.capacityAlertPercent,
)
writer.write(entity, previous, actor, clientIp)
}

fun reset(
actor: String,
clientIp: String?,
) = writer.reset(ItemQuotaSnapshot.of(properties, settings.findOverride()), actor, clientIp)
}

// 영속화 전용 빈. 같은 클래스 안에서 @Transactional 메서드를 부르면 Spring AOP proxy 를 거치지 않아 트랜잭션이
// 무력화되므로(self-invocation), 경계를 나누려면 빈 자체가 갈려야 한다(ExtractionModelWriter 와 같은 이유).
@Service
@ConditionalOnAdminEnabled
class ItemQuotaSettingsWriter(
private val repository: ItemQuotaSettingsJpaRepository,
private val settings: DbItemQuotaSettings,
private val properties: ItemQuotaProperties,
private val auditService: AdminAuditService,
) {
// upsert — PK 가 상수라 save 가 늘 같은 행을 덮어쓴다. "지우고 새로 넣기" 로 수정하면 그 사이 전부
// 기본값으로 돌아가는 창이 생긴다(ExtractionModelWriter.write 와 같은 이유).
@Transactional
fun write(
entity: ItemQuotaSettingsEntity,
previous: ItemQuotaSnapshot,
actor: String,
clientIp: String?,
) {
repository.save(entity)
record(previous, ItemQuotaSnapshot.of(properties, entity), actor, clientIp)
reloadAfterCommit()
}

// 전체 초기화 — 행을 지워 네 노브를 한 번에 env 기본값으로 되돌린다. 급히 조인 값을 원복하는 자리다.
@Transactional
fun reset(
previous: ItemQuotaSnapshot,
actor: String,
clientIp: String?,
) {
repository.deleteById(ItemQuotaSettingsEntity.SINGLE_ROW_ID)
record(previous, ItemQuotaSnapshot.of(properties), actor, clientIp)
reloadAfterCommit()
}

// 바뀐 노브만 남긴다 — 매번 네 값을 다 적으면 로그에서 "이번에 무엇이 달라졌나" 를 사람이 다시 비교해야 한다.
private fun record(
before: ItemQuotaSnapshot,
after: ItemQuotaSnapshot,
actor: String,
clientIp: String?,
) {
val changes =
listOfNotNull(
diff("사용", before.enabled, after.enabled),
diff("계정 한도", before.userLimit, after.userLimit),
diff("전역 상한", before.capacityLimit, after.capacityLimit),
diff("경고선(%)", before.capacityAlertPercent, after.capacityAlertPercent),
)
// 값이 그대로여도 기록은 남긴다 — "누가 이 화면에서 저장을 눌렀나" 자체가 추적 대상이고,
// 변경 없음이 곧 "확인만 했다" 는 정보다.
auditService.record(
actor,
AdminAuditAction.ITEM_QUOTA_UPDATE,
changes.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "변경 없음",
clientIp,
)
}

private fun <T> diff(
label: String,
before: T,
after: T,
): String? = if (before == after) null else "$label: $before → $after"

// 캐시 갱신은 커밋 후로 미룬다 — 커밋 전 reload 면 이후 단계 롤백 시 캐시만 새 값으로 남아 DB 와 어긋난다.
private fun reloadAfterCommit() {
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() = settings.reload()
},
)
}
}

// 화면 입력 한 벌. null 은 "그 노브는 기본값을 쓴다" 는 뜻이라 빈 칸이 그대로 전달된다.
data class ItemQuotaSettingsForm(
val enabled: Boolean?,
val userLimit: Int?,
val capacityLimit: Int?,
val capacityAlertPercent: Int?,
)

// effective 는 지금 판정에 쓰이는 값, defaults 는 오버라이드를 걷어냈을 때의 env 값이다. 둘을 나란히 보여야
// "이 값이 왜 이런가"(기본인가, 누가 바꾼 것인가)를 화면에서 바로 안다. override 가 null 이면 전부 기본값이다.
data class AdminItemQuotaView(
val effective: ItemQuotaSnapshot,
val defaults: ItemQuotaSnapshot,
val override: ItemQuotaSettingsEntity?,
val capacityUsage: ItemQuotaUsage,
) {
val overriddenAt: LocalDateTime? get() = override?.updatedAt
}

data class AdminItemQuotaUserUsage(
val userId: UUID,
val usage: ItemQuotaUsage,
)
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import java.util.UUID
@Component
class ItemQuotaGuard(
private val store: RedisItemQuotaStore,
private val properties: ItemQuotaProperties,
private val settings: ItemQuotaSettings,
) {
private val log = LoggerFactory.getLogger(javaClass)

Expand All @@ -30,17 +30,19 @@ class ItemQuotaGuard(
amount: Int,
errorCode: ErrorCode,
) {
if (!properties.enabled) return
// 값 한 벌을 한 번만 읽는다 — 판정 도중 백오피스 저장이 끼어들어도 이 요청은 끝까지 같은 값으로 판단한다.
val quota = settings.current()
if (!quota.enabled) return

val verdict =
try {
store.tryConsume(
ownerKey = RedisItemQuotaStore.USER_KEY_PREFIX + ownerId,
capacityKey = RedisItemQuotaStore.CAPACITY_KEY,
amount = amount,
ownerLimit = properties.userLimit,
capacityLimit = properties.capacityLimit,
windowMillis = properties.window.toMillis(),
ownerLimit = quota.userLimit,
capacityLimit = quota.capacityLimit,
windowMillis = quota.window.toMillis(),
)
} catch (e: Exception) {
// fail-open — Redis 장애로 등록 기능 전체가 멈추는 것보다, 한도가 잠시 안 걸리는 쪽이 낫다.
Expand All @@ -55,7 +57,7 @@ class ItemQuotaGuard(
}

when (verdict) {
is ItemQuotaVerdict.Allowed -> warnIfCapacityAlertCrossed(verdict.capacityUsed, amount)
is ItemQuotaVerdict.Allowed -> warnIfCapacityAlertCrossed(quota, verdict.capacityUsed, amount)
// 429 는 클라이언트 계약 위반이라 GlobalExceptionHandler 가 info 로 남긴다 — 여기서 또 찍지 않는다.
is ItemQuotaVerdict.OwnerExceeded -> throw ItemQuotaException.exceeded(errorCode, verdict.retryAfterSeconds)
// 503 도 핸들러가 warn 으로 남긴다. 한 번 차면 창이 끝날 때까지 모든 요청이 여기로 오므로,
Expand All @@ -70,10 +72,11 @@ class ItemQuotaGuard(
// 대응은 "상한을 올린다" 가 기본이 아니다. 정상 성장인지, 특정 사용자·IP 의 이상 패턴인지, 파싱 실패로 인한
// 재시도 폭증인지를 먼저 가르고, 정상 성장으로 확인된 뒤에만 올린다.
private fun warnIfCapacityAlertCrossed(
quota: ItemQuotaSnapshot,
capacityUsed: Long,
amount: Int,
) {
if (!properties.crossedCapacityAlert(capacityUsed, amount)) return
if (!quota.crossedCapacityAlert(capacityUsed, amount)) return
// 알림이 매칭하는 줄이라 **사람이 읽는 문구가 아니라 기계가 읽는 형식**으로 쓴다(item.parse.result 와 같은 규약):
// 고정 이벤트 키 + logfmt(`키=값`). 사람이 읽을 설명은 알림 룰의 summary 가 한국어로 담는다.
//
Expand All @@ -86,9 +89,9 @@ class ItemQuotaGuard(
"{} used={} threshold={} limit={} windowSeconds={}",
CAPACITY_ALERT_EVENT,
capacityUsed,
properties.capacityAlertThreshold,
properties.capacityLimit,
properties.window.seconds,
quota.capacityAlertThreshold,
quota.capacityLimit,
quota.window.seconds,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package com.depromeet.piki.common.ratelimit
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration

// 아이템 등록 한도 설정. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다.
// 아이템 등록 한도의 **env 기본값**. @ConfigurationPropertiesScan(PikiApplication)으로 자동 등록된다.
//
// 판정에 쓰이는 실효값은 여기가 아니라 ItemQuotaSnapshot 이다(#934) — 백오피스 오버라이드가 이 값들 위에
// 얹히기 때문이다. 오버라이드가 없는 노브만 여기 값이 그대로 남는다. 소비자는 ItemQuotaSettings.current() 를 읽는다.
//
// 축이 둘이고 서로를 대체하지 않는다. **계정별**(#339)은 한 사람이 얼마나 쓸 수 있는지를, **전역**(#927)은
// 서비스 전체가 얼마나 감당하는지를 정한다. 전자는 남용을, 후자는 정상 사용자가 몰리는 상황을 막는다.
Expand Down Expand Up @@ -74,14 +77,4 @@ data class ItemQuotaProperties(
"item-quota.capacity-alert-percent($capacityAlertPercent)는 1 에서 100 사이여야 한다."
}
}

// 경고선(건수). 정수 나눗셈이라 내림되지만 경고 시점이 한 건 앞당겨질 뿐이라 무해하다.
val capacityAlertThreshold: Int get() = capacityLimit * capacityAlertPercent / 100

// 이번 차감이 경고선을 **처음** 넘겼는지. 넘긴 뒤 매 요청마다 경고하면 창이 끝날 때까지 같은 줄이 반복돼
// 알림이 무뎌지므로, "직전엔 아래였는데 지금은 위" 인 한 건만 참이 된다.
fun crossedCapacityAlert(
capacityUsed: Long,
amount: Int,
): Boolean = capacityUsed >= capacityAlertThreshold && capacityUsed - amount < capacityAlertThreshold
}
Loading
Loading