From 5d9a45801ffbcc49523b942db2c13511df3792a3 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 1 Sep 2026 23:19:29 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Docs]=20ad.picke.store=20=EC=A0=9C?= =?UTF-8?q?=ED=9C=B4=20=EA=B4=91=EA=B3=A0=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-07-16-adfit-reward-design.md | 334 ++++++++++++++++++ .../specs/2026-09-01-ad-picke-store-design.md | 207 +++++++++++ 2 files changed, 541 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-adfit-reward-design.md create mode 100644 docs/superpowers/specs/2026-09-01-ad-picke-store-design.md 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 0000000..2e2d6c8 --- /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 0000000..2cd67d3 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md @@ -0,0 +1,207 @@ +# 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으로 둔다. 테이블이 아니다.** + +어드민에서 지면을 새로 만들어도 앱이 그 지면을 그릴 줄 모르면 아무 일도 일어나지 않는다. +지면 추가는 어차피 앱 배포와 묶이므로, 테이블로 빼면 실제로 쓸 수 없는 유연성만 생긴다. + +초기 지면 목록은 앱팀과 확정 후 채운다. 확정 전까지는 `HOME_FEED`, `BATTLE_RESULT_BOTTOM` 두 개로 시작한다. + +### 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` — **pass-through**. 서브아이디 파라미터 규격을 아직 확인하지 못했다. + 파트너센터 링크생성 화면에서 확인 후 파라미터명만 채운다. + 그전까지 애드픽은 지면별 성과 분리가 안 될 뿐, 노출·클릭·리다이렉트는 정상 동작한다. + +### 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 | 완료 | +| 루트 공개 지면 배포 | 본 구현에 포함 | +| 쿠팡 파트너스 가입·매체 등록 | ID 발급됨(`AF6830373`), 매체 등록 확인 필요 | +| 애드픽 파트너 가입 | 미착수 | +| 애드픽 서브아이디 파라미터 규격 확인 | 미확인. 파트너센터 링크생성 화면 | +| 애드픽 이용정책상 자체 앱 배너 노출 허용 여부 | 미확인 | +| 지면 목록 앱팀 확정 | 미확정 | +| Play Console / App Store Connect "광고 포함" 신고 | 미착수 | +| 개인정보처리방침에 제휴 광고 문구 추가 | 미착수 | + +## 5. 지면 배치 트레이드오프 + +애드픽 캠페인 상당수가 CPI(앱 설치형)다. 단가는 커머스보다 높지만 클릭하면 사용자가 스토어로 나가 +다른 앱을 설치한다. 배틀 진행 중간 지면에 CPI를 깔면 이탈·리텐션에 직접 타격이 온다. + +세션이 자연스럽게 끝나는 지점(배틀 결과 화면)에 CPI를 두고, 피드 중간 인라인은 이탈 부담이 적은 +쿠팡 커머스로 채우는 배치를 권한다. 데이터가 쌓이면 지면별 CTR과 정산액을 보고 조정한다. + +## 6. 테스트 + +실제로 깨지기 쉬운 지점에 집중한다. + +- 쿼리스트링이 이미 붙은 제휴 링크에 `subId` 병합 +- 기간·상태 필터링 (미시작/만료/PAUSED 소재가 노출되지 않을 것) +- 가중 로테이션 분포 +- 없는 code / 만료 code 클릭 시 랜딩으로 302 +- 노출 집계 upsert가 같은 날 중복 호출에 누적될 것 From b9cf9648824a5449e82b0bb8c4ec46b5aeb71798 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 1 Sep 2026 23:31:57 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Feat]=20=EC=A0=9C=ED=9C=B4=20=EA=B4=91?= =?UTF-8?q?=EA=B3=A0(=EC=BF=A0=ED=8C=A1=20=ED=8C=8C=ED=8A=B8=EB=84=88?= =?UTF-8?q?=EC=8A=A4=C2=B7=EC=95=A0=EB=93=9C=ED=94=BD)=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=B0=8F=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ad 도메인 신설: 소재(AdCreative), 클릭 로그, 일별 노출 집계 - 앱 API: GET /api/v1/ads (지면별 가중 로테이션), POST /api/v1/ads/impressions - 클릭 리다이렉트: GET /c/{code} -> 매체별 추적 파라미터 병합 후 302, 클릭 비동기 적재 - 관리자 API: 소재 CRUD, 소재별 노출/클릭/CTR, 클릭 내역 목록 - 쿠팡은 subId를 병합해 지면별 성과를 분리하고, 애드픽은 규격 확인 전까지 pass-through - ad.picke.store 루트 공개 지면 추가 (매체 심사에서 접속 확인 대상) - Swagger "3. 광고 API" 그룹으로 분리 - JwtFilter에 정확 일치 화이트리스트를 분리해 루트·/error가 401로 막히던 문제 해결 --- docs/db/20260901_create_ad_tables.sql | 50 +++++ .../specs/2026-09-01-ad-picke-store-design.md | 4 +- .../ad/controller/AdClickController.java | 52 ++++++ .../domain/ad/controller/AdController.java | 48 +++++ .../ad/controller/AdLandingController.java | 36 ++++ .../ad/dto/request/AdImpressionRequest.java | 14 ++ .../domain/ad/dto/response/AdResponse.java | 50 +++++ .../picke/domain/ad/entity/AdClickLog.java | 51 +++++ .../picke/domain/ad/entity/AdCreative.java | 112 +++++++++++ .../domain/ad/entity/AdImpressionDaily.java | 51 +++++ .../swyp/picke/domain/ad/enums/AdNetwork.java | 17 ++ .../picke/domain/ad/enums/AdSlotCode.java | 20 ++ .../swyp/picke/domain/ad/enums/AdStatus.java | 7 + .../domain/ad/link/AdpickLinkBuilder.java | 26 +++ .../domain/ad/link/AffiliateLinkBuilder.java | 17 ++ .../domain/ad/link/AffiliateLinkResolver.java | 26 +++ .../domain/ad/link/CoupangLinkBuilder.java | 32 ++++ .../ad/repository/AdClickLogRepository.java | 35 ++++ .../ad/repository/AdCreativeRepository.java | 35 ++++ .../AdImpressionDailyRepository.java | 39 ++++ .../domain/ad/service/AdClickService.java | 88 +++++++++ .../domain/ad/service/AdQueryService.java | 131 +++++++++++++ .../admin/controller/AdminAdController.java | 102 ++++++++++ .../dto/ad/request/AdCreativeRequest.java | 62 +++++++ .../dto/ad/response/AdClickLogResponse.java | 26 +++ .../dto/ad/response/AdCreativeResponse.java | 44 +++++ .../dto/ad/response/AdStatsResponse.java | 30 +++ .../domain/admin/service/AdminAdService.java | 153 +++++++++++++++ .../picke/domain/oauth/jwt/JwtFilter.java | 20 +- .../global/common/exception/ErrorCode.java | 4 + .../picke/global/config/SecurityConfig.java | 6 +- .../picke/global/config/SwaggerConfig.java | 17 +- src/main/resources/application.yml | 3 + src/main/resources/templates/ad/landing.html | 84 +++++++++ .../domain/ad/entity/AdCreativeTest.java | 62 +++++++ .../ad/link/CoupangLinkBuilderTest.java | 66 +++++++ .../domain/ad/service/AdClickServiceTest.java | 82 +++++++++ .../domain/ad/service/AdQueryServiceTest.java | 174 ++++++++++++++++++ 38 files changed, 1871 insertions(+), 5 deletions(-) create mode 100644 docs/db/20260901_create_ad_tables.sql create mode 100644 src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/controller/AdController.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java create mode 100644 src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java create mode 100644 src/main/resources/templates/ad/landing.html create mode 100644 src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java create mode 100644 src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java create mode 100644 src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java create mode 100644 src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java diff --git a/docs/db/20260901_create_ad_tables.sql b/docs/db/20260901_create_ad_tables.sql new file mode 100644 index 0000000..00ce169 --- /dev/null +++ b/docs/db/20260901_create_ad_tables.sql @@ -0,0 +1,50 @@ +-- 제휴 광고(쿠팡 파트너스 / 애드픽) 소재·클릭·노출 테이블 +-- 관련 설계: docs/superpowers/specs/2026-09-01-ad-picke-store-design.md + +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/specs/2026-09-01-ad-picke-store-design.md b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md index 2cd67d3..65082ec 100644 --- a/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md +++ b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md @@ -94,7 +94,7 @@ domain/admin/ AdminAdController + AdminAdService (기존 어드민 관례를 앱 설치·이벤트 참여라 문구 성격이 다르기 때문이다. **`ad_click_logs`** — 클릭 원장. creative_id, slot, ip_hash, user_agent, clicked_at. -제휴사 리포트와 대사(對査)하는 용도다. +제휴사 리포트와 대조하는 용도다. **`ad_impression_daily`** — 노출 집계. (creative_id, slot, stat_date) 유니크 + impressions 카운터. @@ -108,7 +108,7 @@ domain/admin/ AdminAdController + AdminAdService (기존 어드민 관례를 사용자를 붙이려면 클릭 URL에 사용자 식별자를 실어야 하는데, 공개 URL에 그걸 넣으면 열거 공격과 프라이버시 문제가 생긴다. 서명된 단기 토큰을 발급하는 방법도 있지만 v1에 그만한 값어치가 없다. -지면별 CTR과 정산 대사에는 userId가 필요 없으므로 **v1은 익명(ip_hash + user_agent)으로 간다.** +지면별 CTR과 정산 대조에는 userId가 필요 없으므로 **v1은 익명(ip_hash + user_agent)으로 간다.** 본인 클릭 어뷰징 탐지가 필요해지면 그때 추가한다. ### 3.5 API 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 0000000..9623de7 --- /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 0000000..e0a004f --- /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 0000000..5565c42 --- /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 0000000..da9563c --- /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 0000000..a65e8b8 --- /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 0000000..0d0540e --- /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 0000000..1527107 --- /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 0000000..234d74c --- /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 0000000..d22d4bf --- /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 0000000..b41e14c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 광고 노출 지면. + * + *

테이블이 아니라 enum인 이유는, 앱이 그릴 줄 모르는 지면을 어드민에서 만들어봐야 + * 아무 일도 일어나지 않기 때문이다. 지면 추가는 어차피 앱 배포와 묶인다. + */ +@Getter +@RequiredArgsConstructor +public enum AdSlotCode { + + HOME_FEED("홈 피드 인라인"), + BATTLE_RESULT_BOTTOM("배틀 결과 하단"); + + private final String description; +} 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 0000000..42ccbb2 --- /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 0000000..c0246ec --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.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 org.springframework.stereotype.Component; + +/** + * 애드픽은 현재 pass-through다. + * + *

서브아이디 파라미터 규격을 아직 확인하지 못했다. 파트너센터 링크생성 화면에서 확인한 뒤 + * 쿠팡과 같은 방식으로 병합하면 된다. 그전까지 애드픽은 지면별 성과 분리만 안 될 뿐, + * 노출·클릭·리다이렉트는 정상 동작한다. + */ +@Component +public class AdpickLinkBuilder implements AffiliateLinkBuilder { + + @Override + public AdNetwork network() { + return AdNetwork.ADPICK; + } + + @Override + public String build(AdCreative creative) { + return creative.getLandingUrl(); + } +} 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 0000000..f8041a7 --- /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 0000000..b3f9be8 --- /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/CoupangLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java new file mode 100644 index 0000000..9eb6205 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java @@ -0,0 +1,32 @@ +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; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 쿠팡 파트너스는 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) { + String subId = creative.getSlot().name() + "_" + creative.getCode(); + + // build(true): landingUrl은 콘솔에서 발급된 인코딩 완료 상태라 재인코딩하면 이중 인코딩된다. + return UriComponentsBuilder.fromUriString(creative.getLandingUrl()) + .replaceQueryParam(SUB_ID_PARAM, subId) + .build(true) + .toUriString(); + } +} 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 0000000..76c025a --- /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 0000000..a018abd --- /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 0000000..fae4165 --- /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 0000000..bd9f6c8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java @@ -0,0 +1,88 @@ +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.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; + + 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())) + .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 0000000..8553712 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java @@ -0,0 +1,131 @@ +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.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 { + + 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(); + + 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(); + + 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(); + + 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 0000000..be3ee8f --- /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 0000000..c767e7c --- /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 0000000..e9e4bee --- /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 0000000..da1c856 --- /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 0000000..26e0f91 --- /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 0000000..5e11104 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java @@ -0,0 +1,153 @@ +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.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@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(); + + @Transactional + public AdCreativeResponse create(AdCreativeRequest 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) { + 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))); + } + + 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 474f915..efde599 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 415672d..b322d62 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,10 @@ 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", "광고 소재 코드 생성에 실패했습니다."), + // 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 d003761..2b2990d 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 2aada9a..aed3409 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 60c036c..f252d69 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -130,6 +130,9 @@ 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} s3: presigned-url: expiration-hours: 6 diff --git a/src/main/resources/templates/ad/landing.html b/src/main/resources/templates/ad/landing.html new file mode 100644 index 0000000..0bce255 --- /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 0000000..42379ef --- /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/CoupangLinkBuilderTest.java b/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java new file mode 100644 index 0000000..dea9512 --- /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 0000000..e542b57 --- /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 0000000..b85f453 --- /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()); + } +} From 7bc33e99cdedc1b78835b7df96d19d33744f86ac Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 2 Sep 2026 00:24:42 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Feat]=20=EA=B4=91=EA=B3=A0=20=EC=A7=80?= =?UTF-8?q?=EB=A9=B4=20=ED=99=95=EC=9E=A5,=20=EC=95=A0=EB=93=9C=ED=94=BD?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=ED=99=94,=20=EC=BF=A0=ED=8C=A1=20=ED=8C=8C=ED=8A=B8=EB=84=88?= =?UTF-8?q?=EC=8A=A4=20=EC=95=84=EC=9D=B4=EB=94=94=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지면을 iOS 실제 화면 기준 5개로 확장하고 CPI 허용 여부를 지면 속성으로 표기 - 애드픽 서브아이디 파라미터를 picke.ad.adpick.sub-id-param 설정값으로 분리 규격 확인 시 배포 없이 환경변수만 채우면 지면별 성과 분리가 켜진다 - 소재 등록·수정 시 제휴 링크의 lptag를 coupang.partners.id와 대조 단축 링크에는 lptag가 없으므로 파라미터가 있을 때만 검사한다 - 링크 병합 로직을 AffiliateLinks로 추출해 매체별 빌더가 공유 - 마이그레이션 SQL은 참고용임을 명시 (운영은 ddl-auto=update로 자동 생성) --- docs/.DS_Store | Bin 0 -> 10244 bytes docs/db/20260901_create_ad_tables.sql | 3 + docs/superpowers/.DS_Store | Bin 0 -> 6148 bytes .../plans/2026-07-16-adfit-reward.md | 1119 +++++++++++++++++ .../specs/2026-09-01-ad-picke-store-design.md | 34 +- src/.DS_Store | Bin 0 -> 6148 bytes src/main/.DS_Store | Bin 0 -> 8196 bytes src/main/java/.DS_Store | Bin 0 -> 6148 bytes src/main/java/com/.DS_Store | Bin 0 -> 6148 bytes src/main/java/com/swyp/.DS_Store | Bin 0 -> 6148 bytes .../picke/domain/ad/enums/AdSlotCode.java | 18 +- .../domain/ad/link/AdpickLinkBuilder.java | 18 +- .../picke/domain/ad/link/AffiliateLinks.java | 31 + .../domain/ad/link/CoupangLinkBuilder.java | 9 +- .../domain/admin/service/AdminAdService.java | 31 + .../global/common/exception/ErrorCode.java | 1 + src/main/resources/application.yml | 10 +- .../domain/ad/link/AdpickLinkBuilderTest.java | 53 + .../admin/service/AdminAdServiceTest.java | 121 ++ 19 files changed, 1426 insertions(+), 22 deletions(-) create mode 100644 docs/.DS_Store create mode 100644 docs/superpowers/.DS_Store create mode 100644 docs/superpowers/plans/2026-07-16-adfit-reward.md create mode 100644 src/.DS_Store create mode 100644 src/main/.DS_Store create mode 100644 src/main/java/.DS_Store create mode 100644 src/main/java/com/.DS_Store create mode 100644 src/main/java/com/swyp/.DS_Store create mode 100644 src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java create mode 100644 src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java create mode 100644 src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4ecaeea97032d58229187c4c139c91b15f3de651 GIT binary patch literal 10244 zcmeHMOK;Oa5S~p7i7BP-BT$NvEOJ5O6=+ozA=Hq9AVR1WsY(izU?hYLgK=a3s-Ik{sCM$!R*5j()5Z@*pYU>+1;77=ku(ST>}8gH>wu_EC5Jh z;bf3vxy1|;fQ zGoTsJ4E$n%zaK0t9CHd=62+?nE3pMY?8j~)sPpXr_pub_6t*Pd2nvy|h|-m)Ee4V9 z*l#JjoWhnw=?+9~K8U(9Q9BeOu8#9t!W>9WqOLUqnt{Cx@a1BFjr(>N&b9B~S;g^~ zgQMIckLCOiA*glraGGIVdAxDzo*PEl>_?L5>pyTXnKY6E$(7<7YZfCrYJ_<^c)(kW zjCoG1+s=|pn`Lusj`@-8`ZN#@Zk3|R!+Wk@Wz9Tm_|;JO7Uh6pq>QpTzOyqmlRaxq zPS5O~wRSFFLVap-dUw}Ir85_<+%2zr8$Np~?j4AFg}|U}|EiyLkIb!Xs>sNPII_>C zsbWhRhXxK09vL!+M@EOohSTZv(PQJsCr(V1%~8j$tk(j5&vVRULEdZ72erVh7VCD! z^=qYwK8cF%HZ7YY9j13__0jLH#jQHpjQ#Eq)7=ax^~HSitE(Z6N^Sab*>h0rR{C z7P^tk{N;xsGG2)2M!{cYc*RX*d%n(x!X+AnhDw!p>NP*lZ6|zQL637V>@#8)z z+i^`o)ViepW-;(R=GSnPdnTX?PQm+QIlRXH_T?aKdSn5JSaizwZpw=X+>zvccxoF! z_0KdXr#jMHS@lSwt7vqp_in1IN8FL@1Nb+mAwEMq zuupV)arExu*yoXVEEZe^S~LTifgWU_PsU{N^Zy(BfB)~n$aP4X0nI?n0O`M8yv~pK z;**kiAjSkL7M90YxNy6cMB#!JH{t!_I364PI9?q4U|*JxaX~$&uq6?DQ2zSIfdBc( POYi>y_(|`7zW@ITUuFXI literal 0 HcmV?d00001 diff --git a/docs/db/20260901_create_ad_tables.sql b/docs/db/20260901_create_ad_tables.sql index 00ce169..d862ecd 100644 --- a/docs/db/20260901_create_ad_tables.sql +++ b/docs/db/20260901_create_ad_tables.sql @@ -1,5 +1,8 @@ -- 제휴 광고(쿠팡 파트너스 / 애드픽) 소재·클릭·노출 테이블 -- 관련 설계: 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, diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c39638ab2f605ed8e56e330a36ce5b54e645d047 GIT binary patch literal 6148 zcmeHKPfrs;6n_K7c0pu;RuN;eu@@7Fq7Z^HhFUP%7z3dMi-2{v9mN1W@n($dOJ;s=-n=(6@3)zm9RMKtM)ewi2>>Z9 z0u#Mh{X#^)C>_$JEeS*-#|R1@uQ(oeI(%b8%s|Y*zsG>syFE|`4{Tu2zJJA$kjHZ6 zyA-+fHczJ+)b&n(?bJOt2qz{!kgo1Te6NcJT+id(!{4DGNHYrVy$!B<9^<2(6yTHR*QAJ;<8#Pq))@5 zyHCsJ&`+*QwEFnSw7geGpY)MI4tLk5l*#cHHa7zrmfG~yvN;e`eC~ORJa9vov(@zg znJ$EMr@%Hj&bW&#FVsa>utNO+u_LCmYWu#;YBayM;n@!L7FeE#A>S#v70%Sk2jsBW zT=c0JGCTWK@tDoskCe+wF(IoIyf@O3rjcad+MlJq%s8V)Hn*sH47_8;;5Iyf71)M- zcmXfr6}*9W@DaYiHUsUa=CIM<~pLw_F zGmo2K!M+a8&34kbmBcz}%ejG%M zr7@?mr4V~ihzdnip+rA1hzdu$rTuamTMAV;5IHlBqh}`ig+k=)Xt$(0ketG}jv0s< zIL<(~YMSEyzw`U||M8%>6*CYs@Lw@N67$7*@zmFMYe#b8uC=f{!6HQXwG_$_tn_s( h6>$}hV3C4-mQWCL8e0m{f)f7-NE+fQX5g **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-09-01-ad-picke-store-design.md b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md index 65082ec..c0ab1e1 100644 --- a/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md +++ b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md @@ -70,7 +70,19 @@ domain/admin/ AdminAdController + AdminAdService (기존 어드민 관례를 어드민에서 지면을 새로 만들어도 앱이 그 지면을 그릴 줄 모르면 아무 일도 일어나지 않는다. 지면 추가는 어차피 앱 배포와 묶이므로, 테이블로 빼면 실제로 쓸 수 없는 유연성만 생긴다. -초기 지면 목록은 앱팀과 확정 후 채운다. 확정 전까지는 `HOME_FEED`, `BATTLE_RESULT_BOTTOM` 두 개로 시작한다. +지면 목록은 iOS Presentation 모듈(Home/Battle/Chat/Profile)의 실제 화면을 기준으로 잡았다. +앱팀 확정 전이므로, 실제로 붙이는 지면에만 소재를 등록하면 된다. 소재가 없는 지면은 빈 배열을 주고 +앱은 지면 자체를 숨기므로 미사용 지면이 남아 있어도 부작용이 없다. + +| 지면 | 화면 | CPI 허용 | +| --- | --- | --- | +| `HOME_FEED` | 홈 피드 인라인 | 아니오 | +| `BATTLE_RESULT_BOTTOM` | 배틀 결과 하단 | 예 | +| `CHAT_ROOM_INLINE` | 관점 목록 인라인 | 아니오 | +| `ATTENDANCE_COMPLETE` | 출석 완료 후 | 예 | +| `PROFILE_BOTTOM` | 프로필 하단 | 예 | + +`cpiFriendly`는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다. 5장 트레이드오프 참조. ### 3.3 데이터 모델 @@ -152,10 +164,19 @@ Host가 광고 도메인이 아니면 최소 응답만 돌려준다. API 도메 `AffiliateLinkBuilder` 인터페이스 하나에 매체별 구현체를 둔다. - `CoupangLinkBuilder` — `subId={slot}_{code}` 병합. 파트너스 리포트에서 지면별 실매출이 갈린다. -- `AdpickLinkBuilder` — **pass-through**. 서브아이디 파라미터 규격을 아직 확인하지 못했다. - 파트너센터 링크생성 화면에서 확인 후 파라미터명만 채운다. +- `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**을 던진다. @@ -180,11 +201,12 @@ Host가 광고 도메인이 아니면 최소 응답만 돌려준다. API 도메 | --- | --- | | `ad.picke.store` DNS + Railway 커스텀 도메인 + TLS | 완료 | | 루트 공개 지면 배포 | 본 구현에 포함 | +| 광고 테이블 생성 | 별도 실행 불필요. `ddl-auto: update`라 배포 시 자동 생성된다 | | 쿠팡 파트너스 가입·매체 등록 | ID 발급됨(`AF6830373`), 매체 등록 확인 필요 | -| 애드픽 파트너 가입 | 미착수 | -| 애드픽 서브아이디 파라미터 규격 확인 | 미확인. 파트너센터 링크생성 화면 | +| 애드픽 파트너 가입 | 미착수. 계정이 필요해 코드로 대신할 수 없다 | +| 애드픽 서브아이디 파라미터 규격 확인 | 미확인. 확인되면 `ADPICK_SUB_ID_PARAM` 환경변수만 채우면 된다 | | 애드픽 이용정책상 자체 앱 배너 노출 허용 여부 | 미확인 | -| 지면 목록 앱팀 확정 | 미확정 | +| 지면 목록 앱팀 확정 | 후보 5개 확정, 앱팀 확인 대기 | | Play Console / App Store Connect "광고 포함" 신고 | 미착수 | | 개인정보처리방침에 제휴 광고 문구 추가 | 미착수 | diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f00383d4bec84de8fe04ada41bc6b23268257570 GIT binary patch literal 6148 zcmeHK%}*0S6n_K7c0pu;R)J`;u@@7FqJU_Op)MG0jDb*sMZmgkhqAJrX?C|%AS6BO z$v?n9z^f+{FCM*k^=9-x(5okX^HC7ciw9%KzGUY2=6!A7Z$G930K~4BF9VnWkia1@ z)``<^MEJ!xB5m5zfk;G+tj9}5j~8(*3Put!5Haw7Ga%mG7*xRl7uccq*GAvPA5vr! zdvV1^P}A@En_|s#gK%{8BWY`ob)1ODjd)jlHMhemoPHGXsM|LuU(Sf6*yzRDAJEr@6;{+pE*%itm@Sxk$SVt=3&UIB2rW~uQ7P}D zE_z8%eN!x^Gxu7`1*MpfRtoMI$(E*(q~AGTq`ts7qlS^r%xPBwhT#_6hegIFO9OXpR_&7&yp4yIPuJ|G$mz@E^|q2a}>z#6ZNrv0{M4W^yw&wxsvgBh87u w*23`^hY%6hR47w$(%Z3A#8y0jLkjvVp&({7HWi`;CHf;EX^5tXfq%-tPmmDbdjJ3c literal 0 HcmV?d00001 diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..635615f09e2ec511611cbcfaaccad61b5fcf2bbd GIT binary patch literal 8196 zcmeHLPfrs;6n_KNc12}@RtwRju@@7FqJU_Op%#ob#y}{+B4FLNLs?jMn%ylG2uaU+ z@&ot*ym~V6;?av&Z$`g?UOnlXKP{w`t1)F>GV^=;=Dpdz-_GpLD*%vmwR8g@4ge8s z9L*l=+6W)#?TBk`GQ13OUyAcoK^1a)!rM@(=7VoG6MB3e+03`LZoM3ops zRSrak<9JH>*ConuAmYwAkGeBa6$(*>2T2HbASsEu*9>R|jxxZvO$au?f^D!`>i0B` zUHcZCD^G2=#A<0)bxXd8&B_78h!}Y@zPC3qc6%f-F*&(E zlGwX*b95vzK6Yz=--yJL*YD2fH=Qk)y^^UC^}2v_^19i-My;8TUEEgrkS~pVzMH1X zC}N!HIeYGWui4i>&=>8C#bOsO#s`OnF6GUEf>qosd;FJ9GlzL;r%IR0o?XgTtfK9f za{+xBWbIv=H~U*$=V|HrfoWl|NB9ii;3xbhVKPXrkYO@T?vW|-h-An!QY7odCL1!otMQO8tN9cV zAC=}q-p+ci!`w33xW54&Q1F346}2MQTbBQO^g6u!+eCE8Mrf0n-V&X=CE7ghmd$}H zUrWa`&@D5J^ZNh6@&EsKJP$fS&A>@!fP`kUGyJgMI7hXT!E5aVwinpAaKE}l5rUm4 m#}P$2j;P9U;hR8L9+Og7mxvaWfBr{+J`(HizgT%EyZa0JhTm@h literal 0 HcmV?d00001 diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9fa091535a273318a57d47f238f540d2a144753e GIT binary patch literal 6148 zcmeHKPfrs;6rTsxc0pu;R)J`;u@@7FqJU_Op)MG0jDb*sAYk2YhqAJrX?C|%AS6BO z$q(QM@Ee$T@#w{?H>2M`ub%YHp9U*(HAeO&Gru?Q-|qWuc4vnW0(QN8g%FbvlENV{ z(S_3=MEJ!xBpuq4Km<{PyCt8Oa3#vc5;G7p@UJr<-rY{(5>BdlzwQ0C(R1mC6xoE1 zDmB8Ie#_sKYQ7gnV`Cqoqcd^hWHMjIQoeJ*Ruk zzW#x}bYCWuIdgV!Xn6Qs(Htl_?p7rb_c_CT9@u`Ju2urCT&Ov&$12MaeHInGU0O8z ze+ylp<;Mr6#hn_)qz??T+}(guChN^@Y=ksg{zYFYn!TYLaNnQjp%;0at*nK}az3Kf zJlo)S#cgDGt|naJ5)DGcLpjSUP7pY(LhYS(-zialj@dklcvVD-YA$E<0Tq>&J=8@n z+39bJ!))$;OSz~NlVp{GdqujXX@u-M=Zn-A8E4cmvblNfN|6zAn>-*(WQ**Qm*f?B zP2Q6CCv1+l7GnoK?`$ zif#`+wku6Xs{7b75H}t(5HoNT1LFH&;SiY9*i4f9L4)e|u8AiW!I*_^%j%#B5>K#+LNndZ;nhIqKPI^0*ir9(= daY#X*B^1P*#->8Fpu|4{l7@JS8ThLV`~(?T(Nq8c literal 0 HcmV?d00001 diff --git a/src/main/java/com/.DS_Store b/src/main/java/com/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e30bc1c19b0e60d8a994a603f04fae9141e91b9e GIT binary patch literal 6148 zcmeHKPfrs;6rTsxc0px2M`ub%YHpMrqKn=!I4nfbkW|F-Yf%+3xW1nhd{G9e}*B!xp@ zq64R2i13SXNZPa|fe4~T-sff4=Ps^A!B}DjVg~+g2E^V?kdQRUCaE3Rzc%_VewQMf z*o|`=VNLJz*KW=C!f1H-1GKd#jvY@XjbvwXxve&BtppHHT#J4zWHohMG7 z>N2}~db`uznM~&NnZEvkfwLvE*LBKk)j<5xN$&H&_Um-98hDjL%_)1VT8!w^sNikU zlG$@0be2{g?V08_YZ#N>Gstpx0!o>zH@&hF(x~{8zECo|!g9cUe};!%KP9RSKRM>6WGuvTvQwQlDd-QNzgQX0$6s2FWdQpUjgrvPE8y zm*f?BL*9{(kBJh`6RgnSzr( jj-?`w;yxTw&}Rt+F{iPq5G^S2hk&FZo?-_6Dg!?N=6%#% literal 0 HcmV?d00001 diff --git a/src/main/java/com/swyp/.DS_Store b/src/main/java/com/swyp/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..469c0b61d1080370bf2a5cb7dc9b0520e37f100d GIT binary patch literal 6148 zcmeHKPfrs;6rX`&yCAYat3Wi_*oz57Q9v}tPzy#IV<41Z5wPxVhqAKFG`m|W5R#tt z==79fT0h*DIG1G7&;4 zP>zXiXnrAloNJqOC{F?qi#bTaXRhNjr!7`HGy|G}e~$tF?slOX@{o(RQ0x24w}d?C z#qUDIqqjsljj*QP^w&S5jvR(WrUG2e-8 z+gbKVqim*U8I5d@l0YPQ6#^k^4?J37jXbN}r#$tKP-=^%DcxHrQZxYh0Fox$~v{dA|JE|i5KCbAKX{wr1 zMt9H2-cx;M|G;2>x<8Z2oIW!&JTh{&Yz{iMyHySNv(7S~1$nD)kCe@UpF$T&<Wj$HZDlRL|JLTxsi@7g*f}x{w(Yz$|y06Y;HjfrO+t4h3=ze z_#^J27w9E=h2Efd=p*`qzTqSu#^>=Up2Rot48DsC_#t-jCid_Xncorr%;(g63UCNU z%g?-145-g&6})lA(S&2uQ{|_6j!nh7P|bj5;7=Lg`-26_F{iL8QEVO1h${df|BMg< zHve$oIhMkl!lpzR0l_jAP^Mya#lSKh{FaK#DQrrV=?IIJVIH+IRyPzZRtLW&!V%6% z)U9SfGjNoFPT4j2`G5QO_y40wx~Cb?4E$FN;KW>Uj$ifF+1gedKWi<}$DmwzTvMV* lfkqw2!r({o5U3Csv$z46Q`nRU9uWN@AZXByX5g테이블이 아니라 enum인 이유는, 앱이 그릴 줄 모르는 지면을 어드민에서 만들어봐야 * 아무 일도 일어나지 않기 때문이다. 지면 추가는 어차피 앱 배포와 묶인다. + * + *

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

{@code cpiFriendly}는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다. + * CPI는 단가가 높지만 클릭하면 사용자가 스토어로 나가 다른 앱을 설치한다. + * 세션이 자연스럽게 끝나는 지점이 아니면 이탈·리텐션에 그대로 타격이 온다. */ @Getter @RequiredArgsConstructor public enum AdSlotCode { - HOME_FEED("홈 피드 인라인"), - BATTLE_RESULT_BOTTOM("배틀 결과 하단"); + 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/link/AdpickLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java index c0246ec..4f5e538 100644 --- a/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java +++ b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java @@ -2,18 +2,23 @@ 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다. + * 애드픽 서브아이디 파라미터명은 설정값으로 둔다. * - *

서브아이디 파라미터 규격을 아직 확인하지 못했다. 파트너센터 링크생성 화면에서 확인한 뒤 - * 쿠팡과 같은 방식으로 병합하면 된다. 그전까지 애드픽은 지면별 성과 분리만 안 될 뿐, - * 노출·클릭·리다이렉트는 정상 동작한다. + *

파트너센터 링크생성 화면에서 규격을 확인하기 전까지는 값이 비어 있고, 그동안은 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; @@ -21,6 +26,9 @@ public AdNetwork network() { @Override public String build(AdCreative creative) { - return creative.getLandingUrl(); + 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/AffiliateLinks.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java new file mode 100644 index 0000000..c383810 --- /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 index 9eb6205..601796b 100644 --- a/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java +++ b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java @@ -3,7 +3,6 @@ import com.swyp.picke.domain.ad.entity.AdCreative; import com.swyp.picke.domain.ad.enums.AdNetwork; import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; /** * 쿠팡 파트너스는 subId를 지원한다. 지면별로 값을 달리 넣으면 파트너스 리포트에서 @@ -21,12 +20,6 @@ public AdNetwork network() { @Override public String build(AdCreative creative) { - String subId = creative.getSlot().name() + "_" + creative.getCode(); - - // build(true): landingUrl은 콘솔에서 발급된 인코딩 완료 상태라 재인코딩하면 이중 인코딩된다. - return UriComponentsBuilder.fromUriString(creative.getLandingUrl()) - .replaceQueryParam(SUB_ID_PARAM, subId) - .build(true) - .toUriString(); + return AffiliateLinks.merge(creative.getLandingUrl(), SUB_ID_PARAM, AffiliateLinks.subIdOf(creative)); } } 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 index 5e11104..ea068f9 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java @@ -20,9 +20,12 @@ 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 @@ -37,8 +40,13 @@ public class AdminAdService { 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()) @@ -59,6 +67,8 @@ public AdCreativeResponse create(AdCreativeRequest request) { @Transactional public AdCreativeResponse update(Long creativeId, AdCreativeRequest request) { + validateCoupangOwnership(request); + AdCreative creative = findById(creativeId); creative.update( @@ -129,6 +139,27 @@ public PageResponse findClickLogs(LocalDate from, LocalDate 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)); 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 b322d62..ee2d1b9 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 @@ -119,6 +119,7 @@ public enum ErrorCode { // 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/resources/application.yml b/src/main/resources/application.yml index f252d69..02aa11b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -133,6 +133,9 @@ picke: 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 @@ -143,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/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 0000000..36d89c4 --- /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/admin/service/AdminAdServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java new file mode 100644 index 0000000..cf3efd2 --- /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"); + } +} From 1fc11bc303a2ff0980d37829ebb013dc37d79154 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 2 Sep 2026 11:40:42 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Fix]=20BattleVoteServiceImplTest=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8C=80=20=EB=B6=88=EC=9D=BC=EC=B9=98?= =?UTF-8?q?=EB=A1=9C=20CI=EA=B0=80=2015=EC=8B=9C(UTC)=20=EC=9D=B4=ED=9B=84?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BattleVoteServiceImpl은 "오늘"을 LocalDate.now(KST)로 판단하는데 테스트는 시스템 기본 시간대의 LocalDate.now()를 썼다. UTC 러너에서는 15:00 UTC(=KST 자정) 이후로 두 값이 하루씩 어긋나 오늘 배틀이 어제 배틀로 취급되면서 크레딧 차감 분기가 반대로 탔다. 14:42 UTC 실행은 통과하고 15:25 UTC 실행은 실패한 이유다. 테스트도 KST 기준으로 맞춘다. 광고 작업과는 무관한 기존 결함이지만 CI를 막고 있어 함께 고친다. --- .../service/BattleVoteServiceImplTest.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) 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 545207c..e876aef 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); From 230cd8d34a270d0c9ae5779fefc91ab7753369b0 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 2 Sep 2026 11:40:42 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Fix]=20=EA=B4=91=EA=B3=A0=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A7=91=EA=B3=84=EC=99=80=20=EA=B2=8C=EC=9E=AC=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=ED=8C=90=EB=8B=A8=EC=9D=84=20KST=EB=A1=9C?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 진입점에서 TimeZone.setDefault(Asia/Seoul)을 하고 있어 런타임 동작은 문제없었지만, 기본 시간대 설정에 기대면 일별 노출 버킷이 엉뚱한 시각에 갈릴 수 있다. BattleVoteServiceImpl과 같은 방식으로 KST를 명시한다. --- .../swyp/picke/domain/ad/service/AdClickService.java | 6 +++++- .../swyp/picke/domain/ad/service/AdQueryService.java | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) 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 index bd9f6c8..7b8193f 100644 --- a/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java @@ -9,6 +9,7 @@ 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; @@ -24,6 +25,9 @@ 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; @@ -34,7 +38,7 @@ public class AdClickService { @Transactional(readOnly = true) public Optional resolveTarget(String code) { return adCreativeRepository.findByCode(code) - .filter(creative -> creative.isServable(LocalDateTime.now())) + .filter(creative -> creative.isServable(LocalDateTime.now(KST))) .map(creative -> new AdClickTarget( creative.getId(), creative.getSlot(), 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 index 8553712..058fc73 100644 --- a/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java @@ -9,6 +9,7 @@ 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; @@ -25,6 +26,9 @@ @RequiredArgsConstructor public class AdQueryService { + /** 노출 집계 버킷과 게재 기간은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final AdCreativeRepository adCreativeRepository; private final AdImpressionDailyRepository adImpressionDailyRepository; @@ -37,7 +41,7 @@ public class AdQueryService { */ @Transactional(readOnly = true) public List findServableAds(AdSlotCode slot, int size) { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(KST); List candidates = adCreativeRepository.findAllBySlotAndStatus(slot, AdStatus.ACTIVE).stream() .filter(creative -> creative.isServable(now)) @@ -54,7 +58,7 @@ public List findServableAds(AdSlotCode slot, int size) { */ @Transactional(readOnly = true) public List findLandingAds() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(KST); return adCreativeRepository.findAllByStatusOrderByIdDesc(AdStatus.ACTIVE).stream() .filter(creative -> creative.isServable(now)) @@ -68,7 +72,7 @@ public List findLandingAds() { */ @Transactional public void recordImpressions(List codes) { - LocalDate today = LocalDate.now(); + LocalDate today = LocalDate.now(KST); List targets = adCreativeRepository.findAllByCodeIn(codes).stream() .map(creative -> new ImpressionTarget(creative.getId(), creative.getSlot()))