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
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</parent>
<groupId>com.tokenphage</groupId>
<artifactId>tokenphage-api</artifactId>
<version>26.08.12</version>
<version>26.08.26</version>
<name>tokenphage-api</name>
<description/>
<url/>
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/tokenphage/api/domain/badge/BadgeCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public enum BadgeCode {
CLAUDE("claude"),
GRASS_CLAUDE("grass-claude"),

/** 클로즈드 베타(2026-07~08) 기여자에게 운영자가 부여하는 배지. 표시 값은 고정 스냅샷에서 온다. */
BETA_TESTER("beta-tester"),

/** 자격이 없어 사용할 수 없음을 알리는 안내 배지. 사용자가 직접 고르는 테마는 아니다. */
LOCKED("locked");

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.tokenphage.api.domain.badge.repository;

import com.tokenphage.api.domain.badge.repository.entity.BadgeSnapshot;
import com.tokenphage.api.domain.badge.repository.entity.BadgeSnapshotId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface BadgeSnapshotRepository extends JpaRepository<BadgeSnapshot, BadgeSnapshotId> {

/**
* 스냅샷 payload JSON 원문을 복합 PK로 조회한다.
* <p>
* jsonb는 ::text로 꺼내야 드라이버가 String으로 반환한다.
*
* @param githubId 배지 주인 GitHub 숫자 ID (null 불허)
* @param badgeCode 배지 코드 (null 불허)
* @return payload JSON 원문, 행이 없으면 null
* @Since 2026-08-23
*/
@Query(value = """
SELECT bs.payload::text
FROM badge_snapshot bs
WHERE bs.github_id = :githubId
AND bs.badge_code = :badgeCode
""", nativeQuery = true)
String findPayload(@Param("githubId") Long githubId, @Param("badgeCode") String badgeCode);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.tokenphage.api.domain.badge.repository.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;

/**
* 값이 고정된 배지의 미리 계산된 표시 데이터(스냅샷) 엔티티.
* <p>
* payload는 String으로 매핑한다 — 엔티티가 JSON 구조를 몰라야 여러 배지가 공유한다.
* 스키마 해석은 각 배지 패키지가 한다. 적재는 마이그레이션이 하므로 setter가 없다.
*/
@Entity
@Table(name = "badge_snapshot")
@IdClass(BadgeSnapshotId.class)
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BadgeSnapshot {

@Id
@Column(name = "github_id")
private Long githubId;

@Id
@Column(name = "badge_code", length = 40)
private String badgeCode;

/** 표시할 값 전부를 담은 JSON 원문. */
@Column(name = "payload", columnDefinition = "jsonb", nullable = false)
private String payload;

@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt = Instant.now();

@Column(name = "updated_at", nullable = false)
private Instant updatedAt = Instant.now();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.tokenphage.api.domain.badge.repository.entity;

import java.io.Serializable;
import java.util.Objects;

/**
* badge_snapshot 복합키(@IdClass) 클래스.
* <p>
* 값 생성자는 findById 호출용이다.
*/
public class BadgeSnapshotId implements Serializable {
private Long githubId;
private String badgeCode;

public BadgeSnapshotId() {
}

/**
* 조회에 쓸 복합키를 만든다.
*
* @param githubId GitHub 숫자 ID
* @param badgeCode 배지 코드 (badge_catalog.code)
* @Since 2026-08-23
*/
public BadgeSnapshotId(Long githubId, String badgeCode) {
this.githubId = githubId;
this.badgeCode = badgeCode;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof BadgeSnapshotId that)) {
return false;
}
return Objects.equals(githubId, that.githubId) &&
Objects.equals(badgeCode, that.badgeCode);
}

@Override
public int hashCode() {
return Objects.hash(githubId, badgeCode);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public class BadgeController {
* @param request 결과 값을 실어 보낼 현재 요청
* @param username 배지를 조회할 GitHub 사용자명
* @param theme 배지 스킨 종류 (예: "gpu", 기본값: "gpu")
* @param mode 색상 모드 ("light" 또는 "dark", 기본값: "light")
* @param mode 색상 모드 — 지원 값은 테마별로 다르다 (카드·잔디 "light"/"dark",
* beta-tester "cyan"/"green"/"purple"). 미지원 값은 테마 기본 모드로 정규화 (기본값: "light")
* @return SVG 형식의 배지 이미지
* @Since 2026-05-27
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* @param yearTokens 최근 1년 총 토큰 — {@code YEAR_TOKENS}
* @param streakDays 연속 사용일 — {@code STREAK_DAYS}
* @param daily1y 최근 1년 일별 사용량 — {@code DAILY_1Y}
* @param snapshot 고정 스냅샷 payload JSON 원문 — {@code BADGE_SNAPSHOT} (없으면 빈 문자열, 해석은 배지 패키지 소유)
*/
public record BadgeResponse(
// ── 공통 ──
Expand All @@ -30,5 +31,7 @@ public record BadgeResponse(
// ── 1년 활동 데이터 (grass 계열) ──
long yearTokens,
int streakDays,
List<DailyCountResponse> daily1y
List<DailyCountResponse> daily1y,
// ── 고정 스냅샷 (beta-tester 등 값이 변하지 않는 배지) ──
String snapshot
) {}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.tokenphage.api.feature.badge.service;

import com.tokenphage.api.domain.badge.repository.BadgeSnapshotRepository;
import com.tokenphage.api.domain.token.repository.DailyTokenUsageRepository;
import com.tokenphage.api.domain.token.repository.projection.CacheTokenSum;
import com.tokenphage.api.domain.user.repository.UserRepository;
Expand Down Expand Up @@ -30,6 +31,7 @@ class BadgeQueryService {

private final UserRepository userRepo;
private final DailyTokenUsageRepository tokenRepo;
private final BadgeSnapshotRepository snapshotRepo;

@Value("${badge.timezone}")
private String timezone;
Expand All @@ -41,14 +43,15 @@ class BadgeQueryService {
* 일별 쿼리는 연간 계열(DAILY_1Y/STREAK_DAYS/YEAR_TOKENS) 또는 DAILY_30D 요구 시에만
* 최대 범위(연간 요구 시 365일, 아니면 30일)로 1회 조회한 뒤 히트바·연간일별·연간총량·streak을 파생한다.
*
* @param username 배지를 조회할 GitHub 사용자명 (null 불허)
* @param needs 테마가 요구하는 데이터 종류 집합 (null 불허)
* @param username 배지를 조회할 GitHub 사용자명 (null 불허)
* @param badgeCode 정규화된 배지 코드 (null 불허) — 스냅샷 복합키의 절반
* @param needs 테마가 요구하는 데이터 종류 집합 (null 불허)
* @return 배지 렌더링에 필요한 집계 데이터 (미요구 필드는 빈값)
* @throws AppException 사용자가 존재하지 않을 경우 (BADGE_001)
* @Since 2026-07-15
*/
BadgeResponse query(String username, Set<BadgeDataNeed> needs) {
log.debug("Querying badge data: username={}, needs={}", username, needs);
BadgeResponse query(String username, String badgeCode, Set<BadgeDataNeed> needs) {
log.debug("Querying badge data: username={}, badgeCode={}, needs={}", username, badgeCode, needs);

// #1. 사용자 조회
User user = userRepo.findByUsername(username)
Expand Down Expand Up @@ -112,7 +115,18 @@ BadgeResponse query(String username, Set<BadgeDataNeed> needs) {
cacheHitRate = calcCacheHitRate(cacheRows.isEmpty() ? null : cacheRows.getFirst());
}

// #11. 조회된 데이터를 BadgeResponse로 조립하여 반환
// #11. 고정 스냅샷: 요구 시 복합 PK로 1회 조회. 행이 없으면 빈 문자열 (적재 누락 신호)
String snapshot = "";
if (needs.contains(BadgeDataNeed.BADGE_SNAPSHOT)) {
String payload = snapshotRepo.findPayload(githubId, badgeCode);
if (payload == null) {
log.warn("Badge snapshot missing: username={}, badgeCode={}", username, badgeCode);
} else {
snapshot = payload;
}
}

// #12. 조회된 데이터를 BadgeResponse로 조립하여 반환
log.debug("Badge data ready: username={}, totalTokens={}, heatbarDays={}, yearDays={}, streakDays={}",
username, totalTokens, heat30bar.size(), daily1y.size(), streakDays);
return new BadgeResponse(
Expand All @@ -123,7 +137,8 @@ BadgeResponse query(String username, Set<BadgeDataNeed> needs) {
cacheHitRate,
yearTokens,
streakDays,
daily1y
daily1y,
snapshot
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.tokenphage.api.feature.badge.service;

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.feature.badge.dto.response.BadgeResponse;
import com.tokenphage.api.feature.badge.dto.response.BadgeSvgResponse;
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;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -42,27 +44,30 @@ public class BadgeRenderService {
*
* @param username 배지를 조회할 GitHub 사용자명 (null 불허)
* @param theme 배지 스킨 종류 (미등록 값은 기본 테마로 정규화)
* @param mode 색상 모드 ("dark"가 아니면 "light"로 정규화)
* @param mode 색상 모드 (테마가 지원하지 않는 값은 그 테마의 기본 모드로 정규화)
* @return SVG 문자열과 자격 판정 결과
* @throws com.tokenphage.api.exception.AppException 사용자가 없을 경우 (BADGE_001)
* @Since 2026-05-27
*/
public BadgeSvgResponse getSvg(String username, String theme, String mode) {

String normalizedTheme = svgBuilder.normalizeTheme(theme);
String normalizedMode = svgBuilder.normalizeMode(mode);

// #0. 자격 확인은 캐시 조회보다 먼저. 캐시-자격 불일치를 막고 회수가 즉시 반영된다.
// 잠금 SVG는 캐시하지 않으므로 부여도 즉시 반영된다.
BadgeGrantResult grant = badgeGrantService.resolveGrant(username, normalizedTheme);
if (!grant.granted()) {
log.info("Badge locked, rendering lock notice: username={}, theme={}", username, normalizedTheme);
// 잠금 테마 자신의 지원 집합으로 다시 정규화한다 — 원 테마의 악센트 모드를 잠금 테마는 모른다.
BadgeMode lockedMode = svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), mode);
String lockedSvg = lockedBadgeTheme.render(
grant.title(), grant.message(), svgBuilder.isDark(normalizedMode));
grant.title(), grant.message(), lockedMode == BadgeMode.DARK);
return new BadgeSvgResponse(lockedSvg, false);
}

String cacheKey = "badge:" + username + ":" + normalizedTheme + ":" + normalizedMode;
// 캐시 키와 렌더가 같은 정규화 결과를 쓰도록 테마 기준으로 모드를 확정한다.
BadgeMode badgeMode = svgBuilder.resolveMode(normalizedTheme, mode);
String cacheKey = "badge:" + username + ":" + normalizedTheme + ":" + badgeMode.getCode();
String cached = redis.opsForValue().get(cacheKey);

// #1. 배지(SVG)가 캐싱되어 있으면 즉시 리턴
Expand All @@ -71,13 +76,13 @@ public BadgeSvgResponse getSvg(String username, String theme, String mode) {
return new BadgeSvgResponse(cached, true);
}

log.info("Badge cache miss, building SVG: username={}, theme={}, mode={}", username, normalizedTheme, normalizedMode);
log.info("Badge cache miss, building SVG: username={}, theme={}, mode={}", username, normalizedTheme, badgeMode.getCode());

// #2. BadgeQueryService.query() : 정규화된 테마가 선언한 needs에 해당하는 사용자 토큰 정보만 조회
BadgeResponse data = queryService.query(username, svgBuilder.needsOf(normalizedTheme));
BadgeResponse data = queryService.query(username, normalizedTheme, svgBuilder.needsOf(normalizedTheme));

// #3. 사용자 토큰 정보 + 정규화된 theme/mode로 배지(SVG) 생성
String svg = svgBuilder.build(data, normalizedTheme, normalizedMode);
String svg = svgBuilder.build(data, normalizedTheme, badgeMode.getCode());

// #4. 생성된 배지(SVG)를 Redis에 캐싱
redis.opsForValue().set(cacheKey, svg, Duration.ofMinutes(cacheTtlMinutes));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ public enum BadgeDataNeed {
STREAK_DAYS(365),

/** 최근 1년 총 토큰 — 1년 일별 데이터에서 파생 → BadgeResponse.yearTokens */
YEAR_TOKENS(365);
YEAR_TOKENS(365),

/** 값이 고정된 배지의 미리 계산된 스냅샷 JSON → BadgeResponse.snapshot */
BADGE_SNAPSHOT(0);

/** 이 데이터를 채우는 데 필요한 일별 조회 창(일). 0이면 일별 조회가 필요 없다. */
private final int dailyWindowDays;
Expand Down
57 changes: 57 additions & 0 deletions src/main/java/com/tokenphage/api/feature/badge/svg/BadgeMode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.tokenphage.api.feature.badge.svg;

import java.util.Set;

/**
* 배지 색상 모드 (URL {@code ?mode=} 축).
* <p>
* 지원 집합과 기본값은 테마마다 다르다({@link BadgeTheme#supportedModes()}).
* 코드 문자열은 URL·Redis 캐시 키로 사용되며, 바꾸면 기존 배지 주소가 깨진다.
*/
public enum BadgeMode {

LIGHT("light"),
DARK("dark"),
CYAN("cyan"),
GREEN("green"),
PURPLE("purple");

private final String code;

BadgeMode(String code) {
this.code = code;
}

/**
* URL 및 캐시 키에 쓰이는 소문자 코드 문자열을 반환한다.
*
* @return 소문자 코드 (예: "cyan")
* @Since 2026-08-23
*/
public String getCode() {
return code;
}

/**
* 원본 mode 문자열을 지원 집합 안의 모드로 정규화한다.
* <p>
* 대소문자 무관 매칭. null·공백·미지원 값은 {@code fallback}으로 접는다.
*
* @param raw 원본 mode 파라미터 (null 허용)
* @param supported 테마가 지원하는 모드 집합 (null 불허)
* @param fallback 미지원 값일 때 반환할 기본 모드 (null 불허)
* @return 지원 집합 안의 모드 또는 fallback
* @Since 2026-08-23
*/
public static BadgeMode from(String raw, Set<BadgeMode> supported, BadgeMode fallback) {
if (raw == null || raw.isBlank()) {
return fallback;
}
for (BadgeMode mode : supported) {
if (mode.code.equalsIgnoreCase(raw)) {
return mode;
}
}
return fallback;
}
}
27 changes: 23 additions & 4 deletions src/main/java/com/tokenphage/api/feature/badge/svg/BadgeTheme.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,33 @@ public interface BadgeTheme {
String name();

/**
* 배지 데이터와 모드로 SVG 문자열을 생성한다.
* 배지 데이터와 색상 모드로 SVG 문자열을 생성한다.
*
* @param data 배지에 표시할 사용자 데이터 (null 불허)
* @param isDark true면 다크 모드, false면 라이트 모드
* @param data 배지에 표시할 사용자 데이터 (null 불허)
* @param mode 색상 모드 (이 테마의 지원 집합 안 값, null 불허)
* @return 완성된 SVG 마크업 문자열
* @Since 2026-05-27
*/
String build(BadgeResponse data, boolean isDark);
String build(BadgeResponse data, BadgeMode mode);

/**
* 이 테마가 지원하는 색상 모드 집합을 선언한다.
* <p>
* 지원하지 않는 mode 값은 디스패처가 {@link #defaultMode()}로 접는다.
* <b>기본 구현을 두지 않는다(정책)</b> — needs()와 같다.
*
* @return 지원 모드 집합
* @Since 2026-08-23
*/
Set<BadgeMode> supportedModes();

/**
* 미지원 mode 값이 들어왔을 때 쓸 기본 모드를 반환한다.
*
* @return 기본 모드
* @Since 2026-08-23
*/
BadgeMode defaultMode();

/**
* 이 테마가 렌더링에 필요로 하는 데이터 종류를 선언한다.
Expand Down
Loading
Loading