diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java new file mode 100644 index 0000000..dcc8c9f --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java @@ -0,0 +1,27 @@ +package io.springaileger.notification; + +import io.springaileger.budget.BudgetDecision; + +/** + * 예산 임계치 도달 시 사용자에게 알림을 전달하는 역할을 정의합니다. + * + * 실제 전송 방식(이메일, Slack, Webhook 등)은 + * 구현체에서 결정되며, budget/notification 로직과 분리됩니다. + */ +public interface BudgetEmailSender { + + /** + * 예산 사용률이 50%에 도달했을 때 전송되는 알림입니다. + */ + void sendHalfUsageWarning(String email, BudgetDecision decision); + + /** + * 예산 사용률이 80%에 도달했을 때 전송되는 경고 알림입니다. + */ + void sendEightyPercentWarning(String email, BudgetDecision decision); + + /** + * 예산이 초과되어 호출이 차단되었을 때 전송되는 알림입니다. + */ + void sendExceededNotification(String email, BudgetDecision decision); +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java new file mode 100644 index 0000000..cd0c625 --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java @@ -0,0 +1,65 @@ +package io.springaileger.notification; + +import io.springaileger.budget.BudgetDecision; +import io.springaileger.budget.BudgetThreshold; + +/** + * 예산 평가 결과를 기반으로 + * 사용자 알림을 발송할지 여부를 판단하는 서비스입니다. + * + * - 임계치(50%, 80%, 100%) 도달 시 알림 전송 + * - 동일 임계치에 대해서는 1회만 알림 발송 + * + * 예산 판단 로직(budget)과 알림 실행 로직을 분리하기 위한 계층입니다. + */ +public class BudgetNotificationService { + + private final BudgetEmailSender emailSender; + private final NotificationStateStore stateStore; + + public BudgetNotificationService( + BudgetEmailSender emailSender, + NotificationStateStore stateStore + ) { + this.emailSender = emailSender; + this.stateStore = stateStore; + } + + /** + * 예산 판단 결과를 바탕으로 필요 시 알림을 전송합니다. + * + * 이미 알림을 보낸 임계치 이하의 상태인 경우 + * 중복 알림을 방지하기 위해 아무 작업도 하지 않습니다. + */ + public void notifyIfNeeded( + String targetId, + BudgetDecision decision + ) { + BudgetThreshold current = decision.threshold(); + BudgetThreshold lastNotified = + stateStore.getLastNotifiedThreshold(targetId); + + // 이미 알림을 보낸 임계치라면 종료 + if (current.ordinal() <= lastNotified.ordinal()) { + return; + } + + switch (current) { + case HALF -> + emailSender.sendHalfUsageWarning(targetId, decision); + + case WARNING -> + emailSender.sendEightyPercentWarning(targetId, decision); + + case EXCEEDED -> + emailSender.sendExceededNotification(targetId, decision); + + default -> { + return; + } + } + + // 알림 발송 후 상태 갱신 + stateStore.updateLastNotifiedThreshold(targetId, current); + } +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java new file mode 100644 index 0000000..6367663 --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java @@ -0,0 +1,31 @@ +package io.springaileger.notification; + +import io.springaileger.budget.BudgetThreshold; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * NotificationStateStore의 인메모리 구현체입니다. + * + * 로컬 실행 및 테스트 환경에서 사용하기 위한 기본 구현이며, + * 프로덕션에서는 DB 또는 외부 저장소 구현체로 교체 가능합니다. + */ +public class InMemoryNotificationStateStore + implements NotificationStateStore { + + private final ConcurrentHashMap store = + new ConcurrentHashMap<>(); + + @Override + public BudgetThreshold getLastNotifiedThreshold(String targetId) { + return store.getOrDefault(targetId, BudgetThreshold.NONE); + } + + @Override + public void updateLastNotifiedThreshold( + String targetId, + BudgetThreshold threshold + ) { + store.put(targetId, threshold); + } +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java new file mode 100644 index 0000000..2704f4a --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java @@ -0,0 +1,22 @@ +package io.springaileger.notification; + +import io.springaileger.budget.BudgetThreshold; + +/** + * 예산 알림의 중복 발송을 방지하기 위해 + * 마지막으로 알림을 보낸 임계치를 저장하는 저장소 인터페이스입니다. + * + * 사용자/테넌트 식별자 기준으로 알림 상태를 관리합니다. + */ +public interface NotificationStateStore { + + /** + * 지정된 대상에 대해 마지막으로 알림을 보낸 임계치를 반환합니다. + */ + BudgetThreshold getLastNotifiedThreshold(String targetId); + + /** + * 지정된 대상의 마지막 알림 임계치를 갱신합니다. + */ + void updateLastNotifiedThreshold(String targetId, BudgetThreshold threshold); +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java b/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java new file mode 100644 index 0000000..45bc296 --- /dev/null +++ b/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java @@ -0,0 +1,41 @@ +package io.springaileger.notification; + +import io.springaileger.budget.*; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.mockito.Mockito.*; + +/** + * BudgetNotificationService가 + * 예산 임계치에 따라 알림을 한 번만 보내는지 검증하는 테스트 + */ +class BudgetNotificationServiceTest { + + @Test + void should_send_notification_only_once_per_threshold() { + // given + BudgetEmailSender emailSender = mock(BudgetEmailSender.class); + NotificationStateStore stateStore = new InMemoryNotificationStateStore(); + + BudgetNotificationService service = + new BudgetNotificationService(emailSender, stateStore); + + BudgetDecision decision = new BudgetDecision( + BudgetState.WARN, + BudgetThreshold.WARNING, + "80% 도달", + BigDecimal.valueOf(80), + BigDecimal.valueOf(100) + ); + + // when: 같은 decision을 두 번 전달 + service.notifyIfNeeded("user@test.com", decision); + service.notifyIfNeeded("user@test.com", decision); + + // then: 메일은 한 번만 전송 + verify(emailSender, times(1)) + .sendEightyPercentWarning("user@test.com", decision); + } +} \ No newline at end of file diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java index 9b35981..37a393a 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java @@ -1,18 +1,20 @@ -package io.tokenledger.budget; +package io.springaileger.budget; import java.math.BigDecimal; /** * 예산 평가 결과를 나타내는 값 객체입니다. - *

+ * * 호출 가능 여부와 * 판단에 필요한 최소한의 정보를 담습니다. */ +// 우리가 "의도한" 형태 public record BudgetDecision( BudgetState state, + BudgetThreshold threshold, String reason, BigDecimal currentUsage, BigDecimal limit -) {} +) {} \ No newline at end of file diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetThreshold.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetThreshold.java new file mode 100644 index 0000000..625ab7d --- /dev/null +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetThreshold.java @@ -0,0 +1,12 @@ +package io.springaileger.budget; + +/** + * 예산 사용률에 따른 임계치 단계입니다. + */ +public enum BudgetThreshold { + + NONE, // 임계치 미도달 + HALF, // 50% + WARNING, // 80% + EXCEEDED // 100% +} \ No newline at end of file diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java index c19e0e6..63f9295 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java @@ -1,7 +1,6 @@ -package io.tokenledger.budget.internal; +package io.springaileger.budget.internal; -import io.tokenledger.budget.*; -import io.tokenledger.budget.exception.BudgetExceededException; +import io.springaileger.budget.*; import java.math.BigDecimal; import java.util.Map; @@ -10,12 +9,9 @@ * BudgetEvaluator의 기본 구현체입니다. * * 판단 기준: - * - 80% 미만 → ALLOW - * - 80% 이상 → WARN - * - 100% 이상 → BLOCK (예외 발생) - * - * 이 클래스의 evaluate 메서드는 부수 효과가 없는 순수 함수로 동작합니다. - * 실제 비용 누적은 BudgetStateStore.addCost를 통해 별도로 수행해야 합니다. + * - 50% 이상 → ALLOW (HALF) + * - 80% 이상 → WARN (WARNING) + * - 100% 이상 → BLOCK (EXCEEDED, 예외 발생) */ public class DefaultBudgetEvaluator implements BudgetEvaluator { @@ -30,34 +26,30 @@ public DefaultBudgetEvaluator( this.monthlyLimit = monthlyLimit; } - @Override - public BudgetDecision evaluate(Map tags) { - return evaluate(tags, BigDecimal.ZERO); - } - @Override public BudgetDecision evaluate( Map tags, BigDecimal costAmount ) { - // ✅ 현재까지 누적 비용 + // 현재까지 누적 비용 BigDecimal accumulated = store.getAccumulatedCost(tags); - // ✅ 이번 호출까지 포함한 비용 (비교용) + // 이번 호출까지 포함한 비용 BigDecimal nextUsage = accumulated.add(costAmount); - // ✅ 경고 기준 (80%) - BigDecimal warnThreshold = - monthlyLimit.multiply(new BigDecimal("0.8")); + // 임계치 계산 + BigDecimal halfThreshold = monthlyLimit.multiply(BigDecimal.valueOf(0.5)); + BigDecimal warnThreshold = monthlyLimit.multiply(BigDecimal.valueOf(0.8)); - /* ===================== - 1️⃣ 차단 (BLOCK) - ===================== */ + /* ===================== + 1️⃣ 차단 (100%) + ===================== */ if (nextUsage.compareTo(monthlyLimit) >= 0) { BudgetDecision decision = new BudgetDecision( BudgetState.BLOCK, + BudgetThreshold.EXCEEDED, "월 예산 초과로 AI 호출이 차단되었습니다", nextUsage, monthlyLimit @@ -66,23 +58,41 @@ public BudgetDecision evaluate( throw new BudgetExceededException(decision); } - /* ===================== - 2️⃣ 경고 (WARN) - ===================== */ + // 비용 누적 (차단이 아닌 경우) + store.addCost(tags, costAmount); + + /* ===================== + 2️⃣ 경고 (80%) + ===================== */ if (nextUsage.compareTo(warnThreshold) >= 0) { return new BudgetDecision( BudgetState.WARN, + BudgetThreshold.WARNING, "월 예산의 80%에 도달했습니다", nextUsage, monthlyLimit ); } - /* ===================== - 3️⃣ 허용 (ALLOW) - ===================== */ + /* ===================== + 3️⃣ 절반 경고 (50%) + ===================== */ + if (nextUsage.compareTo(halfThreshold) >= 0) { + return new BudgetDecision( + BudgetState.ALLOW, + BudgetThreshold.HALF, + "월 예산의 50%에 도달했습니다", + nextUsage, + monthlyLimit + ); + } + + /* ===================== + 4️⃣ 정상 + ===================== */ return new BudgetDecision( BudgetState.ALLOW, + BudgetThreshold.NONE, "예산 범위 내입니다", nextUsage, monthlyLimit