diff --git a/src/main/java/com/tokenphage/api/domain/token/repository/DailyTokenUsageRepository.java b/src/main/java/com/tokenphage/api/domain/token/repository/DailyTokenUsageRepository.java index 893c1eb..9309858 100644 --- a/src/main/java/com/tokenphage/api/domain/token/repository/DailyTokenUsageRepository.java +++ b/src/main/java/com/tokenphage/api/domain/token/repository/DailyTokenUsageRepository.java @@ -53,13 +53,23 @@ void upsertRecord( @Query(value = "DELETE FROM daily_token_usage WHERE github_id = :githubId", nativeQuery = true) int deleteAllByGithubId(@Param("githubId") Long githubId); - @Query("SELECT SUM(d.inputTok + d.outputTok) FROM DailyTokenUsage d WHERE d.githubId = :githubId") + /** + * 사용자의 누적 사용량을 조회한다. + *
+ * 사용량 = input + cache_create + output. 캐시에서 재사용한 cache_read는 제외한다 (ADR-0005). + * + * @param githubId 대상 사용자 (null 불허) + * @return 누적 사용량 (기록이 없으면 null) + * @Since 2026-09-30 + */ + @Query("SELECT SUM(d.inputTok + d.cacheCreateTok + d.outputTok) FROM DailyTokenUsage d WHERE d.githubId = :githubId") Long sumTotalTokens(@Param("githubId") Long githubId); /** - * 사용자의 [from, to] 기간 일별 토큰 합계(input+output)를 날짜 오름차순으로 조회한다. + * 사용자의 [from, to] 기간 일별 사용량을 날짜 오름차순으로 조회한다. *
* 창 길이는 호출자가 정한다(예: 30일 히트바, 365일 잔디). 사용 이력이 있는 날짜만 반환한다.
+ * 사용량 정의는 {@link #sumTotalTokens}와 같다.
*
* @param githubId 대상 사용자 (null 불허)
* @param from 조회 시작일(포함, null 불허)
@@ -68,7 +78,7 @@ void upsertRecord(
* @Since 2026-07-15
*/
@Query(value = """
- SELECT usage_date::text AS date, SUM(input_tok + output_tok) AS total
+ SELECT usage_date::text AS date, SUM(input_tok + cache_create_tok + output_tok) AS total
FROM daily_token_usage
WHERE github_id = :githubId AND usage_date BETWEEN :from AND :to
GROUP BY usage_date ORDER BY usage_date
@@ -77,8 +87,17 @@ List
+ * 사용량 정의는 {@link #sumTotalTokens}와 같다.
+ *
+ * @param githubId 대상 사용자 (null 불허)
+ * @return 모델명·사용량 프로젝션 목록 (최대 5개)
+ * @Since 2026-09-30
+ */
@Query(value = """
- SELECT model, SUM(input_tok + output_tok) AS total
+ SELECT model, SUM(input_tok + cache_create_tok + output_tok) AS total
FROM daily_token_usage WHERE github_id = :githubId
GROUP BY model ORDER BY total DESC LIMIT 5
""", nativeQuery = true)
diff --git a/src/main/java/com/tokenphage/api/domain/token/repository/projection/DailyUsageRow.java b/src/main/java/com/tokenphage/api/domain/token/repository/projection/DailyUsageRow.java
index a9fa73c..b8bf422 100644
--- a/src/main/java/com/tokenphage/api/domain/token/repository/projection/DailyUsageRow.java
+++ b/src/main/java/com/tokenphage/api/domain/token/repository/projection/DailyUsageRow.java
@@ -9,6 +9,6 @@ public interface DailyUsageRow {
/** 사용 날짜 (yyyy-MM-dd) */
String getDate();
- /** 해당 날짜의 총 토큰 수 (input + output) */
+ /** 해당 날짜의 사용량 (input + cache_create + output) */
Long getTotal();
}
diff --git a/src/main/java/com/tokenphage/api/domain/token/repository/projection/ModelUsageRow.java b/src/main/java/com/tokenphage/api/domain/token/repository/projection/ModelUsageRow.java
index b6fe6b4..31ea754 100644
--- a/src/main/java/com/tokenphage/api/domain/token/repository/projection/ModelUsageRow.java
+++ b/src/main/java/com/tokenphage/api/domain/token/repository/projection/ModelUsageRow.java
@@ -9,6 +9,6 @@ public interface ModelUsageRow {
/** 모델 식별자 */
String getModel();
- /** 해당 모델의 총 토큰 수 (input + output) */
+ /** 해당 모델의 사용량 (input + cache_create + output) */
Long getTotal();
}
diff --git a/src/main/java/com/tokenphage/api/feature/badge/service/BadgeQueryService.java b/src/main/java/com/tokenphage/api/feature/badge/service/BadgeQueryService.java
index f567b19..3fc6e84 100644
--- a/src/main/java/com/tokenphage/api/feature/badge/service/BadgeQueryService.java
+++ b/src/main/java/com/tokenphage/api/feature/badge/service/BadgeQueryService.java
@@ -165,7 +165,7 @@ private List
* 오늘→어제→그제 순으로 첫 사용일(total>0)을 앵커로 삼고, 그 날부터 역방향으로 연속 사용일 수를 센다.
- * 셋 다 미사용이면 0이다. total=0 엔트리(캐시 전용 사용일 등)는 미사용으로 취급해 연속을 끊는다.
+ * 셋 다 미사용이면 0이다. total=0 엔트리(cache_read만 있는 날 등)는 미사용으로 취급해 연속을 끊는다.
* (클라이언트 로컬 날짜와 서버 타임존 시차로 서쪽 타임존 사용자가 매일 아침 streak이 0으로 떨어지는 문제를 흡수)
*
* @param tokensByDate 날짜(yyyy-MM-dd)→총합 맵 (조회된 창 범위)
diff --git a/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncValidationTest.java b/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncValidationTest.java
index 6278586..82d4c96 100644
--- a/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncValidationTest.java
+++ b/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncValidationTest.java
@@ -284,7 +284,7 @@ class RecordReq {
@Test
@DisplayName("토큰 4필드가 상한을 1 넘으면 각 필드 위반 - bigint SUM 오버플로 차단")
void 레코드_토큰상한초과_각필드위반발생() {
- // given: 상한이 없으면 Long.MAX 급 값이 저장되고, 이후 SUM(input_tok + output_tok) 이
+ // given: 상한이 없으면 Long.MAX 급 값이 저장되고, 이후 SUM(input_tok + cache_create_tok + output_tok) 이
// Postgres 22003 을 던져 그 사용자 배지가 영구 500 이 된다 (자가 복구 불가)
long over = TOKEN_MAX + 1;
TokenRecordRequest r = new TokenRecordRequest("2026-06-11", "claude", over, over, over, over);
diff --git a/src/test/java/com/tokenphage/api/integration/AuthControllerIntegrationTest.java b/src/test/java/com/tokenphage/api/integration/AuthControllerIntegrationTest.java
index 333e2f8..b863d65 100644
--- a/src/test/java/com/tokenphage/api/integration/AuthControllerIntegrationTest.java
+++ b/src/test/java/com/tokenphage/api/integration/AuthControllerIntegrationTest.java
@@ -89,8 +89,8 @@ class AuthControllerIntegrationTest extends ContainerSupport {
new TokenRecordRequest("2026-07-03", "claude-sonnet-4-6", 25_400, 11_600, 9_800, 2_100),
new TokenRecordRequest("2026-07-04", "claude-opus-4-7", 3_100, 1_450, 700, 150)
);
- /** sumTotalTokens 는 input + output 만 더한다 (cacheRead/cacheCreate 는 제외). 레코드별 소계로 적어 검산이 쉽도록 둔다. */
- private static final long EXPECTED_TOTAL = 16_800L + 14_700L + 1_250L + 37_000L + 4_550L;
+ /** sumTotalTokens 는 input + cacheCreate + output 을 더한다 (cacheRead 는 제외). 레코드별 소계로 적어 검산이 쉽도록 둔다. */
+ private static final long EXPECTED_TOTAL = 17_700L + 15_100L + 1_250L + 39_100L + 4_700L;
@BeforeEach
void setUp() throws InterruptedException {
diff --git a/src/test/java/com/tokenphage/api/integration/DailyTokenUsageRepositoryIntegrationTest.java b/src/test/java/com/tokenphage/api/integration/DailyTokenUsageRepositoryIntegrationTest.java
new file mode 100644
index 0000000..57f3e65
--- /dev/null
+++ b/src/test/java/com/tokenphage/api/integration/DailyTokenUsageRepositoryIntegrationTest.java
@@ -0,0 +1,159 @@
+package com.tokenphage.api.integration;
+
+import com.tokenphage.api.domain.token.repository.DailyTokenUsageRepository;
+import com.tokenphage.api.domain.token.repository.projection.DailyUsageRow;
+import com.tokenphage.api.domain.token.repository.projection.ModelUsageRow;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+/**
+ * 사용량 집계 쿼리(누적·일별·모델별)를 실제 PostgreSQL 에 태워 검증한다.
+ *
+ * 사용량 = input + cache_create + output (cache_read 제외). 새 입력이 input·cache_create 에 어떻게 나뉘든
+ * (Claude는 cache_create 중심, Codex는 input 중심) 같은 작업은 같은 값이어야 한다.
+ * 컨테이너 DB 를 공유하고 롤백이 없어 AfterEach 에서 FK 역순으로 정리한다.
+ */
+@Tag("integration")
+@SpringBootTest
+@DisplayName("DailyTokenUsageRepository 사용량 집계 쿼리")
+class DailyTokenUsageRepositoryIntegrationTest extends ContainerSupport {
+
+ // 싱글턴 컨테이너 DB를 통합테스트 클래스들이 공유하므로 다른 클래스와 겹치지 않는 값을 쓴다 (99901~99907 사용 중).
+ private static final long GITHUB_ID = 99908L;
+ private static final String USERNAME = "cli_usage_test";
+ private static final UUID DEVICE_ID = UUID.fromString("e4eebc99-9c0b-4ef8-bb6d-6bb9bd380a55");
+ private static final LocalDate DAY1 = LocalDate.of(2026, 9, 1);
+ private static final LocalDate DAY2 = LocalDate.of(2026, 9, 2);
+
+ @Autowired
+ private DailyTokenUsageRepository repository;
+
+ @Autowired
+ private JdbcTemplate jdbc;
+
+ @BeforeEach
+ void setUp() {
+ jdbc.update("INSERT INTO users (github_id, username) VALUES (?, ?)", GITHUB_ID, USERNAME);
+ }
+
+ @AfterEach
+ void cleanUp() {
+ // FK 역순: daily_token_usage → users
+ jdbc.update("DELETE FROM daily_token_usage WHERE github_id = ?", GITHUB_ID);
+ jdbc.update("DELETE FROM users WHERE github_id = ?", GITHUB_ID);
+ }
+
+ private void insertUsage(LocalDate date, String model, long input, long output, long cacheRead, long cacheCreate) {
+ jdbc.update("""
+ INSERT INTO daily_token_usage
+ (github_id, device_id, usage_date, model, input_tok, output_tok, cache_read_tok, cache_create_tok)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """, GITHUB_ID, DEVICE_ID, date, model, input, output, cacheRead, cacheCreate);
+ }
+
+ @Nested
+ @DisplayName("sumTotalTokens() - 누적 사용량")
+ class SumTotalTokens {
+
+ @ParameterizedTest(name = "[{index}] {0}")
+ @CsvSource({
+ "Claude형(cache_create 중심), claude-opus-5, 3, 997",
+ "Codex형(input 중심), gpt-5.5, 1000, 0",
+ "경계(input 0), claude-opus-5, 0, 1000"
+ })
+ @DisplayName("새 입력이 input·cache_create에 어떻게 나뉘든 같은 작업이면 누적이 같다")
+ void 누적_새입력분할달라도_같은값(String label, String model, long input, long cacheCreate) {
+ // given: 새 입력 1,000 · 출력 150 · 캐시 재사용 50,000 인 같은 작업
+ insertUsage(DAY1, model, input, 150, 50_000, cacheCreate);
+
+ // when
+ Long total = repository.sumTotalTokens(GITHUB_ID);
+
+ // then: 새 입력 1,000 + 출력 150. 재사용분 cache_read 50,000은 빠진다
+ assertThat(total).isEqualTo(1_150L);
+ }
+ }
+
+ @Nested
+ @DisplayName("findTop5Models() - 모델별 사용량")
+ class FindTop5Models {
+
+ @Test
+ @DisplayName("#23 재현: cache_create가 큰 Claude가 input+output이 큰 gpt보다 위다")
+ void 모델별_이슈23재현_cacheCreate큰Claude가1위() {
+ // given: 이슈 수치를 1/1,000로 줄인 값. input + output만 보면 gpt 40,000 > claude 11,000이다
+ insertUsage(DAY1, "gpt-5.5", 30_000, 10_000, 680_000, 0);
+ insertUsage(DAY1, "claude-opus-5", 100, 10_900, 3_323_000, 177_000);
+
+ // when
+ List