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
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 @@ -41,9 +41,10 @@ void setUp() {
/**
* 가짜 조회 결과를 만든다.
*/
private BadgeGrantRow row(boolean granted, String displayName, String lockedMessage) {
private BadgeGrantRow row(boolean granted, boolean userExists, String displayName, String lockedMessage) {
return new BadgeGrantRow() {
@Override public boolean getGranted() { return granted; }
@Override public boolean getUserExists() { return userExists; }
@Override public String getDisplayName() { return displayName; }
@Override public String getLockedMessage() { return lockedMessage; }
};
Expand All @@ -58,7 +59,7 @@ class ResolveGrantTest {
void 사용가능여부_누구나쓰는배지_허용() {
// given
given(grantRepo.findGrant(USERNAME, PUBLIC_CODE))
.willReturn(row(true, "GPU Card", null));
.willReturn(row(true, true, "GPU Card", null));

// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PUBLIC_CODE);
Expand All @@ -74,7 +75,7 @@ class ResolveGrantTest {
void 사용가능여부_자격필요한배지에자격있음_허용() {
// given
given(grantRepo.findGrant(USERNAME, PRIVATE_CODE))
.willReturn(row(true, "Contributor", "PR을 보내고 이 배지를 받아보세요"));
.willReturn(row(true, true, "Contributor", "PR을 보내고 이 배지를 받아보세요"));

// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE);
Expand All @@ -88,13 +89,14 @@ class ResolveGrantTest {
void 사용가능여부_자격필요한배지에자격없음_거부하고안내문구반환() {
// given
given(grantRepo.findGrant(USERNAME, PRIVATE_CODE))
.willReturn(row(false, "Contributor", "PR을 보내고 이 배지를 받아보세요"));
.willReturn(row(false, true, "Contributor", "PR을 보내고 이 배지를 받아보세요"));

// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE);

// then
assertThat(result.granted()).isFalse();
assertThat(result.userExists()).isTrue();
assertThat(result.title()).isEqualTo("Contributor");
assertThat(result.message()).isEqualTo("PR을 보내고 이 배지를 받아보세요");
}
Expand All @@ -105,7 +107,7 @@ class ResolveGrantTest {
// given
// 안내 문구 미등록 배지. 기본 문구 대체는 렌더러 담당.
given(grantRepo.findGrant(USERNAME, PRIVATE_CODE))
.willReturn(row(false, "Contributor", null));
.willReturn(row(false, true, "Contributor", null));

// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE);
Expand All @@ -128,6 +130,8 @@ class ResolveGrantTest {

// then
assertThat(result.granted()).isFalse();
// 조회 결과가 없으면 사용자 존재를 알 수 없다. 미가입으로 단정해 404를 내지 않는다.
assertThat(result.userExists()).isTrue();
assertThat(result.title()).isNull();
assertThat(result.message()).isNull();
}
Expand All @@ -137,7 +141,7 @@ class ResolveGrantTest {
void 사용가능여부_호출_받은값이바뀌지않고DB조회로전달됨() {
// given
given(grantRepo.findGrant(anyString(), anyString()))
.willReturn(row(true, "GPU Card", null));
.willReturn(row(true, true, "GPU Card", null));
ArgumentCaptor<String> usernameCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> codeCaptor = ArgumentCaptor.forClass(String.class);

Expand All @@ -150,4 +154,43 @@ class ResolveGrantTest {
assertThat(codeCaptor.getValue()).isEqualTo(PUBLIC_CODE);
}
}

@Nested
@DisplayName("가입되지 않은 사용자 판정")
class UserNotFoundTest {

@Test
@DisplayName("사용가능여부_미가입사용자_공개배지여도미가입으로판정")
void 사용가능여부_미가입사용자_공개배지여도미가입으로판정() {
// given
// 공개 배지라 쿼리의 granted는 true지만 users에 행이 없어 userExists가 false다
given(grantRepo.findGrant(USERNAME, PUBLIC_CODE))
.willReturn(row(true, false, "GPU Card", null));

// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PUBLIC_CODE);

// then
assertThat(result.userExists()).isFalse();
assertThat(result.granted()).isFalse();
}

@Test
@DisplayName("사용가능여부_미가입사용자_자격필요배지_잠금문구없이미가입으로판정")
void 사용가능여부_미가입사용자_자격필요배지_잠금문구없이미가입으로판정() {
// given
given(grantRepo.findGrant(USERNAME, PRIVATE_CODE))
.willReturn(row(false, false, "Contributor", "PR을 보내고 이 배지를 받아보세요"));

// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE);

// then
// 미가입이 자격 거부보다 우선한다. 잠금 안내 문구를 싣지 않아 렌더러가 잠금 SVG를 그리지 못한다.
assertThat(result.userExists()).isFalse();
assertThat(result.granted()).isFalse();
assertThat(result.title()).isNull();
assertThat(result.message()).isNull();
}
}
}
Loading
Loading