+ * 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); +} diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshot.java b/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshot.java new file mode 100644 index 0000000..325082e --- /dev/null +++ b/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshot.java @@ -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; + +/** + * 값이 고정된 배지의 미리 계산된 표시 데이터(스냅샷) 엔티티. + *
+ * 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(); +} diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotId.java b/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotId.java new file mode 100644 index 0000000..a05fda0 --- /dev/null +++ b/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotId.java @@ -0,0 +1,46 @@ +package com.tokenphage.api.domain.badge.repository.entity; + +import java.io.Serializable; +import java.util.Objects; + +/** + * badge_snapshot 복합키(@IdClass) 클래스. + *
+ * 값 생성자는 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);
+ }
+}
diff --git a/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java b/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java
index 3c3ac0c..afad0a7 100644
--- a/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java
+++ b/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java
@@ -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
*/
diff --git a/src/main/java/com/tokenphage/api/feature/badge/dto/response/BadgeResponse.java b/src/main/java/com/tokenphage/api/feature/badge/dto/response/BadgeResponse.java
index 2a6b457..e70b7c2 100644
--- a/src/main/java/com/tokenphage/api/feature/badge/dto/response/BadgeResponse.java
+++ b/src/main/java/com/tokenphage/api/feature/badge/dto/response/BadgeResponse.java
@@ -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(
// ── 공통 ──
@@ -30,5 +31,7 @@ public record BadgeResponse(
// ── 1년 활동 데이터 (grass 계열) ──
long yearTokens,
int streakDays,
- List
+ * 지원 집합과 기본값은 테마마다 다르다({@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 문자열을 지원 집합 안의 모드로 정규화한다.
+ *
+ * 대소문자 무관 매칭. 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
+ * 지원하지 않는 mode 값은 디스패처가 {@link #defaultMode()}로 접는다.
+ * 기본 구현을 두지 않는다(정책) — needs()와 같다.
+ *
+ * @return 지원 모드 집합
+ * @Since 2026-08-23
+ */
+ Set
- * "dark"(대소문자 무관)면 "dark"를, 그 외 모든 값(null 포함)은 "light"를 반환한다.
+ * 테마별로 정규화한다 — 테마가 지원하지 않는 값(null·공백 포함)은 그 테마의
+ * {@link BadgeTheme#defaultMode()}로 접는다. 캐시 키 카디널리티가 테마별 지원 집합으로 묶인다.
*
- * @param mode 원본 mode 파라미터 (null 허용)
- * @return "dark" 또는 "light"
- * @Since 2026-06-11
- */
- public String normalizeMode(String mode) {
- return DARK_MODE.equalsIgnoreCase(mode) ? DARK_MODE : LIGHT_MODE;
- }
-
- /**
- * mode 문자열이 다크 모드인지 판정한다.
- *
- * BadgeTheme.build()는 boolean isDark를 받으므로, 디스패처를 거치지 않고 테마를 직접
- * 호출하는 쪽(잠금 안내 렌더)이 같은 기준으로 변환할 수 있게 노출한다.
- *
- * @param mode 원본 또는 정규화된 mode 문자열 (null 허용)
- * @return "dark"면 true, 그 외에는 false
- * @Since 2026-08-10
+ * @param theme 원본 theme 파라미터 (null 허용, 미등록이면 기본 테마 기준)
+ * @param mode 원본 mode 파라미터 (null 허용)
+ * @return 테마 지원 집합 내부 모드
+ * @Since 2026-08-23
*/
- public boolean isDark(String mode) {
- return DARK_MODE.equalsIgnoreCase(mode);
+ public BadgeMode resolveMode(String theme, String mode) {
+ BadgeTheme selected = themes.get(normalizeTheme(theme));
+ return BadgeMode.from(mode, selected.supportedModes(), selected.defaultMode());
}
}
diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeTheme.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeTheme.java
new file mode 100644
index 0000000..821be1e
--- /dev/null
+++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeTheme.java
@@ -0,0 +1,264 @@
+package com.tokenphage.api.feature.badge.svg.theme.betatester;
+
+import com.tokenphage.api.domain.badge.BadgeCode;
+import com.tokenphage.api.feature.badge.dto.response.BadgeResponse;
+import com.tokenphage.api.feature.badge.svg.BadgeDataNeed;
+import com.tokenphage.api.feature.badge.svg.BadgeMode;
+import com.tokenphage.api.feature.badge.svg.BadgeTheme;
+import com.tokenphage.api.feature.badge.svg.SvgText;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+/**
+ * macOS 터미널 창 모양의 베타 테스터 배지 (310×134, 자격 부여 전용, 단독 테마).
+ *
+ * 표시 값 전부를 고정 스냅샷(BADGE_SNAPSHOT)에서 읽어 렌더 시점 집계가 없다.
+ * 좌표·색은 badge-basic.svg.txt 실측값. 애니메이션은 reduced-motion 지원과
+ * 타이핑 효과(clip-path + steps) 재현을 위해 SMIL 대신 인라인 CSS를 쓴다.
+ */
+@Slf4j
+@Component
+public class BetaTesterBadgeTheme implements BadgeTheme {
+
+ /** 본문 좌측 원점 x — 커서 위치 계산의 기준. */
+ private static final int BODY_X = 95;
+
+ /** 유저명(16px 모노) 글리프 폭 — 프로토타입 7자 커서 x=176 역산 (95 + 8×10.125). */
+ private static final double NAME_CHAR_WIDTH = 10.125;
+
+ /** 유저명 표시 상한 — 19자일 때 커서 x=297.5로 우측 경계(298) 안. */
+ private static final int MAX_NAME_CHARS = 19;
+
+ /** 커서 rect 상단 y — 프로토타입 54에서 상단 여백 정리로 12 올렸다. */
+ private static final int CURSOR_Y = 42;
+
+ /** 모노스페이스 폰트 스택 — 터미널 정체성 유지 (프로토타입 그대로, 기존 Pretendard 스택 미사용). */
+ private static final String FONT = "'SF Mono',SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace";
+
+ /** 기간 텍스트 시작 x — 배너("BETA TESTER" 10px 모노 11자) 우측 끝 161 + 여백 8. */
+ private static final int PERIOD_X = 169;
+
+ /** 통계 3칸 x — 라벨 7px 폭(BADGE SERVED 12자 = 57.6)이 다음 열을 침범하지 않는 간격. */
+ private static final int[] STAT_XS = {95, 152, 224};
+
+ /** 통계 라벨 3종 (STAT_XS와 같은 순서). */
+ private static final String[] STAT_LABELS = {"SYNCS RUN", "BADGE SERVED", "TOKENS ADDED"};
+
+ /** macOS 신호등 색 (좌→우, 프로토타입 실측). */
+ private static final String TRAFFIC_RED = "#FF5F57";
+ private static final String TRAFFIC_YELLOW = "#FEBC2E";
+ private static final String TRAFFIC_GREEN = "#28C840";
+
+ /**
+ * 테마 식별자를 반환한다.
+ *
+ * @return "beta-tester"
+ * @Since 2026-08-23
+ */
+ @Override
+ public String name() {
+ return BadgeCode.BETA_TESTER.getCode();
+ }
+
+ /**
+ * 고정 스냅샷 하나만 요구한다 — 라이브 집계 데이터를 쓰지 않는다.
+ *
+ * @return BADGE_SNAPSHOT 단일 집합
+ * @Since 2026-08-23
+ */
+ @Override
+ public Set
+ * 스냅샷이 비어 있으면 통계 3칸은 "-", 순번·기간은 미표시로 그린다(잠금 배지로 바꾸지 않는다).
+ *
+ * @param data 배지 데이터 (snapshot 필드 사용, null 불허)
+ * @param mode 색상 모드 (지원 집합 밖이면 cyan으로 접힘)
+ * @return 완성된 SVG 마크업 문자열
+ * @Since 2026-08-23
+ */
+ @Override
+ public String build(BadgeResponse data, BadgeMode mode) {
+ BadgeMode resolved = supportedModes().contains(mode) ? mode : defaultMode();
+ BetaTesterColors c = BetaTesterColors.forMode(resolved);
+ BetaTesterSnapshot snap = BetaTesterSnapshot.parse(data.snapshot());
+ String modeKey = resolved.getCode();
+ String displayName = truncateName(data.username());
+ log.debug("Building beta tester badge: user={}, mode={}", data.username(), modeKey);
+
+ StringBuilder sb = new StringBuilder();
+ appendRoot(sb, data.username());
+ appendStyle(sb, modeKey);
+ appendDefs(sb, modeKey, c);
+ sb.append("".formatted(SvgText.LINK_URL, SvgText.LINK_URL));
+ sb.append("