diff --git a/build.gradle b/build.gradle index 7fa850ee..3cd76fbf 100644 --- a/build.gradle +++ b/build.gradle @@ -98,6 +98,9 @@ dependencies { // webp 디코더 implementation 'com.twelvemonkeys.imageio:imageio-webp:3.13.1' + + // AdMob SSV 서명 검증 (Tink) + implementation 'com.google.crypto.tink:apps-rewardedads:1.15.0' } dependencyManagement { diff --git a/commitlint.config.js b/commitlint.config.js index eb430e83..217657d0 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -20,6 +20,7 @@ module.exports = { "ai", "report", "pdf", + "ads", "moderation", "search", "stats", @@ -120,6 +121,9 @@ module.exports = { pdf: { description: '📄 PDF 내보내기 도메인 (예: 기록 PDF 생성, 렌더링)' }, + ads: { + description: '📺 광고 도메인 (예: 보상형 광고 크리스탈 충전, SSV 검증)' + }, moderation: { description: '🚨 신고/차단 도메인 (예: 공유글 신고, 공유 활동 중지)' }, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/api/AdRewardController.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/AdRewardController.java new file mode 100644 index 00000000..9c49329b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/AdRewardController.java @@ -0,0 +1,165 @@ +package com.devkor.ifive.nadab.domain.ads.api; + +import com.devkor.ifive.nadab.domain.ads.api.dto.request.AdRewardSessionCreateRequest; +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardQuoteResponse; +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardSessionCreateResponse; +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardSessionStatusResponse; +import com.devkor.ifive.nadab.domain.ads.application.AdRewardCallbackService; +import com.devkor.ifive.nadab.domain.ads.application.AdRewardSessionService; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardFeature; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "보상형 광고 크리스탈 충전 API", description = "크리스탈 부족 시 광고 시청으로 부족분을 충전(AdMob Rewarded + SSV)") +@RestController +@RequestMapping("${api_prefix}/ad-rewards") +@RequiredArgsConstructor +public class AdRewardController { + + private final AdRewardSessionService adRewardSessionService; + private final AdRewardCallbackService adRewardCallbackService; + + @GetMapping("/quote") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "광고 보상 부족분 조회", + description = """ + 크리스탈이 부족해 기능 실행(PDF 내보내기, 물어보기)이 막혔을 때 호출합니다. 광고로 채워야 할 부족분을 계산해 반환하는 순수 조회이며, 세션을 만들지 않습니다.
+ 선택한 기능의 비용(crystalCost)·현재 보유 크리스탈(balance)·부족분(requiredCrystal, 0 이상)을 반환합니다.
+ 크리스탈이 부족한 상황에서 호출하므로 보통 requiredCrystal > 0입니다. requiredCrystal이 0이면 이미 크리스탈이 충분하다는 의미(광고 시청 불필요)입니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "부족분 조회 성공", + content = @Content(schema = @Schema(implementation = AdRewardQuoteResponse.class), mediaType = "application/json") + ), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse( + responseCode = "404", + description = "- ErrorCode: WALLET_NOT_FOUND - 지갑을 찾을 수 없음", + content = @Content + ) + } + ) + public ResponseEntity> quote( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam AdRewardFeature feature + ) { + return ApiResponseEntity.ok(adRewardSessionService.quote(principal.getId(), feature)); + } + + @PostMapping("/sessions") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "광고 보상 세션 발급", + description = """ + 광고를 보기로 확정한 시점(광고 보기 버튼을 눌렀을 때)에 호출합니다. 부족분(requiredCrystal)을 확정 계산해 지급 예정 크리스탈(rewardAmount)로 세션을 만들고, AdMob custom_data에 담을 sessionKey를 반환합니다.
+ 흐름은 광고 보기 버튼 클릭 -> 이 api 호출(서버에서 세션 생성) -> 클라이언트에서 응답으로 받은 sessionKey를 AdMob custom_data에 담아 광고 시청입니다.
+ ※ AdMob SSV에는 custom_data(sessionKey)만 설정하세요. user_id는 비워둡니다 — 내부 식별자를 넣으면 노출되며, 유저 바인딩은 sessionKey로 이뤄집니다.
+ 같은 사용자의 기존 대기(PENDING) 세션은 만료 처리되어, 활성 세션은 항상 1개만 유지됩니다.
+ 이미 크리스탈이 충분하면 AD_REWARD_NOT_NEEDED(400)로 거부되며, 이 경우 광고가 불필요합니다.
+ 광고 시청 후에는 지급 완료 여부를 세션 상태 조회(GET /ad-rewards/sessions/{sessionKey})로 확인하세요. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "세션 발급 성공", + content = @Content(schema = @Schema(implementation = AdRewardSessionCreateResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "400", + description = "- ErrorCode: AD_REWARD_NOT_NEEDED - 이미 크리스탈이 충분해 광고 시청이 필요하지 않음", + content = @Content + ), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + - ErrorCode: WALLET_NOT_FOUND - 지갑을 찾을 수 없음 + """, + content = @Content + ) + } + ) + public ResponseEntity> createSession( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody AdRewardSessionCreateRequest request + ) { + return ApiResponseEntity.ok(adRewardSessionService.createSession(principal.getId(), request.feature())); + } + + @GetMapping("/sessions/{sessionKey}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "광고 보상 세션 상태 조회 (폴링)", + description = """ + 광고 시청 후 서버의 크리스탈 지급 완료 여부를 sessionKey로 폴링합니다. 본인 세션만 조회할 수 있습니다.
+ 지급은 AdMob 클라이언트 콜백이 아니라 서버-서버 SSV 검증으로 이뤄지므로, 클라이언트 보상 콜백을 믿지 말고 이 API로 REWARDED를 확인한 뒤에 진행하세요. 광고 백엔드는 크리스탈 지급만 담당하고, 실제 차감·실행은 기존 기능 API가 그대로 처리합니다.
+ 광고 시청 완료 후 보통 1~2초 내에 REWARDED가 됩니다(서버-서버 SSV 전달 시간). 폴링 간격·타임아웃은 FE가 정하면 됩니다.
+ REWARDED가 일정 시간 안 떠서 FE가 폴링을 멈추는 경우(= 폴링 타임아웃)의 사용자 안내(실패/재시도 표시/다시 기능화면으로 돌아가기 등)는 FE·기획 정책입니다. 다만 폴링을 멈춰도 리워드가 영구히 사라지는 건 아닙니다 — SSV가 늦게 도착하면 세션 유효시간 내에서 서버가 지급하기 때문입니다.
+ status 값:
+ - PENDING: 지급 대기 중(SSV 콜백 대기)
+ - REWARDED: 지급 완료. rewardAmount만큼 충전되어 크리스탈이 충분해졌으므로, 크리스탈 부족으로 막혔던 그 기능의 기존 API(예: PDF 내보내기 생성 POST /pdf-exports, 물어보기 대화권 충전·사용)를 다시 호출하면 정상 차감·실행됩니다.
+ - EXPIRED: 세션 TTL이 지나 만료됨(지급 없이 종료). 광고를 다시 보려면 세션 발급(POST /ad-rewards/sessions)부터 다시 시작하세요.
+ status는 계산값으로, PENDING이라도 TTL이 지났으면 EXPIRED로 반환됩니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "상태 조회 성공", + content = @Content(schema = @Schema(implementation = AdRewardSessionStatusResponse.class), mediaType = "application/json") + ), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse( + responseCode = "403", + description = "- ErrorCode: AD_REWARD_SESSION_ACCESS_FORBIDDEN - 본인의 광고 보상 세션이 아님", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = "- ErrorCode: AD_REWARD_SESSION_NOT_FOUND - 광고 보상 세션을 찾을 수 없음", + content = @Content + ) + } + ) + public ResponseEntity> getSessionStatus( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable String sessionKey + ) { + return ApiResponseEntity.ok(adRewardSessionService.getStatus(principal.getId(), sessionKey)); + } + + // 공개 엔드포인트: 구글 SSV 콜백(인증 없음, 서명검증으로 보호). 구글은 HTTP 상태코드만 본다. + @GetMapping("/ssv") + @Operation(hidden = true) + public ResponseEntity ssvCallback(HttpServletRequest request) { + boolean retry = adRewardCallbackService.handle(request); + // 일시 오류만 5xx로 구글 재시도 유도, 나머지 비즈니스 결과는 200 + return retry ? ResponseEntity.internalServerError().build() : ResponseEntity.ok().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/request/AdRewardSessionCreateRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/request/AdRewardSessionCreateRequest.java new file mode 100644 index 00000000..29fb6d36 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/request/AdRewardSessionCreateRequest.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.ads.api.dto.request; + +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardFeature; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record AdRewardSessionCreateRequest( + @Schema(description = "광고 보상 대상 기능", example = "PDF_REPORT_AND_ANSWER") + @NotNull + AdRewardFeature feature +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardQuoteResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardQuoteResponse.java new file mode 100644 index 00000000..69644834 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardQuoteResponse.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.ads.api.dto.response; + +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardFeature; +import io.swagger.v3.oas.annotations.media.Schema; + +// 부족분 조회 응답 +public record AdRewardQuoteResponse( + @Schema(description = "대상 기능", example = "PDF_REPORT_AND_ANSWER") + AdRewardFeature feature, + + @Schema(description = "기능 사용 비용(크리스탈)", example = "100") + long crystalCost, + + @Schema(description = "현재 보유 크리스탈", example = "30") + long balance, + + @Schema(description = "부족분 (광고로 채워야 할 크리스탈, 0 이상). 0이면 이미 충분해 광고 불필요", example = "70") + long requiredCrystal +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardSessionCreateResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardSessionCreateResponse.java new file mode 100644 index 00000000..976d39ef --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardSessionCreateResponse.java @@ -0,0 +1,18 @@ +package com.devkor.ifive.nadab.domain.ads.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +// 세션 발급(commit) 응답. sessionKey를 AdMob custom_data로 실어 광고를 띄운다. +public record AdRewardSessionCreateResponse( + @Schema(description = "세션 토큰(AdMob custom_data로 전달)", example = "aB3x... (40자)") + String sessionKey, + + @Schema(description = "지급 예정 크리스탈 N(확정값)", example = "70") + long rewardAmount, + + @Schema(description = "세션 만료 시각", example = "2026-09-19T12:10:00Z") + OffsetDateTime expiresAt +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardSessionStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardSessionStatusResponse.java new file mode 100644 index 00000000..90fe17fb --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardSessionStatusResponse.java @@ -0,0 +1,14 @@ +package com.devkor.ifive.nadab.domain.ads.api.dto.response; + +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSessionStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +// 폴링 응답. status는 계산값(TTL 지나면 EXPIRED). +public record AdRewardSessionStatusResponse( + @Schema(description = "세션 상태(PENDING 대기 / REWARDED 지급완료 / EXPIRED 만료)", example = "REWARDED") + AdRewardSessionStatus status, + + @Schema(description = "지급 예정/완료 크리스탈 N", example = "70") + long rewardAmount +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardCallbackService.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardCallbackService.java new file mode 100644 index 00000000..89c87cc3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardCallbackService.java @@ -0,0 +1,59 @@ +package com.devkor.ifive.nadab.domain.ads.application; + +import com.devkor.ifive.nadab.domain.ads.infra.AdRewardVerifyResult; +import com.devkor.ifive.nadab.domain.ads.infra.AdmobSignatureVerifier; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +// SSV 콜백 오케스트레이션: 검증(트랜잭션 밖) → 지급 처리. 검증 자체가 외부 I/O라 tx 밖에서 수행. +@Slf4j +@Service +@RequiredArgsConstructor +public class AdRewardCallbackService { + + private final AdmobSignatureVerifier verifier; + private final AdRewardGrantService grantService; + + // 반환 true = 구글 재시도 유도(5xx), false = 처리 완료(200). + public boolean handle(HttpServletRequest request) { + // getParameter는 URL 디코드된 값(파싱용). 서명 검증은 verifier가 원본 getQueryString 사용. + String sessionKey = nullSafe(request.getParameter("custom_data")); + String transactionId = nullSafe(request.getParameter("transaction_id")); + String signature = nullSafe(request.getParameter("signature")); + String keyId = nullSafe(request.getParameter("key_id")); + + // probe(콘솔 Verify, custom_data 없음)·서명 메타 없음 → 무시하고 200 + if (sessionKey.isBlank() || signature.isBlank() || keyId.isBlank()) { + log.info("SSV 콜백 무시(probe 또는 서명 메타 없음)"); + return false; + } + + AdRewardVerifyResult verifyResult = verifier.verify(request); + if (verifyResult == AdRewardVerifyResult.TRANSIENT_ERROR) { + // 키 fetch 등 일시 오류 — 유효할 수도 있으니 구글이 재시도하게(리워드 살림) + try { + grantService.logTransientError(sessionKey, transactionId); + } catch (Exception e) { + // 로그 저장 실패(DB 문제 등)해도 재시도는 유도해야 하므로 결과는 그대로 5xx + log.error("SSV 일시 오류 로그 저장 실패", e); + } + return true; + } + boolean signatureValid = verifyResult == AdRewardVerifyResult.VALID; + + try { + grantService.process(sessionKey, transactionId, signatureValid); + return false; + } catch (Exception e) { + // 지급 처리 중 일시 오류(DB 등) — 재시도 유도(리워드 살림) + log.error("SSV 지급 처리 중 오류(재시도 유도)", e); + return true; + } + } + + private String nullSafe(String value) { + return value == null ? "" : value.trim(); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardGrantService.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardGrantService.java new file mode 100644 index 00000000..59fa41c1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardGrantService.java @@ -0,0 +1,123 @@ +package com.devkor.ifive.nadab.domain.ads.application; + +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardEvent; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardLog; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSession; +import com.devkor.ifive.nadab.domain.ads.core.repository.AdRewardLogRepository; +import com.devkor.ifive.nadab.domain.ads.core.repository.AdRewardSessionRepository; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; + +// SSV 검증 통과 후 지급 처리(멱등). 비즈니스 결과는 정상 반환(→200), 일시 오류는 예외가 전파되어 재시도 유도(→5xx). +@Slf4j +@Service +@RequiredArgsConstructor +public class AdRewardGrantService { + + private static final String REF_TYPE = "AD_REWARD_SESSION"; + + private final AdRewardSessionRepository sessionRepository; + private final AdRewardLogRepository logRepository; + private final UserWalletRepository userWalletRepository; + private final CrystalLogRepository crystalLogRepository; + + // 반환은 처리 결과(로깅/테스트용). null = 처리할 세션 없음(앱로그만). 어떤 결과든 재시도 유도 안 함(200). + @Transactional + public AdRewardEvent process(String sessionKey, String transactionId, boolean signatureValid) { + // 서명 확정 무효 — 지급 X. 실제 세션이 있으면 위변조 흔적으로 기록 + if (!signatureValid) { + sessionRepository.findBySessionKey(sessionKey).ifPresent(s -> + saveLog(s, AdRewardEvent.SSV_SIGNATURE_INVALID, transactionId, null)); + return AdRewardEvent.SSV_SIGNATURE_INVALID; + } + + AdRewardSession session = sessionRepository.findBySessionKeyForUpdate(sessionKey).orElse(null); + if (session == null) { + log.warn("SSV 콜백: 세션 없음(무시) sessionKey={}", mask(sessionKey)); + return null; + } + + // 멱등: 이미 지급된 세션 + if (session.isRewarded()) { + saveLog(session, AdRewardEvent.DUPLICATE, transactionId, "already rewarded"); + return AdRewardEvent.DUPLICATE; + } + + // 만료 + if (!session.isPending() || session.isExpired(OffsetDateTime.now())) { + session.markExpired(); + saveLog(session, AdRewardEvent.SESSION_EXPIRED, transactionId, null); + return AdRewardEvent.SESSION_EXPIRED; + } + + // txid 누락 — 콜백 불량으로 지급 불가(재시도 무의미) + if (transactionId == null || transactionId.isBlank()) { + saveLog(session, AdRewardEvent.GRANT_FAILED, transactionId, "missing transaction_id"); + return AdRewardEvent.GRANT_FAILED; + } + + // 멱등: 이미 처리된 txid(중복 콜백) + if (sessionRepository.existsByTransactionId(transactionId)) { + saveLog(session, AdRewardEvent.DUPLICATE, transactionId, "duplicate transaction_id"); + return AdRewardEvent.DUPLICATE; + } + + int rows = userWalletRepository.charge(session.getUserId(), session.getRewardAmount()); + // charge(clearAutomatically)가 영속성 컨텍스트를 비워 기존 session은 detached → 재로드해 이후 변경이 flush되게 + AdRewardSession managed = sessionRepository.findById(session.getId()) + .orElseThrow(() -> new IllegalStateException("locked ad reward session missing: " + session.getId())); + if (rows == 0) { + // 지갑 없음 등 — 영구 실패(재시도 무의미). 세션이 있는데 지갑이 없는 건 정상적으로 불가 → 이상신호 + log.error("SSV 지급 실패: 지갑 없음(데이터 이상) sessionId={}, userId={}", managed.getId(), managed.getUserId()); + saveLog(managed, AdRewardEvent.GRANT_FAILED, transactionId, "charge returned 0 (wallet missing)"); + return AdRewardEvent.GRANT_FAILED; + } + + long balanceAfter = userWalletRepository.findByUserId(managed.getUserId()) + .map(UserWallet::getCrystalBalance) + .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); + crystalLogRepository.save(CrystalLog.createConfirmed( + managed.getUser(), managed.getRewardAmount(), balanceAfter, + CrystalLogReason.AD_REWARD_GRANT, REF_TYPE, managed.getId())); + + managed.markRewarded(transactionId); + saveLog(managed, AdRewardEvent.GRANTED, transactionId, null); + return AdRewardEvent.GRANTED; + } + + // 검증 일시 오류를 durable 로그로 남김. 구글 재시도로 콜백이 반복되므로 세션당 1건만 기록. + @Transactional + public void logTransientError(String sessionKey, String transactionId) { + sessionRepository.findBySessionKey(sessionKey).ifPresent(session -> { + if (!logRepository.existsBySessionIdAndEvent(session.getId(), AdRewardEvent.SSV_TRANSIENT_ERROR)) { + saveLog(session, AdRewardEvent.SSV_TRANSIENT_ERROR, transactionId, null); + } + }); + } + + private void saveLog(AdRewardSession session, AdRewardEvent event, String transactionId, String detail) { + logRepository.save(AdRewardLog.create(session.getUser(), session, event, detail, blankToNull(transactionId))); + } + + private String blankToNull(String v) { + return (v == null || v.isBlank()) ? null : v; + } + + private String mask(String v) { + if (v == null || v.length() <= 6) { + return "***"; + } + return v.substring(0, 3) + "***" + v.substring(v.length() - 3); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardPricing.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardPricing.java new file mode 100644 index 00000000..17fa7eff --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardPricing.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.ads.application; + +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardFeature; +import com.devkor.ifive.nadab.domain.askchat.application.AskChatWalletChargeService; +import com.devkor.ifive.nadab.domain.pdfexport.core.entity.PdfExportType; +import org.springframework.stereotype.Component; + +// 기능별 필요 크리스탈. 가격은 여기서 새로 정의하지 않고 기존 소스에 위임(중복정의 방지). +@Component +public class AdRewardPricing { + + public long crystalCost(AdRewardFeature feature) { + return switch (feature) { + case PDF_REPORT_ONLY -> PdfExportType.REPORT_ONLY.getCrystalCost(); + case PDF_ANSWER_ONLY -> PdfExportType.ANSWER_ONLY.getCrystalCost(); + case PDF_REPORT_AND_ANSWER -> PdfExportType.REPORT_AND_ANSWER.getCrystalCost(); + case ASK_CHAT_TURN_CHARGE -> AskChatWalletChargeService.ASK_CHAT_TURN_CHARGE_CRYSTAL_COST; + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardSessionService.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardSessionService.java new file mode 100644 index 00000000..e9dc674e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardSessionService.java @@ -0,0 +1,107 @@ +package com.devkor.ifive.nadab.domain.ads.application; + +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardQuoteResponse; +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardSessionCreateResponse; +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardSessionStatusResponse; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardEvent; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardFeature; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardLog; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSession; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSessionStatus; +import com.devkor.ifive.nadab.domain.ads.core.repository.AdRewardLogRepository; +import com.devkor.ifive.nadab.domain.ads.core.repository.AdRewardSessionRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.security.util.SecureRandomBytesGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.Base64; + +@Service +@RequiredArgsConstructor +public class AdRewardSessionService { + + private static final int SESSION_KEY_BYTES = 16; // 128비트 + private static final long SESSION_TTL_SECONDS = 600; // 10분 + + private final AdRewardSessionRepository sessionRepository; + private final AdRewardLogRepository logRepository; + private final UserWalletRepository userWalletRepository; + private final UserRepository userRepository; + private final AdRewardPricing pricing; + private final SecureRandomBytesGenerator secureRandomBytesGenerator; + + // preview: 부족분 조회 + @Transactional(readOnly = true) + public AdRewardQuoteResponse quote(Long userId, AdRewardFeature feature) { + long crystalCost = pricing.crystalCost(feature); + long balance = balanceOf(userId); + long requiredCrystal = Math.max(0, crystalCost - balance); + return new AdRewardQuoteResponse(feature, crystalCost, balance, requiredCrystal); + } + + // 세션 발급: 활성 세션 1개 + fresh N 확정 + SESSION_CREATED 로그 + @Transactional + public AdRewardSessionCreateResponse createSession(Long userId, AdRewardFeature feature) { + long crystalCost = pricing.crystalCost(feature); + long balance = balanceOf(userId); + long n = crystalCost - balance; + if (n <= 0) { + // 이미 충분 → 광고 불필요 + throw new BadRequestException(ErrorCode.AD_REWARD_NOT_NEEDED); + } + + sessionRepository.expirePendingSessions(userId); + + String sessionKey = generateSessionKey(); + OffsetDateTime expiresAt = OffsetDateTime.now().plusSeconds(SESSION_TTL_SECONDS); + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + AdRewardSession session = sessionRepository.save( + AdRewardSession.create(user, sessionKey, feature, n, expiresAt)); + logRepository.save(AdRewardLog.create(user, session, AdRewardEvent.SESSION_CREATED, null, null)); + + return new AdRewardSessionCreateResponse(sessionKey, n, expiresAt); + } + + // poll: 상태 조회(본인 세션만). status는 계산값 — TTL 지나면 EXPIRED + @Transactional(readOnly = true) + public AdRewardSessionStatusResponse getStatus(Long userId, String sessionKey) { + AdRewardSession session = sessionRepository.findBySessionKey(sessionKey) + .orElseThrow(() -> new NotFoundException(ErrorCode.AD_REWARD_SESSION_NOT_FOUND)); + if (!session.getUserId().equals(userId)) { + throw new ForbiddenException(ErrorCode.AD_REWARD_SESSION_ACCESS_FORBIDDEN); + } + return new AdRewardSessionStatusResponse(effectiveStatus(session), session.getRewardAmount()); + } + + private AdRewardSessionStatus effectiveStatus(AdRewardSession session) { + if (session.isRewarded()) { + return AdRewardSessionStatus.REWARDED; + } + if (session.isPending() && session.isExpired(OffsetDateTime.now())) { + return AdRewardSessionStatus.EXPIRED; + } + return session.getStatus(); + } + + private long balanceOf(Long userId) { + return userWalletRepository.findByUserId(userId) + .map(UserWallet::getCrystalBalance) + .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); + } + + private String generateSessionKey() { + byte[] bytes = secureRandomBytesGenerator.generate(SESSION_KEY_BYTES); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardEvent.java new file mode 100644 index 00000000..71afbbf4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardEvent.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.ads.core.entity; + +public enum AdRewardEvent { + SESSION_CREATED, // 세션 발급(광고 보기 확정 시점) + SSV_SIGNATURE_INVALID, // 서명 확정 무효(위·변조) → 지급X, 재시도 무의미 + SSV_TRANSIENT_ERROR, // 공개키 fetch 네트워크 오류로 검증 못함 → 5xx 재시도 유도 + GRANTED, // 지급 완료(charge + CrystalLog) + GRANT_FAILED, // 서명 유효하나 지급 못 함(지갑 없음·txid 누락 등) + DUPLICATE, // 이미 지급됨 / txid 중복 콜백 → 멱등 무시 + SESSION_EXPIRED // TTL 만료 세션에 콜백 도착 → 지급X +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardFeature.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardFeature.java new file mode 100644 index 00000000..117deb62 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardFeature.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.ads.core.entity; + +// 광고 보상 대상 기능 식별자. 가격은 여기 두지 않고 AdRewardPricing이 기존 소스로 위임. +public enum AdRewardFeature { + PDF_REPORT_ONLY, + PDF_ANSWER_ONLY, + PDF_REPORT_AND_ANSWER, + ASK_CHAT_TURN_CHARGE +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardLog.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardLog.java new file mode 100644 index 00000000..d7edd969 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardLog.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.ads.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "ad_reward_logs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdRewardLog extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "session_id", nullable = false) + private AdRewardSession session; + + @Enumerated(EnumType.STRING) + @Column(name = "event", nullable = false, length = 30) + private AdRewardEvent event; + + @Column(name = "detail", length = 255) + private String detail; + + @Column(name = "transaction_id", length = 120) + private String transactionId; + + public static AdRewardLog create(User user, AdRewardSession session, AdRewardEvent event, String detail, String transactionId) { + AdRewardLog log = new AdRewardLog(); + log.user = user; + log.session = session; + log.event = event; + log.detail = detail; + log.transactionId = transactionId; + return log; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardSession.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardSession.java new file mode 100644 index 00000000..d0c35dd4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardSession.java @@ -0,0 +1,86 @@ +package com.devkor.ifive.nadab.domain.ads.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.AuditableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "ad_reward_sessions") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdRewardSession extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "session_key", nullable = false, length = 80) + private String sessionKey; + + @Enumerated(EnumType.STRING) + @Column(name = "feature", nullable = false, length = 40) + private AdRewardFeature feature; + + @Column(name = "reward_amount", nullable = false) + private long rewardAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private AdRewardSessionStatus status; + + @Column(name = "transaction_id", length = 120) + private String transactionId; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + @Column(name = "rewarded_at") + private OffsetDateTime rewardedAt; + + public static AdRewardSession create(User user, String sessionKey, AdRewardFeature feature, + long rewardAmount, OffsetDateTime expiresAt) { + AdRewardSession session = new AdRewardSession(); + session.user = user; + session.sessionKey = sessionKey; + session.feature = feature; + session.rewardAmount = rewardAmount; + session.status = AdRewardSessionStatus.PENDING; + session.expiresAt = expiresAt; + return session; + } + + public boolean isPending() { + return status == AdRewardSessionStatus.PENDING; + } + + public boolean isRewarded() { + return status == AdRewardSessionStatus.REWARDED; + } + + public boolean isExpired(OffsetDateTime now) { + return expiresAt.isBefore(now); + } + + public void markRewarded(String transactionId) { + this.status = AdRewardSessionStatus.REWARDED; + this.transactionId = transactionId; + this.rewardedAt = OffsetDateTime.now(); + } + + public void markExpired() { + this.status = AdRewardSessionStatus.EXPIRED; + } + + public Long getUserId() { + return user.getId(); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardSessionStatus.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardSessionStatus.java new file mode 100644 index 00000000..2309216a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardSessionStatus.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.ads.core.entity; + +public enum AdRewardSessionStatus { + PENDING, // 지급 대기 — 광고 시청·SSV 콜백을 기다리는 상태 + REWARDED, // 지급 완료(SSV 검증 성공 → 크리스탈 적립) + EXPIRED // TTL 만료(지급 없이 종료) +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/core/repository/AdRewardLogRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/repository/AdRewardLogRepository.java new file mode 100644 index 00000000..10e548ba --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/repository/AdRewardLogRepository.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.ads.core.repository; + +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardEvent; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdRewardLogRepository extends JpaRepository { + + // 세션당 특정 이벤트 로그 존재 여부(일시 오류를 세션당 1건만 남기기 위한 멱등 체크) + boolean existsBySessionIdAndEvent(Long sessionId, AdRewardEvent event); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/core/repository/AdRewardSessionRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/repository/AdRewardSessionRepository.java new file mode 100644 index 00000000..08d6a05e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/core/repository/AdRewardSessionRepository.java @@ -0,0 +1,38 @@ +package com.devkor.ifive.nadab.domain.ads.core.repository; + +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSession; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface AdRewardSessionRepository extends JpaRepository { + + // SSV 콜백 지급 처리용: 동시 콜백을 직렬화하는 비관적 락 조회 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT s FROM AdRewardSession s + WHERE s.sessionKey = :sessionKey + """) + Optional findBySessionKeyForUpdate(@Param("sessionKey") String sessionKey); + + // 폴링 조회용 + Optional findBySessionKey(String sessionKey); + + boolean existsByTransactionId(String transactionId); + + // 활성 세션 1개: 세션 발급 전 같은 유저의 PENDING 세션을 만료 처리(동시세션 과지급·이중차감 차단) + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE AdRewardSession s + SET s.status = com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSessionStatus.EXPIRED, + s.updatedAt = CURRENT_TIMESTAMP + WHERE s.user.id = :userId + AND s.status = com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSessionStatus.PENDING + """) + int expirePendingSessions(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/infra/AdRewardVerifyResult.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/infra/AdRewardVerifyResult.java new file mode 100644 index 00000000..6741a8e6 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/infra/AdRewardVerifyResult.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.ads.infra; + +public enum AdRewardVerifyResult { + VALID, // 서명 유효 + INVALID_SIGNATURE, // 서명 확정 무효 → 200(재시도 무의미) + TRANSIENT_ERROR // 키 fetch/네트워크 등 일시 오류 → 5xx(구글 재시도 유도, 리워드 살림) +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/ads/infra/AdmobSignatureVerifier.java b/src/main/java/com/devkor/ifive/nadab/domain/ads/infra/AdmobSignatureVerifier.java new file mode 100644 index 00000000..7ff8e906 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/ads/infra/AdmobSignatureVerifier.java @@ -0,0 +1,56 @@ +package com.devkor.ifive.nadab.domain.ads.infra; + +import com.google.crypto.tink.apps.rewardedads.RewardedAdsVerifier; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +// AdMob SSV 서명 검증(Tink). 공개키는 Tink가 gstatic에서 자동 fetch/캐시 +@Slf4j +@Component +public class AdmobSignatureVerifier { + + private final RewardedAdsVerifier verifier; + + public AdmobSignatureVerifier() throws GeneralSecurityException { + this(new RewardedAdsVerifier.Builder() + .fetchVerifyingPublicKeysWith(RewardedAdsVerifier.KEYS_DOWNLOADER_INSTANCE_PROD) + .build()); + } + + // 테스트에서 검증기(키 소스) 주입용 + AdmobSignatureVerifier(RewardedAdsVerifier verifier) { + this.verifier = verifier; + } + + // 콜백을 받은 원본 쿼리스트링 그대로 검증(디코드/재정렬 금지) + public AdRewardVerifyResult verify(HttpServletRequest request) { + String rewardUrl = request.getRequestURL().toString() + + (request.getQueryString() == null ? "" : "?" + request.getQueryString()); + try { + verifier.verify(rewardUrl); + return AdRewardVerifyResult.VALID; + } catch (Exception e) { + // 키 fetch/네트워크(IOException) 계열은 일시 오류 — 서명이 유효할 수도 있으니 재시도 유도 + if (hasCause(e, IOException.class)) { + log.warn("SSV 검증 일시 오류(재시도 유도): {}", e.toString()); + return AdRewardVerifyResult.TRANSIENT_ERROR; + } + // 그 외는 서명 확정 무효 — 재시도 무의미 + log.warn("SSV 서명 무효: {}", e.getMessage()); + return AdRewardVerifyResult.INVALID_SIGNATURE; + } + } + + private boolean hasCause(Throwable throwable, Class type) { + for (Throwable c = throwable; c != null; c = c.getCause()) { + if (type.isInstance(c)) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java index 601aaff1..9ae0f1a8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java @@ -16,5 +16,7 @@ public enum CrystalLogReason { PDF_EXPORT_GENERATE, + AD_REWARD_GRANT, + ADMIN_ADJUST } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index e1b451b7..d2737afe 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -481,6 +481,14 @@ public enum ErrorCode { PDF_EXPORT_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PDF 생성에 실패했습니다. 차감된 크리스탈은 자동 환불됩니다"), PDF_EXPORT_GENERATION_TIMEOUT(HttpStatus.INTERNAL_SERVER_ERROR, "시간 내에 생성이 완료되지 않아 취소되었습니다. 차감된 크리스탈은 자동 환불됩니다"), + // ==================== AD_REWARD (보상형 광고 크리스탈 충전) ==================== + // 400 Bad Request + AD_REWARD_NOT_NEEDED(HttpStatus.BAD_REQUEST, "이미 크리스탈이 충분해 광고 시청이 필요하지 않습니다"), + // 403 Forbidden + AD_REWARD_SESSION_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, "본인의 광고 보상 세션만 조회할 수 있습니다"), + // 404 Not Found + AD_REWARD_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "광고 보상 세션을 찾을 수 없습니다"), + // ==================== ADMIN (어드민) ==================== // 401 Unauthorized ADMIN_PAGE_INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "관리자 페이지 비밀번호가 올바르지 않습니다"); diff --git a/src/main/resources/db/migration/V20260919_2149__CH_create_ad_reward_tables.sql b/src/main/resources/db/migration/V20260919_2149__CH_create_ad_reward_tables.sql new file mode 100644 index 00000000..7d6481ae --- /dev/null +++ b/src/main/resources/db/migration/V20260919_2149__CH_create_ad_reward_tables.sql @@ -0,0 +1,46 @@ +CREATE TABLE ad_reward_sessions ( + id BIGSERIAL PRIMARY KEY, + + user_id BIGINT NOT NULL, + + session_key VARCHAR(80) NOT NULL, + feature VARCHAR(40) NOT NULL, + reward_amount BIGINT NOT NULL, + + status VARCHAR(20) NOT NULL, + transaction_id VARCHAR(120), + + expires_at TIMESTAMPTZ NOT NULL, + rewarded_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uq_ad_reward_sessions_session_key UNIQUE (session_key), + CONSTRAINT uq_ad_reward_sessions_transaction_id UNIQUE (transaction_id), + + CONSTRAINT fk_ad_reward_sessions_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- createSession마다 도는 expirePendingSessions(WHERE user_id) + 유저 삭제 CASCADE 조회용 +CREATE INDEX idx_ad_reward_sessions_user_id ON ad_reward_sessions (user_id); + + +CREATE TABLE ad_reward_logs ( + id BIGSERIAL PRIMARY KEY, + + user_id BIGINT NOT NULL, + session_id BIGINT NOT NULL, + + event VARCHAR(30) NOT NULL, + detail VARCHAR(255), + transaction_id VARCHAR(120), + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT fk_ad_reward_logs_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + + CONSTRAINT fk_ad_reward_logs_session + FOREIGN KEY (session_id) REFERENCES ad_reward_sessions(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardCallbackServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardCallbackServiceTest.java new file mode 100644 index 00000000..3f0adda3 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardCallbackServiceTest.java @@ -0,0 +1,117 @@ +package com.devkor.ifive.nadab.domain.ads.application; + +import com.devkor.ifive.nadab.domain.ads.infra.AdRewardVerifyResult; +import com.devkor.ifive.nadab.domain.ads.infra.AdmobSignatureVerifier; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * SSV 콜백 오케스트레이션의 분기 검증(200=false / 5xx=true, 검증→지급 위임). + * 검증기·지급 서비스는 mock — 여기서 보는 건 handle의 분기와 재시도 판단이다. + */ +class AdRewardCallbackServiceTest { + + private static final String SESSION_KEY = "sk-1"; + private static final String TX_ID = "tx-1"; + + private final AdmobSignatureVerifier verifier = mock(AdmobSignatureVerifier.class); + private final AdRewardGrantService grantService = mock(AdRewardGrantService.class); + private final AdRewardCallbackService service = new AdRewardCallbackService(verifier, grantService); + + @Test + void probe_콜백은_검증없이_200() { + // custom_data 없음(콘솔 Verify probe) + boolean retry = service.handle(request(null, TX_ID, "sig", "1")); + + assertThat(retry).isFalse(); + verify(verifier, never()).verify(any()); + verify(grantService, never()).process(anyString(), any(), anyBoolean()); + verify(grantService, never()).logTransientError(anyString(), any()); + } + + @Test + void 서명메타_없으면_검증없이_200() { + boolean retry = service.handle(request(SESSION_KEY, TX_ID, null, "1")); // signature 없음 + + assertThat(retry).isFalse(); + verify(verifier, never()).verify(any()); + } + + @Test + void 일시오류면_로그_남기고_5xx() { + when(verifier.verify(any())).thenReturn(AdRewardVerifyResult.TRANSIENT_ERROR); + + boolean retry = service.handle(request(SESSION_KEY, TX_ID, "sig", "1")); + + assertThat(retry).isTrue(); + verify(grantService).logTransientError(SESSION_KEY, TX_ID); + verify(grantService, never()).process(anyString(), any(), anyBoolean()); + } + + @Test + void 일시오류_로그저장_실패해도_5xx() { + when(verifier.verify(any())).thenReturn(AdRewardVerifyResult.TRANSIENT_ERROR); + doThrow(new RuntimeException("db down")).when(grantService).logTransientError(anyString(), any()); + + boolean retry = service.handle(request(SESSION_KEY, TX_ID, "sig", "1")); + + assertThat(retry).isTrue(); + } + + @Test + void 유효서명이면_지급처리를_true로_위임하고_200() { + when(verifier.verify(any())).thenReturn(AdRewardVerifyResult.VALID); + + boolean retry = service.handle(request(SESSION_KEY, TX_ID, "sig", "1")); + + assertThat(retry).isFalse(); + verify(grantService).process(SESSION_KEY, TX_ID, true); + } + + @Test + void 무효서명이면_지급처리를_false로_위임하고_200() { + when(verifier.verify(any())).thenReturn(AdRewardVerifyResult.INVALID_SIGNATURE); + + boolean retry = service.handle(request(SESSION_KEY, TX_ID, "sig", "1")); + + assertThat(retry).isFalse(); + verify(grantService).process(SESSION_KEY, TX_ID, false); + } + + @Test + void 지급처리중_예외면_5xx() { + when(verifier.verify(any())).thenReturn(AdRewardVerifyResult.VALID); + when(grantService.process(anyString(), any(), anyBoolean())).thenThrow(new RuntimeException("db down")); + + boolean retry = service.handle(request(SESSION_KEY, TX_ID, "sig", "1")); + + assertThat(retry).isTrue(); + } + + private MockHttpServletRequest request(String customData, String transactionId, String signature, String keyId) { + MockHttpServletRequest request = new MockHttpServletRequest(); + if (customData != null) { + request.setParameter("custom_data", customData); + } + if (transactionId != null) { + request.setParameter("transaction_id", transactionId); + } + if (signature != null) { + request.setParameter("signature", signature); + } + if (keyId != null) { + request.setParameter("key_id", keyId); + } + return request; + } +} \ No newline at end of file diff --git a/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardFlowIntegrationTest.java b/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardFlowIntegrationTest.java new file mode 100644 index 00000000..f93c8860 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardFlowIntegrationTest.java @@ -0,0 +1,67 @@ +package com.devkor.ifive.nadab.domain.ads.application; + +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardSessionCreateResponse; +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardSessionStatusResponse; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardEvent; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardFeature; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSessionStatus; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.security.util.SecureRandomBytesGenerator; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 실제 사용자 시퀀스: 세션 발급 → (광고 시청 가정) SSV 지급 → 폴링에서 REWARDED. + * 서명 검증(암호 라운드트립)은 배포 시 AdMob Verify로 커버하고, 여기선 검증 통과(true)를 가정한다. + */ +@DataJpaTest +@ActiveProfiles("test") +@Import({AdRewardSessionService.class, AdRewardGrantService.class, AdRewardPricing.class, SecureRandomBytesGenerator.class}) +class AdRewardFlowIntegrationTest extends PostgresIntegrationTestSupport { + + private static final AdRewardFeature FEATURE = AdRewardFeature.ASK_CHAT_TURN_CHARGE; + + @Autowired AdRewardSessionService sessionService; + @Autowired AdRewardGrantService grantService; + @Autowired AdRewardPricing pricing; + @Autowired UserRepository userRepository; + @Autowired UserWalletRepository walletRepository; + + @Test + void 세션발급부터_지급_폴링까지_실제_흐름() { + long cost = pricing.crystalCost(FEATURE); + User user = createUser(0L); + + // 1) 광고 보기 확정 → 세션 발급 (부족분 = 비용) + AdRewardSessionCreateResponse created = sessionService.createSession(user.getId(), FEATURE); + assertThat(created.rewardAmount()).isEqualTo(cost); + + // 2) 광고 시청 후 구글 SSV 콜백(서명 검증 통과 가정) → 지급 + AdRewardEvent result = grantService.process(created.sessionKey(), "tx-flow-1", true); + assertThat(result).isEqualTo(AdRewardEvent.GRANTED); + + // 3) 잔액 충전 + 폴링에서 REWARDED 확인 + assertThat(walletRepository.findByUserId(user.getId()).orElseThrow().getCrystalBalance()) + .isEqualTo(cost); + AdRewardSessionStatusResponse status = sessionService.getStatus(user.getId(), created.sessionKey()); + assertThat(status.status()).isEqualTo(AdRewardSessionStatus.REWARDED); + assertThat(status.rewardAmount()).isEqualTo(cost); + } + + private User createUser(long balance) { + User user = User.createUser("ad-flow+" + System.nanoTime() + "@test.com", "hashed_password"); + user.updateNickname("af" + System.nanoTime()); + User saved = userRepository.save(user); + walletRepository.save(UserWallet.create(saved, balance)); + return saved; + } +} \ No newline at end of file diff --git a/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardGrantServiceIntegrationTest.java b/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardGrantServiceIntegrationTest.java new file mode 100644 index 00000000..253be4ab --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardGrantServiceIntegrationTest.java @@ -0,0 +1,195 @@ +package com.devkor.ifive.nadab.domain.ads.application; + +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardEvent; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardFeature; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSession; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSessionStatus; +import com.devkor.ifive.nadab.domain.ads.core.repository.AdRewardLogRepository; +import com.devkor.ifive.nadab.domain.ads.core.repository.AdRewardSessionRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * SSV 지급 처리(process)·일시오류 로그의 통합 검증. + * 특히 charge(clearAutomatically) 이후 세션이 실제로 REWARDED로 반영되는지(재로드 누락 시 이중 지급 회귀)를 본다. + */ +@DataJpaTest +@ActiveProfiles("test") +@Import(AdRewardGrantService.class) +class AdRewardGrantServiceIntegrationTest extends PostgresIntegrationTestSupport { + + private static final AdRewardFeature FEATURE = AdRewardFeature.ASK_CHAT_TURN_CHARGE; + private static final long REWARD = 100L; + + @Autowired AdRewardGrantService grantService; + @Autowired UserRepository userRepository; + @Autowired UserWalletRepository walletRepository; + @Autowired AdRewardSessionRepository sessionRepository; + @Autowired AdRewardLogRepository logRepository; + @Autowired CrystalLogRepository crystalLogRepository; + + @Test + void 유효_콜백이면_충전되고_세션이_REWARDED로_반영된다() { + User user = createUser(0L); + AdRewardSession session = pendingSession(user, REWARD, future()); + + AdRewardEvent result = grantService.process(session.getSessionKey(), "tx-1", true); + + assertThat(result).isEqualTo(AdRewardEvent.GRANTED); + + AdRewardSession reloaded = sessionRepository.findBySessionKey(session.getSessionKey()).orElseThrow(); + assertThat(reloaded.getStatus()).isEqualTo(AdRewardSessionStatus.REWARDED); + assertThat(reloaded.getTransactionId()).isEqualTo("tx-1"); + assertThat(reloaded.getRewardedAt()).isNotNull(); + + assertThat(balanceOf(user)).isEqualTo(REWARD); + assertThat(logRepository.existsBySessionIdAndEvent(session.getId(), AdRewardEvent.GRANTED)).isTrue(); + + List crystalLogs = crystalLogsOf(user); + assertThat(crystalLogs).hasSize(1); + assertThat(crystalLogs.get(0).getReason()).isEqualTo(CrystalLogReason.AD_REWARD_GRANT); + assertThat(crystalLogs.get(0).getDelta()).isEqualTo(REWARD); + assertThat(crystalLogs.get(0).getBalanceAfter()).isEqualTo(REWARD); + } + + @Test + void 중복_콜백이어도_한_번만_지급된다() { + User user = createUser(0L); + AdRewardSession session = pendingSession(user, REWARD, future()); + + assertThat(grantService.process(session.getSessionKey(), "tx-1", true)).isEqualTo(AdRewardEvent.GRANTED); + assertThat(grantService.process(session.getSessionKey(), "tx-1", true)).isEqualTo(AdRewardEvent.DUPLICATE); + + assertThat(balanceOf(user)).isEqualTo(REWARD); // 두 번째 콜백에도 잔액은 그대로 + assertThat(crystalLogsOf(user)).hasSize(1); + } + + @Test + void 다른_세션이_이미_쓴_txid면_DUPLICATE로_막는다() { + User user = createUser(0L); + AdRewardSession first = pendingSession(user, REWARD, future()); + assertThat(grantService.process(first.getSessionKey(), "tx-1", true)).isEqualTo(AdRewardEvent.GRANTED); + + AdRewardSession second = pendingSession(user, REWARD, future()); + AdRewardEvent result = grantService.process(second.getSessionKey(), "tx-1", true); // 같은 txid 재사용 + + assertThat(result).isEqualTo(AdRewardEvent.DUPLICATE); + assertThat(balanceOf(user)).isEqualTo(REWARD); // 첫 지급분만, 재충전 없음 + assertThat(sessionRepository.findBySessionKey(second.getSessionKey()).orElseThrow().getStatus()) + .isEqualTo(AdRewardSessionStatus.PENDING); + } + + @Test + void 만료된_세션은_지급하지_않고_EXPIRED로_남긴다() { + User user = createUser(0L); + AdRewardSession session = pendingSession(user, REWARD, past()); + + AdRewardEvent result = grantService.process(session.getSessionKey(), "tx-1", true); + + assertThat(result).isEqualTo(AdRewardEvent.SESSION_EXPIRED); + assertThat(balanceOf(user)).isZero(); + assertThat(sessionRepository.findBySessionKey(session.getSessionKey()).orElseThrow().getStatus()) + .isEqualTo(AdRewardSessionStatus.EXPIRED); + assertThat(logRepository.existsBySessionIdAndEvent(session.getId(), AdRewardEvent.SESSION_EXPIRED)).isTrue(); + assertThat(crystalLogsOf(user)).isEmpty(); + } + + @Test + void 서명이_무효면_지급하지_않고_로그만_남긴다() { + User user = createUser(0L); + AdRewardSession session = pendingSession(user, REWARD, future()); + + AdRewardEvent result = grantService.process(session.getSessionKey(), "tx-1", false); + + assertThat(result).isEqualTo(AdRewardEvent.SSV_SIGNATURE_INVALID); + assertThat(balanceOf(user)).isZero(); + assertThat(sessionRepository.findBySessionKey(session.getSessionKey()).orElseThrow().getStatus()) + .isEqualTo(AdRewardSessionStatus.PENDING); + assertThat(logRepository.existsBySessionIdAndEvent(session.getId(), AdRewardEvent.SSV_SIGNATURE_INVALID)).isTrue(); + assertThat(crystalLogsOf(user)).isEmpty(); + } + + @Test + void txid가_없으면_GRANT_FAILED이고_지급하지_않는다() { + User user = createUser(0L); + AdRewardSession session = pendingSession(user, REWARD, future()); + + AdRewardEvent result = grantService.process(session.getSessionKey(), " ", true); + + assertThat(result).isEqualTo(AdRewardEvent.GRANT_FAILED); + assertThat(balanceOf(user)).isZero(); + assertThat(sessionRepository.findBySessionKey(session.getSessionKey()).orElseThrow().getStatus()) + .isEqualTo(AdRewardSessionStatus.PENDING); + assertThat(logRepository.existsBySessionIdAndEvent(session.getId(), AdRewardEvent.GRANT_FAILED)).isTrue(); + } + + @Test + void 존재하지_않는_세션이면_무시한다() { + AdRewardEvent result = grantService.process("no-such-session-key", "tx-1", true); + assertThat(result).isNull(); + } + + @Test + void 일시오류_로그는_세션당_한_건만_남긴다() { + User user = createUser(0L); + AdRewardSession session = pendingSession(user, REWARD, future()); + + grantService.logTransientError(session.getSessionKey(), "tx-1"); + grantService.logTransientError(session.getSessionKey(), "tx-1"); + + long count = logRepository.findAll().stream() + .filter(log -> log.getSession().getId().equals(session.getId())) + .filter(log -> log.getEvent() == AdRewardEvent.SSV_TRANSIENT_ERROR) + .count(); + assertThat(count).isEqualTo(1); + } + + /* ── 픽스처·조회 ─────────────────────────────────────────────────── */ + + private User createUser(long balance) { + User user = User.createUser("ad-grant+" + System.nanoTime() + "@test.com", "hashed_password"); + user.updateNickname("ag" + System.nanoTime()); + User saved = userRepository.save(user); + walletRepository.save(UserWallet.create(saved, balance)); + return saved; + } + + private AdRewardSession pendingSession(User user, long rewardAmount, OffsetDateTime expiresAt) { + return sessionRepository.save( + AdRewardSession.create(user, "sk-" + System.nanoTime(), FEATURE, rewardAmount, expiresAt)); + } + + private long balanceOf(User user) { + return walletRepository.findByUserId(user.getId()).orElseThrow().getCrystalBalance(); + } + + private List crystalLogsOf(User user) { + return crystalLogRepository.findAll().stream() + .filter(log -> log.getUser().getId().equals(user.getId())) + .toList(); + } + + private static OffsetDateTime future() { + return OffsetDateTime.now().plusMinutes(10); + } + + private static OffsetDateTime past() { + return OffsetDateTime.now().minusSeconds(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardSessionServiceIntegrationTest.java b/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardSessionServiceIntegrationTest.java new file mode 100644 index 00000000..e321b291 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardSessionServiceIntegrationTest.java @@ -0,0 +1,188 @@ +package com.devkor.ifive.nadab.domain.ads.application; + +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardQuoteResponse; +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardSessionCreateResponse; +import com.devkor.ifive.nadab.domain.ads.api.dto.response.AdRewardSessionStatusResponse; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardEvent; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardFeature; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSession; +import com.devkor.ifive.nadab.domain.ads.core.entity.AdRewardSessionStatus; +import com.devkor.ifive.nadab.domain.ads.core.repository.AdRewardLogRepository; +import com.devkor.ifive.nadab.domain.ads.core.repository.AdRewardSessionRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.security.util.SecureRandomBytesGenerator; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 세션 발급(quote/createSession/getStatus)의 통합 검증. + */ +@DataJpaTest +@ActiveProfiles("test") +@Import({AdRewardSessionService.class, AdRewardPricing.class, SecureRandomBytesGenerator.class}) +class AdRewardSessionServiceIntegrationTest extends PostgresIntegrationTestSupport { + + private static final AdRewardFeature FEATURE = AdRewardFeature.ASK_CHAT_TURN_CHARGE; + + @Autowired AdRewardSessionService sessionService; + @Autowired AdRewardPricing pricing; + @Autowired UserRepository userRepository; + @Autowired UserWalletRepository walletRepository; + @Autowired AdRewardSessionRepository sessionRepository; + @Autowired AdRewardLogRepository logRepository; + + @Test + void createSession_부족분으로_PENDING_세션과_로그를_만든다() { + long cost = pricing.crystalCost(FEATURE); + User user = createUser(0L); + + AdRewardSessionCreateResponse response = sessionService.createSession(user.getId(), FEATURE); + + assertThat(response.sessionKey()).isNotBlank(); + assertThat(response.rewardAmount()).isEqualTo(cost); // N = 비용 - 보유(0) + assertThat(response.expiresAt()).isAfter(OffsetDateTime.now()); + + AdRewardSession session = sessionRepository.findBySessionKey(response.sessionKey()).orElseThrow(); + assertThat(session.getStatus()).isEqualTo(AdRewardSessionStatus.PENDING); + assertThat(session.getRewardAmount()).isEqualTo(cost); + assertThat(session.getFeature()).isEqualTo(FEATURE); + assertThat(logRepository.existsBySessionIdAndEvent(session.getId(), AdRewardEvent.SESSION_CREATED)).isTrue(); + } + + @Test + void createSession_이미_충분하면_거부한다() { + long cost = pricing.crystalCost(FEATURE); + User user = createUser(cost); // 보유 = 비용 → 부족분 0 + + assertThatThrownBy(() -> sessionService.createSession(user.getId(), FEATURE)) + .isInstanceOf(BadRequestException.class); + } + + @Test + void createSession_기존_PENDING_세션을_만료시킨다() { + User user = createUser(0L); + + AdRewardSessionCreateResponse first = sessionService.createSession(user.getId(), FEATURE); + AdRewardSessionCreateResponse second = sessionService.createSession(user.getId(), FEATURE); + + assertThat(sessionRepository.findBySessionKey(first.sessionKey()).orElseThrow().getStatus()) + .isEqualTo(AdRewardSessionStatus.EXPIRED); + assertThat(sessionRepository.findBySessionKey(second.sessionKey()).orElseThrow().getStatus()) + .isEqualTo(AdRewardSessionStatus.PENDING); + } + + @Test + void quote_비용_보유_부족분을_반환한다() { + long cost = pricing.crystalCost(FEATURE); + User user = createUser(30L); + + AdRewardQuoteResponse response = sessionService.quote(user.getId(), FEATURE); + + assertThat(response.crystalCost()).isEqualTo(cost); + assertThat(response.balance()).isEqualTo(30L); + assertThat(response.requiredCrystal()).isEqualTo(cost - 30L); + } + + @Test + void quote_보유가_충분하면_부족분은_0이다() { + long cost = pricing.crystalCost(FEATURE); + User user = createUser(cost + 50L); + + AdRewardQuoteResponse response = sessionService.quote(user.getId(), FEATURE); + + assertThat(response.requiredCrystal()).isZero(); + } + + @Test + void getStatus_본인_세션이_아니면_거부한다() { + User owner = createUser(0L); + User other = createUser(0L); + AdRewardSession session = pendingSession(owner, 100L, future()); + + assertThatThrownBy(() -> sessionService.getStatus(other.getId(), session.getSessionKey())) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void getStatus_없는_세션이면_NotFound() { + User user = createUser(0L); + + assertThatThrownBy(() -> sessionService.getStatus(user.getId(), "no-such-session-key")) + .isInstanceOf(NotFoundException.class); + } + + @Test + void getStatus_지급완료면_REWARDED를_반환한다() { + User user = createUser(0L); + AdRewardSession session = AdRewardSession.create(user, "sk-" + System.nanoTime(), FEATURE, 100L, future()); + session.markRewarded("tx-1"); + sessionRepository.save(session); + + AdRewardSessionStatusResponse response = sessionService.getStatus(user.getId(), session.getSessionKey()); + + assertThat(response.status()).isEqualTo(AdRewardSessionStatus.REWARDED); + assertThat(response.rewardAmount()).isEqualTo(100L); + } + + @Test + void getStatus_대기중이고_만료전이면_PENDING을_반환한다() { + User user = createUser(0L); + AdRewardSession session = pendingSession(user, 100L, future()); + + AdRewardSessionStatusResponse response = sessionService.getStatus(user.getId(), session.getSessionKey()); + + assertThat(response.status()).isEqualTo(AdRewardSessionStatus.PENDING); + } + + @Test + void getStatus_PENDING이라도_TTL이_지나면_EXPIRED로_반환한다() { + User user = createUser(0L); + AdRewardSession session = pendingSession(user, 100L, past()); + + AdRewardSessionStatusResponse response = sessionService.getStatus(user.getId(), session.getSessionKey()); + + assertThat(response.status()).isEqualTo(AdRewardSessionStatus.EXPIRED); + assertThat(response.rewardAmount()).isEqualTo(100L); + // 계산값일 뿐 DB 상태는 그대로 PENDING + assertThat(sessionRepository.findBySessionKey(session.getSessionKey()).orElseThrow().getStatus()) + .isEqualTo(AdRewardSessionStatus.PENDING); + } + + /* ── 픽스처 ─────────────────────────────────────────────────────── */ + + private User createUser(long balance) { + User user = User.createUser("ad-session+" + System.nanoTime() + "@test.com", "hashed_password"); + user.updateNickname("as" + System.nanoTime()); + User saved = userRepository.save(user); + walletRepository.save(UserWallet.create(saved, balance)); + return saved; + } + + private AdRewardSession pendingSession(User user, long rewardAmount, OffsetDateTime expiresAt) { + return sessionRepository.save( + AdRewardSession.create(user, "sk-" + System.nanoTime(), FEATURE, rewardAmount, expiresAt)); + } + + private static OffsetDateTime future() { + return OffsetDateTime.now().plusMinutes(10); + } + + private static OffsetDateTime past() { + return OffsetDateTime.now().minusSeconds(1); + } +} \ No newline at end of file diff --git a/src/test/java/com/devkor/ifive/nadab/domain/ads/infra/AdmobSignatureVerifierTest.java b/src/test/java/com/devkor/ifive/nadab/domain/ads/infra/AdmobSignatureVerifierTest.java new file mode 100644 index 00000000..7a2153fe --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/ads/infra/AdmobSignatureVerifierTest.java @@ -0,0 +1,68 @@ +package com.devkor.ifive.nadab.domain.ads.infra; + +import com.google.crypto.tink.apps.rewardedads.RewardedAdsVerifier; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class AdmobSignatureVerifierTest { + + private final RewardedAdsVerifier tinkVerifier = mock(RewardedAdsVerifier.class); + private final AdmobSignatureVerifier verifier = new AdmobSignatureVerifier(tinkVerifier); + + @Test + void 서명이_유효하면_VALID() throws Exception { + doNothing().when(tinkVerifier).verify(anyString()); + + AdRewardVerifyResult result = verifier.verify(request("a=1&b=2&signature=sig&key_id=1")); + + assertThat(result).isEqualTo(AdRewardVerifyResult.VALID); + } + + @Test + void 원본_쿼리스트링_그대로_Tink에_전달() throws Exception { + doNothing().when(tinkVerifier).verify(anyString()); + + verifier.verify(request("ad_network=1&custom_data=abc&signature=sig&key_id=1")); + + verify(tinkVerifier).verify( + eq("http://localhost/api/v1/ad-rewards/ssv?ad_network=1&custom_data=abc&signature=sig&key_id=1")); + } + + @Test + void 서명_확정무효면_INVALID_SIGNATURE() throws Exception { + doThrow(new GeneralSecurityException("invalid ecdsa signature")) + .when(tinkVerifier).verify(anyString()); + + AdRewardVerifyResult result = verifier.verify(request("a=1&signature=bad&key_id=1")); + + assertThat(result).isEqualTo(AdRewardVerifyResult.INVALID_SIGNATURE); + } + + @Test + void 키fetch_등_일시오류면_TRANSIENT_ERROR() throws Exception { + doThrow(new GeneralSecurityException("failed to fetch keys", new IOException("connect timeout"))) + .when(tinkVerifier).verify(anyString()); + + AdRewardVerifyResult result = verifier.verify(request("a=1&signature=sig&key_id=1")); + + assertThat(result).isEqualTo(AdRewardVerifyResult.TRANSIENT_ERROR); + } + + private MockHttpServletRequest request(String queryString) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/ad-rewards/ssv"); + request.setQueryString(queryString); + return request; + } +} \ No newline at end of file