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 @@ -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")
/**
* 사용자의 누적 사용량을 조회한다.
* <p>
* 사용량 = 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] 기간 일별 사용량을 날짜 오름차순으로 조회한다.
* <p>
* 창 길이는 호출자가 정한다(예: 30일 히트바, 365일 잔디). 사용 이력이 있는 날짜만 반환한다.
* 사용량 정의는 {@link #sumTotalTokens}와 같다.
*
* @param githubId 대상 사용자 (null 불허)
* @param from 조회 시작일(포함, null 불허)
Expand All @@ -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
Expand All @@ -77,8 +87,17 @@ List<DailyUsageRow> findDailyTotalsBetween(@Param("githubId") Long githubId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);

/**
* 사용자의 모델별 사용량 상위 5개를 내림차순으로 조회한다.
* <p>
* 사용량 정의는 {@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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ public interface DailyUsageRow {
/** 사용 날짜 (yyyy-MM-dd) */
String getDate();

/** 해당 날짜의 총 토큰 수 (input + output) */
/** 해당 날짜의 사용량 (input + cache_create + output) */
Long getTotal();
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ public interface ModelUsageRow {
/** 모델 식별자 */
String getModel();

/** 해당 모델의 총 토큰 수 (input + output) */
/** 해당 모델의 사용량 (input + cache_create + output) */
Long getTotal();
}
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ private List<DailyCountResponse> fillDaily(Map<String, Long> tokensByDate, Local
* 연속 사용일(streak)을 GitHub 스타일 + 2일 유예 규칙으로 계산한다.
* <p>
* 오늘→어제→그제 순으로 첫 사용일(total&gt;0)을 앵커로 삼고, 그 날부터 역방향으로 연속 사용일 수를 센다.
* 셋 다 미사용이면 0이다. total=0 엔트리(캐시 전용 사용일 등)는 미사용으로 취급해 연속을 끊는다.
* 셋 다 미사용이면 0이다. total=0 엔트리(cache_read만 있는 날 등)는 미사용으로 취급해 연속을 끊는다.
* (클라이언트 로컬 날짜와 서버 타임존 시차로 서쪽 타임존 사용자가 매일 아침 streak이 0으로 떨어지는 문제를 흡수)
*
* @param tokensByDate 날짜(yyyy-MM-dd)→총합 맵 (조회된 창 범위)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 에 태워 검증한다.
* <p>
* 사용량 = 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<ModelUsageRow> 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<DailyUsageRow> 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<DailyUsageRow> daily = repository.findDailyTotalsBetween(GITHUB_ID, DAY1, DAY2);
List<ModelUsageRow> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenRecordRequest> BIG_RECORDS = List.of(
new TokenRecordRequest("2026-06-01", "claude-opus-4-8", 80_000, 30_000, 5_000, 2_000)
);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading