From f6de185891f2c001419216d41ee6ccba59fbdec5 Mon Sep 17 00:00:00 2001 From: nahjjun Date: Thu, 12 Feb 2026 15:17:24 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20Feat:=20=EA=B4=91=EA=B3=A0=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=88=98=EB=8F=99=20=EC=A7=80=EA=B8=89=20?= =?UTF-8?q?api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ads/controller/AdsController.java | 21 +++++++++ .../ads/controller/AdsControllerDocs.java | 45 +++++++++++++++++-- .../domain/ads/dto/TestAdRewardReqDto.java | 17 +++++++ .../domain/ads/enums/AdsErrorCode.java | 20 +++++++++ .../domain/ads/service/AdsService.java | 45 ++++++++++++++++++- 5 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/egobook_be/domain/ads/dto/TestAdRewardReqDto.java create mode 100644 src/main/java/com/example/egobook_be/domain/ads/enums/AdsErrorCode.java diff --git a/src/main/java/com/example/egobook_be/domain/ads/controller/AdsController.java b/src/main/java/com/example/egobook_be/domain/ads/controller/AdsController.java index 5be8bdd..c7028ff 100644 --- a/src/main/java/com/example/egobook_be/domain/ads/controller/AdsController.java +++ b/src/main/java/com/example/egobook_be/domain/ads/controller/AdsController.java @@ -1,15 +1,20 @@ package com.example.egobook_be.domain.ads.controller; +import com.example.egobook_be.domain.ads.dto.TestAdRewardReqDto; import com.example.egobook_be.domain.ads.dto.UserAdStatusResDto; import com.example.egobook_be.domain.ads.service.AdsService; import com.example.egobook_be.global.response.GlobalResponse; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -76,6 +81,22 @@ public ResponseEntity callback( } } + @Override + public ResponseEntity> grantTestAdReward( + @Parameter(hidden = true) + @AuthenticationPrincipal(expression = "userAuthDto.userId") Long userId, + + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "보상 요청 정보", required = true, + content = @Content(schema = @Schema(implementation = TestAdRewardReqDto.class)) + ) + @RequestBody @Valid TestAdRewardReqDto reqDto + ){ + adsService.grantTestAdReward(userId, reqDto); + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponse.success("광고 보상 수령 완료", null)); + } + @Override public ResponseEntity> getUserAdStatus( @Parameter(hidden = true) diff --git a/src/main/java/com/example/egobook_be/domain/ads/controller/AdsControllerDocs.java b/src/main/java/com/example/egobook_be/domain/ads/controller/AdsControllerDocs.java index e6db293..0ba2b60 100644 --- a/src/main/java/com/example/egobook_be/domain/ads/controller/AdsControllerDocs.java +++ b/src/main/java/com/example/egobook_be/domain/ads/controller/AdsControllerDocs.java @@ -1,5 +1,6 @@ package com.example.egobook_be.domain.ads.controller; +import com.example.egobook_be.domain.ads.dto.TestAdRewardReqDto; import com.example.egobook_be.domain.ads.dto.UserAdStatusResDto; import com.example.egobook_be.domain.ads.enums.AdRewardType; import com.example.egobook_be.global.response.GlobalResponse; @@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -56,16 +58,16 @@ public interface AdsControllerDocs { ResponseEntity callback( @Parameter(hidden = true) HttpServletRequest request, // 원본 쿼리 스트링 추출용 (서명 검증에 필수임) - @Parameter(description = "AdMob에서 생성한 SSV 콜백 서명 값", required = false, example = "TEST_PASS") + @Parameter(description = "AdMob에서 생성한 SSV 콜백 서명 값", required = true, example = "TEST_PASS") @RequestParam("signature") String signature, - @Parameter(description = "서명 검증에 사용할 키 ID", required = false, example = "test_key_123") + @Parameter(description = "서명 검증에 사용할 키 ID", required = true, example = "test_key_123") @RequestParam("key_id") String keyId, - @Parameter(description = "광고 시청 고유 트랜잭션 ID (중복 방지 키)", required = false, example = "TX_TEST_001") + @Parameter(description = "광고 시청 고유 트랜잭션 ID (중복 방지 키)", required = true, example = "TX_TEST_001") @RequestParam("transaction_id") String transactionId, - @Parameter(description = "사용자 식별자 (앱에서 설정한 값)", required = false, example = "1") + @Parameter(description = "사용자 식별자 (앱에서 설정한 값)", required = true, example = "1") @RequestParam("user_id") String userId, @Parameter(description = "지급할 보상 아이템 타입 (INK or WEEK_COUNSEL)", example = "INK") @@ -78,6 +80,41 @@ ResponseEntity callback( @RequestParam(value = "custom_data", required = false) String weeklyCounselId ); + + @Operation(summary = "광고 보상 수동 지급 (Client Trigger)", + description = """ + **[ 개요 ]** + AdMob 계정 이슈 또는 테스트 환경을 위해, **클라이언트가 직접 보상을 요청하는 비상용 API**입니다. + 구글의 서버 검증(SSV)을 거치지 않으므로, **백엔드에서 엄격한 일일 횟수 제한**을 적용합니다. + + **[ 보안 및 제약 사항 ]** + - **일일 제한:** 하루 최대 10회까지만 호출 가능합니다. (초과 시 429 or 400 에러) + + **[ 프론트 요청 가이드 ]** + 1. **잉크 보상 (INK):** + - ```rewardType```: "INK" + - ```targetId```: null (보낼 필요 없음) + 2. **주간 AI 리포트 잠금 해제 (WEEK_COUNSEL):** + - ```rewardType```: "WEEK_COUNSEL" + - ```targetId```: **잠금 해제할 WeeklyCounsel의 ID (PK)** + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "보상 지급 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (지원하지 않는 보상 타입 등)"), + @ApiResponse(responseCode = "429", description = "일일 보상 획득 한도 초과 (Too Many Requests)") + }) + @PostMapping("/testReward") + ResponseEntity> grantTestAdReward( + @Parameter(hidden = true) + @AuthenticationPrincipal(expression = "userAuthDto.userId") Long userId, + + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "보상 요청 정보", required = true, + content = @Content(schema = @Schema(implementation = TestAdRewardReqDto.class)) + ) + @RequestBody @Valid TestAdRewardReqDto reqDto + ); + @Operation(summary = "오늘의 광고 현황 및 기대 보상 조회", description = "사용자가 오늘 시청 가능한 남은 광고 수와 획득 가능한 재화 정보를 반환합니다.") @ApiResponses(value = { diff --git a/src/main/java/com/example/egobook_be/domain/ads/dto/TestAdRewardReqDto.java b/src/main/java/com/example/egobook_be/domain/ads/dto/TestAdRewardReqDto.java new file mode 100644 index 0000000..18aa305 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/ads/dto/TestAdRewardReqDto.java @@ -0,0 +1,17 @@ +package com.example.egobook_be.domain.ads.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record TestAdRewardReqDto( + @Schema(description = "보상 타입 (INK 또는 WEEK_COUNSEL)", example = "INK") + @NotBlank + String rewardType, + + @Schema(description = "타겟 ID (INK일 경우 null, 주간 리포트일 경우 해당 리포트 ID)", example = "105") + Long targetId, // 선택적 값이므로 Long 객체 사용 (null 허용) + + @Schema(description = "광고 단위 ID (로깅용, 선택 사항)", example = "ca-app-pub-test/12345") + String adUnitId +) { +} diff --git a/src/main/java/com/example/egobook_be/domain/ads/enums/AdsErrorCode.java b/src/main/java/com/example/egobook_be/domain/ads/enums/AdsErrorCode.java new file mode 100644 index 0000000..a9fa205 --- /dev/null +++ b/src/main/java/com/example/egobook_be/domain/ads/enums/AdsErrorCode.java @@ -0,0 +1,20 @@ +package com.example.egobook_be.domain.ads.enums; + +import com.example.egobook_be.global.exception.model.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AdsErrorCode implements BaseErrorCode { + /** 400 */ + UNDEFINED_AD_REWARD_TYPE(HttpStatus.BAD_REQUEST, "정의되지 않은 보상 타입입니다."), + EXCEED_DAILY_ADS_NUM(HttpStatus.BAD_REQUEST, "하루 광고 시청 횟수를 초과하였습니다."), + + /** 409 */ + TRANSACTION_ID_ALREADY_EXIST(HttpStatus.CONFLICT, "광고의 고유한 Transaction ID가 이미 DB에 존재합니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/example/egobook_be/domain/ads/service/AdsService.java b/src/main/java/com/example/egobook_be/domain/ads/service/AdsService.java index 472ac81..320579e 100644 --- a/src/main/java/com/example/egobook_be/domain/ads/service/AdsService.java +++ b/src/main/java/com/example/egobook_be/domain/ads/service/AdsService.java @@ -2,9 +2,11 @@ +import com.example.egobook_be.domain.ads.dto.TestAdRewardReqDto; import com.example.egobook_be.domain.ads.dto.UserAdStatusResDto; import com.example.egobook_be.domain.ads.enums.AdRewardType; +import com.example.egobook_be.domain.ads.enums.AdsErrorCode; import com.example.egobook_be.domain.ads.mapper.AdsMapper; import com.example.egobook_be.domain.ads.repository.AdRewardHistoryRepository; import com.example.egobook_be.domain.ego_room.entity.WeeklyCounsel; @@ -13,6 +15,7 @@ import com.example.egobook_be.domain.user.entity.User; import com.example.egobook_be.domain.user.repository.InkLogRepository; import com.example.egobook_be.domain.user.repository.UserRepository; +import com.example.egobook_be.global.exception.CustomException; import com.example.egobook_be.global.util.AdMobVerifier; import com.example.egobook_be.global.util.InkLogUtil; import lombok.RequiredArgsConstructor; @@ -23,6 +26,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; +import java.util.UUID; @Slf4j @Service @@ -90,7 +94,7 @@ public void adMobCallbackInk( switch (type) { case AdRewardType.INK -> { if (isDailyInkLimitReached(Long.parseLong(userIdStr))) { - log.warn("User {} reached daily INK limit. Transaction {} skipped.", userIdStr, transactionId); + log.warn("User {}는 일일 광고 시청 횟수를 초과했습니다. Transaction {}가 스킵되었습니다.", userIdStr, transactionId); return; // 잉크 안 주고 종료 } rewardInk(transactionId, userIdStr, adUnitId); @@ -161,6 +165,45 @@ private boolean isDailyInkLimitReached(Long userId) { return currentCount >= DAILY_AD_LIMIT; } + @Transactional + public void grantTestAdReward(Long userId, TestAdRewardReqDto reqDto) { + // 1. "TEST_" 접두사를 붙여서 랜덤 transactionId를 생성한다. + String mockTransactionId = "TEST_" + UUID.randomUUID().toString(); + + // 2. 멱등성(중복) 체크 - AdRewardHistory 엔티티에 인덱스 설정을 걸어두었으므로 빠르다 + if (historyRepository.existsByTransactionId(mockTransactionId)) { + throw new CustomException(AdsErrorCode.TRANSACTION_ID_ALREADY_EXIST); + } + + /* + * 3. 보상이 어느 종류의 보상인지에 따라 수행되는 작업을 분류한다. + * (1) rewardType이 AdRewardType.INK인 경우 + * - 사용자에게 잉크 지급 + * - 잉크 보상 Log 기록 + * - AdRewardHistory에 해당 기록 추가 + * (2) rewardType이 AdRewardType.WEEK_COUNSEL인 경우 + * - AdRewardHistory에 해당 기록 추가 (어떤 주간 AI 기록을 위한 광고를 본 것인지 기록) + */ + AdRewardType type; + try { + type = AdRewardType.valueOf(reqDto.rewardType()); + } catch (IllegalArgumentException | NullPointerException e) { + log.error("[AdMob Callback] 정의되지 않은 보상 타입입니다: {}", reqDto.rewardType()); + throw new CustomException(AdsErrorCode.UNDEFINED_AD_REWARD_TYPE); + } + switch (type) { + case AdRewardType.INK -> { + if (isDailyInkLimitReached(userId)) { + log.warn("User {}는 일일 광고 시청 횟수를 초과했습니다. Transaction {}가 스킵되었습니다.", userId, mockTransactionId); + throw new CustomException(AdsErrorCode.EXCEED_DAILY_ADS_NUM); + } + rewardInk(mockTransactionId, userId.toString(), reqDto.adUnitId()); + } + case AdRewardType.WEEK_COUNSEL -> rewardWeekCounsel(mockTransactionId, userId.toString(), reqDto.targetId().toString(), reqDto.adUnitId()); + } + } + + /**