From b348839a8aeecaa619789d070715897ec07e33ca Mon Sep 17 00:00:00 2001 From: Chanhae Lee <146611988+coldsunn@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:12:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(ads):=20=EB=B3=B4=EC=83=81=ED=98=95=20?= =?UTF-8?q?=EA=B4=91=EA=B3=A0=20=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=83=88=20?= =?UTF-8?q?=EC=B6=A9=EC=A0=84=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ads): 보상형 광고 세션·로그 테이블과 엔티티·리포지토리 추가 ad_reward_sessions(세션)·ad_reward_logs(감사 로그) 테이블 두개 추가 및 마이그레이션 추가, 엔티티·리포지토리(비관적 락 조회, 활성 세션 만료 포함) 추가 * feat(ads): AdMob SSV 서명 검증 컴포넌트(Tink) 추가 요청 받은 원본 쿼리 그대로 Tink로 SSV 콜백 서명 검증, 결과를 유효/무효/일시오류로 구분 * feat(ads): 부족분 조회·보상 세션 발급/상태 조회 API 추가 quote(부족분 미리보기)·createSession(세션 발급)·getStatus(폴링) 세가지 서비스 로직 구현, 에러코드 3종 추가 * feat(ads): SSV 콜백 수신과 크리스탈 지급 처리(멱등), 광고 보상 API 추가 SSV 콜백 처리 + 멱등 지급(비관적 락·txid·상태), 광고 보상 컨트롤러(/ad-rewards) 연결 * test(ads): 보상형 광고 세션·지급·콜백 테스트 추가 지급/멱등/만료/세션 발급·조회/콜백 분기/E2E 흐름 검증 --- build.gradle | 3 + commitlint.config.js | 4 + .../domain/ads/api/AdRewardController.java | 165 +++++++++++++++ .../request/AdRewardSessionCreateRequest.java | 12 ++ .../dto/response/AdRewardQuoteResponse.java | 20 ++ .../AdRewardSessionCreateResponse.java | 18 ++ .../AdRewardSessionStatusResponse.java | 14 ++ .../application/AdRewardCallbackService.java | 59 ++++++ .../ads/application/AdRewardGrantService.java | 123 +++++++++++ .../ads/application/AdRewardPricing.java | 20 ++ .../application/AdRewardSessionService.java | 107 ++++++++++ .../domain/ads/core/entity/AdRewardEvent.java | 11 + .../ads/core/entity/AdRewardFeature.java | 9 + .../domain/ads/core/entity/AdRewardLog.java | 47 +++++ .../ads/core/entity/AdRewardSession.java | 86 ++++++++ .../core/entity/AdRewardSessionStatus.java | 7 + .../repository/AdRewardLogRepository.java | 11 + .../repository/AdRewardSessionRepository.java | 38 ++++ .../ads/infra/AdRewardVerifyResult.java | 7 + .../ads/infra/AdmobSignatureVerifier.java | 56 +++++ .../wallet/core/entity/CrystalLogReason.java | 2 + .../nadab/global/core/response/ErrorCode.java | 8 + ...60919_2149__CH_create_ad_reward_tables.sql | 46 +++++ .../AdRewardCallbackServiceTest.java | 117 +++++++++++ .../AdRewardFlowIntegrationTest.java | 67 ++++++ .../AdRewardGrantServiceIntegrationTest.java | 195 ++++++++++++++++++ ...AdRewardSessionServiceIntegrationTest.java | 188 +++++++++++++++++ .../ads/infra/AdmobSignatureVerifierTest.java | 68 ++++++ 28 files changed, 1508 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/api/AdRewardController.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/request/AdRewardSessionCreateRequest.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardQuoteResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardSessionCreateResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/api/dto/response/AdRewardSessionStatusResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardCallbackService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardGrantService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardPricing.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardSessionService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardEvent.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardFeature.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardLog.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardSession.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/core/entity/AdRewardSessionStatus.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/core/repository/AdRewardLogRepository.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/core/repository/AdRewardSessionRepository.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/infra/AdRewardVerifyResult.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/ads/infra/AdmobSignatureVerifier.java create mode 100644 src/main/resources/db/migration/V20260919_2149__CH_create_ad_reward_tables.sql create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardCallbackServiceTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardFlowIntegrationTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardGrantServiceIntegrationTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/ads/application/AdRewardSessionServiceIntegrationTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/ads/infra/AdmobSignatureVerifierTest.java 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 From 3fcb467825e23beb8df8868db163cc18c77d939f Mon Sep 17 00:00:00 2001 From: Lee Ilseob Date: Sun, 27 Sep 2026 20:59:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(stats):=20=EB=AC=BC=EC=96=B4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=86=B5=EA=B3=84=20=ED=83=AD=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B8=B0=EA=B0=84=EB=B3=84=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(stats): 물어보기 통계 탭 이름과 위치 변경 * feat(stats): 물어보기 통계 일간·주간·월간 조회 추가 --- .../application/AskChatStatsService.java | 8 -- .../stats/controller/StatsController.java | 40 ++++++++- .../resources/templates/stats/ask-chat.html | 84 ++++++++++++++++++- .../templates/stats/fragments/tabs.html | 6 +- .../StatsControllerTemplateTest.java | 75 ++++++++++++++++- 5 files changed, 192 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java index d77f22a4..82e41316 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java @@ -19,7 +19,6 @@ import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatWalletStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatWalletSummaryDto; import com.devkor.ifive.nadab.domain.stats.core.repository.AskChatStatsRepository; -import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,19 +37,12 @@ @Transactional(readOnly = true) public class AskChatStatsService { - public static final int DEFAULT_CHART_DAYS = 7; - private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter REFRESHED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final AskChatStatsRepository repository; - public AskChatStatsViewModel getAskChatStats() { - LocalDate endDate = TodayDateTimeProvider.getTodayDate(); - return getAskChatStats(endDate.minusDays(DEFAULT_CHART_DAYS - 1L), endDate); - } - public AskChatStatsViewModel getAskChatStats(LocalDate startDate, LocalDate endDate) { Objects.requireNonNull(startDate, "startDate must not be null"); Objects.requireNonNull(endDate, "endDate must not be null"); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index 50f4323d..da1831b6 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -25,6 +25,7 @@ import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -57,10 +58,45 @@ public class StatsController { private final DailyQuestionOverviewCsvExporter dailyQuestionOverviewCsvExporter; @GetMapping("/stats/ask-chat") - public String askChatStats(Model model) { - AskChatStatsViewModel vm = askChatStatsService.getAskChatStats(); + public String askChatStats( + @RequestParam(defaultValue = "daily") String period, + @RequestParam(required = false) String date, + @RequestParam(required = false) String week, + @RequestParam(required = false) String month, + Model model + ) { + LocalDate startDate; + LocalDate endDate; + String periodValue; + switch (period) { + case "daily" -> { + startDate = StatsPeriodResolver.resolveDaily(date); + endDate = startDate; + periodValue = startDate.toString(); + } + case "weekly" -> { + startDate = StatsPeriodResolver.resolveWeekly(week); + endDate = startDate.plusDays(6); + periodValue = StatsPeriodResolver.formatIsoWeek(startDate); + } + case "monthly" -> { + YearMonth selectedMonth = StatsPeriodResolver.resolveMonthly(month); + startDate = selectedMonth.atDay(1); + endDate = selectedMonth.atEndOfMonth(); + periodValue = selectedMonth.toString(); + } + default -> throw new BadRequestException(ErrorCode.VALIDATION_FAILED); + } + LocalDate today = TodayDateTimeProvider.getTodayDate(); + if (endDate.isAfter(today)) { + endDate = today; + } + + AskChatStatsViewModel vm = askChatStatsService.getAskChatStats(startDate, endDate); model.addAttribute("vm", vm); model.addAttribute("activeTab", "ask-chat"); + model.addAttribute("period", period); + model.addAttribute("periodValue", periodValue); return "stats/ask-chat"; } diff --git a/src/main/resources/templates/stats/ask-chat.html b/src/main/resources/templates/stats/ask-chat.html index e80d7dae..5b4d74a4 100644 --- a/src/main/resources/templates/stats/ask-chat.html +++ b/src/main/resources/templates/stats/ask-chat.html @@ -3,7 +3,7 @@ - NADAB — Ask Chat Stats + NADAB — Stats @@ -121,6 +121,20 @@ .tab-link:hover { color: var(--text-1); } .tab-link.active { color: #fff; background: var(--accent); } + .period-tabs { display: flex; gap: 8px; margin-bottom: 12px; } + .period-link { + padding: 8px 18px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-2); + background: var(--surface); + font-size: .78rem; + font-weight: 600; + text-decoration: none; + } + .period-link:hover { color: var(--text-1); border-color: var(--accent); } + .period-link.active { color: #fff; border-color: var(--accent); background: var(--accent); } + .period-panel, .panel, .summary-card { @@ -129,6 +143,7 @@ background: var(--surface); } .period-panel { margin-bottom: 20px; padding: 20px 22px; } + .period-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; } .period-eyebrow { margin-bottom: 7px; color: var(--accent); @@ -139,6 +154,33 @@ } .period-title { color: var(--text-1); font-family: 'DM Mono', monospace; font-size: 1.28rem; font-weight: 500; } .period-note { margin-top: 8px; color: var(--text-2); font-size: .78rem; line-height: 1.5; } + .period-picker { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; } + .period-picker-field { display: grid; gap: 6px; } + .period-picker-label { color: var(--text-2); font-size: .68rem; } + .period-picker-input { + min-height: 36px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-1); + background: var(--surface-2); + color-scheme: dark; + font: 500 .78rem 'DM Mono', monospace; + } + .period-picker-submit, .period-picker-current { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-1); + background: var(--surface-2); + font: 600 .78rem 'Syne', sans-serif; + text-decoration: none; + cursor: pointer; + } + .period-picker-submit:hover, .period-picker-current:hover { border-color: var(--accent); } .summary-grid { display: grid; @@ -214,6 +256,7 @@ body { padding: 20px 14px 40px; } .page-header { align-items: stretch; flex-direction: column; } .header-right { flex-wrap: wrap; gap: 8px 12px; } + .period-panel-header { align-items: stretch; flex-direction: column; } .chart-grid, .detail-grid { grid-template-columns: 1fr; } } @media (max-width: 520px) { @@ -248,10 +291,43 @@
+ +
-
Ask Chat 집계 기간
-
2026-08-25 ~ 2026-08-31
-
최근 7일 · Asia/Seoul 기준 · 세션, 메시지, 대화권, RAG read model을 한 화면에서 확인합니다.
+
+
+
Ask Chat 집계 기간
+
2026-08-25 ~ 2026-08-31
+
Asia/Seoul 기준 · 진행 중인 주·월은 오늘까지 집계합니다.
+
+
+ +
+ + + + +
+ + 오늘 + 이번 주 + 이번 달 +
+
diff --git a/src/main/resources/templates/stats/fragments/tabs.html b/src/main/resources/templates/stats/fragments/tabs.html index 31412b1e..42be6d6b 100644 --- a/src/main/resources/templates/stats/fragments/tabs.html +++ b/src/main/resources/templates/stats/fragments/tabs.html @@ -14,6 +14,9 @@ 유형 + 물어보기 질문 @@ -23,9 +26,6 @@ 전체 - Ask Chat diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java index c2a4e619..36fc033a 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java @@ -10,6 +10,7 @@ import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; import com.devkor.ifive.nadab.domain.stats.application.helper.DailyQuestionOverviewCsvExporter; +import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyRagStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyWalletStatsViewModel; @@ -41,6 +42,7 @@ import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.global.security.filter.JwtAuthenticationFilter; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -49,6 +51,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.ui.ExtendedModelMap; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; @@ -59,6 +62,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -76,6 +80,8 @@ class StatsControllerTemplateTest { @Autowired private MockMvc mockMvc; + @Autowired + private StatsController statsController; @MockitoBean private AskChatStatsService askChatStatsService; @@ -227,11 +233,14 @@ void askChatStats_renders_usage_quality_wallet_and_rag_dashboard() throws Except ), "2026-08-31 12:00:00" ); - when(askChatStatsService.getAskChatStats()).thenReturn(stats); + when(askChatStatsService.getAskChatStats(any(LocalDate.class), any(LocalDate.class))).thenReturn(stats); String html = mockMvc.perform(get("/stats/ask-chat")) .andExpect(status().isOk()) .andExpect(content().string(containsString("Ask Chat 집계 기간"))) + .andExpect(content().string(containsString("aria-label=\"물어보기 통계 기간\""))) + .andExpect(content().string(containsString("name=\"date\" type=\"date\""))) + .andExpect(content().string(containsString("name=\"period\" value=\"daily\""))) .andExpect(content().string(containsString("2026-08-25 ~ 2026-08-31"))) .andExpect(content().string(containsString("usageChart"))) .andExpect(content().string(containsString("답변 성공률"))) @@ -245,8 +254,54 @@ void askChatStats_renders_usage_quality_wallet_and_rag_dashboard() throws Except assertThat(html) .contains("일별 Ask Chat 상세", "TIMEOUT", "90.0%", "320.5 ms", "780 ms") - .contains("href=\"/stats/ask-chat\""); - verify(askChatStatsService).getAskChatStats(); + .contains("href=\"/stats/ask-chat\"", "href=\"/stats/ask-chat?period=weekly\""); + LocalDate today = TodayDateTimeProvider.getTodayDate(); + verify(askChatStatsService).getAskChatStats(today, today); + + mockMvc.perform(get("/stats/ask-chat").param("period", "weekly").param("week", "2025-W01")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("name=\"week\" type=\"week\""))) + .andExpect(content().string(containsString("value=\"2025-W01\""))) + .andExpect(content().string(containsString("name=\"period\" value=\"weekly\""))) + .andExpect(content().string(containsString("이번 주"))); + + mockMvc.perform(get("/stats/ask-chat").param("period", "monthly").param("month", "2026-08")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("name=\"month\" type=\"month\""))) + .andExpect(content().string(containsString("value=\"2026-08\""))) + .andExpect(content().string(containsString("name=\"period\" value=\"monthly\""))) + .andExpect(content().string(containsString("이번 달"))); + } + + @Test + void askChatStats_selects_daily_weekly_and_monthly_ranges() { + ExtendedModelMap model = new ExtendedModelMap(); + + statsController.askChatStats("daily", "2026-08-13", null, null, model); + assertThat(model.get("periodValue")).isEqualTo("2026-08-13"); + verify(askChatStatsService).getAskChatStats( + LocalDate.of(2026, 8, 13), LocalDate.of(2026, 8, 13)); + + statsController.askChatStats("weekly", null, "2025-W01", null, model); + assertThat(model.get("periodValue")).isEqualTo("2025-W01"); + verify(askChatStatsService).getAskChatStats( + LocalDate.of(2024, 12, 30), LocalDate.of(2025, 1, 5)); + + statsController.askChatStats("monthly", null, null, "2026-08", model); + assertThat(model.get("periodValue")).isEqualTo("2026-08"); + verify(askChatStatsService).getAskChatStats( + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31)); + } + + @Test + void askChatStats_caps_current_week_and_month_at_today() { + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + statsController.askChatStats("weekly", null, null, null, new ExtendedModelMap()); + verify(askChatStatsService).getAskChatStats(StatsPeriodResolver.resolveWeekly(null), today); + + statsController.askChatStats("monthly", null, null, null, new ExtendedModelMap()); + verify(askChatStatsService).getAskChatStats(today.withDayOfMonth(1), today); } @Test @@ -329,6 +384,14 @@ void statsPages_reject_invalid_or_future_period_parameters() throws Exception { .andExpect(status().isBadRequest()); mockMvc.perform(get("/stats/monthly").param("month", "2026-13")) .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/ask-chat").param("period", "yearly")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/ask-chat").param("date", "2999-01-01")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/ask-chat").param("period", "weekly").param("week", "invalid")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/ask-chat").param("period", "monthly").param("month", "2026-13")) + .andExpect(status().isBadRequest()); mockMvc.perform(get("/stats/question").param("questionId", "not-a-number")) .andExpect(status().isBadRequest()); mockMvc.perform(get("/stats/question/overview").param("questionLevel", "0")) @@ -626,8 +689,12 @@ void all_stats_templates_use_common_tab_fragment() throws Exception { "th:href=\"@{/stats/question}\"", ">질문", "th:href=\"@{/stats/ask-chat}\"", - ">Ask Chat" + ">물어보기" ); + assertThat(fragment.indexOf(">유형")) + .isLessThan(fragment.indexOf(">물어보기")); + assertThat(fragment.indexOf(">물어보기")) + .isLessThan(fragment.indexOf(">질문")); for (String template : List.of("daily", "weekly", "monthly", "type", "question", "question-overview", "withdrawal", "total", "ask-chat")) { String source = new ClassPathResource("templates/stats/" + template + ".html")