diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 00000000..4ecaeea9 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/db/20260901_create_ad_tables.sql b/docs/db/20260901_create_ad_tables.sql new file mode 100644 index 00000000..d862ecdc --- /dev/null +++ b/docs/db/20260901_create_ad_tables.sql @@ -0,0 +1,53 @@ +-- 제휴 광고(쿠팡 파트너스 / 애드픽) 소재·클릭·노출 테이블 +-- 관련 설계: docs/superpowers/specs/2026-09-01-ad-picke-store-design.md +-- +-- 이 파일은 참고용이다. 운영은 spring.jpa.hibernate.ddl-auto=update 라 배포 시 자동 생성된다. +-- 스키마를 손으로 관리하는 환경이나 사후 검증이 필요할 때 쓴다. + +CREATE TABLE IF NOT EXISTS ad_creatives ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(16) NOT NULL UNIQUE, + network VARCHAR(20) NOT NULL, + slot VARCHAR(40) NOT NULL, + title VARCHAR(100) NOT NULL, + subtitle VARCHAR(200), + image_url VARCHAR(500) NOT NULL, + cta_text VARCHAR(30) NOT NULL, + landing_url VARCHAR(1000) NOT NULL, + status VARCHAR(20) NOT NULL, + weight INTEGER NOT NULL DEFAULT 1, + starts_at TIMESTAMP, + ends_at TIMESTAMP, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- 지면 조회는 (slot, status)로만 들어온다. +CREATE INDEX IF NOT EXISTS idx_ad_creatives_slot_status ON ad_creatives (slot, status); + +CREATE TABLE IF NOT EXISTS ad_click_logs ( + id BIGSERIAL PRIMARY KEY, + creative_id BIGINT NOT NULL, + slot VARCHAR(40) NOT NULL, + ip_hash VARCHAR(64), + user_agent VARCHAR(500), + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ad_click_logs_creative ON ad_click_logs (creative_id); +CREATE INDEX IF NOT EXISTS idx_ad_click_logs_created_at ON ad_click_logs (created_at); + +-- 노출은 raw 로그로 쌓지 않는다. 배너가 스크롤에 걸릴 때마다 행이 생기면 금방 수천만 건이 된다. +CREATE TABLE IF NOT EXISTS ad_impression_daily ( + id BIGSERIAL PRIMARY KEY, + creative_id BIGINT NOT NULL, + slot VARCHAR(40) NOT NULL, + stat_date DATE NOT NULL, + impressions BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uk_ad_impression_daily UNIQUE (creative_id, slot, stat_date) +); + +CREATE INDEX IF NOT EXISTS idx_ad_impression_daily_date ON ad_impression_daily (stat_date); diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store new file mode 100644 index 00000000..c39638ab Binary files /dev/null and b/docs/superpowers/.DS_Store differ diff --git a/docs/superpowers/plans/2026-07-16-adfit-reward.md b/docs/superpowers/plans/2026-07-16-adfit-reward.md new file mode 100644 index 00000000..01757d89 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-adfit-reward.md @@ -0,0 +1,1119 @@ +# 애드핏 리워드 크레딧 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** AdMob을 완전히 제거하고, 애드핏 전면 광고 시청 후 티켓 방식으로 크레딧 20을 지급하는 API를 구현한다. + +**Architecture:** 클라이언트가 티켓을 발급받고(`POST /ticket`) 애드핏 광고를 노출한 뒤 티켓으로 청구한다(`POST /claim`). 애드핏은 S2S 콜백을 제공하지 않으므로 서버는 광고 시청을 증명할 수 없고, 대신 JWT 인증 + 티켓 1회성 + 최소 경과시간 + 일일 한도로 남용을 억제한다. 기존 `AdRewardHistory` / `CreditService` 파이프라인을 그대로 재사용한다. + +**Tech Stack:** Spring Boot, Spring Data JPA, PostgreSQL, JUnit5 + Mockito + AssertJ, Gradle + +**설계 문서:** `docs/superpowers/specs/2026-07-16-adfit-reward-design.md` + +## Global Constraints + +- 대상 브랜치: `dev`에서 `feat/adfit-reward` 분기. 작업 완료 후 `dev`로 PR (레포 Git Flow 컨벤션). +- **커밋 메시지는 한국어로 작성한다.** `Co-Authored-By` 라인을 절대 추가하지 않는다. +- 유저 조회는 `userService.findCurrentUser()`를 사용한다. 컨트롤러에서 `@AuthenticationPrincipal`을 쓰지 않는다 (`AttendanceController` 컨벤션). +- 크레딧 금액은 항상 `CreditType.FREE_CHARGE.getDefaultAmount()`(20) 고정. 클라이언트 값을 신뢰하지 않는다. +- 시간대는 `ZoneId.of("Asia/Seoul")`. `AttendanceService`의 `SEOUL_ZONE` 패턴을 따른다. +- 설정값은 `AdFitConfig`(`@Configuration` + `@Value` + `@Getter`)로 외부화한다 — 삭제될 `AdMobConfig`와 동일한 패턴. +- 테스트는 `@ExtendWith(MockitoExtension.class)` + `@InjectMocks` / `@Mock` + BDDMockito(`given`/`verify`) + AssertJ. `@Value` 필드는 `ReflectionTestUtils.setField`로 주입한다 (기존 `AdMobRewardServiceTest` 컨벤션). +- 스키마 마이그레이션 스크립트를 작성하지 않는다. `ddl-auto: update`가 `ad_reward_ticket`을 생성한다. `ad_reward_history`는 변경하지 않는다. +- 테스트 실행: `./gradlew test`, 단일 클래스는 `./gradlew test --tests ""` + +## File Structure + +**삭제 (Task 1)** +- `src/main/java/com/swyp/picke/global/config/AdMobConfig.java` +- `src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java` +- `src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardService.java` +- `src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java` +- `src/main/java/com/swyp/picke/domain/reward/dto/request/AdMobRewardRequest.java` +- `src/main/java/com/swyp/picke/domain/reward/dto/response/AdMobRewardResponse.java` +- `src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java` + +**신규** +- `domain/reward/entity/AdRewardTicket.java` — 티켓 엔티티 (Task 2) +- `domain/reward/repository/AdRewardTicketRepository.java` — 티켓 조회 (Task 2) +- `global/config/AdFitConfig.java` — 한도/시간 설정 (Task 3) +- `domain/reward/dto/response/AdFitTicketResponse.java` (Task 3) +- `domain/reward/dto/request/AdFitClaimRequest.java` (Task 4) +- `domain/reward/dto/response/AdFitClaimResponse.java` (Task 4) +- `domain/reward/service/AdFitRewardService.java` — 인터페이스 (Task 3) +- `domain/reward/service/AdFitRewardServiceImpl.java` — 발급(Task 3) + 청구(Task 4) +- `domain/reward/controller/AdFitRewardController.java` (Task 5) +- `src/test/java/com/swyp/picke/domain/reward/service/AdFitRewardServiceTest.java` (Task 3~4) + +**수정** +- `build.gradle:51-53` (Task 1), `application.yml:76-81` (Task 1, 3) +- `SecurityConfig.java:49` (Task 1, 5), `JwtFilter.java:30` (Task 1) +- `SwaggerConfig.java:88,97` (Task 1) +- `ErrorCode.java:110` (Task 1, 2) +- `docs/api-specs/reward-api.md` (Task 6) + +**유지 (변경 없음)** +`AdRewardHistory`, `RewardItem`, `CreditService`, `CreditType`, `UserService`, `StaticTextFileController` + +--- + +## Task 0: 브랜치 생성 + +- [ ] **Step 1: dev 최신화 후 브랜치 분기** + +```bash +cd /Users/suhwonji/Desktop/SideProject/Server +git checkout dev +git pull origin dev +git checkout -b feat/adfit-reward +git status --short --branch +``` + +기대: `## feat/adfit-reward` 출력, 변경사항 없음 + +--- + +## Task 1: AdMob 완전 제거 + +AdMob 코드를 먼저 지워야 `AdRewardHistoryRepository` 등 재사용 대상이 깨끗한 상태에서 신규 코드를 얹을 수 있다. 이 태스크의 산출물은 **AdMob 흔적이 0이면서 빌드가 통과하는 상태**다. + +**Files:** +- Delete: 위 "삭제" 목록 7개 파일 +- Modify: `build.gradle`, `src/main/resources/application.yml`, `SecurityConfig.java`, `JwtFilter.java`, `SwaggerConfig.java`, `ErrorCode.java` + +**Interfaces:** +- Consumes: 없음 +- Produces: 없음 (제거 전용). `AdRewardHistory`, `AdRewardHistoryRepository.existsByTransactionId(String)`, `RewardItem`은 그대로 남는다. + +- [ ] **Step 1: 파일 7개 삭제** + +```bash +cd /Users/suhwonji/Desktop/SideProject/Server +git rm src/main/java/com/swyp/picke/global/config/AdMobConfig.java \ + src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java \ + src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardService.java \ + src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java \ + src/main/java/com/swyp/picke/domain/reward/dto/request/AdMobRewardRequest.java \ + src/main/java/com/swyp/picke/domain/reward/dto/response/AdMobRewardResponse.java \ + src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java +``` + +- [ ] **Step 2: Tink 의존성 제거** + +`build.gradle`에서 51~53행 3줄(주석 포함)을 삭제한다: + +```gradle + // AdMob SSV 검증을 위한 Tink 라이브러리 + implementation 'com.google.crypto.tink:apps-rewardedads:1.9.1' + testImplementation 'com.google.crypto.tink:apps-rewardedads:1.9.1' +``` + +- [ ] **Step 3: application.yml에서 admob 블록 제거** + +76~81행을 삭제한다: + +```yaml +admob: + app-id: ${ADMOB_APP_ID} + reward: + unit-id: + ios: ${ADMOB_REWARD_UNIT_ID_IOS} + android: ${ADMOB_REWARD_UNIT_ID_ANDROID} +``` + +- [ ] **Step 4: SecurityConfig에서 permitAll 항목 제거** + +`SecurityConfig.java:49`의 아래 한 줄을 삭제한다. **애드핏 엔드포인트를 여기 추가하지 않는다** — 인증이 필요하다. + +```java + "/api/v1/admob/reward/**", +``` + +- [ ] **Step 5: JwtFilter에서 제외 경로 제거** + +`JwtFilter.java:30`의 아래 한 줄을 삭제한다: + +```java + "/api/v1/admob/reward", +``` + +- [ ] **Step 6: SwaggerConfig에서 admob 경로 제거** + +88행과 97행에서 `, "/api/v1/admob/**"` 부분만 각각 제거한다. + +88행 (사용자 API 그룹): +```java + .pathsToExclude("/api/v1/admin/**", "/api/v1/files/**", "/api/v1/resources/**", "/api/test/**") +``` + +97행 (관리자 API 그룹): +```java + .pathsToMatch("/api/v1/admin/**", "/api/v1/files/**", "/api/v1/resources/**", "/api/test/**") +``` + +- [ ] **Step 7: ErrorCode에서 AdMob 전용 코드 제거** + +`ErrorCode.java:110`의 아래 한 줄을 삭제한다 (애드핏에는 서명 검증이 없다): + +```java + REWARD_INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "REWARD_401", "AdMob 서명 검증에 실패했습니다."), +``` + +- [ ] **Step 8: 잔존 참조 확인** + +```bash +grep -rn -i "admob\|tink" --include="*.java" --include="*.yml" --include="*.gradle" . | grep -v "^./.git" | grep -v "^./docs" +``` + +기대: 출력 없음. (`docs/`는 Task 6에서 정리하므로 제외) + +- [ ] **Step 9: 빌드 및 전체 테스트** + +```bash +./gradlew clean build +``` + +기대: `BUILD SUCCESSFUL`. 컴파일 에러가 나면 8단계에서 놓친 참조가 있는 것이다. + +- [ ] **Step 10: 커밋** + +```bash +git add -A +git commit -m "chore: AdMob 리워드 연동 및 Tink 의존성 제거 + +계정 승인 거부로 AdMob 광고를 받을 수 없어 애드핏으로 교체한다. +지급 파이프라인(AdRewardHistory, CreditService)은 재사용하므로 남긴다." +``` + +--- + +## Task 2: 티켓 엔티티와 에러 코드 + +**Files:** +- Create: `src/main/java/com/swyp/picke/domain/reward/entity/AdRewardTicket.java` +- Create: `src/main/java/com/swyp/picke/domain/reward/repository/AdRewardTicketRepository.java` +- Modify: `src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java` +- Modify: `src/main/java/com/swyp/picke/domain/reward/repository/AdRewardHistoryRepository.java` + +**Interfaces:** +- Consumes: `BaseEntity`(`getId()`, `getCreatedAt()`), `User` +- Produces: + - `AdRewardTicket.builder().user(User).ticketId(String).build()` + - `AdRewardTicket#getTicketId(): String`, `#getUser(): User`, `#getUsedAt(): LocalDateTime`, `#isUsed(): boolean`, `#markUsed(LocalDateTime): void` + - `AdRewardTicketRepository#findByTicketId(String): Optional` + - `AdRewardHistoryRepository#countByUserIdAndCreatedAtBetween(Long, LocalDateTime, LocalDateTime): long` + - `ErrorCode.REWARD_TICKET_NOT_FOUND`, `REWARD_TICKET_ALREADY_USED`, `REWARD_TICKET_EXPIRED`, `REWARD_TICKET_TOO_SOON`, `REWARD_DAILY_LIMIT_EXCEEDED` + +- [ ] **Step 1: 티켓 엔티티 작성** + +`src/main/java/com/swyp/picke/domain/reward/entity/AdRewardTicket.java`: + +```java +package com.swyp.picke.domain.reward.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 애드핏 광고 시청 보상 청구용 1회성 티켓. + * 애드핏은 S2S 콜백을 제공하지 않으므로 서버가 광고 시청을 증명할 수 없다. + * 티켓은 증명이 아니라 남용 억제 수단이다 (1회성 + 최소 경과시간 + 일일 한도). + */ +@Entity +@Getter +@Table(name = "ad_reward_ticket") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdRewardTicket extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "ticket_id", unique = true, nullable = false) + private String ticketId; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Builder + public AdRewardTicket(User user, String ticketId) { + this.user = user; + this.ticketId = ticketId; + } + + public boolean isUsed() { + return this.usedAt != null; + } + + public void markUsed(LocalDateTime at) { + this.usedAt = at; + } +} +``` + +- [ ] **Step 2: 티켓 리포지토리 작성** + +`src/main/java/com/swyp/picke/domain/reward/repository/AdRewardTicketRepository.java`: + +```java +package com.swyp.picke.domain.reward.repository; + +import com.swyp.picke.domain.reward.entity.AdRewardTicket; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AdRewardTicketRepository extends JpaRepository { + + Optional findByTicketId(String ticketId); +} +``` + +- [ ] **Step 3: 일일 한도 집계 메서드 추가** + +`AdRewardHistoryRepository`에 아래 메서드를 추가한다 (기존 `existsByTransactionId`는 유지): + +```java + /** + * 당일 보상 지급 건수. 일일 한도 강제에 사용한다. + * 기준은 실제 지급 이력이며, 티켓 발급 건수가 아니다. + */ + long countByUserIdAndCreatedAtBetween(Long userId, LocalDateTime start, LocalDateTime end); +``` + +`import java.time.LocalDateTime;`를 추가한다. + +- [ ] **Step 4: ErrorCode 추가** + +`ErrorCode.java`의 `// Reward` 섹션(107~109행 근처, `REWARD_INVALID_TYPE` 아래)에 추가한다: + +```java + REWARD_TICKET_NOT_FOUND(HttpStatus.NOT_FOUND, "REWARD_404_2", "유효하지 않은 티켓입니다."), + REWARD_TICKET_ALREADY_USED(HttpStatus.CONFLICT, "REWARD_409", "이미 사용된 티켓입니다."), + REWARD_TICKET_EXPIRED(HttpStatus.GONE, "REWARD_410", "만료된 티켓입니다."), + REWARD_TICKET_TOO_SOON(HttpStatus.BAD_REQUEST, "REWARD_400_2", "광고 시청이 완료되지 않았습니다."), + REWARD_DAILY_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "REWARD_429", "오늘 받을 수 있는 광고 보상을 모두 받았습니다."), +``` + +- [ ] **Step 5: 컴파일 확인** + +```bash +./gradlew compileJava +``` + +기대: `BUILD SUCCESSFUL` + +- [ ] **Step 6: 커밋** + +```bash +git add -A +git commit -m "feat: 애드핏 보상 티켓 엔티티와 에러 코드 추가 + +ad_reward_ticket 테이블은 ddl-auto: update가 생성하므로 +마이그레이션 스크립트를 두지 않는다." +``` + +--- + +## Task 3: 티켓 발급 + +**Files:** +- Create: `src/main/java/com/swyp/picke/global/config/AdFitConfig.java` +- Create: `src/main/java/com/swyp/picke/domain/reward/dto/response/AdFitTicketResponse.java` +- Create: `src/main/java/com/swyp/picke/domain/reward/service/AdFitRewardService.java` +- Create: `src/main/java/com/swyp/picke/domain/reward/service/AdFitRewardServiceImpl.java` +- Create: `src/test/java/com/swyp/picke/domain/reward/service/AdFitRewardServiceTest.java` +- Modify: `src/main/resources/application.yml` + +**Interfaces:** +- Consumes: Task 2의 `AdRewardTicket`, `AdRewardTicketRepository`, `AdRewardHistoryRepository#countByUserIdAndCreatedAtBetween`, `ErrorCode.REWARD_DAILY_LIMIT_EXCEEDED` +- Produces: + - `AdFitConfig#getDailyLimit(): int`, `#getMinWatchSeconds(): long`, `#getTicketTtlSeconds(): long` + - `AdFitRewardService#issueTicket(): AdFitTicketResponse` + - `AdFitTicketResponse(String ticketId, long expiresInSeconds)` — record, 정적 팩토리 `of(String, long)` + +- [ ] **Step 1: 설정값 추가** + +`application.yml` 끝에 추가한다 (제거된 `admob` 블록 자리): + +```yaml +adfit: + reward: + daily-limit: 10 # 하루 최대 지급 횟수 (20크레딧 × 10 = 200/일) + min-watch-seconds: 5 # 티켓 발급~청구 최소 간격. 실제 광고 길이 측정 후 조정 필요 + ticket-ttl-seconds: 300 # 티켓 만료 (5분) +``` + +- [ ] **Step 2: AdFitConfig 작성** + +`src/main/java/com/swyp/picke/global/config/AdFitConfig.java`: + +```java +package com.swyp.picke.global.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Getter +@Configuration +public class AdFitConfig { + + /** 하루 최대 보상 지급 횟수 */ + @Value("${adfit.reward.daily-limit}") + private int dailyLimit; + + /** 티켓 발급 후 청구까지 최소 경과시간(초). 즉시 청구 자동화를 차단한다. */ + @Value("${adfit.reward.min-watch-seconds}") + private long minWatchSeconds; + + /** 티켓 만료 시간(초) */ + @Value("${adfit.reward.ticket-ttl-seconds}") + private long ticketTtlSeconds; +} +``` + +- [ ] **Step 3: 응답 DTO 작성** + +`src/main/java/com/swyp/picke/domain/reward/dto/response/AdFitTicketResponse.java`: + +```java +package com.swyp.picke.domain.reward.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "애드핏 광고 보상 티켓 발급 응답") +public record AdFitTicketResponse( + + @Schema(description = "보상 청구에 사용할 1회성 티켓 ID", example = "9f1c8e2a-4b7d-4c1e-9a3f-2b8c6d5e4f10") + String ticketId, + + @Schema(description = "티켓 만료까지 남은 시간(초)", example = "300") + long expiresInSeconds +) { + public static AdFitTicketResponse of(String ticketId, long expiresInSeconds) { + return new AdFitTicketResponse(ticketId, expiresInSeconds); + } +} +``` + +- [ ] **Step 4: 서비스 인터페이스 작성** + +`src/main/java/com/swyp/picke/domain/reward/service/AdFitRewardService.java`: + +```java +package com.swyp.picke.domain.reward.service; + +import com.swyp.picke.domain.reward.dto.response.AdFitTicketResponse; + +public interface AdFitRewardService { + + /** 광고 노출 전 1회성 티켓을 발급한다. 일일 한도 초과 시 거부한다. */ + AdFitTicketResponse issueTicket(); +} +``` + +- [ ] **Step 5: 실패하는 테스트 작성** + +`src/test/java/com/swyp/picke/domain/reward/service/AdFitRewardServiceTest.java`: + +```java +package com.swyp.picke.domain.reward.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.swyp.picke.domain.reward.dto.response.AdFitTicketResponse; +import com.swyp.picke.domain.reward.entity.AdRewardTicket; +import com.swyp.picke.domain.reward.repository.AdRewardHistoryRepository; +import com.swyp.picke.domain.reward.repository.AdRewardTicketRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.user.service.UserService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.config.AdFitConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +@ExtendWith(MockitoExtension.class) +class AdFitRewardServiceTest { + + @InjectMocks + private AdFitRewardServiceImpl rewardService; + + @Mock + private AdRewardTicketRepository ticketRepository; + + @Mock + private AdRewardHistoryRepository adRewardHistoryRepository; + + @Mock + private UserService userService; + + @Mock + private CreditService creditService; + + @Mock + private AdFitConfig adFitConfig; + + private User user; + + @BeforeEach + void setUp() { + user = User.builder().build(); + ReflectionTestUtils.setField(user, "id", 1L); + } + + @Test + @DisplayName("일일 한도 미달이면 티켓이 발급된다") + void issueTicket_success() { + given(userService.findCurrentUser()).willReturn(user); + given(adFitConfig.getDailyLimit()).willReturn(10); + given(adFitConfig.getTicketTtlSeconds()).willReturn(300L); + given(adRewardHistoryRepository.countByUserIdAndCreatedAtBetween(anyLong(), any(), any())) + .willReturn(3L); + + AdFitTicketResponse response = rewardService.issueTicket(); + + assertThat(response.ticketId()).isNotBlank(); + assertThat(response.expiresInSeconds()).isEqualTo(300L); + verify(ticketRepository).save(any(AdRewardTicket.class)); + } + + @Test + @DisplayName("일일 한도에 도달하면 티켓 발급이 거부된다") + void issueTicket_dailyLimitExceeded() { + given(userService.findCurrentUser()).willReturn(user); + given(adFitConfig.getDailyLimit()).willReturn(10); + given(adRewardHistoryRepository.countByUserIdAndCreatedAtBetween(anyLong(), any(), any())) + .willReturn(10L); + + assertThatThrownBy(() -> rewardService.issueTicket()) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REWARD_DAILY_LIMIT_EXCEEDED); + + verify(ticketRepository, never()).save(any(AdRewardTicket.class)); + } +} +``` + +주의: `User.builder().build()`와 `CustomException`의 필드명(`errorCode`)이 실제 구현과 다르면 컴파일/단언이 실패한다. `src/main/java/com/swyp/picke/domain/user/entity/User.java`와 `global/common/exception/CustomException.java`를 열어 실제 빌더 필수값과 필드명을 확인하고 맞춘다. 기존 `AdMobRewardServiceTest`가 삭제되었으므로, 필요하면 `git show HEAD~2 -- src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java`로 이전 테스트의 `User` 생성 방식을 참고한다. + +- [ ] **Step 6: 테스트 실패 확인** + +```bash +./gradlew test --tests "com.swyp.picke.domain.reward.service.AdFitRewardServiceTest" +``` + +기대: 컴파일 실패 — `AdFitRewardServiceImpl` 클래스 없음 + +- [ ] **Step 7: 최소 구현 작성** + +`src/main/java/com/swyp/picke/domain/reward/service/AdFitRewardServiceImpl.java`: + +```java +package com.swyp.picke.domain.reward.service; + +import com.swyp.picke.domain.reward.dto.response.AdFitTicketResponse; +import com.swyp.picke.domain.reward.entity.AdRewardTicket; +import com.swyp.picke.domain.reward.repository.AdRewardHistoryRepository; +import com.swyp.picke.domain.reward.repository.AdRewardTicketRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.user.service.UserService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.config.AdFitConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdFitRewardServiceImpl implements AdFitRewardService { + + private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); + + private final AdRewardTicketRepository ticketRepository; + private final AdRewardHistoryRepository adRewardHistoryRepository; + private final UserService userService; + private final CreditService creditService; + private final AdFitConfig adFitConfig; + + @Override + @Transactional + public AdFitTicketResponse issueTicket() { + User user = userService.findCurrentUser(); + + // 광고를 보여주기 전에 미리 차단한다. 실제 한도 강제는 claim에서 한다. + if (countTodayRewards(user.getId()) >= adFitConfig.getDailyLimit()) { + log.info("[AdFit] 일일 한도 초과로 티켓 발급 거부: userId={}", user.getId()); + throw new CustomException(ErrorCode.REWARD_DAILY_LIMIT_EXCEEDED); + } + + String ticketId = UUID.randomUUID().toString(); + ticketRepository.save(AdRewardTicket.builder() + .user(user) + .ticketId(ticketId) + .build()); + + log.info("[AdFit] 티켓 발급: userId={}, ticketId={}", user.getId(), ticketId); + return AdFitTicketResponse.of(ticketId, adFitConfig.getTicketTtlSeconds()); + } + + /** 당일 실제 지급 건수. 티켓 발급 수가 아니라 이력 기준이다. */ + private long countTodayRewards(Long userId) { + LocalDate today = LocalDate.now(SEOUL_ZONE); + return adRewardHistoryRepository.countByUserIdAndCreatedAtBetween( + userId, today.atStartOfDay(), today.plusDays(1).atStartOfDay()); + } +} +``` + +- [ ] **Step 8: 테스트 통과 확인** + +```bash +./gradlew test --tests "com.swyp.picke.domain.reward.service.AdFitRewardServiceTest" +``` + +기대: PASS (2개 테스트) + +- [ ] **Step 9: 커밋** + +```bash +git add -A +git commit -m "feat: 애드핏 보상 티켓 발급 구현 + +광고 노출 전 1회성 티켓을 발급하고, 일일 한도 초과 시 이 단계에서 차단한다. +발급 시 차단은 UX 목적이며 실제 한도 강제는 청구 시점에서 한다." +``` + +--- + +## Task 4: 크레딧 청구 + +**Files:** +- Create: `src/main/java/com/swyp/picke/domain/reward/dto/request/AdFitClaimRequest.java` +- Create: `src/main/java/com/swyp/picke/domain/reward/dto/response/AdFitClaimResponse.java` +- Modify: `src/main/java/com/swyp/picke/domain/reward/service/AdFitRewardService.java` +- Modify: `src/main/java/com/swyp/picke/domain/reward/service/AdFitRewardServiceImpl.java` +- Modify: `src/test/java/com/swyp/picke/domain/reward/service/AdFitRewardServiceTest.java` + +**Interfaces:** +- Consumes: Task 3의 `AdFitRewardServiceImpl`, `AdFitConfig`; Task 2의 `AdRewardTicket#markUsed`, `#isUsed`; 기존 `AdRewardHistory.builder()`, `AdRewardHistoryRepository#existsByTransactionId`, `CreditService#addCredit(Long, CreditType, int, Long)`, `#getTotalPoints(Long)` +- Produces: + - `AdFitRewardService#claim(AdFitClaimRequest): AdFitClaimResponse` + - `AdFitClaimRequest(String ticketId)` — record + - `AdFitClaimResponse(int rewardedAmount, int totalCredit)` — record, 정적 팩토리 `of(int, int)` + +- [ ] **Step 1: 요청/응답 DTO 작성** + +`src/main/java/com/swyp/picke/domain/reward/dto/request/AdFitClaimRequest.java`: + +```java +package com.swyp.picke.domain.reward.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "애드핏 광고 보상 청구 요청") +public record AdFitClaimRequest( + + @Schema(description = "발급받은 티켓 ID", example = "9f1c8e2a-4b7d-4c1e-9a3f-2b8c6d5e4f10") + @NotBlank(message = "티켓 ID는 필수입니다.") + String ticketId +) { +} +``` + +`src/main/java/com/swyp/picke/domain/reward/dto/response/AdFitClaimResponse.java`: + +```java +package com.swyp.picke.domain.reward.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "애드핏 광고 보상 청구 결과") +public record AdFitClaimResponse( + + @Schema(description = "이번 청구로 지급된 크레딧. 이미 처리된 티켓이면 0", example = "20") + int rewardedAmount, + + @Schema(description = "지급 후 유저의 총 크레딧", example = "145") + int totalCredit +) { + public static AdFitClaimResponse of(int rewardedAmount, int totalCredit) { + return new AdFitClaimResponse(rewardedAmount, totalCredit); + } +} +``` + +- [ ] **Step 2: 인터페이스에 claim 추가** + +`AdFitRewardService.java`에 추가한다: + +```java + /** + * 티켓을 검증하고 크레딧을 지급한다. + * 애드핏은 S2S 콜백이 없어 광고 시청을 증명할 수 없다. + * 티켓 1회성 + 최소 경과시간 + 일일 한도로 남용을 억제한다. + */ + AdFitClaimResponse claim(AdFitClaimRequest request); +``` + +import 2개를 추가한다: +```java +import com.swyp.picke.domain.reward.dto.request.AdFitClaimRequest; +import com.swyp.picke.domain.reward.dto.response.AdFitClaimResponse; +``` + +- [ ] **Step 3: 실패하는 테스트 작성** + +`AdFitRewardServiceTest.java`에 아래 테스트들을 추가한다. 기존 import에 더해 `AdRewardHistory`, `CreditType`, `RewardItem`, `Duration`, `Optional`, `eq`, `times`, `willAnswer` 등이 필요하다. + +```java + private AdRewardTicket ticketIssuedSecondsAgo(User owner, long secondsAgo) { + AdRewardTicket ticket = AdRewardTicket.builder() + .user(owner) + .ticketId("ticket-uuid") + .build(); + ReflectionTestUtils.setField(ticket, "createdAt", + LocalDateTime.now(ZoneId.of("Asia/Seoul")).minusSeconds(secondsAgo)); + return ticket; + } + + private void givenClaimConfig() { + given(adFitConfig.getDailyLimit()).willReturn(10); + given(adFitConfig.getMinWatchSeconds()).willReturn(5L); + given(adFitConfig.getTicketTtlSeconds()).willReturn(300L); + } + + @Test + @DisplayName("정상 티켓으로 청구하면 크레딧 20이 적립되고 티켓이 사용 처리된다") + void claim_success() { + AdRewardTicket ticket = ticketIssuedSecondsAgo(user, 10); + given(userService.findCurrentUser()).willReturn(user); + given(ticketRepository.findByTicketId("ticket-uuid")).willReturn(Optional.of(ticket)); + givenClaimConfig(); + given(adRewardHistoryRepository.countByUserIdAndCreatedAtBetween(anyLong(), any(), any())) + .willReturn(0L); + given(adRewardHistoryRepository.existsByTransactionId("ticket-uuid")).willReturn(false); + given(adRewardHistoryRepository.saveAndFlush(any(AdRewardHistory.class))) + .willAnswer(invocation -> { + AdRewardHistory saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 99L); + return saved; + }); + given(creditService.getTotalPoints(1L)).willReturn(145); + + AdFitClaimResponse response = rewardService.claim(new AdFitClaimRequest("ticket-uuid")); + + assertThat(response.rewardedAmount()).isEqualTo(CreditType.FREE_CHARGE.getDefaultAmount()); + assertThat(response.totalCredit()).isEqualTo(145); + assertThat(ticket.isUsed()).isTrue(); + verify(creditService).addCredit( + eq(1L), eq(CreditType.FREE_CHARGE), + eq(CreditType.FREE_CHARGE.getDefaultAmount()), eq(99L)); + } + + @Test + @DisplayName("존재하지 않는 티켓이면 거부된다") + void claim_ticketNotFound() { + given(userService.findCurrentUser()).willReturn(user); + given(ticketRepository.findByTicketId("nope")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> rewardService.claim(new AdFitClaimRequest("nope"))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REWARD_TICKET_NOT_FOUND); + } + + @Test + @DisplayName("타인의 티켓이면 존재하지 않는 것과 동일하게 거부된다") + void claim_otherUsersTicket() { + User other = User.builder().build(); + ReflectionTestUtils.setField(other, "id", 2L); + AdRewardTicket ticket = ticketIssuedSecondsAgo(other, 10); + + given(userService.findCurrentUser()).willReturn(user); + given(ticketRepository.findByTicketId("ticket-uuid")).willReturn(Optional.of(ticket)); + + assertThatThrownBy(() -> rewardService.claim(new AdFitClaimRequest("ticket-uuid"))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REWARD_TICKET_NOT_FOUND); + + verify(creditService, never()).addCredit(anyLong(), any(), anyInt(), anyLong()); + } + + @Test + @DisplayName("이미 사용된 티켓이면 거부된다") + void claim_alreadyUsed() { + AdRewardTicket ticket = ticketIssuedSecondsAgo(user, 10); + ticket.markUsed(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + + given(userService.findCurrentUser()).willReturn(user); + given(ticketRepository.findByTicketId("ticket-uuid")).willReturn(Optional.of(ticket)); + + assertThatThrownBy(() -> rewardService.claim(new AdFitClaimRequest("ticket-uuid"))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REWARD_TICKET_ALREADY_USED); + } + + @Test + @DisplayName("만료된 티켓이면 거부된다") + void claim_expired() { + AdRewardTicket ticket = ticketIssuedSecondsAgo(user, 301); + + given(userService.findCurrentUser()).willReturn(user); + given(ticketRepository.findByTicketId("ticket-uuid")).willReturn(Optional.of(ticket)); + given(adFitConfig.getTicketTtlSeconds()).willReturn(300L); + + assertThatThrownBy(() -> rewardService.claim(new AdFitClaimRequest("ticket-uuid"))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REWARD_TICKET_EXPIRED); + } + + @Test + @DisplayName("최소 경과시간 전에 청구하면 거부된다") + void claim_tooSoon() { + AdRewardTicket ticket = ticketIssuedSecondsAgo(user, 1); + + given(userService.findCurrentUser()).willReturn(user); + given(ticketRepository.findByTicketId("ticket-uuid")).willReturn(Optional.of(ticket)); + given(adFitConfig.getTicketTtlSeconds()).willReturn(300L); + given(adFitConfig.getMinWatchSeconds()).willReturn(5L); + + assertThatThrownBy(() -> rewardService.claim(new AdFitClaimRequest("ticket-uuid"))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REWARD_TICKET_TOO_SOON); + } + + @Test + @DisplayName("한도 초과분 티켓을 미리 발급받아 청구해도 한도에서 막힌다") + void claim_dailyLimitEnforcedAtClaim() { + AdRewardTicket ticket = ticketIssuedSecondsAgo(user, 10); + + given(userService.findCurrentUser()).willReturn(user); + given(ticketRepository.findByTicketId("ticket-uuid")).willReturn(Optional.of(ticket)); + givenClaimConfig(); + given(adRewardHistoryRepository.countByUserIdAndCreatedAtBetween(anyLong(), any(), any())) + .willReturn(10L); + + assertThatThrownBy(() -> rewardService.claim(new AdFitClaimRequest("ticket-uuid"))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REWARD_DAILY_LIMIT_EXCEEDED); + + verify(creditService, never()).addCredit(anyLong(), any(), anyInt(), anyLong()); + } + + @Test + @DisplayName("이미 지급 이력이 있는 티켓이면 재지급 없이 멱등 응답한다") + void claim_idempotent() { + AdRewardTicket ticket = ticketIssuedSecondsAgo(user, 10); + + given(userService.findCurrentUser()).willReturn(user); + given(ticketRepository.findByTicketId("ticket-uuid")).willReturn(Optional.of(ticket)); + givenClaimConfig(); + given(adRewardHistoryRepository.countByUserIdAndCreatedAtBetween(anyLong(), any(), any())) + .willReturn(0L); + given(adRewardHistoryRepository.existsByTransactionId("ticket-uuid")).willReturn(true); + given(creditService.getTotalPoints(1L)).willReturn(145); + + AdFitClaimResponse response = rewardService.claim(new AdFitClaimRequest("ticket-uuid")); + + assertThat(response.rewardedAmount()).isZero(); + assertThat(response.totalCredit()).isEqualTo(145); + verify(creditService, never()).addCredit(anyLong(), any(), anyInt(), anyLong()); + } +``` + +- [ ] **Step 4: 테스트 실패 확인** + +```bash +./gradlew test --tests "com.swyp.picke.domain.reward.service.AdFitRewardServiceTest" +``` + +기대: 컴파일 실패 — `claim` 메서드 없음 + +- [ ] **Step 5: claim 구현** + +`AdFitRewardServiceImpl.java`에 추가한다: + +```java + @Override + @Transactional + public AdFitClaimResponse claim(AdFitClaimRequest request) { + User user = userService.findCurrentUser(); + + AdRewardTicket ticket = ticketRepository.findByTicketId(request.ticketId()) + .orElseThrow(() -> new CustomException(ErrorCode.REWARD_TICKET_NOT_FOUND)); + + // 타인의 티켓은 존재 여부를 노출하지 않고 NOT_FOUND로 응답한다 + if (!ticket.getUser().getId().equals(user.getId())) { + log.warn("[AdFit] 타인 티켓 청구 시도: userId={}, ticketId={}", user.getId(), request.ticketId()); + throw new CustomException(ErrorCode.REWARD_TICKET_NOT_FOUND); + } + + if (ticket.isUsed()) { + throw new CustomException(ErrorCode.REWARD_TICKET_ALREADY_USED); + } + + LocalDateTime now = LocalDateTime.now(SEOUL_ZONE); + long elapsedSeconds = Duration.between(ticket.getCreatedAt(), now).getSeconds(); + + if (elapsedSeconds > adFitConfig.getTicketTtlSeconds()) { + throw new CustomException(ErrorCode.REWARD_TICKET_EXPIRED); + } + if (elapsedSeconds < adFitConfig.getMinWatchSeconds()) { + log.info("[AdFit] 최소 경과시간 미달 청구: userId={}, elapsed={}s", user.getId(), elapsedSeconds); + throw new CustomException(ErrorCode.REWARD_TICKET_TOO_SOON); + } + + // 한도를 실제로 강제하는 지점. 발급 시 검사만으로는 티켓 선발급으로 우회된다. + if (countTodayRewards(user.getId()) >= adFitConfig.getDailyLimit()) { + throw new CustomException(ErrorCode.REWARD_DAILY_LIMIT_EXCEEDED); + } + + int amount = CreditType.FREE_CHARGE.getDefaultAmount(); + + if (adRewardHistoryRepository.existsByTransactionId(ticket.getTicketId())) { + log.info("[AdFit] 이미 처리된 티켓: ticketId={}", ticket.getTicketId()); + return AdFitClaimResponse.of(0, creditService.getTotalPoints(user.getId())); + } + + ticket.markUsed(now); + + AdRewardHistory history = AdRewardHistory.builder() + .transactionId(ticket.getTicketId()) + .user(user) + .rewardAmount(amount) + .rewardItem(RewardItem.POINT) + .build(); + adRewardHistoryRepository.saveAndFlush(history); + + // history.getId()를 referenceId로 써서 CreditHistory unique 충돌을 피한다 (기존 AdMob 구현과 동일) + creditService.addCredit(user.getId(), CreditType.FREE_CHARGE, amount, history.getId()); + log.info("[AdFit] 보상 지급 완료: userId={}, amount={}, historyId={}", + user.getId(), amount, history.getId()); + + return AdFitClaimResponse.of(amount, creditService.getTotalPoints(user.getId())); + } +``` + +import를 추가한다: +```java +import com.swyp.picke.domain.reward.dto.request.AdFitClaimRequest; +import com.swyp.picke.domain.reward.dto.response.AdFitClaimResponse; +import com.swyp.picke.domain.reward.entity.AdRewardHistory; +import com.swyp.picke.domain.reward.enums.RewardItem; +import com.swyp.picke.domain.user.enums.CreditType; +import java.time.Duration; +import java.time.LocalDateTime; +``` + +- [ ] **Step 6: 테스트 통과 확인** + +```bash +./gradlew test --tests "com.swyp.picke.domain.reward.service.AdFitRewardServiceTest" +``` + +기대: PASS (10개 테스트) + +- [ ] **Step 7: 커밋** + +```bash +git add -A +git commit -m "feat: 애드핏 광고 보상 크레딧 청구 구현 + +티켓 소유자/사용여부/만료/최소 경과시간/일일 한도를 순서대로 검증한다. +크레딧은 클라이언트 값을 믿지 않고 FREE_CHARGE 고정값만 지급한다." +``` + +--- + +## Task 5: 컨트롤러와 보안 설정 + +**Files:** +- Create: `src/main/java/com/swyp/picke/domain/reward/controller/AdFitRewardController.java` +- Verify: `SecurityConfig.java` (애드핏 경로가 permitAll에 없어야 함) + +**Interfaces:** +- Consumes: Task 3~4의 `AdFitRewardService#issueTicket()`, `#claim(AdFitClaimRequest)` +- Produces: `POST /api/v1/reward/adfit/ticket`, `POST /api/v1/reward/adfit/claim` + +- [ ] **Step 1: 컨트롤러 작성** + +`src/main/java/com/swyp/picke/domain/reward/controller/AdFitRewardController.java`: + +```java +package com.swyp.picke.domain.reward.controller; + +import com.swyp.picke.domain.reward.dto.request.AdFitClaimRequest; +import com.swyp.picke.domain.reward.dto.response.AdFitClaimResponse; +import com.swyp.picke.domain.reward.dto.response.AdFitTicketResponse; +import com.swyp.picke.domain.reward.service.AdFitRewardService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/reward/adfit") +@Tag(name = "광고 보상 API", description = "애드핏 광고 시청 보상 티켓 발급 및 크레딧 청구") +public class AdFitRewardController { + + private final AdFitRewardService adFitRewardService; + + @Operation(summary = "광고 보상 티켓 발급", + description = "애드핏 광고를 노출하기 전에 호출한다. 일일 한도 초과 시 429로 거부된다.") + @PostMapping("/ticket") + public ApiResponse issueTicket() { + return ApiResponse.onSuccess(adFitRewardService.issueTicket()); + } + + @Operation(summary = "광고 보상 크레딧 청구", + description = "광고 시청 완료 후 발급받은 티켓으로 크레딧을 청구한다. 티켓은 1회만 사용 가능하다.") + @PostMapping("/claim") + public ApiResponse claim(@Valid @RequestBody AdFitClaimRequest request) { + return ApiResponse.onSuccess(adFitRewardService.claim(request)); + } +} +``` + +- [ ] **Step 2: 인증 필수 확인** + +```bash +grep -n "reward" src/main/java/com/swyp/picke/global/config/SecurityConfig.java src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java +``` + +기대: 출력 없음. 출력이 있으면 애드핏 경로가 인증 예외로 열려 있다는 뜻이므로 **반드시 제거**한다. 이 API는 JWT 인증이 필수다. + +- [ ] **Step 3: 빌드 및 전체 테스트** + +```bash +./gradlew clean build +``` + +기대: `BUILD SUCCESSFUL` + +- [ ] **Step 4: 애플리케이션 기동 후 인증 없이 호출 시 401 확인** + +```bash +./gradlew bootRun & +sleep 30 +curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8080/api/v1/reward/adfit/ticket +``` + +기대: `401` + +확인 후 `kill %1`로 종료한다. 로컬 DB/환경변수가 없어 기동이 실패하면 이 단계는 건너뛰고 Step 2의 grep 결과로 갈음한다. + +- [ ] **Step 5: 커밋** + +```bash +git add -A +git commit -m "feat: 애드핏 광고 보상 API 엔드포인트 추가 + +AdMob SSV와 달리 우리 앱이 직접 호출하므로 JWT 인증을 필수로 둔다. +유저 식별을 토큰에서 하므로 custom_data 방식의 사칭 위험이 사라진다." +``` + +--- + +## Task 6: API 문서 갱신 + +**Files:** +- Modify: `docs/api-specs/reward-api.md` + +- [ ] **Step 1: 기존 문서 확인** + +```bash +cat docs/api-specs/reward-api.md +``` + +- [ ] **Step 2: 애드핏 API로 재작성** + +AdMob SSV 콜백 명세를 삭제하고 아래 2개 엔드포인트로 교체한다. 기존 문서의 서식(헤더 구성, 표 스타일)을 그대로 따른다. + +- `POST /api/v1/reward/adfit/ticket` — 인증 필요. 응답 `{ ticketId, expiresInSeconds }` +- `POST /api/v1/reward/adfit/claim` — 인증 필요. 요청 `{ ticketId }`, 응답 `{ rewardedAmount, totalCredit }` +- 에러 코드 표: `REWARD_404_2`, `REWARD_409`, `REWARD_410`, `REWARD_400_2`, `REWARD_429` +- 클라이언트 호출 순서: 티켓 발급 → 광고 노출 → 청구 +- 제약 명시: 티켓 1회성, 만료 5분, 최소 경과시간 5초, 일일 10회 + +- [ ] **Step 3: 커밋** + +```bash +git add -A +git commit -m "docs: 광고 보상 API 문서를 애드핏 기준으로 갱신" +``` + +- [ ] **Step 4: PR 생성** + +```bash +git push -u origin feat/adfit-reward +gh pr create --base dev --title "feat: AdMob 제거 및 애드핏 광고 보상 연동" --body "$(cat <<'EOF' +## 요약 +AdMob 계정 승인 거부로 광고를 받을 수 없어 애드핏으로 교체한다. + +- AdMob 연동 및 Tink 의존성 완전 제거 +- 애드핏 티켓 방식 리워드 크레딧 구현 (발급 → 광고 노출 → 청구) +- 지급 파이프라인(AdRewardHistory, CreditService)은 재사용, CreditType 무변경 + +## 설계 +docs/superpowers/specs/2026-07-16-adfit-reward-design.md + +## 알려진 한계 +애드핏은 S2S 콜백을 제공하지 않아 서버가 광고 시청을 증명할 수 없다. +티켓 1회성 + 최소 경과시간 + 일일 한도로 남용을 억제하는 구조이며, 증명이 아니다. +크레딧은 현금화 경로가 없어 위조 시 피해는 배틀 참여 증가로 제한된다. + +## 배포 전 확인 +- 환경변수 ADMOB_APP_ID, ADMOB_REWARD_UNIT_ID_IOS, ADMOB_REWARD_UNIT_ID_ANDROID 제거 +- app-ads.txt를 애드핏 항목으로 교체 (애드핏 승인 후) +- adfit.reward.min-watch-seconds는 실제 광고 길이 측정 후 조정 필요 +EOF +)" +``` + +--- + +## 배포 전 운영 작업 (코드 외) + +- [ ] 카카오 고객센터에 리워드 형태 사용 가능 여부 문의 — **구현 착수 전 권장** (설계 문서 11절) +- [ ] 애드핏 매체 등록 및 승인 +- [ ] `static/app-ads.txt`를 애드핏 항목으로 교체 +- [ ] 배포 환경에서 `ADMOB_*` 환경변수 제거 +- [ ] iOS/Android 앱에 애드핏 SDK 연동 (별도 작업, 본 계획 범위 밖) +- [ ] 실제 광고 길이 측정 후 `adfit.reward.min-watch-seconds` 조정 diff --git a/docs/superpowers/specs/2026-07-16-adfit-reward-design.md b/docs/superpowers/specs/2026-07-16-adfit-reward-design.md new file mode 100644 index 00000000..2e2d6c88 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-adfit-reward-design.md @@ -0,0 +1,334 @@ +# 애드핏 리워드 크레딧 설계 + +작성일: 2026-07-16 +대상 브랜치: `dev` +상태: 설계 확정 대기 + +## 1. 배경 + +현재 서버는 AdMob 리워드 광고 + SSV(서버 사이드 검증)로 크레딧을 지급한다. 유저가 리워드 광고를 끝까지 보면 구글이 서버로 서명된 콜백을 보내고, 서버가 Tink `RewardedAdsVerifier`로 검증한 뒤 `FREE_CHARGE`(20 크레딧)를 적립한다. + +구현 자체는 정상 동작하나 **AdMob 계정 승인이 거부되어 광고를 받을 수 없다.** 앱은 스토어에 출시되어 있고 심사도 통과한 상태다. 승인 사유 규명 대신 광고 네트워크를 교체하기로 결정했다. + +### 대체 네트워크 조사 결과 + +| 네트워크 | 리워드 상품 | S2S 검증 | 결론 | +|---|---|---|---| +| 네이버 GFA | 없음 (광고주 전용 플랫폼) | 해당 없음 | 불가 — 매체용 SDK 자체가 없음 | +| 카카오 애드핏 | **없음** (배너/네이티브/비즈보드/앱전환/앱종료) | **없음** | 리워드 상품은 없으나 정책상 매체 자체 구현을 상정 | +| Unity LevelPlay | 있음 | 있음 (MD5 공유 시크릿) | 가능하나 승인 수 주 소요, 지연 사례 다수 | +| AppLovin MAX | 있음 | 있음 (SHA1 공유 시크릿) | 가능하나 인디 거절 보고 다수 | + +국내 네트워크는 리워드 + S2S를 제공하지 않는다. 이는 글로벌 네트워크의 영역이다. + +**애드핏을 선택했다.** 개인 자격으로 사업자등록 없이 등록 가능하고 승인 문턱이 낮아, 승인이 수 주 걸리고 결과도 불확실한 글로벌 네트워크보다 빠르게 수익화를 재개할 수 있다. + +### 애드핏 리워드의 정책적 근거 + +애드핏은 리워드 SDK 포맷을 제공하지 않지만, [서비스 운영정책](https://adfit.kakao.com/web/html/use_kakao.html) 5.3.2~5.3.3은 리워드 동영상 광고를 명시적으로 규율한다: + +- 5.3.2 — 리워드 광고는 사용자의 명확한 행동(버튼 클릭 등)이 있는 경우에만 노출 +- 5.3.3 — 보상 조건, 지급 여부/시점, **지급 제외 사유(시청 중단, 중복 시청, 부정 시청)** 를 유저에게 명확히 고지 + +지급 제외 사유를 매체가 안내하라는 조항은 **매체가 직접 리워드 로직을 구현하는 형태를 전제**한 것으로 읽힌다. 즉 애드핏은 리워드 포맷과 검증 콜백을 제공하지 않을 뿐, 매체가 광고를 노출하고 자체적으로 보상을 지급하는 것을 금지하지 않는 것으로 해석한다. + +**이 해석은 공개 문서만으로는 확정할 수 없다.** 상품 카탈로그에 리워드가 없는데 정책에는 규율이 있는 모순이 존재한다. 리스크 항목(11절)을 참조. + +## 2. 목표 / 비목표 + +### 목표 + +- "광고를 끝까지 보면 크레딧 20 지급" UX를 유지한다. +- AdMob 의존을 완전히 제거한다 (폴백 유지하지 않음). +- S2S 검증이 없는 환경에서 남용을 **억제**한다. +- 기존 크레딧 지급 파이프라인(`AdRewardHistory`, `CreditService`)을 그대로 재사용한다. + +### 비목표 + +- 애드핏 배너/전면 광고 자체의 수익화 — 클라이언트 전용 작업이며 서버 변경 없음 +- `CreditService` / `CreditType` / 배치 잡 변경 +- 광고 노출 원격 스위치 (remote config) +- 글로벌 리워드 네트워크(LevelPlay/AppLovin) 연동 — 추후 필요 시 별도 설계 +- 완전한 광고 시청 증명 — 애드핏이 S2S를 제공하지 않는 한 불가능 + +### 성공 기준 + +1. 인증된 유저가 애드핏 전면 광고를 본 뒤 크레딧 20을 정확히 1회 받는다. +2. 동일 티켓으로 두 번 청구하면 두 번째는 거부되고 크레딧이 증가하지 않는다. +3. 광고를 보지 않고 티켓 발급 즉시 청구하면 거부된다. +4. 하루 한도 초과 시 거부된다. +5. AdMob 관련 코드/설정/의존성이 저장소에 남아있지 않고 빌드가 통과한다. + +## 3. 핵심 설계 결정 + +### 3.1 티켓 방식을 채택한다 + +애드핏은 S2S 콜백을 주지 않으므로 서버가 "광고를 봤다"를 증명할 수 없다. 클라이언트 신고를 받되, 자동화 난이도를 올려 남용을 억제한다. + +``` +1. POST /api/v1/reward/adfit/ticket (JWT) → 티켓 발급 (UUID) +2. 앱: 애드핏 전면(앱전환) 광고 표시 +3. 광고 닫힘 콜백 수신 +4. POST /api/v1/reward/adfit/claim (JWT) {ticketId} → 크레딧 20 +``` + +티켓 없이 `/claim` 단일 엔드포인트로 만들면 광고와 무관한 "누르면 20크레딧" API가 된다. 티켓 + 최소 경과시간은 S2S 없이 취할 수 있는 최선의 억제책이다. + +### 3.2 JWT 인증을 사용한다 — AdMob 대비 개선점 + +기존 AdMob SSV 엔드포인트는 구글이 호출해야 하므로 **비인증으로 열려 있고**(`SecurityConfig.java:49`), 유저를 `custom_data` 문자열로 식별했다. 이는 임의 유저 태그를 넣어 **사칭이 가능한 구조**다. + +애드핏 방식은 우리 앱이 직접 호출하므로 JWT 인증을 태울 수 있다. 유저 식별은 토큰에서 추출하며 **사칭이 원천 차단된다.** 즉 "누구인가"의 신뢰도는 올라가고, "광고를 봤는가"의 신뢰도만 내려간다. + +### 3.3 `provider` 컬럼을 두지 않는다 + +AdMob을 완전히 제거하므로 네트워크 구분이 불필요하다. 애드핏 티켓은 UUID라 기존 `ad_reward_history.transaction_id` unique 제약과 충돌하지 않는다. + +추후 다른 네트워크를 추가할 때 `provider` 컬럼을 도입한다. 지금 넣는 것은 YAGNI다. + +### 3.4 스키마 마이그레이션 스크립트가 필요 없다 + +`ad_reward_ticket`은 신규 테이블이므로 `ddl-auto: update`가 자동 생성한다. `ad_reward_history`는 변경하지 않는다. + +단, 일일 한도 조회 성능을 위한 인덱스는 `ddl-auto`가 만들지 않으므로 선택적으로 `db/migration/` 컨벤션에 따라 추가한다(6.3절). + +### 3.5 크레딧 금액은 서버 고정값을 쓴다 + +기존 정책을 유지한다. 클라이언트가 보낸 어떤 금액도 신뢰하지 않고 `CreditType.FREE_CHARGE.getDefaultAmount()`(20)만 지급한다. + +## 4. 아키텍처 + +``` +AdFitRewardController ── JWT 인증 필수 + │ + ▼ +AdFitRewardService (interface + Impl) ← 기존 컨벤션(인터페이스 분리) 준수 + │ + ├── AdRewardTicketRepository 티켓 발급/검증/사용 처리 + ├── AdRewardHistoryRepository 중복 방지 + 이력 (기존 재사용) + ├── UserService findCurrentUser() (기존 재사용) + └── CreditService 크레딧 적립 (기존 재사용, 무변경) +``` + +기존 `AdMobRewardServiceImpl.processReward()`의 지급 로직(50~77행: 중복 방지 → 유저 조회 → 이력 저장 → 크레딧 적립)을 `AdFitRewardServiceImpl`로 이관한다. AdMob 고유의 서명 검증(38~48행)은 폐기하고, 그 자리에 티켓 검증이 들어간다. + +`RewardGrantService` 같은 별도 공용 서비스는 만들지 않는다. 네트워크가 하나뿐이므로 추상화의 실익이 없다. + +## 5. API 명세 + +### 5.1 티켓 발급 + +``` +POST /api/v1/reward/adfit/ticket +Authorization: Bearer +``` + +응답 200: +```json +{ + "isSuccess": true, + "result": { + "ticketId": "9f1c8e2a-...", + "expiresInSeconds": 300 + } +} +``` + +- 유저당 미사용 티켓이 이미 있으면 기존 티켓을 재발급하지 않고 신규 발급한다. 미사용 티켓은 만료로 자연 정리된다. +- **일일 한도 초과 시 이 단계에서도 거부한다.** 광고를 보여준 뒤 청구에서 거부하면 유저가 광고만 보고 보상을 못 받는 최악의 UX가 된다. 단 이는 UX 목적의 사전 차단이며, **실제 한도 강제는 청구 시점에 이루어진다**(7절). + +### 5.2 크레딧 청구 + +``` +POST /api/v1/reward/adfit/claim +Authorization: Bearer +Content-Type: application/json + +{ "ticketId": "9f1c8e2a-..." } +``` + +응답 200: +```json +{ + "isSuccess": true, + "result": { "rewardedAmount": 20, "totalCredit": 145 } +} +``` + +기존 `ApiResponse` 래퍼를 그대로 사용한다. (AdMob과 달리 외부 네트워크가 응답 포맷을 강제하지 않는다.) + +## 6. 데이터 모델 + +### 6.1 `ad_reward_ticket` (신규) + +| 컬럼 | 타입 | 비고 | +|---|---|---| +| `id` | bigint PK | `BaseEntity` 상속 | +| `ticket_id` | varchar unique, not null | UUID | +| `user_id` | bigint FK, not null | | +| `used_at` | timestamp nullable | 사용 시각. null이면 미사용 | +| `created_at` / `updated_at` | timestamp | `BaseEntity` 제공 | + +발급 시각은 `BaseEntity.createdAt`을 그대로 쓴다. 별도 `issued_at`을 두지 않는다. + +### 6.2 `ad_reward_history` (기존, 무변경) + +`transaction_id`에 티켓 UUID를 저장한다. 기존 unique 제약이 중복 청구 방지의 최종 방어선으로 그대로 동작한다. + +### 6.3 인덱스 (선택) + +일일 한도 조회는 `ad_reward_history`를 `user_id` + `created_at` 범위로 집계한다. 트래픽이 늘면 복합 인덱스를 추가한다: + +```sql +-- db/migration/V20260716_01__add_ad_reward_history_user_created_idx.sql +CREATE INDEX IF NOT EXISTS idx_ad_reward_history_user_created + ON ad_reward_history (user_id, created_at); +``` + +초기 규모에서는 불필요할 수 있다. 도입 여부는 구현 시 판단한다. + +## 7. 검증 및 남용 억제 + +`/claim` 처리 순서: + +| 순서 | 검증 | 실패 시 | +|---|---|---| +| 1 | JWT에서 userId 추출 | 401 (기존 `JwtFilter`) | +| 2 | 티켓 존재 | `REWARD_TICKET_NOT_FOUND` | +| 3 | 티켓 소유자 == 요청자 | `REWARD_TICKET_NOT_FOUND` (존재 여부를 노출하지 않음) | +| 4 | 미사용 (`used_at IS NULL`) | `REWARD_TICKET_ALREADY_USED` | +| 5 | 만료 전 (발급 후 5분 이내) | `REWARD_TICKET_EXPIRED` | +| 6 | 발급~청구 간격 ≥ 5초 | `REWARD_TICKET_TOO_SOON` | +| 7 | 일일 한도 미초과 (**실제 강제 지점**) | `REWARD_DAILY_LIMIT_EXCEEDED` | +| 8 | `transaction_id` 중복 아님 | 멱등 응답 (재지급 없음) | + +### 일일 한도는 발급과 청구 양쪽에서 검사한다 + +발급 시에만 검사하면 우회된다. 청구 0회 상태에서 티켓 20개를 연속 발급하면 매 발급이 한도 검사를 통과하고(청구 횟수가 0이므로), 이후 20개를 모두 청구해 한도의 2배를 받을 수 있다. + +- **발급 시 검사** — UX 목적. 광고를 보여주기 전에 미리 차단해, 유저가 광고만 보고 보상을 못 받는 상황을 막는다. +- **청구 시 검사** — 보안 목적. 한도를 실제로 강제하는 지점. 미사용 티켓 재고와 무관하게 당일 청구 횟수를 기준으로 판단한다. + +한도 산정 기준은 `ad_reward_history`의 당일 적립 건수이며, 티켓 발급 건수가 아니다. + +### 파라미터 기본값 + +| 값 | 기본 | 근거 | +|---|---|---| +| 일일 한도 | **10회** | 20크레딧 × 10 = 200/일. 출석 5/일, 배틀 진입 −5인 경제에서 충분히 넉넉하며 남용 피해 상한을 고정한다. | +| 최소 경과시간 | **5초** | 전면 광고 로드+노출에 최소한 소요되는 시간. 즉시 청구 자동화를 차단한다. | +| 티켓 만료 | **5분** | 광고 로드 실패/유저 이탈 시 티켓이 무한정 남지 않게 한다. | + +세 값 모두 `application.yml`의 `adfit.reward.*`로 외부화하여 코드 수정 없이 조정한다. **최소 경과시간은 실제 애드핏 전면 광고 길이를 앱에서 측정한 뒤 조정이 필요하다.** + +### 동시성 + +같은 티켓으로 동시에 두 번 청구하는 경쟁 조건은 `ad_reward_history.transaction_id`의 unique 제약이 최종 방어한다. 두 요청이 모두 검증을 통과해도 `saveAndFlush` 시점에 한쪽이 `DataIntegrityViolationException`으로 실패하고 롤백되므로 **이중 지급은 발생하지 않는다.** + +**이 예외를 잡지 않는다.** `@Transactional` 내부에서 잡아도 트랜잭션이 이미 rollback-only로 마킹되어 있어, 멱등 응답을 반환하려면 `REQUIRES_NEW` 분리 등의 복잡도가 필요하다. 얻는 것에 비해 비용이 크다: + +- 안전성은 이미 unique 제약으로 확보되어 있다 (이중 지급 없음). +- 동일 티켓 동시 청구는 정상 클라이언트에서 발생하지 않는다. 광고 시청 후 1회 호출하는 흐름이기 때문이다. +- 즉 이 경로를 타는 것은 사실상 공격자뿐이며, 공격자가 500을 받는 것은 문제가 아니다. + +순차적 중복 청구(네트워크 재시도 등 정상 케이스)는 티켓 `used_at`과 `existsByTransactionId` 검사가 먼저 잡아내므로 정상적인 에러/멱등 응답을 받는다. + +참고로 `CreditService.addCredit`도 `(user, creditType, referenceId)` 중복 시 조용히 무시하는 멱등 구현이나(`CreditService.java:60`), `referenceId`로 매번 새로운 `history.getId()`를 넘기므로 이 방어선은 본 흐름에서 작동하지 않는다. 중복 방어는 `transaction_id` unique에 의존한다. + +## 8. 에러 처리 + +`ErrorCode`에 추가: + +```java +REWARD_TICKET_NOT_FOUND(HttpStatus.NOT_FOUND, "REWARD_404_2", "유효하지 않은 티켓입니다."), +REWARD_TICKET_ALREADY_USED(HttpStatus.CONFLICT, "REWARD_409", "이미 사용된 티켓입니다."), +REWARD_TICKET_EXPIRED(HttpStatus.GONE, "REWARD_410", "만료된 티켓입니다."), +REWARD_TICKET_TOO_SOON(HttpStatus.BAD_REQUEST, "REWARD_400_2", "광고 시청이 완료되지 않았습니다."), +REWARD_DAILY_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "REWARD_429", "오늘 받을 수 있는 광고 보상을 모두 받았습니다."), +``` + +제거: `REWARD_INVALID_SIGNATURE` (AdMob 전용) + +AdMob SSV는 스펙상 실패해도 200을 반환해야 했으나(`AdMobRewardController.java:43-46`), 애드핏은 우리 앱이 호출하므로 **정상적인 HTTP 에러 코드를 반환한다.** 클라이언트가 사유별로 다른 안내를 띄울 수 있다. + +## 9. AdMob 제거 범위 + +### 삭제 + +- `global/config/AdMobConfig.java` +- `domain/reward/controller/AdMobRewardController.java` +- `domain/reward/service/AdMobRewardService.java`, `AdMobRewardServiceImpl.java` +- `domain/reward/dto/request/AdMobRewardRequest.java` +- `domain/reward/dto/response/AdMobRewardResponse.java` +- `test/.../domain/reward/service/AdMobRewardServiceTest.java` + +### 수정 + +| 파일 | 변경 | +|---|---| +| `build.gradle:51-53` | Tink 의존성 2줄 제거 | +| `application.yml:76-81` | `admob.*` 블록 제거 | +| `SecurityConfig.java:49` | `/api/v1/admob/reward/**` permitAll 제거. 애드핏 엔드포인트는 **permitAll에 추가하지 않는다** (인증 필수) | +| `JwtFilter.java:30` | `/api/v1/admob/reward` 제외 항목 제거 | +| `SwaggerConfig.java:88,97` | `/api/v1/admob/**` 2곳 제거 | +| `ErrorCode.java:110` | `REWARD_INVALID_SIGNATURE` 제거 | +| `docs/api-specs/reward-api.md` | 애드핏 API로 재작성 | +| `static/app-ads.txt` | AdMob 항목 → 애드핏 항목으로 교체 (운영 작업, 애드핏 승인 후) | + +### 유지 + +`AdRewardHistory`, `AdRewardHistoryRepository`, `RewardItem`, `CreditService`, `CreditType`, `UserService`, `StaticTextFileController`(app-ads.txt 서빙 자체는 애드핏도 필요) + +### 환경변수 정리 + +`ADMOB_APP_ID`, `ADMOB_REWARD_UNIT_ID_IOS`, `ADMOB_REWARD_UNIT_ID_ANDROID` — 배포 환경에서 제거 + +## 10. 테스트 전략 + +기존 `AdMobRewardServiceTest`의 구조(Mockito 단위 테스트)를 참고하되 대상은 `AdFitRewardServiceImpl`이다. + +- 정상 흐름: 티켓 발급 → 5초 경과 → 청구 → 크레딧 20 적립, `used_at` 기록 +- 중복 청구: 같은 티켓 2회 → 두 번째 거부, 크레딧 불변 +- 타인 티켓: 다른 유저의 티켓 청구 → 거부 +- 만료: 5분 초과 → 거부 +- 조기 청구: 5초 미만 → 거부 +- 일일 한도 (발급): 한도 도달 시 티켓 발급 거부 +- 일일 한도 (청구, 우회 방지): 한도 미달 상태에서 티켓을 한도 이상 미리 발급받아 두고 전부 청구해도 한도까지만 지급 +- 멱등: 이미 지급 이력이 있는 티켓 → 재지급 없이 응답 +- 회귀: AdMob 제거 후 전체 빌드 및 기존 테스트 통과 + +동시성 경쟁 조건은 unique 제약에 의존하며 단위 테스트로 검증하지 않는다(7절 참조). + +## 11. 한계 및 리스크 + +### 광고 시청을 증명할 수 없다 (수용) + +애드핏이 S2S 콜백을 제공하지 않으므로 근본적으로 해결 불가능하다. 티켓/최소시간/일일한도는 **자동화 난이도를 올리는 억제책**이지 증명이 아니다. 앱을 리버스 엔지니어링하면 티켓 발급 후 5초 대기 후 청구하는 스크립트를 만들 수 있다. + +**피해 규모는 제한적이다.** 크레딧은 현금화 경로가 없고(IAP 없음) 소비처가 배틀 진입(−5)과 주제 제안(−100)뿐이다. 위조의 이득은 "배틀을 더 하는 것"이며 금전적 손실이 아니다. 일일 한도가 피해 상한을 200크레딧/일/유저로 고정한다. + +이 트레이드오프는 애드핏을 선택한 대가다. 리워드 + S2S가 필요하면 글로벌 네트워크(LevelPlay/AppLovin)로 가야 하며, 그 경우 승인에 수 주가 걸리고 결과도 불확실하다. + +### 애드핏 정책 해석이 확정적이지 않다 (미해결) + +1절의 해석 — 애드핏이 매체 자체 리워드 구현을 허용한다 — 은 운영정책 조항으로부터의 추론이며 공식 확인이 아니다. 상품 카탈로그에 리워드가 없는데 정책에 규율이 있는 모순이 존재한다. + +**완화책:** 구현 착수 전 카카오 고객센터에 "리워드 형태(광고 시청 완료 시 앱 내 재화 지급)로 애드핏 전면 광고를 사용해도 되는지"를 문의한다. 답변에 따라: + +- 허용 → 그대로 진행 +- 불허 → 애드핏은 배너 전용으로 축소하고 리워드는 글로벌 네트워크로 재검토 (본 설계 폐기) + +문의 없이 진행할 경우 최악의 시나리오는 애드핏 계정 정지이며, AdMob도 막힌 상태라 수익원이 0이 된다. **문의 비용이 며칠인 데 비해 리스크가 크므로 선행을 권장한다.** + +### 최소 경과시간이 실측 기반이 아니다 + +5초는 추정치다. 애드핏 전면 광고가 이보다 짧으면 정상 유저가 거부당하고, 훨씬 길면 억제 효과가 약해진다. 앱 연동 후 실제 광고 길이를 측정해 조정한다. + +## 12. 미결 사항 + +| 항목 | 결정 필요 시점 | +|---|---| +| 카카오 정책 문의 결과 | 구현 착수 전 (권장) | +| 최소 경과시간 실측값 | 앱 연동 후 | +| 일일 한도 10회 적정성 | 운영 데이터 확인 후 | +| `ad_reward_history` 복합 인덱스 도입 | 트래픽 증가 시 | diff --git a/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md new file mode 100644 index 00000000..c0ab1e14 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md @@ -0,0 +1,229 @@ +# ad.picke.store 제휴 광고 설계 + +- 작성일: 2026-09-01 +- 상태: 승인됨 (구현 진행) +- 범위: 쿠팡 파트너스 + 애드픽 제휴 광고를 앱 지면에 노출하고, 클릭을 추적·집계한다. + +## 1. 배경과 목표 + +앱 화면 중간중간에 제휴 광고 배너를 노출해 수익을 만든다. 배너는 **앱이 네이티브로 렌더**하고, +탭하면 **외부 브라우저로 제휴 링크에 다이렉트**된다. + +매체는 두 곳이다. + +| 매체 | 식별자 | 성격 | +| --- | --- | --- | +| 쿠팡 파트너스 | `AF6830373` | 커머스 CPS. 상품 구매 전환 | +| 애드픽 | 가입 예정 | 성과형 CPA/CPI. 앱 설치·이벤트 참여 | + +네이버(쇼핑커넥트)는 **범위에서 제외**한다. 가입 단위가 블로그·인스타 같은 크리에이터 채널이라 +앱을 매체로 등록하는 경로가 없고, 인증 채널 밖에 링크를 게시하면 약관 위반 소지가 있다. + +### 목표가 아닌 것 + +- 쿠팡 파트너스 오픈API 연동. 파트너스 실적 요건 충족 후 승인제라 지금은 쓸 수 없다. + 소재 자동 수급은 `AdCreative` 생성 경로만 추가하면 되므로 나중에 얹는다. +- 사용자별 클릭 귀속. 3.4 참조. +- AdMob 리워드 광고 통합. 이미 `reward` 도메인에 별도로 존재한다. + +## 2. 접근 방식 + +**어드민 수동 등록 + 서버 리다이렉트 트래킹.** + +각 매체 콘솔에서 뽑은 완성형 제휴 링크를 어드민에 소재로 등록한다. 앱은 지면 코드로 소재를 조회해 +네이티브로 그리고, 탭하면 우리 서버의 리다이렉트 엔드포인트를 거쳐 제휴 링크로 나간다. + +두 매체를 하나의 파이프라인으로 처리할 수 있고 외부 API 의존이 없다. 대신 소재를 사람이 채워야 하고, +상품 가격·품절이 실시간 반영되지 않는다. 소재 수가 수십 개 규모라 감당 가능한 비용으로 본다. + +### 기각한 대안 + +- **WebView 임베드**: 소재 교체가 앱 배포와 무관해지지만, 스크롤 중첩·렌더 지연·다크모드 불일치가 생긴다. + "앱 UI에 네이티브로 보여야 한다"는 요구와 어긋난다. +- **애드픽 마이도메인**: 애드픽이 자체 도메인 트래킹 링크를 지원하나, 애드픽 링크만 커버한다. + 쿠팡까지 한곳에서 집계하고 클릭 로그를 우리 DB에 두려면 자체 리다이렉트가 맞다. + +## 3. 설계 + +### 3.1 패키지 구조 + +``` +domain/ad/ + controller/ AdController 앱 조회 API + AdClickController /c/{code} 302 리다이렉트 + AdLandingController ad.picke.store 루트 공개 지면 + service/ AdQueryService AdClickService + link/ AffiliateLinkBuilder CoupangLinkBuilder AdpickLinkBuilder + entity/ AdCreative AdClickLog AdImpressionDaily + enums/ AdNetwork AdSlotCode AdStatus + repository/ dto/ +domain/admin/ AdminAdController + AdminAdService (기존 어드민 관례를 따른다) +``` + +`reward` 도메인의 `AdRewardHistory`(AdMob)와 이름이 겹쳐 보이지만, 그쪽은 리워드 광고 시청 보상이고 +이쪽은 제휴 광고다. `AdNetwork` enum으로 구분된다. + +### 3.2 지면(slot) + +`AdSlotCode` **enum으로 둔다. 테이블이 아니다.** + +어드민에서 지면을 새로 만들어도 앱이 그 지면을 그릴 줄 모르면 아무 일도 일어나지 않는다. +지면 추가는 어차피 앱 배포와 묶이므로, 테이블로 빼면 실제로 쓸 수 없는 유연성만 생긴다. + +지면 목록은 iOS Presentation 모듈(Home/Battle/Chat/Profile)의 실제 화면을 기준으로 잡았다. +앱팀 확정 전이므로, 실제로 붙이는 지면에만 소재를 등록하면 된다. 소재가 없는 지면은 빈 배열을 주고 +앱은 지면 자체를 숨기므로 미사용 지면이 남아 있어도 부작용이 없다. + +| 지면 | 화면 | CPI 허용 | +| --- | --- | --- | +| `HOME_FEED` | 홈 피드 인라인 | 아니오 | +| `BATTLE_RESULT_BOTTOM` | 배틀 결과 하단 | 예 | +| `CHAT_ROOM_INLINE` | 관점 목록 인라인 | 아니오 | +| `ATTENDANCE_COMPLETE` | 출석 완료 후 | 예 | +| `PROFILE_BOTTOM` | 프로필 하단 | 예 | + +`cpiFriendly`는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다. 5장 트레이드오프 참조. + +### 3.3 데이터 모델 + +**`ad_creatives`** — 소재. `BaseEntity` 상속(id, created_at, updated_at). + +| 컬럼 | 타입 | 설명 | +| --- | --- | --- | +| `code` | varchar(16) unique | 공개 클릭 URL용 짧은 코드. PK 노출 방지 | +| `network` | varchar | `COUPANG` \| `ADPICK` | +| `slot` | varchar | `AdSlotCode` | +| `title` | varchar(100) | 배너 주 문구 | +| `subtitle` | varchar(200) nullable | 보조 문구 | +| `image_url` | varchar(500) | 소재 이미지 | +| `cta_text` | varchar(30) | "구매하러 가기" / "설치하고 받기" 등 | +| `landing_url` | varchar(1000) | 콘솔에서 뽑은 원본 제휴 링크 | +| `status` | varchar | `DRAFT` \| `ACTIVE` \| `PAUSED` | +| `weight` | int | 가중 로테이션. 기본 1 | +| `starts_at` / `ends_at` | timestamp nullable | 게재 기간. null이면 무제한 | + +`cta_text`를 매체별 하드코딩이 아니라 소재 단위로 두는 이유는, 쿠팡은 상품 구매이고 애드픽은 +앱 설치·이벤트 참여라 문구 성격이 다르기 때문이다. + +**`ad_click_logs`** — 클릭 원장. creative_id, slot, ip_hash, user_agent, clicked_at. +제휴사 리포트와 대조하는 용도다. + +**`ad_impression_daily`** — 노출 집계. (creative_id, slot, stat_date) 유니크 + impressions 카운터. + +노출은 raw 로그로 쌓지 않는다. 배너가 스크롤에 걸릴 때마다 행이 생기면 금방 수천만 건이 된다. +일별 upsert 카운터로 CTR을 뽑는 데 충분하다. + +### 3.4 클릭 로그는 익명이다 + +`/c/{code}`는 **외부 브라우저에서 열린다.** Authorization 헤더가 없다. + +사용자를 붙이려면 클릭 URL에 사용자 식별자를 실어야 하는데, 공개 URL에 그걸 넣으면 열거 공격과 +프라이버시 문제가 생긴다. 서명된 단기 토큰을 발급하는 방법도 있지만 v1에 그만한 값어치가 없다. + +지면별 CTR과 정산 대조에는 userId가 필요 없으므로 **v1은 익명(ip_hash + user_agent)으로 간다.** +본인 클릭 어뷰징 탐지가 필요해지면 그때 추가한다. + +### 3.5 API + +모든 앱/어드민 API는 `/api/v1/` 아래에 둔다. + +**앱** + +- `GET /api/v1/ads?slot={AdSlotCode}` → `ApiResponse>` + `{ code, network, title, subtitle, imageUrl, ctaText, clickUrl, label }` + `clickUrl` = `https://ad.picke.store/c/{code}` + `label`은 `"광고"` 고정. 표시광고법 대응이므로 앱이 반드시 렌더해야 한다. +- `POST /api/v1/ads/impressions` — `{ codes: [...] }` 묶음 전송 + +조회 시점에 노출을 집계하면 엔드포인트 하나를 아끼지만 **조회 ≠ 실제 노출**이라 CTR이 왜곡된다. +지면 성과로 배치를 정할 것이므로 분리한다. + +**클릭 리다이렉트** + +- `GET /c/{code}` → 302 Location: 제휴 링크 + +`/api/v1` 밑에 두지 않는다. 공개 숏링크라 짧아야 하고, JSON API가 아니라 브라우저 진입점이다. + +`landing_url`에 매체별 추적 파라미터를 **병합**한다. 원본 링크에 이미 쿼리스트링이 있으므로 +단순 문자열 결합이 아니다. 클릭 로그는 비동기로 적재해 리다이렉트를 DB 쓰기가 붙잡지 않게 한다. + +코드가 없거나 만료면 404 대신 랜딩 페이지로 302한다. 사용자에게 실패를 보이지 않는다. + +**랜딩** — `GET /` (Host: `ad.picke.store`) → Thymeleaf `ad/landing` + +ACTIVE 소재를 카드로 나열하고 하단에 쿠팡 파트너스 수수료 고지 문구를 넣는다. +쿠팡 파트너스 매체 심사에서 URL 접속 확인을 하므로, 빈 페이지면 반려된다. + +Host가 광고 도메인이 아니면 최소 응답만 돌려준다. API 도메인 루트에 광고 페이지가 뜨면 안 된다. + +**어드민** — `/api/v1/admin/ads` CRUD, `/api/v1/admin/ads/stats?from=&to=` +이미지 업로드는 기존 S3 presigned 경로를 재사용한다. + +### 3.6 매체별 링크 빌더 + +`AffiliateLinkBuilder` 인터페이스 하나에 매체별 구현체를 둔다. + +- `CoupangLinkBuilder` — `subId={slot}_{code}` 병합. 파트너스 리포트에서 지면별 실매출이 갈린다. +- `AdpickLinkBuilder` — 파라미터명을 `picke.ad.adpick.sub-id-param` 설정값으로 둔다. + 서브아이디 규격을 아직 확인하지 못해 기본값은 비어 있고, 그동안은 pass-through로 원본 링크를 넘긴다. + 파트너센터 링크생성 화면에서 규격이 확인되면 **배포 없이 환경변수만 채우면** 쿠팡과 같은 방식으로 붙는다. + 그전까지 애드픽은 지면별 성과 분리가 안 될 뿐, 노출·클릭·리다이렉트는 정상 동작한다. + +### 3.6.1 쿠팡 파트너스 아이디 대조 + +남의 파트너스 링크를 잘못 붙여넣으면 우리가 광고를 싣고 수수료는 남이 받는다. +소재 등록·수정 시 `landingUrl`의 `lptag`를 `coupang.partners.id`와 대조해 다르면 거부한다. + +다만 `link.coupang.com` 단축 링크에는 `lptag`가 드러나지 않으므로 **파라미터가 있을 때만** 본다. +없다고 막으면 정상적인 단축 링크를 쓸 수 없다. + +### 3.7 인증 우회 경로 + +`JwtFilter`가 SecurityConfig보다 먼저 돌면서 **토큰이 없으면 무조건 401**을 던진다. +따라서 `SecurityConfig.permitAll`만으로는 공개 엔드포인트가 뚫리지 않고, `JwtFilter.WHITELIST`에도 넣어야 한다. + +그런데 `isWhitelisted`가 `startsWith` 매칭이라 `"/"`를 넣으면 전체 인증이 무력화된다. +**정확히 일치할 때만 통과하는 `EXACT_WHITELIST`를 분리해 `/`와 `/error`를 넣는다.** + +`/error`가 빠져 있던 탓에 존재하지 않는 모든 경로가 404 대신 401로 나오고 있었다. 같이 고친다. + +### 3.8 Swagger 분리 + +광고 API는 별도 그룹 `3. 광고 API`로 띄운다. `/api/v1/ads/**`, `/api/v1/admin/ads/**`를 매칭하고, +기존 사용자·관리자 그룹에서는 제외해 섞이지 않게 한다. + +기존 `userApi` 그룹은 `FE_USED_OPERATIONS` 화이트리스트로 필터링되므로, 광고 API를 거기 넣으면 +어차피 보이지 않는다. 별도 그룹이 구조적으로 맞다. + +## 4. 운영 선행 작업 + +| 항목 | 상태 | +| --- | --- | +| `ad.picke.store` DNS + Railway 커스텀 도메인 + TLS | 완료 | +| 루트 공개 지면 배포 | 본 구현에 포함 | +| 광고 테이블 생성 | 별도 실행 불필요. `ddl-auto: update`라 배포 시 자동 생성된다 | +| 쿠팡 파트너스 가입·매체 등록 | ID 발급됨(`AF6830373`), 매체 등록 확인 필요 | +| 애드픽 파트너 가입 | 미착수. 계정이 필요해 코드로 대신할 수 없다 | +| 애드픽 서브아이디 파라미터 규격 확인 | 미확인. 확인되면 `ADPICK_SUB_ID_PARAM` 환경변수만 채우면 된다 | +| 애드픽 이용정책상 자체 앱 배너 노출 허용 여부 | 미확인 | +| 지면 목록 앱팀 확정 | 후보 5개 확정, 앱팀 확인 대기 | +| Play Console / App Store Connect "광고 포함" 신고 | 미착수 | +| 개인정보처리방침에 제휴 광고 문구 추가 | 미착수 | + +## 5. 지면 배치 트레이드오프 + +애드픽 캠페인 상당수가 CPI(앱 설치형)다. 단가는 커머스보다 높지만 클릭하면 사용자가 스토어로 나가 +다른 앱을 설치한다. 배틀 진행 중간 지면에 CPI를 깔면 이탈·리텐션에 직접 타격이 온다. + +세션이 자연스럽게 끝나는 지점(배틀 결과 화면)에 CPI를 두고, 피드 중간 인라인은 이탈 부담이 적은 +쿠팡 커머스로 채우는 배치를 권한다. 데이터가 쌓이면 지면별 CTR과 정산액을 보고 조정한다. + +## 6. 테스트 + +실제로 깨지기 쉬운 지점에 집중한다. + +- 쿼리스트링이 이미 붙은 제휴 링크에 `subId` 병합 +- 기간·상태 필터링 (미시작/만료/PAUSED 소재가 노출되지 않을 것) +- 가중 로테이션 분포 +- 없는 code / 만료 code 클릭 시 랜딩으로 302 +- 노출 집계 upsert가 같은 날 중복 호출에 누적될 것 diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 00000000..f00383d4 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 00000000..635615f0 Binary files /dev/null and b/src/main/.DS_Store differ diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 00000000..9fa09153 Binary files /dev/null and b/src/main/java/.DS_Store differ diff --git a/src/main/java/com/.DS_Store b/src/main/java/com/.DS_Store new file mode 100644 index 00000000..e30bc1c1 Binary files /dev/null and b/src/main/java/com/.DS_Store differ diff --git a/src/main/java/com/swyp/.DS_Store b/src/main/java/com/swyp/.DS_Store new file mode 100644 index 00000000..469c0b61 Binary files /dev/null and b/src/main/java/com/swyp/.DS_Store differ diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java new file mode 100644 index 00000000..9623de7a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java @@ -0,0 +1,52 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.service.AdClickService; +import com.swyp.picke.domain.ad.service.AdClickService.AdClickTarget; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.servlet.view.RedirectView; + +/** + * 제휴 링크 클릭 진입점. + * + *

/api/v1 아래에 두지 않는다. 외부 브라우저가 여는 공개 숏링크라 짧아야 하고, JSON API가 아니다. + */ +@Controller +@RequiredArgsConstructor +public class AdClickController { + + private static final String FORWARDED_FOR = "X-Forwarded-For"; + + private final AdClickService adClickService; + + @Value("${picke.ad.base-url:https://ad.picke.store}") + private String adBaseUrl; + + @GetMapping("/c/{code}") + public RedirectView click(@PathVariable String code, HttpServletRequest request) { + Optional target = adClickService.resolveTarget(code); + + if (target.isEmpty()) { + // 만료되었거나 없는 코드다. 404를 보여주는 대신 랜딩으로 흘려보낸다. + return new RedirectView(adBaseUrl + "/"); + } + + AdClickTarget clickTarget = target.get(); + adClickService.recordClick(clickTarget, resolveClientIp(request), request.getHeader("User-Agent")); + + return new RedirectView(clickTarget.redirectUrl()); + } + + private String resolveClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR); + if (forwardedFor == null || forwardedFor.isBlank()) { + return request.getRemoteAddr(); + } + return forwardedFor.split(",")[0].trim(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java new file mode 100644 index 00000000..e0a004f0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java @@ -0,0 +1,48 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.dto.request.AdImpressionRequest; +import com.swyp.picke.domain.ad.dto.response.AdResponse; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.service.AdQueryService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +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 = "앱 지면에 노출할 제휴 광고 조회 및 노출 집계") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/ads") +public class AdController { + + private final AdQueryService adQueryService; + + @Operation(summary = "지면별 광고 조회", + description = "게재 가능한 소재가 없으면 빈 배열을 준다. 앱은 이때 지면 자체를 숨긴다.") + @GetMapping + public ApiResponse> getAds( + @Parameter(description = "노출 지면", example = "HOME_FEED") + @RequestParam AdSlotCode slot, + @Parameter(description = "받아갈 소재 개수", example = "1") + @RequestParam(defaultValue = "1") int size + ) { + return ApiResponse.onSuccess(adQueryService.findServableAds(slot, size)); + } + + @Operation(summary = "광고 노출 집계", + description = "조회가 아니라 실제로 화면에 그려진 시점에 호출한다. 조회를 노출로 세면 CTR이 왜곡된다.") + @PostMapping("/impressions") + public ApiResponse recordImpressions(@Valid @RequestBody AdImpressionRequest request) { + adQueryService.recordImpressions(request.codes()); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java new file mode 100644 index 00000000..5565c42a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.service.AdQueryService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * ad.picke.store 루트 공개 지면. + * + *

쿠팡 파트너스 매체 심사에서 URL 접속 확인을 하므로 실제 콘텐츠가 있어야 한다. + * 광고 도메인이 아닌 Host로 들어오면 최소 응답만 준다. API 도메인 루트에 광고 페이지가 뜨면 안 된다. + */ +@Controller +@RequiredArgsConstructor +public class AdLandingController { + + private final AdQueryService adQueryService; + + @Value("${picke.ad.host:ad.picke.store}") + private String adHost; + + @GetMapping("/") + public Object landing(HttpServletRequest request, Model model) { + if (!adHost.equalsIgnoreCase(request.getServerName())) { + return ResponseEntity.ok("PICKE"); + } + + model.addAttribute("ads", adQueryService.findLandingAds()); + return "ad/landing"; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java b/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java new file mode 100644 index 00000000..da9563c4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.ad.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +@Schema(description = "광고 노출 집계 요청") +public record AdImpressionRequest( + + @Schema(description = "실제로 화면에 노출된 소재 코드 목록", example = "[\"a1b2c3d4\"]") + @NotEmpty(message = "노출된 소재 코드는 최소 1개 이상이어야 합니다.") + List codes +) { +} diff --git a/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java b/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java new file mode 100644 index 00000000..a65e8b8b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java @@ -0,0 +1,50 @@ +package com.swyp.picke.domain.ad.dto.response; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "앱 지면에 노출할 제휴 광고 소재") +public record AdResponse( + + @Schema(description = "소재 코드", example = "a1b2c3d4") + String code, + + @Schema(description = "매체", example = "COUPANG") + AdNetwork network, + + @Schema(description = "배너 주 문구", example = "지금 인기 있는 무선 이어폰") + String title, + + @Schema(description = "배너 보조 문구", example = "리뷰 1만 개 이상") + String subtitle, + + @Schema(description = "소재 이미지 URL") + String imageUrl, + + @Schema(description = "버튼 문구", example = "구매하러 가기") + String ctaText, + + @Schema(description = "탭 시 이동할 URL. 외부 브라우저로 열어야 한다.", + example = "https://ad.picke.store/c/a1b2c3d4") + String clickUrl, + + @Schema(description = "광고 표기 라벨. 표시광고법 대응이므로 반드시 렌더해야 한다.", example = "광고") + String label +) { + + private static final String AD_LABEL = "광고"; + + public static AdResponse of(AdCreative creative, String clickUrl) { + return new AdResponse( + creative.getCode(), + creative.getNetwork(), + creative.getTitle(), + creative.getSubtitle(), + creative.getImageUrl(), + creative.getCtaText(), + clickUrl, + AD_LABEL + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java new file mode 100644 index 00000000..0d0540ed --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java @@ -0,0 +1,51 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 클릭 원장. 제휴사 리포트와 대조하는 용도다. + * + *

클릭 시각은 {@code BaseEntity.createdAt}이다. /c/{code}는 외부 브라우저에서 열려 + * Authorization 헤더가 없으므로 사용자를 특정하지 않는다. + */ +@Entity +@Getter +@Table(name = "ad_click_logs", indexes = { + @Index(name = "idx_ad_click_logs_creative", columnList = "creative_id") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdClickLog extends BaseEntity { + + @Column(name = "creative_id", nullable = false) + private Long creativeId; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 40) + private AdSlotCode slot; + + /** 원본 IP는 저장하지 않는다. 중복 클릭 판별에 필요한 정도만 남긴다. */ + @Column(name = "ip_hash", length = 64) + private String ipHash; + + @Column(name = "user_agent", length = 500) + private String userAgent; + + @Builder + private AdClickLog(Long creativeId, AdSlotCode slot, String ipHash, String userAgent) { + this.creativeId = creativeId; + this.slot = slot; + this.ipHash = ipHash; + this.userAgent = userAgent; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java new file mode 100644 index 00000000..15271079 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java @@ -0,0 +1,112 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 제휴 광고 소재. 각 매체 콘솔에서 발급한 완성형 제휴 링크를 어드민이 등록한다. + */ +@Entity +@Getter +@Table(name = "ad_creatives") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdCreative extends BaseEntity { + + /** 공개 클릭 URL(/c/{code})에 노출되는 짧은 코드. PK를 그대로 드러내지 않기 위해 둔다. */ + @Column(name = "code", nullable = false, unique = true, length = 16) + private String code; + + @Enumerated(EnumType.STRING) + @Column(name = "network", nullable = false, length = 20) + private AdNetwork network; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 40) + private AdSlotCode slot; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "subtitle", length = 200) + private String subtitle; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + /** 쿠팡은 "구매하러 가기", 애드픽 CPI는 "설치하고 받기" 식으로 성격이 달라 소재 단위로 둔다. */ + @Column(name = "cta_text", nullable = false, length = 30) + private String ctaText; + + @Column(name = "landing_url", nullable = false, length = 1000) + private String landingUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private AdStatus status; + + @Column(name = "weight", nullable = false) + private int weight; + + @Column(name = "starts_at") + private LocalDateTime startsAt; + + @Column(name = "ends_at") + private LocalDateTime endsAt; + + @Builder + private AdCreative(String code, AdNetwork network, AdSlotCode slot, String title, String subtitle, + String imageUrl, String ctaText, String landingUrl, AdStatus status, Integer weight, + LocalDateTime startsAt, LocalDateTime endsAt) { + this.code = code; + this.network = network; + this.slot = slot; + this.title = title; + this.subtitle = subtitle; + this.imageUrl = imageUrl; + this.ctaText = ctaText; + this.landingUrl = landingUrl; + this.status = status != null ? status : AdStatus.DRAFT; + this.weight = weight != null ? weight : 1; + this.startsAt = startsAt; + this.endsAt = endsAt; + } + + public void update(AdNetwork network, AdSlotCode slot, String title, String subtitle, String imageUrl, + String ctaText, String landingUrl, AdStatus status, Integer weight, + LocalDateTime startsAt, LocalDateTime endsAt) { + this.network = network; + this.slot = slot; + this.title = title; + this.subtitle = subtitle; + this.imageUrl = imageUrl; + this.ctaText = ctaText; + this.landingUrl = landingUrl; + this.status = status; + this.weight = weight != null ? weight : 1; + this.startsAt = startsAt; + this.endsAt = endsAt; + } + + /** 게재 가능 여부. status와 기간을 함께 본다. */ + public boolean isServable(LocalDateTime now) { + if (status != AdStatus.ACTIVE) { + return false; + } + if (startsAt != null && now.isBefore(startsAt)) { + return false; + } + return endsAt == null || !now.isAfter(endsAt); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java new file mode 100644 index 00000000..234d74cd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java @@ -0,0 +1,51 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 일별 노출 집계. + * + *

노출을 raw 로그로 쌓으면 배너가 스크롤에 걸릴 때마다 행이 생겨 금방 수천만 건이 된다. + * CTR 산출에는 일별 카운터로 충분하다. + */ +@Entity +@Getter +@Table(name = "ad_impression_daily", uniqueConstraints = { + @UniqueConstraint(name = "uk_ad_impression_daily", columnNames = {"creative_id", "slot", "stat_date"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdImpressionDaily extends BaseEntity { + + @Column(name = "creative_id", nullable = false) + private Long creativeId; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 40) + private AdSlotCode slot; + + @Column(name = "stat_date", nullable = false) + private LocalDate statDate; + + @Column(name = "impressions", nullable = false) + private long impressions; + + @Builder + private AdImpressionDaily(Long creativeId, AdSlotCode slot, LocalDate statDate, long impressions) { + this.creativeId = creativeId; + this.slot = slot; + this.statDate = statDate; + this.impressions = impressions; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java new file mode 100644 index 00000000..d22d4bf5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 제휴 광고 매체. AdMob 리워드 광고(reward 도메인)와는 무관하다. + */ +@Getter +@RequiredArgsConstructor +public enum AdNetwork { + + COUPANG("쿠팡 파트너스"), + ADPICK("애드픽"); + + private final String description; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java new file mode 100644 index 00000000..e2bfc97a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 광고 노출 지면. + * + *

테이블이 아니라 enum인 이유는, 앱이 그릴 줄 모르는 지면을 어드민에서 만들어봐야 + * 아무 일도 일어나지 않기 때문이다. 지면 추가는 어차피 앱 배포와 묶인다. + * + *

목록은 iOS Presentation 모듈(Home/Battle/Chat/Profile)의 실제 화면을 기준으로 잡았다. + * 앱팀 확정 전이므로 실제로 붙이는 지면만 소재를 등록하면 된다. + * 소재가 없는 지면은 빈 배열을 반환하고 앱은 지면 자체를 숨긴다. + * + *

{@code cpiFriendly}는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다. + * CPI는 단가가 높지만 클릭하면 사용자가 스토어로 나가 다른 앱을 설치한다. + * 세션이 자연스럽게 끝나는 지점이 아니면 이탈·리텐션에 그대로 타격이 온다. + */ +@Getter +@RequiredArgsConstructor +public enum AdSlotCode { + + HOME_FEED("홈 피드 인라인", false), + BATTLE_RESULT_BOTTOM("배틀 결과 하단", true), + CHAT_ROOM_INLINE("관점 목록 인라인", false), + ATTENDANCE_COMPLETE("출석 완료 후", true), + PROFILE_BOTTOM("프로필 하단", true); + + private final String description; + + /** 앱 설치형(CPI) 광고를 놓아도 되는 지면인지. */ + private final boolean cpiFriendly; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java new file mode 100644 index 00000000..42ccbb24 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.ad.enums; + +public enum AdStatus { + DRAFT, + ACTIVE, + PAUSED +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java new file mode 100644 index 00000000..4f5e5388 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 애드픽 서브아이디 파라미터명은 설정값으로 둔다. + * + *

파트너센터 링크생성 화면에서 규격을 확인하기 전까지는 값이 비어 있고, 그동안은 pass-through로 + * 원본 링크를 그대로 넘긴다. 지면별 성과 분리만 안 될 뿐 노출·클릭·리다이렉트는 정상 동작한다. + * 규격이 확인되면 배포 없이 {@code picke.ad.adpick.sub-id-param} 만 채우면 쿠팡과 같은 방식으로 붙는다. + */ +@Component +public class AdpickLinkBuilder implements AffiliateLinkBuilder { + + @Value("${picke.ad.adpick.sub-id-param:}") + private String subIdParam; + + @Override + public AdNetwork network() { + return AdNetwork.ADPICK; + } + + @Override + public String build(AdCreative creative) { + if (!StringUtils.hasText(subIdParam)) { + return creative.getLandingUrl(); + } + return AffiliateLinks.merge(creative.getLandingUrl(), subIdParam, AffiliateLinks.subIdOf(creative)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java new file mode 100644 index 00000000..f8041a7b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; + +/** + * 매체별 최종 이동 URL을 만든다. + * + *

소재의 landingUrl은 각 매체 콘솔에서 발급한 완성형 링크라 이미 쿼리스트링을 갖고 있다. + * 추적 파라미터는 문자열 결합이 아니라 병합이어야 한다. + */ +public interface AffiliateLinkBuilder { + + AdNetwork network(); + + String build(AdCreative creative); +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java new file mode 100644 index 00000000..b3f9be88 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +public class AffiliateLinkResolver { + + private final Map builders = new EnumMap<>(AdNetwork.class); + + public AffiliateLinkResolver(List builderList) { + builderList.forEach(builder -> builders.put(builder.network(), builder)); + } + + public String resolve(AdCreative creative) { + AffiliateLinkBuilder builder = builders.get(creative.getNetwork()); + if (builder == null) { + return creative.getLandingUrl(); + } + return builder.build(creative); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java new file mode 100644 index 00000000..c383810f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java @@ -0,0 +1,31 @@ +package com.swyp.picke.domain.ad.link; + +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 제휴 링크에 추적 파라미터를 끼워 넣는 공통 규칙. + */ +final class AffiliateLinks { + + private AffiliateLinks() { + } + + /** + * 이미 쿼리스트링이 붙어 있는 제휴 링크에 파라미터를 병합한다. + * + *

단순 문자열 결합이 아니다. 같은 이름의 파라미터가 이미 있으면 우리 값으로 덮는다. + * build(true)로 두는 이유는 landingUrl이 각 매체 콘솔에서 인코딩까지 끝난 상태로 오기 때문이다. + * 여기서 다시 인코딩하면 이중 인코딩된다. + */ + static String merge(String landingUrl, String paramName, String value) { + return UriComponentsBuilder.fromUriString(landingUrl) + .replaceQueryParam(paramName, value) + .build(true) + .toUriString(); + } + + /** 지면별 성과를 가르기 위한 추적값. 영문·숫자·밑줄만 쓰므로 인코딩이 필요 없다. */ + static String subIdOf(com.swyp.picke.domain.ad.entity.AdCreative creative) { + return creative.getSlot().name() + "_" + creative.getCode(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java new file mode 100644 index 00000000..601796b3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java @@ -0,0 +1,25 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import org.springframework.stereotype.Component; + +/** + * 쿠팡 파트너스는 subId를 지원한다. 지면별로 값을 달리 넣으면 파트너스 리포트에서 + * 지면별 실매출이 갈려, 어느 지면이 돈이 되는지 데이터로 볼 수 있다. + */ +@Component +public class CoupangLinkBuilder implements AffiliateLinkBuilder { + + private static final String SUB_ID_PARAM = "subId"; + + @Override + public AdNetwork network() { + return AdNetwork.COUPANG; + } + + @Override + public String build(AdCreative creative) { + return AffiliateLinks.merge(creative.getLandingUrl(), SUB_ID_PARAM, AffiliateLinks.subIdOf(creative)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java new file mode 100644 index 00000000..76c025a0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.ad.repository; + +import com.swyp.picke.domain.ad.entity.AdClickLog; +import com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AdClickLogRepository extends JpaRepository { + + @Query("select l.creativeId as creativeId, count(l) as total from AdClickLog l " + + "where l.createdAt >= :from and l.createdAt < :to " + + "group by l.creativeId") + List countByCreativeBetween(@Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + @Query("select new com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse(" + + "l.id, c.code, c.title, c.network, l.slot, l.createdAt) " + + "from AdClickLog l join AdCreative c on c.id = l.creativeId " + + "where l.createdAt >= :from and l.createdAt < :to " + + "order by l.id desc") + Page findClickLogs(@Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + Pageable pageable); + + interface CreativeCount { + Long getCreativeId(); + + long getTotal(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java new file mode 100644 index 00000000..a018abd4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.ad.repository; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AdCreativeRepository extends JpaRepository { + + Optional findByCode(String code); + + boolean existsByCode(String code); + + List findAllBySlotAndStatus(AdSlotCode slot, AdStatus status); + + List findAllByStatusOrderByIdDesc(AdStatus status); + + List findAllByOrderByIdDesc(); + + @Query("select c from AdCreative c " + + "where (:network is null or c.network = :network) " + + "and (:slot is null or c.slot = :slot) " + + "and (:status is null or c.status = :status) " + + "order by c.id desc") + List search(@Param("network") AdNetwork network, + @Param("slot") AdSlotCode slot, + @Param("status") AdStatus status); + + List findAllByCodeIn(List codes); +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java new file mode 100644 index 00000000..fae41652 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.ad.repository; + +import com.swyp.picke.domain.ad.entity.AdImpressionDaily; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AdImpressionDailyRepository extends JpaRepository { + + Optional findByCreativeIdAndSlotAndStatDate(Long creativeId, AdSlotCode slot, + LocalDate statDate); + + /** + * ON CONFLICT는 PostgreSQL 전용이라 테스트 H2에서 깨진다. 갱신 후 0건이면 삽입하는 방식으로 둔다. + */ + @Modifying + @Query("update AdImpressionDaily a set a.impressions = a.impressions + :delta " + + "where a.creativeId = :creativeId and a.slot = :slot and a.statDate = :statDate") + int increment(@Param("creativeId") Long creativeId, + @Param("slot") AdSlotCode slot, + @Param("statDate") LocalDate statDate, + @Param("delta") long delta); + + @Query("select a.creativeId as creativeId, sum(a.impressions) as total from AdImpressionDaily a " + + "where a.statDate >= :from and a.statDate <= :to " + + "group by a.creativeId") + List sumByCreativeBetween(@Param("from") LocalDate from, @Param("to") LocalDate to); + + interface CreativeCount { + Long getCreativeId(); + + long getTotal(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java new file mode 100644 index 00000000..7b8193f8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java @@ -0,0 +1,92 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.entity.AdClickLog; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.link.AffiliateLinkResolver; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdClickService { + + private static final int USER_AGENT_MAX_LENGTH = 500; + + /** 게재 기간 판단은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AdCreativeRepository adCreativeRepository; + private final AdClickLogRepository adClickLogRepository; + private final AffiliateLinkResolver affiliateLinkResolver; + + /** + * 클릭 코드로 최종 이동 대상을 찾는다. 코드가 없거나 게재 기간이 지났으면 비어 있는 값을 준다. + */ + @Transactional(readOnly = true) + public Optional resolveTarget(String code) { + return adCreativeRepository.findByCode(code) + .filter(creative -> creative.isServable(LocalDateTime.now(KST))) + .map(creative -> new AdClickTarget( + creative.getId(), + creative.getSlot(), + affiliateLinkResolver.resolve(creative) + )); + } + + /** + * 클릭 적재가 리다이렉트를 붙잡으면 안 되므로 비동기로 둔다. + * 적재에 실패해도 사용자는 정상적으로 제휴처로 이동해야 한다. + */ + @Async + @Transactional + public void recordClick(AdClickTarget target, String clientIp, String userAgent) { + try { + adClickLogRepository.save(AdClickLog.builder() + .creativeId(target.creativeId()) + .slot(target.slot()) + .ipHash(hashIp(clientIp)) + .userAgent(truncate(userAgent)) + .build()); + } catch (Exception e) { + log.warn("[AdClick] 클릭 적재 실패 creativeId={}: {}", target.creativeId(), e.getMessage()); + } + } + + /** 원본 IP는 남기지 않는다. 중복 클릭 판별에 필요한 정도만 해시로 보관한다. */ + private String hashIp(String clientIp) { + if (clientIp == null || clientIp.isBlank()) { + return null; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(clientIp.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + return null; + } + } + + private String truncate(String userAgent) { + if (userAgent == null) { + return null; + } + return userAgent.length() > USER_AGENT_MAX_LENGTH + ? userAgent.substring(0, USER_AGENT_MAX_LENGTH) + : userAgent; + } + + public record AdClickTarget(Long creativeId, AdSlotCode slot, String redirectUrl) { + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java new file mode 100644 index 00000000..058fc734 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java @@ -0,0 +1,135 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.dto.response.AdResponse; +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.entity.AdImpressionDaily; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdQueryService { + + /** 노출 집계 버킷과 게재 기간은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AdCreativeRepository adCreativeRepository; + private final AdImpressionDailyRepository adImpressionDailyRepository; + + @Value("${picke.ad.base-url:https://ad.picke.store}") + private String adBaseUrl; + + /** + * 지면에 노출할 소재를 가중 로테이션으로 고른다. 게재 가능한 소재가 없으면 빈 목록을 준다. + * 앱은 빈 목록을 받으면 지면 자체를 숨긴다. 광고가 없는 건 오류가 아니다. + */ + @Transactional(readOnly = true) + public List findServableAds(AdSlotCode slot, int size) { + LocalDateTime now = LocalDateTime.now(KST); + + List candidates = adCreativeRepository.findAllBySlotAndStatus(slot, AdStatus.ACTIVE).stream() + .filter(creative -> creative.isServable(now)) + .toList(); + + return weightedSample(candidates, size).stream() + .map(creative -> AdResponse.of(creative, buildClickUrl(creative))) + .toList(); + } + + /** + * ad.picke.store 루트 공개 지면에 나열할 소재. 매체 심사에서 실제 콘텐츠를 확인하므로 + * 로테이션 없이 게재 가능한 소재를 모두 보여준다. + */ + @Transactional(readOnly = true) + public List findLandingAds() { + LocalDateTime now = LocalDateTime.now(KST); + + return adCreativeRepository.findAllByStatusOrderByIdDesc(AdStatus.ACTIVE).stream() + .filter(creative -> creative.isServable(now)) + .map(creative -> AdResponse.of(creative, buildClickUrl(creative))) + .toList(); + } + + /** + * 조회 시점이 아니라 앱이 실제로 화면에 그린 시점에 호출된다. + * 조회를 노출로 세면 CTR이 실제보다 낮게 왜곡되기 때문이다. + */ + @Transactional + public void recordImpressions(List codes) { + LocalDate today = LocalDate.now(KST); + + List targets = adCreativeRepository.findAllByCodeIn(codes).stream() + .map(creative -> new ImpressionTarget(creative.getId(), creative.getSlot())) + .toList(); + + targets.forEach(target -> increaseImpression(target, today)); + } + + private void increaseImpression(ImpressionTarget target, LocalDate today) { + if (adImpressionDailyRepository.increment(target.creativeId(), target.slot(), today, 1L) > 0) { + return; + } + + try { + adImpressionDailyRepository.save(AdImpressionDaily.builder() + .creativeId(target.creativeId()) + .slot(target.slot()) + .statDate(today) + .impressions(1L) + .build()); + } catch (DataIntegrityViolationException e) { + // 같은 (소재, 지면, 날짜) 행을 다른 요청이 먼저 만든 경우다. 갱신으로 되돌린다. + adImpressionDailyRepository.increment(target.creativeId(), target.slot(), today, 1L); + } + } + + private String buildClickUrl(AdCreative creative) { + return adBaseUrl + "/c/" + creative.getCode(); + } + + private List weightedSample(List candidates, int size) { + List pool = new ArrayList<>(candidates); + List picked = new ArrayList<>(); + + while (!pool.isEmpty() && picked.size() < size) { + picked.add(pickOne(pool)); + } + return picked; + } + + /** 가중치에 비례해 하나를 뽑고 풀에서 제거한다. 같은 소재가 한 응답에 두 번 담기지 않게 한다. */ + private AdCreative pickOne(List pool) { + int totalWeight = pool.stream().mapToInt(creative -> Math.max(1, creative.getWeight())).sum(); + int threshold = ThreadLocalRandom.current().nextInt(totalWeight); + + int accumulated = 0; + for (Iterator iterator = pool.iterator(); iterator.hasNext(); ) { + AdCreative creative = iterator.next(); + accumulated += Math.max(1, creative.getWeight()); + if (threshold < accumulated) { + iterator.remove(); + return creative; + } + } + return pool.remove(pool.size() - 1); + } + + private record ImpressionTarget(Long creativeId, AdSlotCode slot) { + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java new file mode 100644 index 00000000..be3ee8fc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java @@ -0,0 +1,102 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.admin.dto.ad.request.AdCreativeRequest; +import com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse; +import com.swyp.picke.domain.admin.dto.ad.response.AdCreativeResponse; +import com.swyp.picke.domain.admin.dto.ad.response.AdStatsResponse; +import com.swyp.picke.domain.admin.service.AdminAdService; +import com.swyp.picke.global.common.response.ApiResponse; +import com.swyp.picke.global.common.response.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +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 = "제휴 광고 소재 관리 및 노출/클릭 집계") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/ads") +@PreAuthorize("hasRole('ADMIN')") +public class AdminAdController { + + private final AdminAdService adminAdService; + + @Operation(summary = "광고 소재 목록", description = "매체/지면/상태로 필터링한다. 값을 비우면 전체를 준다.") + @GetMapping + public ApiResponse> findAll( + @RequestParam(required = false) AdNetwork network, + @RequestParam(required = false) AdSlotCode slot, + @RequestParam(required = false) AdStatus status + ) { + return ApiResponse.onSuccess(adminAdService.findAll(network, slot, status)); + } + + @Operation(summary = "광고 소재 등록", description = "각 매체 콘솔에서 발급한 제휴 링크를 그대로 넣는다.") + @PostMapping + public ApiResponse create(@Valid @RequestBody AdCreativeRequest request) { + return ApiResponse.onSuccess(adminAdService.create(request)); + } + + @Operation(summary = "광고 소재 수정") + @PutMapping("/{creativeId}") + public ApiResponse update( + @Parameter(description = "소재 ID", example = "1") + @PathVariable Long creativeId, + @Valid @RequestBody AdCreativeRequest request + ) { + return ApiResponse.onSuccess(adminAdService.update(creativeId, request)); + } + + @Operation(summary = "광고 소재 삭제") + @DeleteMapping("/{creativeId}") + public ApiResponse delete( + @Parameter(description = "소재 ID", example = "1") + @PathVariable Long creativeId + ) { + adminAdService.delete(creativeId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "소재별 노출/클릭/CTR", + description = "우리 DB 기준 수치다. 제휴사 정산 리포트와 대조하는 용도로 쓴다.") + @GetMapping("/stats") + public ApiResponse> findStats( + @Parameter(description = "집계 시작일", example = "2026-09-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @Parameter(description = "집계 종료일(포함)", example = "2026-09-30") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminAdService.findStats(from, to)); + } + + @Operation(summary = "광고 클릭 내역", description = "언제 어떤 소재가 눌렸는지 최신순으로 본다.") + @GetMapping("/clicks") + public ApiResponse> findClickLogs( + @Parameter(description = "조회 시작일", example = "2026-09-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @Parameter(description = "조회 종료일(포함)", example = "2026-09-30") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @Parameter(description = "1부터 시작", example = "1") + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess(adminAdService.findClickLogs(from, to, page, size)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java new file mode 100644 index 00000000..c767e7cd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java @@ -0,0 +1,62 @@ +package com.swyp.picke.domain.admin.dto.ad.request; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +@Schema(description = "제휴 광고 소재 등록/수정 요청") +public record AdCreativeRequest( + + @Schema(description = "매체", example = "COUPANG") + @NotNull(message = "매체는 필수입니다.") + AdNetwork network, + + @Schema(description = "노출 지면", example = "HOME_FEED") + @NotNull(message = "노출 지면은 필수입니다.") + AdSlotCode slot, + + @Schema(description = "배너 주 문구") + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 100, message = "제목은 100자를 초과할 수 없습니다.") + String title, + + @Schema(description = "배너 보조 문구") + @Size(max = 200, message = "보조 문구는 200자를 초과할 수 없습니다.") + String subtitle, + + @Schema(description = "소재 이미지 URL") + @NotBlank(message = "이미지 URL은 필수입니다.") + @Size(max = 500, message = "이미지 URL은 500자를 초과할 수 없습니다.") + String imageUrl, + + @Schema(description = "버튼 문구", example = "구매하러 가기") + @NotBlank(message = "버튼 문구는 필수입니다.") + @Size(max = 30, message = "버튼 문구는 30자를 초과할 수 없습니다.") + String ctaText, + + @Schema(description = "각 매체 콘솔에서 발급한 제휴 링크") + @NotBlank(message = "제휴 링크는 필수입니다.") + @Size(max = 1000, message = "제휴 링크는 1000자를 초과할 수 없습니다.") + String landingUrl, + + @Schema(description = "게재 상태", example = "ACTIVE") + @NotNull(message = "게재 상태는 필수입니다.") + AdStatus status, + + @Schema(description = "가중 로테이션 가중치. 클수록 자주 노출된다.", example = "1") + @Positive(message = "가중치는 1 이상이어야 합니다.") + Integer weight, + + @Schema(description = "게재 시작 시각. 비우면 즉시 시작") + LocalDateTime startsAt, + + @Schema(description = "게재 종료 시각. 비우면 무제한") + LocalDateTime endsAt +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java new file mode 100644 index 00000000..e9e4bee4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.admin.dto.ad.response; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "광고 클릭 내역 한 건") +public record AdClickLogResponse( + + Long clickId, + + @Schema(description = "소재 코드", example = "a1b2c3d4") + String code, + + @Schema(description = "소재 제목") + String title, + + AdNetwork network, + + AdSlotCode slot, + + @Schema(description = "클릭 시각") + LocalDateTime clickedAt +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java new file mode 100644 index 00000000..da1c8566 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java @@ -0,0 +1,44 @@ +package com.swyp.picke.domain.admin.dto.ad.response; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "제휴 광고 소재") +public record AdCreativeResponse( + Long id, + String code, + AdNetwork network, + AdSlotCode slot, + String title, + String subtitle, + String imageUrl, + String ctaText, + String landingUrl, + AdStatus status, + int weight, + LocalDateTime startsAt, + LocalDateTime endsAt +) { + + public static AdCreativeResponse from(AdCreative creative) { + return new AdCreativeResponse( + creative.getId(), + creative.getCode(), + creative.getNetwork(), + creative.getSlot(), + creative.getTitle(), + creative.getSubtitle(), + creative.getImageUrl(), + creative.getCtaText(), + creative.getLandingUrl(), + creative.getStatus(), + creative.getWeight(), + creative.getStartsAt(), + creative.getEndsAt() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java new file mode 100644 index 00000000..26e0f912 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java @@ -0,0 +1,30 @@ +package com.swyp.picke.domain.admin.dto.ad.response; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "소재별 노출/클릭 집계") +public record AdStatsResponse( + Long creativeId, + String code, + AdNetwork network, + AdSlotCode slot, + String title, + + @Schema(description = "기간 내 노출 수") + long impressions, + + @Schema(description = "기간 내 클릭 수") + long clicks, + + @Schema(description = "클릭률(%). 노출이 0이면 0", example = "1.25") + double ctr +) { + + public static AdStatsResponse of(Long creativeId, String code, AdNetwork network, AdSlotCode slot, + String title, long impressions, long clicks) { + double ctr = impressions == 0 ? 0d : Math.round(clicks * 10000d / impressions) / 100d; + return new AdStatsResponse(creativeId, code, network, slot, title, impressions, clicks, ctr); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java new file mode 100644 index 00000000..ea068f95 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java @@ -0,0 +1,184 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import com.swyp.picke.domain.admin.dto.ad.request.AdCreativeRequest; +import com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse; +import com.swyp.picke.domain.admin.dto.ad.response.AdCreativeResponse; +import com.swyp.picke.domain.admin.dto.ad.response.AdStatsResponse; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.common.response.PageResponse; +import java.security.SecureRandom; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@RequiredArgsConstructor +public class AdminAdService { + + private static final String CODE_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789"; + private static final int CODE_LENGTH = 8; + private static final int CODE_MAX_ATTEMPTS = 10; + + private final AdCreativeRepository adCreativeRepository; + private final AdClickLogRepository adClickLogRepository; + private final AdImpressionDailyRepository adImpressionDailyRepository; + private final SecureRandom random = new SecureRandom(); + + @Value("${coupang.partners.id:}") + private String coupangPartnersId; + + @Transactional + public AdCreativeResponse create(AdCreativeRequest request) { + validateCoupangOwnership(request); + + AdCreative creative = AdCreative.builder() + .code(generateUniqueCode()) + .network(request.network()) + .slot(request.slot()) + .title(request.title()) + .subtitle(request.subtitle()) + .imageUrl(request.imageUrl()) + .ctaText(request.ctaText()) + .landingUrl(request.landingUrl()) + .status(request.status()) + .weight(request.weight()) + .startsAt(request.startsAt()) + .endsAt(request.endsAt()) + .build(); + + return AdCreativeResponse.from(adCreativeRepository.save(creative)); + } + + @Transactional + public AdCreativeResponse update(Long creativeId, AdCreativeRequest request) { + validateCoupangOwnership(request); + + AdCreative creative = findById(creativeId); + + creative.update( + request.network(), + request.slot(), + request.title(), + request.subtitle(), + request.imageUrl(), + request.ctaText(), + request.landingUrl(), + request.status(), + request.weight(), + request.startsAt(), + request.endsAt() + ); + + return AdCreativeResponse.from(creative); + } + + @Transactional + public void delete(Long creativeId) { + adCreativeRepository.delete(findById(creativeId)); + } + + @Transactional(readOnly = true) + public List findAll(AdNetwork network, AdSlotCode slot, AdStatus status) { + return adCreativeRepository.search(network, slot, status).stream() + .map(AdCreativeResponse::from) + .toList(); + } + + /** + * 소재별 노출/클릭/CTR. 우리 DB 기준 수치이므로 제휴사 정산 리포트와 대조하는 용도다. + */ + @Transactional(readOnly = true) + public List findStats(LocalDate from, LocalDate to) { + Map impressions = adImpressionDailyRepository.sumByCreativeBetween(from, to).stream() + .collect(Collectors.toMap( + AdImpressionDailyRepository.CreativeCount::getCreativeId, + AdImpressionDailyRepository.CreativeCount::getTotal)); + + Map clicks = adClickLogRepository + .countByCreativeBetween(from.atStartOfDay(), to.plusDays(1).atStartOfDay()).stream() + .collect(Collectors.toMap( + AdClickLogRepository.CreativeCount::getCreativeId, + AdClickLogRepository.CreativeCount::getTotal)); + + return adCreativeRepository.findAllByOrderByIdDesc().stream() + .map(creative -> AdStatsResponse.of( + creative.getId(), + creative.getCode(), + creative.getNetwork(), + creative.getSlot(), + creative.getTitle(), + impressions.getOrDefault(creative.getId(), 0L), + clicks.getOrDefault(creative.getId(), 0L))) + .toList(); + } + + /** + * 클릭 내역 목록. 어드민에서 "지금 광고가 실제로 눌리고 있는지"를 바로 확인하는 용도다. + */ + @Transactional(readOnly = true) + public PageResponse findClickLogs(LocalDate from, LocalDate to, int page, int size) { + return PageResponse.of(adClickLogRepository.findClickLogs( + from.atStartOfDay(), + to.plusDays(1).atStartOfDay(), + PageRequest.of(Math.max(0, page - 1), size))); + } + + /** + * 남의 파트너스 링크를 잘못 붙여넣으면 우리가 광고를 싣고 수수료는 남이 받는다. + * + *

다만 link.coupang.com 단축 링크에는 lptag가 드러나지 않으므로, 파라미터가 있을 때만 대조한다. + * 없다고 막으면 정상적인 단축 링크를 쓸 수 없다. + */ + private void validateCoupangOwnership(AdCreativeRequest request) { + if (request.network() != AdNetwork.COUPANG || !StringUtils.hasText(coupangPartnersId)) { + return; + } + + String lptag = UriComponentsBuilder.fromUriString(request.landingUrl()) + .build() + .getQueryParams() + .getFirst("lptag"); + + if (lptag != null && !coupangPartnersId.equals(lptag)) { + throw new CustomException(ErrorCode.AD_COUPANG_PARTNER_MISMATCH); + } + } + + private AdCreative findById(Long creativeId) { + return adCreativeRepository.findById(creativeId) + .orElseThrow(() -> new CustomException(ErrorCode.AD_CREATIVE_NOT_FOUND)); + } + + /** 헷갈리기 쉬운 글자(l, o, 0, 1)를 뺀 알파벳으로 코드를 만든다. 어드민이 눈으로 옮겨 적는 일이 있다. */ + private String generateUniqueCode() { + for (int attempt = 0; attempt < CODE_MAX_ATTEMPTS; attempt++) { + String code = randomCode(); + if (!adCreativeRepository.existsByCode(code)) { + return code; + } + } + throw new CustomException(ErrorCode.AD_CODE_GENERATION_FAILED); + } + + private String randomCode() { + return random.ints(CODE_LENGTH, 0, CODE_ALPHABET.length()) + .mapToObj(index -> String.valueOf(CODE_ALPHABET.charAt(index))) + .collect(Collectors.joining()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java index 474f9158..efde5994 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.time.LocalDate; import java.util.List; +import java.util.Set; @Slf4j @RequiredArgsConstructor @@ -53,7 +54,21 @@ public class JwtFilter extends OncePerRequestFilter { "/app-ads.txt", "/terms", "/privacy-policy", - "/robots.txt" + "/robots.txt", + "/c/", + "/api/v1/ads" + ); + + /** + * WHITELIST는 startsWith로 매칭하므로 "/"를 넣으면 전체 인증이 무력화된다. + * 루트와 에러 포워딩처럼 정확히 일치할 때만 열어야 하는 경로는 여기 둔다. + * + *

/error가 빠져 있으면 존재하지 않는 경로가 404 대신 401로 나온다. + * 스프링이 404를 /error로 포워딩하는데 그 경로가 다시 인증에 막히기 때문이다. + */ + private static final Set EXACT_WHITELIST = Set.of( + "/", + "/error" ); @Override @@ -143,6 +158,9 @@ private String resolveToken(HttpServletRequest request) { } private boolean isWhitelisted(String uri) { + if (EXACT_WHITELIST.contains(uri)) { + return true; + } return WHITELIST.stream().anyMatch(white -> uri.equals(white) || uri.startsWith(white)); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 415672dd..ee2d1b9a 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -116,6 +116,11 @@ public enum ErrorCode { PHILOSOPHER_CALC_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "USER_500_PHIL", "철학자 유형을 계산할 수 없습니다."), RECAP_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_RECAP", "존재하지 않는 리캡입니다."), + // Ad (제휴 광고) + AD_CREATIVE_NOT_FOUND(HttpStatus.NOT_FOUND, "AD_404", "존재하지 않는 광고 소재입니다."), + AD_CODE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AD_500_CODE", "광고 소재 코드 생성에 실패했습니다."), + AD_COUPANG_PARTNER_MISMATCH(HttpStatus.BAD_REQUEST, "AD_400_LPTAG", "우리 쿠팡 파트너스 아이디가 아닌 제휴 링크입니다. 링크를 다시 확인해 주세요."), + // Attendance ATTENDANCE_ALREADY_CHECKED(HttpStatus.CONFLICT, "ATTENDANCE_409", "오늘 이미 출석체크를 완료했습니다."); diff --git a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index d0037617..2b2990d6 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -57,7 +57,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/app-ads.txt", "/robots.txt", "/terms", - "/privacy-policy" + "/privacy-policy", + "/error", + "/c/**", + "/api/v1/ads", + "/api/v1/ads/**" ).permitAll() // 2. 관리자 HTML 화면 렌더링 요청 diff --git a/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java b/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java index 2aada9a9..aed34099 100644 --- a/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java @@ -85,7 +85,7 @@ public GroupedOpenApi userApi() { return GroupedOpenApi.builder() .group("1. 사용자 API") .pathsToMatch("/api/v1/**") - .pathsToExclude("/api/v1/admin/**", "/api/v1/files/**", "/api/v1/resources/**", "/api/test/**", "/api/v1/admob/**") + .pathsToExclude("/api/v1/admin/**", "/api/v1/files/**", "/api/v1/resources/**", "/api/test/**", "/api/v1/admob/**", "/api/v1/ads/**") .addOpenApiCustomizer(feUsedApiOnlyCustomizer()) .build(); } @@ -95,6 +95,21 @@ public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() .group("2. 관리자 API") .pathsToMatch("/api/v1/admin/**", "/api/v1/files/**", "/api/v1/resources/**", "/api/test/**", "/api/v1/admob/**") + .pathsToExclude("/api/v1/admin/ads/**") + .build(); + } + + /** + * 제휴 광고는 별도 그룹으로 띄운다. + * + *

사용자 그룹은 FE_USED_OPERATIONS 화이트리스트로 걸러지므로 거기에 넣으면 어차피 보이지 않는다. + * 앱용과 관리자용을 한 그룹에 모아 광고 연동만 따로 볼 수 있게 한다. + */ + @Bean + public GroupedOpenApi adApi() { + return GroupedOpenApi.builder() + .group("3. 광고 API") + .pathsToMatch("/api/v1/ads", "/api/v1/ads/**", "/api/v1/admin/ads", "/api/v1/admin/ads/**") .build(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 60c036c8..02aa11b4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -130,6 +130,12 @@ admin: picke: baseUrl: ${PICKE_BASE_URL:https://picke.store} + ad: + host: ${AD_HOST:ad.picke.store} + base-url: ${AD_BASE_URL:https://ad.picke.store} + adpick: + # 애드픽 파트너센터 링크생성 화면에서 규격 확인 후 채운다. 비어 있으면 원본 링크를 그대로 넘긴다. + sub-id-param: ${ADPICK_SUB_ID_PARAM:} s3: presigned-url: expiration-hours: 6 @@ -140,4 +146,9 @@ media: ffmpeg: path: ${FFMPEG_PATH:ffmpeg} ffprobe: - path: ${FFPROBE_PATH:ffprobe} \ No newline at end of file + path: ${FFPROBE_PATH:ffprobe} + +coupang: + partners: + # 제휴 링크에 lptag로 노출되는 공개 식별자다. 소재 등록 시 남의 링크가 아닌지 대조하는 데 쓴다. + id: ${COUPANG_PARTNERS_ID:AF6830373} diff --git a/src/main/resources/templates/ad/landing.html b/src/main/resources/templates/ad/landing.html new file mode 100644 index 00000000..0bce255c --- /dev/null +++ b/src/main/resources/templates/ad/landing.html @@ -0,0 +1,84 @@ + + + + + + PICKE 추천 - 오늘의 제휴 상품 + + + + +

+
+

PICKE 추천

+

PICKE가 고른 오늘의 추천 상품과 제휴 혜택입니다.

+
+ +
+

추천 상품

+ + + +

준비 중인 추천 상품이 곧 올라옵니다.

+
+ +
+

+ 이 사이트는 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다. +

+

+ 개인정보처리방침 · + 이용약관 +

+

© PICKE

+
+
+ + diff --git a/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java b/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java new file mode 100644 index 00000000..42379efd --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java @@ -0,0 +1,62 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdCreativeTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 1, 12, 0); + + private AdCreative creative(AdStatus status, LocalDateTime startsAt, LocalDateTime endsAt) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/abcdef") + .status(status) + .weight(1) + .startsAt(startsAt) + .endsAt(endsAt) + .build(); + } + + @Test + @DisplayName("ACTIVE이고 기간이 열려 있으면 게재한다") + void isServable_true() { + assertThat(creative(AdStatus.ACTIVE, null, null).isServable(NOW)).isTrue(); + } + + @Test + @DisplayName("PAUSED와 DRAFT는 기간과 무관하게 게재하지 않는다") + void isServable_falseWhenNotActive() { + assertThat(creative(AdStatus.PAUSED, null, null).isServable(NOW)).isFalse(); + assertThat(creative(AdStatus.DRAFT, null, null).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("시작 전 소재는 게재하지 않는다") + void isServable_falseBeforeStart() { + assertThat(creative(AdStatus.ACTIVE, NOW.plusDays(1), null).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("종료된 소재는 게재하지 않는다") + void isServable_falseAfterEnd() { + assertThat(creative(AdStatus.ACTIVE, null, NOW.minusSeconds(1)).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("종료 시각과 정확히 같은 순간까지는 게재한다") + void isServable_trueAtExactEnd() { + assertThat(creative(AdStatus.ACTIVE, null, NOW).isServable(NOW)).isTrue(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java b/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java new file mode 100644 index 00000000..36d89c41 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java @@ -0,0 +1,53 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdpickLinkBuilderTest { + + private static final String LANDING = "https://adpick.co.kr/?ac=offer&tac=campaign&id=123"; + + private AdCreative creative() { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("앱 설치하고 포인트 받기") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("설치하고 받기") + .landingUrl(LANDING) + .status(AdStatus.ACTIVE) + .weight(1) + .build(); + } + + private AdpickLinkBuilder builder(String subIdParam) { + AdpickLinkBuilder builder = new AdpickLinkBuilder(); + ReflectionTestUtils.setField(builder, "subIdParam", subIdParam); + return builder; + } + + @Test + @DisplayName("파라미터명이 비어 있으면 원본 링크를 그대로 넘긴다") + void build_passThroughWhenParamNotConfigured() { + assertThat(builder("").build(creative())).isEqualTo(LANDING); + assertThat(builder(null).build(creative())).isEqualTo(LANDING); + } + + @Test + @DisplayName("파라미터명을 채우면 배포 없이 지면별 추적값이 붙는다") + void build_mergesSubIdWhenConfigured() { + String url = builder("subid").build(creative()); + + assertThat(url).contains("subid=BATTLE_RESULT_BOTTOM_abc12345"); + assertThat(url).contains("ac=offer"); + assertThat(url).contains("id=123"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java b/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java new file mode 100644 index 00000000..dea9512f --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java @@ -0,0 +1,66 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CoupangLinkBuilderTest { + + private final CoupangLinkBuilder builder = new CoupangLinkBuilder(); + + private AdCreative creative(String landingUrl) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl(landingUrl) + .status(AdStatus.ACTIVE) + .weight(1) + .build(); + } + + @Test + @DisplayName("쿼리스트링이 이미 있는 제휴 링크에도 subId를 병합한다") + void build_mergesSubIdIntoExistingQueryString() { + String url = builder.build(creative("https://link.coupang.com/re/AFF?lptag=AF6830373&pageKey=123")); + + assertThat(url).contains("lptag=AF6830373"); + assertThat(url).contains("pageKey=123"); + assertThat(url).contains("subId=HOME_FEED_abc12345"); + assertThat(url).doesNotContain("??"); + } + + @Test + @DisplayName("쿼리스트링이 없는 링크에는 subId를 새로 붙인다") + void build_appendsSubIdWhenNoQueryString() { + String url = builder.build(creative("https://link.coupang.com/a/abcdef")); + + assertThat(url).isEqualTo("https://link.coupang.com/a/abcdef?subId=HOME_FEED_abc12345"); + } + + @Test + @DisplayName("이미 subId가 있으면 우리 값으로 덮어쓴다") + void build_replacesExistingSubId() { + String url = builder.build(creative("https://link.coupang.com/a/abcdef?subId=old")); + + assertThat(url).contains("subId=HOME_FEED_abc12345"); + assertThat(url).doesNotContain("subId=old"); + } + + @Test + @DisplayName("인코딩된 파라미터를 이중 인코딩하지 않는다") + void build_doesNotDoubleEncode() { + String url = builder.build(creative("https://link.coupang.com/a/x?q=%EC%9D%B4%EC%96%B4%ED%8F%B0")); + + assertThat(url).contains("q=%EC%9D%B4%EC%96%B4%ED%8F%B0"); + assertThat(url).doesNotContain("%25"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java new file mode 100644 index 00000000..e542b577 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java @@ -0,0 +1,82 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.link.AffiliateLinkResolver; +import com.swyp.picke.domain.ad.link.CoupangLinkBuilder; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.service.AdClickService.AdClickTarget; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdClickServiceTest { + + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdClickLogRepository adClickLogRepository; + + private AdClickService adClickService() { + return new AdClickService( + adCreativeRepository, + adClickLogRepository, + new AffiliateLinkResolver(List.of(new CoupangLinkBuilder()))); + } + + private AdCreative creative(AdStatus status, LocalDateTime endsAt) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/abcdef") + .status(status) + .weight(1) + .endsAt(endsAt) + .build(); + } + + @Test + @DisplayName("없는 코드는 이동 대상을 주지 않는다") + void resolveTarget_emptyWhenCodeMissing() { + when(adCreativeRepository.findByCode("nope0000")).thenReturn(Optional.empty()); + + assertThat(adClickService().resolveTarget("nope0000")).isEmpty(); + } + + @Test + @DisplayName("게재가 끝난 소재는 이동 대상을 주지 않는다") + void resolveTarget_emptyWhenExpired() { + when(adCreativeRepository.findByCode("abc12345")) + .thenReturn(Optional.of(creative(AdStatus.ACTIVE, LocalDateTime.now().minusDays(1)))); + + assertThat(adClickService().resolveTarget("abc12345")).isEmpty(); + } + + @Test + @DisplayName("게재 중인 소재는 매체 규칙이 적용된 최종 URL을 준다") + void resolveTarget_returnsResolvedUrl() { + when(adCreativeRepository.findByCode("abc12345")) + .thenReturn(Optional.of(creative(AdStatus.ACTIVE, null))); + + AdClickTarget target = adClickService().resolveTarget("abc12345").orElseThrow(); + + assertThat(target.slot()).isEqualTo(AdSlotCode.HOME_FEED); + assertThat(target.redirectUrl()).contains("subId=HOME_FEED_abc12345"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java new file mode 100644 index 00000000..b85f4533 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java @@ -0,0 +1,174 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.dto.response.AdResponse; +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdQueryServiceTest { + + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdImpressionDailyRepository adImpressionDailyRepository; + + @InjectMocks + private AdQueryService adQueryService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(adQueryService, "adBaseUrl", "https://ad.picke.store"); + } + + private AdCreative creative(String code, AdStatus status, int weight, + LocalDateTime startsAt, LocalDateTime endsAt) { + return AdCreative.builder() + .code(code) + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/" + code) + .status(status) + .weight(weight) + .startsAt(startsAt) + .endsAt(endsAt) + .build(); + } + + @Test + @DisplayName("게재 기간이 지난 소재는 응답에서 제외한다") + void findServableAds_excludesExpired() { + AdCreative live = creative("live0001", AdStatus.ACTIVE, 1, null, null); + AdCreative expired = creative("dead0001", AdStatus.ACTIVE, 1, null, LocalDateTime.now().minusDays(1)); + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(live, expired)); + + List result = adQueryService.findServableAds(AdSlotCode.HOME_FEED, 5); + + assertThat(result).hasSize(1); + assertThat(result.get(0).code()).isEqualTo("live0001"); + } + + @Test + @DisplayName("게재 가능한 소재가 없으면 빈 목록을 준다. 광고 없음은 오류가 아니다") + void findServableAds_returnsEmpty() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of()); + + assertThat(adQueryService.findServableAds(AdSlotCode.HOME_FEED, 1)).isEmpty(); + } + + @Test + @DisplayName("clickUrl은 광고 도메인의 짧은 코드 경로로 만든다") + void findServableAds_buildsClickUrl() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(creative("abc12345", AdStatus.ACTIVE, 1, null, null))); + + AdResponse response = adQueryService.findServableAds(AdSlotCode.HOME_FEED, 1).get(0); + + assertThat(response.clickUrl()).isEqualTo("https://ad.picke.store/c/abc12345"); + assertThat(response.label()).isEqualTo("광고"); + } + + @Test + @DisplayName("요청 개수만큼만 주고 같은 소재를 두 번 담지 않는다") + void findServableAds_limitsSizeWithoutDuplicates() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of( + creative("aaaa1111", AdStatus.ACTIVE, 1, null, null), + creative("bbbb2222", AdStatus.ACTIVE, 1, null, null), + creative("cccc3333", AdStatus.ACTIVE, 1, null, null))); + + List result = adQueryService.findServableAds(AdSlotCode.HOME_FEED, 2); + + assertThat(result).hasSize(2); + assertThat(result.stream().map(AdResponse::code).distinct()).hasSize(2); + } + + @Test + @DisplayName("가중치가 큰 소재가 확연히 자주 뽑힌다") + void findServableAds_weightedRotation() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of( + creative("heavy001", AdStatus.ACTIVE, 99, null, null), + creative("light001", AdStatus.ACTIVE, 1, null, null))); + + long heavyPicks = java.util.stream.IntStream.range(0, 500) + .mapToObj(i -> adQueryService.findServableAds(AdSlotCode.HOME_FEED, 1).get(0).code()) + .filter("heavy001"::equals) + .count(); + + assertThat(heavyPicks).isGreaterThan(400); + } + + @Test + @DisplayName("노출 집계는 같은 날 반복 호출하면 기존 행을 누적한다") + void recordImpressions_incrementsExistingRow() { + AdCreative creative = creative("abc12345", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(creative, "id", 7L); + when(adCreativeRepository.findAllByCodeIn(List.of("abc12345"))).thenReturn(List.of(creative)); + when(adImpressionDailyRepository.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class), anyLong())) + .thenReturn(1); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionDailyRepository, never()).save(any()); + } + + @Test + @DisplayName("그날 첫 노출이면 집계 행을 새로 만든다") + void recordImpressions_insertsWhenAbsent() { + AdCreative creative = creative("abc12345", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(creative, "id", 7L); + when(adCreativeRepository.findAllByCodeIn(List.of("abc12345"))).thenReturn(List.of(creative)); + when(adImpressionDailyRepository.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class), anyLong())) + .thenReturn(0); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionDailyRepository, times(1)).save(any()); + } + + @Test + @DisplayName("동시에 같은 집계 행을 만들면 갱신으로 되돌린다") + void recordImpressions_retriesOnConcurrentInsert() { + AdCreative creative = creative("abc12345", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(creative, "id", 7L); + when(adCreativeRepository.findAllByCodeIn(List.of("abc12345"))).thenReturn(List.of(creative)); + when(adImpressionDailyRepository.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class), anyLong())) + .thenReturn(0, 1); + when(adImpressionDailyRepository.save(any())).thenThrow(new DataIntegrityViolationException("duplicate")); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionDailyRepository, times(2)) + .increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class), anyLong()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java new file mode 100644 index 00000000..cf3efd24 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java @@ -0,0 +1,121 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import com.swyp.picke.domain.admin.dto.ad.request.AdCreativeRequest; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AdminAdServiceTest { + + private static final String OUR_PARTNERS_ID = "AF6830373"; + + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdClickLogRepository adClickLogRepository; + @Mock + private AdImpressionDailyRepository adImpressionDailyRepository; + + @InjectMocks + private AdminAdService adminAdService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(adminAdService, "coupangPartnersId", OUR_PARTNERS_ID); + when(adCreativeRepository.existsByCode(anyString())).thenReturn(false); + when(adCreativeRepository.save(any(AdCreative.class))).thenAnswer(call -> call.getArgument(0)); + } + + private AdCreativeRequest request(AdNetwork network, String landingUrl) { + return new AdCreativeRequest( + network, + AdSlotCode.HOME_FEED, + "무선 이어폰", + null, + "https://img.example.com/1.jpg", + "구매하러 가기", + landingUrl, + AdStatus.ACTIVE, + 1, + null, + null + ); + } + + @Test + @DisplayName("남의 파트너스 아이디가 박힌 쿠팡 링크는 등록을 막는다") + void create_rejectsForeignPartnerLink() { + AdCreativeRequest request = request(AdNetwork.COUPANG, + "https://link.coupang.com/re/AFF?lptag=AF9999999&pageKey=1"); + + assertThatThrownBy(() -> adminAdService.create(request)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.AD_COUPANG_PARTNER_MISMATCH); + + verify(adCreativeRepository, never()).save(any()); + } + + @Test + @DisplayName("우리 파트너스 아이디면 통과한다") + void create_allowsOwnPartnerLink() { + AdCreativeRequest request = request(AdNetwork.COUPANG, + "https://link.coupang.com/re/AFF?lptag=" + OUR_PARTNERS_ID + "&pageKey=1"); + + assertThatCode(() -> adminAdService.create(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("lptag가 드러나지 않는 단축 링크는 막지 않는다") + void create_allowsShortLinkWithoutLptag() { + AdCreativeRequest request = request(AdNetwork.COUPANG, "https://link.coupang.com/a/abcdef"); + + assertThatCode(() -> adminAdService.create(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("애드픽 소재는 쿠팡 아이디 검증 대상이 아니다") + void create_skipsValidationForOtherNetworks() { + AdCreativeRequest request = request(AdNetwork.ADPICK, "https://adpick.co.kr/?lptag=AF9999999"); + + assertThatCode(() -> adminAdService.create(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("소재 코드는 헷갈리는 글자 없이 만들어진다") + void create_generatesReadableCode() { + AdCreativeRequest request = request(AdNetwork.COUPANG, "https://link.coupang.com/a/abcdef"); + + String code = adminAdService.create(request).code(); + + assertThat(code).hasSize(8); + assertThat(code).doesNotContain("l", "o", "0", "1"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java index 545207c4..e876aef6 100644 --- a/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java +++ b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java @@ -26,6 +26,7 @@ import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -75,10 +76,20 @@ class BattleVoteServiceImplTest { @InjectMocks private BattleVoteServiceImpl battleVoteService; + /** + * BattleVoteServiceImpl은 "오늘"을 KST로 판단한다(LocalDate.now(KST)). + * 테스트가 시스템 기본 시간대를 쓰면 UTC 러너에서 15시 이후로 하루가 어긋나 실패한다. + */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static LocalDate today() { + return LocalDate.now(KST); + } + @Test @DisplayName("오늘 배틀이 아니면 최초 사전 투표 시 BATTLE_ENTRY 크레딧을 차감한다") void preVote_chargesBattleEntryCreditForPastBattle() { - Battle battle = battle(100L, LocalDate.now().minusDays(1)); + Battle battle = battle(100L, today().minusDays(1)); User user = user(10L); BattleOption option = option(201L, battle, BattleOptionLabel.A); @@ -99,7 +110,7 @@ void preVote_chargesBattleEntryCreditForPastBattle() { @Test @DisplayName("오늘 배틀이면 최초 사전 투표 시 크레딧을 차감하지 않는다") void preVote_doesNotChargeBattleEntryCreditForTodayBattle() { - Battle battle = battle(100L, LocalDate.now()); + Battle battle = battle(100L, today()); User user = user(10L); BattleOption option = option(201L, battle, BattleOptionLabel.A); @@ -119,7 +130,7 @@ void preVote_doesNotChargeBattleEntryCreditForTodayBattle() { @Test @DisplayName("오늘의 배틀이라도 오늘 이미 무료 진입을 사용했다면 크레딧을 차감한다") void preVote_chargesBattleEntryCreditWhenFreeEntryAlreadyUsedToday() { - Battle battle = battle(100L, LocalDate.now()); + Battle battle = battle(100L, today()); User user = user(10L); BattleOption option = option(201L, battle, BattleOptionLabel.A); @@ -127,7 +138,7 @@ void preVote_chargesBattleEntryCreditWhenFreeEntryAlreadyUsedToday() { when(userRepository.findById(10L)).thenReturn(Optional.of(user)); when(battleOptionRepository.findById(201L)).thenReturn(Optional.of(option)); when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.empty()); - when(battleVoteRepository.existsByUserIdAndBattle_TargetDate(10L, LocalDate.now())).thenReturn(true); + when(battleVoteRepository.existsByUserIdAndBattle_TargetDate(10L, today())).thenReturn(true); when(userBattleService.getUserBattleStatus(user, battle)) .thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.NONE)); @@ -140,7 +151,7 @@ void preVote_chargesBattleEntryCreditWhenFreeEntryAlreadyUsedToday() { @Test @DisplayName("이미 사전 투표한 배틀이면 옵션 변경 시 추가 차감하지 않는다") void preVote_doesNotChargeAgainWhenVoteAlreadyExists() { - Battle battle = battle(100L, LocalDate.now().minusDays(1)); + Battle battle = battle(100L, today().minusDays(1)); User user = user(10L); BattleOption oldOption = option(200L, battle, BattleOptionLabel.B); BattleOption newOption = option(201L, battle, BattleOptionLabel.A);