Skip to content
Merged
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ src/test/java/com/tokenphage/api/feature/badge/svg/GpuBadgeLevelsReportTest.java

### 마스코트 export 산출물 — ExportTest가 로컬 재생성. 레포엔 두지 않고 tokenphage-assets로 배포한다 ###
assets/

### 에이전트 하네스 로컬 상태 — 도구가 생성하며 레포와 무관하다 ###
.omc/
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ public interface UserBadgeGrantRepository
/**
* 배지 사용 가능 여부를 DB 왕복 한 번으로 확인한다.
* <p>
* 자격 필요 여부와 자격 보유를 한 쿼리에서 본다. 공개 배지면 EXISTS는 실행되지 않는다.
* 자격 필요 여부와 자격 보유를 한 쿼리에서 본다. 공개 배지면 자격 EXISTS는 실행되지 않는다.
* 자격은 github_id 기준이고 요청은 username이라 users를 조인한다.
* 사용자 존재 여부(userExists)도 같은 왕복에서 본다.
*
* @param username 배지 주인 GitHub 사용자명 (null 불허)
* @param badgeCode 정리된 배지 코드 (null 불허)
* @return 사용 가능 여부와 안내 문구. 등록되지 않은 배지 코드면 null
* @return 사용 가능 여부·사용자 존재 여부·안내 문구. 등록되지 않은 배지 코드면 null
* @Since 2026-08-10
*/
@Query(value = """
Expand All @@ -29,6 +30,11 @@ public interface UserBadgeGrantRepository
WHERE u.username = :username
AND ubg.badge_code = c.code
) AS granted,
EXISTS (
SELECT 1
FROM users u2
WHERE u2.username = :username
) AS userExists,
c.display_name AS displayName,
c.locked_message AS lockedMessage
FROM badge_catalog c
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ public interface BadgeGrantRow {

/** 자격이 없을 때 보여줄 안내 문구. NULL이면 호출 측이 기본 문구를 쓴다. */
String getLockedMessage();

/** 배지 주인이 가입된 사용자면 true. primitive다 — EXISTS는 NULL을 반환하지 않는다. */
boolean getUserExists();
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
/**
* 배지 사용 가능 여부 확인 결과.
*
* @param granted 쓸 수 있으면 true
* @param title 잠금 안내 제목(배지 이름). 거부일 때만 채워진다
* @param message 잠금 안내 문구. null이면 받는 쪽이 기본 문구로 채운다
* @param granted 쓸 수 있으면 true
* @param userExists 배지 주인이 가입된 사용자면 true. false면 받는 쪽이 404로 끊는다
* @param title 잠금 안내 제목(배지 이름). 거부일 때만 채워진다
* @param message 잠금 안내 문구. null이면 받는 쪽이 기본 문구로 채운다
*/
public record BadgeGrantResult(boolean granted, String title, String message) {
public record BadgeGrantResult(boolean granted, boolean userExists, String title, String message) {

/**
* 사용 가능 결과를 만든다.
Expand All @@ -18,18 +19,32 @@ public record BadgeGrantResult(boolean granted, String title, String message) {
* @Since 2026-08-10
*/
public static BadgeGrantResult allow() {
return new BadgeGrantResult(true, null, null);
return new BadgeGrantResult(true, true, null, null);
}

/**
* 거부 결과를 안내 문구와 함께 만든다.
* <p>
* 배지 주인은 가입돼 있고 자격만 없는 상태다. 받는 쪽은 잠금 안내를 그린다.
*
* @param title 배지 이름 (null이면 기본 제목으로 대체)
* @param message 안내 문구 (null이면 기본 문구로 대체)
* @return 거부 결과
* @Since 2026-08-10
*/
public static BadgeGrantResult deny(String title, String message) {
return new BadgeGrantResult(false, title, message);
return new BadgeGrantResult(false, true, title, message);
}

/**
* 가입되지 않은 사용자라는 결과를 만든다.
* <p>
* 자격 거부와 구분된다. 잠금 안내를 그릴 대상이 아니므로 문구를 싣지 않는다.
*
* @return 미가입 결과
* @Since 2026-09-06
*/
public static BadgeGrantResult userNotFound() {
return new BadgeGrantResult(false, false, null, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,25 @@ public class BadgeGrantService {
* <p>
* 판단 기준은 뷰어가 아니라 /badge/{username}의 주인이다.
* 등록되지 않은 코드는 거부한다. 안내 문구 표시는 feature/badge가 정한다.
* 가입되지 않은 주인은 자격 거부보다 먼저 가려낸다 — 공개 배지는 미가입이어도 granted가 참이라
* granted만으로는 구분되지 않는다. 미가입을 어떤 응답으로 바꿀지는 feature/badge가 정한다.
*
* @param username 배지 주인 GitHub 사용자명 (null 불허)
* @param badgeCode 정리된 배지 코드 (null 불허)
* @return 사용 가능 여부와 안내 문구
* @return 사용 가능 여부·사용자 존재 여부와 안내 문구
* @Since 2026-08-10
*/
public BadgeGrantResult resolveGrant(String username, String badgeCode) {
BadgeGrantRow row = grantRepo.findGrant(username, badgeCode);
if (row == null) {
// 조회 결과가 없으면 사용자 존재도 알 수 없다. 미가입으로 단정하지 않고 거부만 한다.
log.info("Badge code not found in catalog, denying: badgeCode={}", badgeCode);
return BadgeGrantResult.deny(null, null);
}
if (!row.getUserExists()) {
log.info("Badge owner not registered: username={}, badgeCode={}", username, badgeCode);
return BadgeGrantResult.userNotFound();
}
if (row.getGranted()) {
log.info("Badge grant allow: username={}, badgeCode={}", username, badgeCode);
return BadgeGrantResult.allow();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import jakarta.servlet.ServletException;

import java.time.format.DateTimeParseException;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -93,6 +100,36 @@ public ResponseEntity<ErrorResponse> handleInvalidArgument(DateTimeParseExceptio
.body(new ErrorResponse("INVALID_ARGUMENT", "Request contains an invalid value."));
}

/**
* 라우팅·프로토콜 단계에서 발생한 요청 오류를 예외가 지정한 4xx로 반환한다.
* <p>
* 이 예외들은 모두 {@code org.springframework.web.ErrorResponse}를 구현해 상태를 스스로 안다.
* 응답 코드·메시지를 그 상태에서 파생하므로 예외를 추가할 때 매핑 표를 고칠 필요가 없다.
* 예외 메시지는 내부 구현(정적 리소스 폴백 등)을 노출하므로 로그에만 남긴다.
*
* @param e 라우팅·프로토콜 단계 예외
* @return 예외가 지정한 상태와 그 상태명을 코드로 담은 응답
* @Since 2026-09-09
*/
@ExceptionHandler({
NoResourceFoundException.class,
HttpRequestMethodNotSupportedException.class,
HttpMediaTypeNotSupportedException.class,
HttpMediaTypeNotAcceptableException.class,
MissingServletRequestParameterException.class
})
public ResponseEntity<ErrorResponse> handleWebRequestException(ServletException e) {
// 목록에 ErrorResponse 미구현 예외가 잘못 추가되면 기존 500 경로로 되돌린다
if (!(e instanceof org.springframework.web.ErrorResponse errorResponse)) {
return handleException(e);
}
HttpStatus status = HttpStatus.valueOf(errorResponse.getStatusCode().value());
log.warn("Request could not be handled: status={}, reason={}", status.value(), e.getMessage());
return ResponseEntity
.status(status)
.body(new ErrorResponse(status.name(), status.getReasonPhrase() + "."));
}

/**
* 예상치 못한 예외를 처리한다.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class BadgeController {
* <p>
* 배지 주인에게 자격이 없으면 잠금 안내 SVG를 200으로 반환한다.
* 4xx는 깨진 이미지로 보인다. 대신 결과 값을 요청 속성에 넣어 접근 기록에 남긴다.
* 다만 배지 주인이 가입돼 있지 않으면 테마와 무관하게 404(BADGE_001)로 끊는다.
*
* @param request 결과 값을 실어 보낼 현재 요청
* @param username 배지를 조회할 GitHub 사용자명
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import com.tokenphage.api.domain.badge.BadgeCode;
import com.tokenphage.api.domain.badge.service.BadgeGrantResult;
import com.tokenphage.api.domain.badge.service.BadgeGrantService;
import com.tokenphage.api.exception.AppException;
import com.tokenphage.api.feature.badge.dto.response.BadgeResponse;
import com.tokenphage.api.feature.badge.dto.response.BadgeSvgResponse;
import com.tokenphage.api.feature.badge.exception.BadgeErrorCode;
import com.tokenphage.api.feature.badge.svg.BadgeMode;
import com.tokenphage.api.feature.badge.svg.SvgBuilder;
import com.tokenphage.api.feature.badge.svg.theme.locked.LockedBadgeTheme;
Expand Down Expand Up @@ -33,20 +35,16 @@ public class BadgeRenderService {
/**
* 사용자 배지 SVG를 반환한다.
* <p>
* theme/mode를 등록된 값으로 정규화한 뒤 캐시 키를 구성한다. 정규화로 캐시 키 카디널리티를
* 유한하게 제한하여, 임의 theme/mode 입력에 의한 Redis 키 무한 증식을 방지한다.
* 캐시에 값이 있으면 즉시 반환하고, 없으면 DB에서 조회해 SVG를 생성한 뒤 캐시에 저장한다.
* 캐시 TTL은 설정값 badge.cache-ttl-minutes를 따른다.
*
* <p>
* 자격이 필요한 배지인데 배지 주인에게 자격이 없으면 잠금 안내 SVG를 대신 반환한다.
* 이때 잠금 SVG는 캐시에 저장하지 않으므로 자격을 부여하면 다음 요청에 즉시 반영된다.
* theme/mode를 등록된 값으로 정규화해 임의 입력에 의한 캐시 키 증식을 막는다.
* 캐시 적중이면 즉시 반환하고, 아니면 조회·생성 후 badge.cache-ttl-minutes 동안 캐시한다.
* 자격이 없으면 잠금 SVG를 반환하며, 이 SVG는 캐시하지 않아 부여가 즉시 반영된다.
* 배지 주인이 가입돼 있지 않으면 테마와 무관하게 404로 끊는다.
*
* @param username 배지를 조회할 GitHub 사용자명 (null 불허)
* @param theme 배지 스킨 종류 (미등록 값은 기본 테마로 정규화)
* @param mode 색상 모드 (테마가 지원하지 않는 값은 그 테마의 기본 모드로 정규화)
* @return SVG 문자열과 자격 판정 결과
* @throws com.tokenphage.api.exception.AppException 사용자가 없을 경우 (BADGE_001)
* @throws AppException 배지 주인이 가입되지 않았을 경우 (BADGE_001)
* @Since 2026-05-27
*/
public BadgeSvgResponse getSvg(String username, String theme, String mode) {
Expand All @@ -56,6 +54,13 @@ public BadgeSvgResponse getSvg(String username, String theme, String mode) {
// #0. 자격 확인은 캐시 조회보다 먼저. 캐시-자격 불일치를 막고 회수가 즉시 반영된다.
// 잠금 SVG는 캐시하지 않으므로 부여도 즉시 반영된다.
BadgeGrantResult grant = badgeGrantService.resolveGrant(username, normalizedTheme);

// #0-1. 미가입은 자격 거부보다 먼저 끊는다. 공개 테마와 자격 테마의 응답을 404로 통일한다.
if (!grant.userExists()) {
log.info("Badge owner not registered, returning 404: username={}", username);
throw new AppException(BadgeErrorCode.USER_NOT_FOUND);
}

if (!grant.granted()) {
log.info("Badge locked, rendering lock notice: username={}, theme={}", username, normalizedTheme);
// 잠금 테마 자신의 지원 집합으로 다시 정규화한다 — 원 테마의 악센트 모드를 잠금 테마는 모른다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

import java.util.List;

Expand All @@ -17,6 +18,14 @@ public record SyncRequest(
String deviceId,

@NotEmpty(message = "records must not be empty")
@Size(max = MAX_RECORDS, message = "records must not exceed 10000 entries")
@Valid
List<TokenRecordRequest> records
) {}
) {

/**
* 한 요청의 레코드 상한. 1년치 x 모델 10종 약 3,650 의 여유분이다.
* JSON 바디는 톰캣 maxPostSize 대상이 아니라 이 제약이 유일한 방어선이다.
*/
public static final int MAX_RECORDS = 10_000;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.tokenphage.api.feature.sync.dto.request;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;

public record TokenRecordRequest(

Expand All @@ -11,17 +13,39 @@ public record TokenRecordRequest(
String date,

@NotBlank(message = "model must not be blank")
@Size(max = MODEL_MAX_LENGTH, message = "model must not exceed 80 characters")
String model,

@PositiveOrZero(message = "inputTok must be zero or positive")
@Max(value = TOKEN_MAX, message = "inputTok must not exceed 1e11")
long inputTok,

@PositiveOrZero(message = "outputTok must be zero or positive")
@Max(value = TOKEN_MAX, message = "outputTok must not exceed 1e11")
long outputTok,

@PositiveOrZero(message = "cacheReadTok must be zero or positive")
@Max(value = TOKEN_MAX, message = "cacheReadTok must not exceed 1e11")
long cacheReadTok,

@PositiveOrZero(message = "cacheCreateTok must be zero or positive")
@Max(value = TOKEN_MAX, message = "cacheCreateTok must not exceed 1e11")
long cacheCreateTok
) {}
) {

/**
* daily_token_usage.model 컬럼 폭(V1__init.sql). 초과하면 네이티브 INSERT 에서
* Postgres 22001 이 나고 @Transactional 이라 sync 배치 전체가 롤백된다.
*/
public static final int MODEL_MAX_LENGTH = 80;

/**
* 토큰 1건 상한. 배지 최고 레벨 임계(1B)의 100배다.
* <p>
* 상한을 두는 이유는 집계 SUM 의 bigint 오버플로(Postgres 22003) 차단이다.
* 한 요청이 레코드 상한을 꽉 채우고 모든 필드가 이 값이어도 4e15 로
* bigint 범위(9.2e18)에 2000배 이상 여백이 남는다.
* 여백 검증은 SyncSchemaConstraintTest 가 담당한다.
*/
public static final long TOKEN_MAX = 100_000_000_000L;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
* BadgeCacheInvalidator 의 evict 동작(Redis 키 삭제, SCAN 순회)을 검증한다.
Expand Down Expand Up @@ -48,16 +48,16 @@ class Evict {

@SuppressWarnings("unchecked")
Cursor<String> cursor = (Cursor<String>) mock(Cursor.class);
when(cursor.hasNext()).thenReturn(true, true, false);
when(cursor.next()).thenReturn(key1, key2);
when(redis.scan(any(ScanOptions.class))).thenReturn(cursor);
given(cursor.hasNext()).willReturn(true, true, false);
given(cursor.next()).willReturn(key1, key2);
given(redis.scan(any(ScanOptions.class))).willReturn(cursor);

// when
invalidator.evict(username);

// then — SCAN(badge:octocat:*)으로 찾은 실제 키를 일괄 삭제한다
verify(redis).delete(List.of(key1, key2));
verify(cursor).close();
then(redis).should().delete(List.of(key1, key2));
then(cursor).should().close();
}

@Test
Expand All @@ -68,15 +68,15 @@ class Evict {

@SuppressWarnings("unchecked")
Cursor<String> cursor = (Cursor<String>) mock(Cursor.class);
when(cursor.hasNext()).thenReturn(false);
when(redis.scan(any(ScanOptions.class))).thenReturn(cursor);
given(cursor.hasNext()).willReturn(false);
given(redis.scan(any(ScanOptions.class))).willReturn(cursor);

// when
invalidator.evict(username);

// then
verify(redis, never()).delete(anyCollection());
verify(cursor, times(1)).close();
then(redis).should(never()).delete(anyCollection());
then(cursor).should(times(1)).close();
}
}
}
Loading
Loading