Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -76,6 +81,22 @@ public ResponseEntity<Void> callback(
}
}

@Override
public ResponseEntity<GlobalResponse<Void>> 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<GlobalResponse<UserAdStatusResDto>> getUserAdStatus(
@Parameter(hidden = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.*;
Expand Down Expand Up @@ -56,16 +58,16 @@ public interface AdsControllerDocs {
ResponseEntity<Void> 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")
Expand All @@ -78,6 +80,41 @@ ResponseEntity<Void> 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<GlobalResponse<Void>> 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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -23,6 +26,7 @@
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.UUID;

@Slf4j
@Service
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
}
}




/**
Expand Down
Loading