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 findDailyTotalsBetween(@Param("githubId") Long githubId, @Param("from") LocalDate from, @Param("to") LocalDate to); + /** + * 사용자의 모델별 사용량 상위 5개를 내림차순으로 조회한다. + *

+ * 사용량 정의는 {@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 fillDaily(Map tokensByDate, Local * 연속 사용일(streak)을 GitHub 스타일 + 2일 유예 규칙으로 계산한다. *

* 오늘→어제→그제 순으로 첫 사용일(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 top = repository.findTop5Models(GITHUB_ID); + + // then: cache_create를 새 입력으로 세면 claude 188,000 > gpt 40,000 + assertThat(top) + .extracting(ModelUsageRow::getModel, ModelUsageRow::getTotal) + .containsExactly(tuple("claude-opus-5", 188_000L), tuple("gpt-5.5", 40_000L)); + } + } + + @Nested + @DisplayName("findDailyTotalsBetween() - 일별 사용량") + class FindDailyTotalsBetween { + + @Test + @DisplayName("같은 날 Claude형·Codex형 행을 같은 식으로 합산한다") + void 일별_Claude형과Codex형같은날_합산() { + // given: 같은 작업(각 1,150)을 두 형식으로 같은 날에 기록 + insertUsage(DAY1, "claude-opus-5", 3, 150, 50_000, 997); + insertUsage(DAY1, "gpt-5.5", 1_000, 150, 50_000, 0); + + // when + List daily = repository.findDailyTotalsBetween(GITHUB_ID, DAY1, DAY1); + + // then: 1,150 + 1,150 + assertThat(daily) + .extracting(DailyUsageRow::getDate, DailyUsageRow::getTotal) + .containsExactly(tuple("2026-09-01", 2_300L)); + } + } + + @Nested + @DisplayName("지표 간 불변식 - 세 쿼리가 같은 식을 쓴다") + class CrossMetricConsistency { + + @Test + @DisplayName("일별 합계와 모델별 합계가 누적과 같다") + void 불변식_일별합계와모델별합계_누적과일치() { + // given: 필드마다 자릿수를 달리해(I 1 · O 10 · CR 100 · CC 1,000의 1~4배) 합에 포함된 필드가 드러나게 한다 + insertUsage(DAY1, "claude-opus-5", 1, 10, 100, 1_000); + insertUsage(DAY1, "gpt-5.5", 2, 20, 200, 2_000); + insertUsage(DAY2, "claude-opus-5", 3, 30, 300, 3_000); + insertUsage(DAY2, "gpt-5.5", 4, 40, 400, 4_000); + + // when: 같은 데이터에 세 지표 쿼리를 모두 태운다 + Long total = repository.sumTotalTokens(GITHUB_ID); + List daily = repository.findDailyTotalsBetween(GITHUB_ID, DAY1, DAY2); + List models = repository.findTop5Models(GITHUB_ID); + + // then: 일별 3,033 + 7,077 = 모델별 6,066 + 4,044 = 누적 10,110 (I 10 + CC 10,000 + O 100) + assertThat(total).isEqualTo(10_110L); + assertThat(daily).extracting(DailyUsageRow::getTotal).containsExactly(3_033L, 7_077L); + assertThat(models).extracting(ModelUsageRow::getTotal).containsExactly(6_066L, 4_044L); + } + } +} diff --git a/src/test/java/com/tokenphage/api/integration/ResetControllerIntegrationTest.java b/src/test/java/com/tokenphage/api/integration/ResetControllerIntegrationTest.java index 07e57ee..adff20a 100644 --- a/src/test/java/com/tokenphage/api/integration/ResetControllerIntegrationTest.java +++ b/src/test/java/com/tokenphage/api/integration/ResetControllerIntegrationTest.java @@ -53,7 +53,7 @@ class ResetControllerIntegrationTest extends ContainerSupport { private static final String USERNAME = "cli_reset_test"; private static final String DEVICE_ID = "d3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44"; - // reset이 토큰 사용량을 실제로 비우는지 검증하기 위한 적재 데이터(합계 110,000). + // reset이 토큰 사용량을 실제로 비우는지 검증하기 위한 적재 데이터(합계 112,000). private static final List BIG_RECORDS = List.of( new TokenRecordRequest("2026-06-01", "claude-opus-4-8", 80_000, 30_000, 5_000, 2_000) ); @@ -93,7 +93,7 @@ void postReset_withoutAuth_returns401() { void postReset_wipesUsage() { // Arrange: 실 sync로 데이터 적재 assertThat(doSync().getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(tokenRepo.sumTotalTokens(GITHUB_ID)).isEqualTo(110_000L); + assertThat(tokenRepo.sumTotalTokens(GITHUB_ID)).isEqualTo(112_000L); // Act assertThat(doReset().getStatusCode()).isEqualTo(HttpStatus.OK); diff --git a/src/test/java/com/tokenphage/api/integration/SyncControllerIntegrationTest.java b/src/test/java/com/tokenphage/api/integration/SyncControllerIntegrationTest.java index ea30e77..cd900d0 100644 --- a/src/test/java/com/tokenphage/api/integration/SyncControllerIntegrationTest.java +++ b/src/test/java/com/tokenphage/api/integration/SyncControllerIntegrationTest.java @@ -60,7 +60,7 @@ class SyncControllerIntegrationTest extends ContainerSupport { new TokenRecordRequest("2026-05-03", "claude-haiku-4-5-20251001", 800, 400, 300, 80), new TokenRecordRequest("2026-05-03", "claude-sonnet-4-6", 3100, 1800, 900, 200) ); - private static final long EXPECTED_TOTAL = 20_150L; + private static final long EXPECTED_TOTAL = 21_430L; @BeforeEach void setUp() {