diff --git a/.gitignore b/.gitignore index 162d9972..d010f309 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ out/ .env postgres_data/ src/main/resources/application-local.yml -.claude \ No newline at end of file +.claude + +### macOS ### +.DS_Store +**/.DS_Store diff --git a/docs/db/20260901_create_ad_tables.sql b/docs/db/20260901_create_ad_tables.sql new file mode 100644 index 00000000..22fd04bc --- /dev/null +++ b/docs/db/20260901_create_ad_tables.sql @@ -0,0 +1,72 @@ +-- 제휴 광고(쿠팡 파트너스 / 애드픽) 소재·클릭·노출 테이블 +-- 관련 설계: docs/superpowers/specs/2026-09-01-ad-picke-store-design.md +-- +-- 이 파일은 참고용이다. 운영은 spring.jpa.hibernate.ddl-auto=update 라 배포 시 자동 생성된다. +-- 스키마를 손으로 관리하는 환경이나 사후 검증이 필요할 때 쓴다. +-- +-- CHECK 제약은 엔티티의 @Check 로도 선언되어 있어 테이블 최초 생성 시 함께 만들어진다. +-- 다만 ddl-auto=update 는 이미 있는 제약을 갱신하지 않는다. +-- enum 에 값을 추가하면 아래 ALTER 를 별도로 실행해야 한다. +-- ALTER TABLE ad_creatives DROP CONSTRAINT ck_ad_creatives_slot; +-- ALTER TABLE ad_creatives ADD CONSTRAINT ck_ad_creatives_slot CHECK (slot IN (...)); +-- 빠뜨리면 새 지면의 INSERT 가 조용히 막힌다. AdCheckConstraintTest 가 제약과 enum 의 불일치를 먼저 잡는다. + +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, + source VARCHAR(20) NOT NULL DEFAULT 'MANUAL', + external_id VARCHAR(64), + target_os VARCHAR(20) NOT NULL DEFAULT 'ALL', + weight INTEGER NOT NULL DEFAULT 1, + starts_at TIMESTAMP, + ends_at TIMESTAMP, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT ck_ad_creatives_network CHECK (network IN ('COUPANG', 'ADPICK')), + CONSTRAINT ck_ad_creatives_slot CHECK (slot IN ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', 'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')), + CONSTRAINT ck_ad_creatives_status CHECK (status IN ('DRAFT', 'ACTIVE', 'PAUSED')), + CONSTRAINT ck_ad_creatives_source CHECK (source IN ('MANUAL', 'ADPICK_API')), + CONSTRAINT ck_ad_creatives_target_os CHECK (target_os IN ('ALL', 'ANDROID', 'IOS')), + -- 애드픽 캠페인을 다시 찾는 키. 수동 등록 소재는 external_id 가 NULL 이라 중복되지 않는다. + CONSTRAINT uk_ad_creatives_external UNIQUE (source, external_id) +); + +-- 지면 조회는 (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, + CONSTRAINT ck_ad_click_logs_slot CHECK (slot IN ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', 'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')) +); + +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), + CONSTRAINT ck_ad_impression_daily_slot CHECK (slot IN ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', 'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')) +); + +CREATE INDEX IF NOT EXISTS idx_ad_impression_daily_date ON ad_impression_daily (stat_date); diff --git a/docs/erd/ad.puml b/docs/erd/ad.puml new file mode 100644 index 00000000..12b81323 --- /dev/null +++ b/docs/erd/ad.puml @@ -0,0 +1,82 @@ +@startuml ad +hide circle +hide methods +skinparam linetype ortho + +entity "ad_creatives" as ad_creatives { + * id : BIGINT <> + -- + code : VARCHAR(16) <> + network : VARCHAR(20) + slot : VARCHAR(40) + title : VARCHAR(100) + subtitle : VARCHAR(200) <> + image_url : VARCHAR(500) + cta_text : VARCHAR(30) + landing_url : VARCHAR(1000) + status : VARCHAR(20) + source : VARCHAR(20) + external_id : VARCHAR(64) <> + target_os : VARCHAR(20) + weight : INTEGER + starts_at : TIMESTAMP <> + ends_at : TIMESTAMP <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "ad_click_logs" as ad_click_logs { + * id : BIGINT <> + -- + creative_id : BIGINT + slot : VARCHAR(40) + ip_hash : VARCHAR(64) <> + user_agent : VARCHAR(500) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "ad_impression_daily" as ad_impression_daily { + * id : BIGINT <> + -- + creative_id : BIGINT + slot : VARCHAR(40) + stat_date : DATE + impressions : BIGINT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +ad_creatives ||--o{ ad_click_logs : clicked +ad_creatives ||--o{ ad_impression_daily : impressed + +note bottom of ad_creatives + 제휴 광고 소재. 각 매체 콘솔에서 발급한 + 완성형 제휴 링크를 어드민이 등록한다. + network : COUPANG | ADPICK + status : DRAFT | ACTIVE | PAUSED + source : MANUAL | ADPICK_API + target_os : ALL | ANDROID | IOS + + ADPICK_API 소재는 캠페인 API 가 주기적으로 + 덮어쓴다. external_id 는 애드픽 apOffer 다. +end note + +note bottom of ad_click_logs + 클릭 원장. 제휴사 리포트와 대조하는 용도다. + /c/{code}는 외부 브라우저에서 열려 + Authorization 헤더가 없으므로 사용자를 특정하지 않는다. +end note + +note bottom of ad_impression_daily + 일별 노출 집계. (creative_id, slot, stat_date) 유니크. + 노출을 raw 로그로 쌓으면 배너가 스크롤에 걸릴 때마다 + 행이 생겨 금방 수천만 건이 된다. +end note + +note top of ad_creatives + creative_id는 논리적 참조다. + 소재를 지워도 집계 이력은 남기려고 FK를 걸지 않았다. +end note + +@enduml diff --git a/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md new file mode 100644 index 00000000..d49c42d5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md @@ -0,0 +1,269 @@ +# ad.picke.store 제휴 광고 설계 + +- 작성일: 2026-09-01 +- 상태: 승인됨 (구현 진행) +- 범위: 쿠팡 파트너스 + 애드픽 제휴 광고를 앱 지면에 노출하고, 클릭을 추적·집계한다. + +## 1. 배경과 목표 + +앱 화면 중간중간에 제휴 광고 배너를 노출해 수익을 만든다. 배너는 **앱이 네이티브로 렌더**하고, +탭하면 **외부 브라우저로 제휴 링크에 다이렉트**된다. + +매체는 두 곳이다. + +| 매체 | 식별자 | 성격 | +| --- | --- | --- | +| 쿠팡 파트너스 | `AF6830373` | 커머스 CPS. 상품 구매 전환 | +| 애드픽 | 가입 예정 | 성과형 CPA/CPI. 앱 설치·이벤트 참여 | + +네이버(쇼핑커넥트)는 **범위에서 제외**한다. 가입 단위가 블로그·인스타 같은 크리에이터 채널이라 +앱을 매체로 등록하는 경로가 없고, 인증 채널 밖에 링크를 게시하면 약관 위반 소지가 있다. + +### 목표가 아닌 것 + +- 쿠팡 파트너스 오픈API 연동. 파트너스 실적 요건 충족 후 승인제라 지금은 쓸 수 없다. + 소재 자동 수급은 `AdCreative` 생성 경로만 추가하면 되므로 나중에 얹는다. +- 사용자별 클릭 귀속. 3.4 참조. +- AdMob 리워드 광고 통합. 이미 `reward` 도메인에 별도로 존재한다. + +## 2. 접근 방식 + +**어드민 수동 등록 + 서버 리다이렉트 트래킹.** + +각 매체 콘솔에서 뽑은 완성형 제휴 링크를 어드민에 소재로 등록한다. 앱은 지면 코드로 소재를 조회해 +네이티브로 그리고, 탭하면 우리 서버의 리다이렉트 엔드포인트를 거쳐 제휴 링크로 나간다. + +두 매체를 하나의 파이프라인으로 처리할 수 있고 외부 API 의존이 없다. 대신 소재를 사람이 채워야 하고, +상품 가격·품절이 실시간 반영되지 않는다. 소재 수가 수십 개 규모라 감당 가능한 비용으로 본다. + +### 기각한 대안 + +- **WebView 임베드**: 소재 교체가 앱 배포와 무관해지지만, 스크롤 중첩·렌더 지연·다크모드 불일치가 생긴다. + "앱 UI에 네이티브로 보여야 한다"는 요구와 어긋난다. +- **애드픽 마이도메인**: 애드픽이 자체 도메인 트래킹 링크를 지원하나, 애드픽 링크만 커버한다. + 쿠팡까지 한곳에서 집계하고 클릭 로그를 우리 DB에 두려면 자체 리다이렉트가 맞다. + +## 3. 설계 + +### 3.1 패키지 구조 + +``` +domain/ad/ + controller/ AdController 앱 조회 API + AdClickController /c/{code} 302 리다이렉트 + AdLandingController ad.picke.store 루트 공개 지면 + service/ AdQueryService AdClickService + link/ AffiliateLinkBuilder CoupangLinkBuilder AdpickLinkBuilder + entity/ AdCreative AdClickLog AdImpressionDaily + enums/ AdNetwork AdSlotCode AdStatus + repository/ dto/ +domain/admin/ AdminAdController + AdminAdService (기존 어드민 관례를 따른다) +``` + +`reward` 도메인의 `AdRewardHistory`(AdMob)와 이름이 겹쳐 보이지만, 그쪽은 리워드 광고 시청 보상이고 +이쪽은 제휴 광고다. `AdNetwork` enum으로 구분된다. + +### 3.2 지면(slot) + +`AdSlotCode` **enum으로 둔다. 테이블이 아니다.** + +어드민에서 지면을 새로 만들어도 앱이 그 지면을 그릴 줄 모르면 아무 일도 일어나지 않는다. +지면 추가는 어차피 앱 배포와 묶이므로, 테이블로 빼면 실제로 쓸 수 없는 유연성만 생긴다. + +지면 목록은 iOS Presentation 모듈(Home/Battle/Chat/Profile)의 실제 화면을 기준으로 잡았다. +앱팀 확정 전이므로, 실제로 붙이는 지면에만 소재를 등록하면 된다. 소재가 없는 지면은 빈 배열을 주고 +앱은 지면 자체를 숨기므로 미사용 지면이 남아 있어도 부작용이 없다. + +| 지면 | 화면 | CPI 허용 | +| --- | --- | --- | +| `HOME_FEED` | 홈 피드 인라인 | 아니오 | +| `BATTLE_RESULT_BOTTOM` | 배틀 결과 하단 | 예 | +| `CHAT_ROOM_INLINE` | 관점 목록 인라인 | 아니오 | +| `ATTENDANCE_COMPLETE` | 출석 완료 후 | 예 | +| `PROFILE_BOTTOM` | 프로필 하단 | 예 | + +`cpiFriendly`는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다. 5장 트레이드오프 참조. + +### 3.3 데이터 모델 + +**`ad_creatives`** — 소재. `BaseEntity` 상속(id, created_at, updated_at). + +| 컬럼 | 타입 | 설명 | +| --- | --- | --- | +| `code` | varchar(16) unique | 공개 클릭 URL용 짧은 코드. PK 노출 방지 | +| `network` | varchar | `COUPANG` \| `ADPICK` | +| `slot` | varchar | `AdSlotCode` | +| `title` | varchar(100) | 배너 주 문구 | +| `subtitle` | varchar(200) nullable | 보조 문구 | +| `image_url` | varchar(500) | 소재 이미지 | +| `cta_text` | varchar(30) | "구매하러 가기" / "설치하고 받기" 등 | +| `landing_url` | varchar(1000) | 콘솔에서 뽑은 원본 제휴 링크 | +| `status` | varchar | `DRAFT` \| `ACTIVE` \| `PAUSED` | +| `weight` | int | 가중 로테이션. 기본 1 | +| `starts_at` / `ends_at` | timestamp nullable | 게재 기간. null이면 무제한 | + +`cta_text`를 매체별 하드코딩이 아니라 소재 단위로 두는 이유는, 쿠팡은 상품 구매이고 애드픽은 +앱 설치·이벤트 참여라 문구 성격이 다르기 때문이다. + +**`ad_click_logs`** — 클릭 원장. creative_id, slot, ip_hash, user_agent, clicked_at. +제휴사 리포트와 대조하는 용도다. + +**`ad_impression_daily`** — 노출 집계. (creative_id, slot, stat_date) 유니크 + impressions 카운터. + +노출은 raw 로그로 쌓지 않는다. 배너가 스크롤에 걸릴 때마다 행이 생기면 금방 수천만 건이 된다. +일별 upsert 카운터로 CTR을 뽑는 데 충분하다. + +### 3.4 클릭 로그는 익명이다 + +`/c/{code}`는 **외부 브라우저에서 열린다.** Authorization 헤더가 없다. + +사용자를 붙이려면 클릭 URL에 사용자 식별자를 실어야 하는데, 공개 URL에 그걸 넣으면 열거 공격과 +프라이버시 문제가 생긴다. 서명된 단기 토큰을 발급하는 방법도 있지만 v1에 그만한 값어치가 없다. + +지면별 CTR과 정산 대조에는 userId가 필요 없으므로 **v1은 익명(ip_hash + user_agent)으로 간다.** +본인 클릭 어뷰징 탐지가 필요해지면 그때 추가한다. + +### 3.5 API + +모든 앱/어드민 API는 `/api/v1/` 아래에 둔다. + +**앱** + +- `GET /api/v1/ads?slot={AdSlotCode}` → `ApiResponse>` + `{ code, network, title, subtitle, imageUrl, ctaText, clickUrl, label }` + `clickUrl` = `https://ad.picke.store/c/{code}` + `label`은 `"광고"` 고정. 표시광고법 대응이므로 앱이 반드시 렌더해야 한다. +- `POST /api/v1/ads/impressions` — `{ codes: [...] }` 묶음 전송 + +조회 시점에 노출을 집계하면 엔드포인트 하나를 아끼지만 **조회 ≠ 실제 노출**이라 CTR이 왜곡된다. +지면 성과로 배치를 정할 것이므로 분리한다. + +**클릭 리다이렉트** + +- `GET /c/{code}` → 302 Location: 제휴 링크 + +`/api/v1` 밑에 두지 않는다. 공개 숏링크라 짧아야 하고, JSON API가 아니라 브라우저 진입점이다. + +`landing_url`에 매체별 추적 파라미터를 **병합**한다. 원본 링크에 이미 쿼리스트링이 있으므로 +단순 문자열 결합이 아니다. 클릭 로그는 비동기로 적재해 리다이렉트를 DB 쓰기가 붙잡지 않게 한다. + +코드가 없거나 만료면 404 대신 랜딩 페이지로 302한다. 사용자에게 실패를 보이지 않는다. + +**랜딩** — `GET /` (Host: `ad.picke.store`) → Thymeleaf `ad/landing` + +ACTIVE 소재를 카드로 나열하고 하단에 쿠팡 파트너스 수수료 고지 문구를 넣는다. +쿠팡 파트너스 매체 심사에서 URL 접속 확인을 하므로, 빈 페이지면 반려된다. + +Host가 광고 도메인이 아니면 최소 응답만 돌려준다. API 도메인 루트에 광고 페이지가 뜨면 안 된다. + +**어드민** — `/api/v1/admin/ads` CRUD, `/api/v1/admin/ads/stats?from=&to=` +이미지 업로드는 기존 S3 presigned 경로를 재사용한다. + +### 3.6 매체별 링크 빌더 + +`AffiliateLinkBuilder` 인터페이스 하나에 매체별 구현체를 둔다. + +- `CoupangLinkBuilder` — `subId={slot}_{code}` 병합. 파트너스 리포트에서 지면별 실매출이 갈린다. +- `AdpickLinkBuilder` — 파라미터명을 `picke.ad.adpick.sub-id-param` 설정값으로 둔다. + 서브아이디 규격을 아직 확인하지 못해 기본값은 비어 있고, 그동안은 pass-through로 원본 링크를 넘긴다. + 파트너센터 링크생성 화면에서 규격이 확인되면 **배포 없이 환경변수만 채우면** 쿠팡과 같은 방식으로 붙는다. + 그전까지 애드픽은 지면별 성과 분리가 안 될 뿐, 노출·클릭·리다이렉트는 정상 동작한다. + +### 3.5.1 애드픽 캠페인 자동 수급 + +애드픽은 캠페인 리스트 JSON API(`offers.php`)를 인증 없이 공개한다. 쿠팡 오픈API와 달리 승인 절차가 없어 +애드픽 소재는 사람이 등록하지 않고 주기 동기화로 채운다. + +수집한 캠페인은 **같은 `ad_creatives` 테이블에 `source = ADPICK_API`로 저장한다.** 별도 테이블을 두지 않으므로 +로테이션·노출 집계·클릭 추적 경로를 그대로 탄다. + +응답에서 쓰는 필드는 `apOffer`(캠페인 ID), `apAppTitle`, `apHeadline`/`apAppPromoText`, `apImages.icon`, +`apTrackingLink`, `apOS`, `apRemain`이다. + +호출 제약이 있다. 애드픽 가이드가 **최대 1분에 1회 이하 호출과 저장 후 사용**을 요구하고, 실제로 짧은 간격으로 +연달아 호출하면 403을 돌려준다. 그래서 요청 때마다 부르지 않고 스케줄러로만 부른다. + +동기화 규칙은 이렇다. + +- `apRemain`이 0이면 게재하지 않는다. 잔여가 없는 캠페인은 클릭해도 전환이 잡히지 않는다. +- 피드에서 사라진 캠페인은 지우지 않고 내린다. 쌓인 노출·클릭 집계가 어느 소재의 것인지 계속 읽혀야 한다. +- **`PAUSED`는 동기화가 되돌리지 않는다.** 별도 플래그 없이 어드민의 끄기 스위치로 쓴다. +- 동기화가 내용을 덮어쓰므로 어드민에서 수정·삭제는 막고, 게재 상태만 바꾸게 한다. + +`affId`(애드픽 회원 아이디)가 비어 있으면 동기화를 건너뛴다. 가입 전에도 나머지 기능은 그대로 돈다. + +### 3.5.2 OS 타깃팅 + +애드픽 앱 설치형 캠페인은 `apOS`로 OS가 갈린다. iOS 사용자에게 Android 캠페인을 보여주면 클릭해도 +전환이 일어나지 않으므로, 소재에 `target_os`를 두고 조회 시 요청 OS와 맞는 것만 준다. + +`GET /api/v1/ads`에 `os` 파라미터가 필요하다. 값을 안 보내면 `ALL`로 보고 모든 소재를 후보로 둔다. + +### 3.6.1 쿠팡 파트너스 아이디 대조 + +남의 파트너스 링크를 잘못 붙여넣으면 우리가 광고를 싣고 수수료는 남이 받는다. +소재 등록·수정 시 `landingUrl`의 `lptag`를 `coupang.partners.id`와 대조해 다르면 거부한다. + +다만 `link.coupang.com` 단축 링크에는 `lptag`가 드러나지 않으므로 **파라미터가 있을 때만** 본다. +없다고 막으면 정상적인 단축 링크를 쓸 수 없다. + +### 3.7 인증 우회 경로 + +`JwtFilter`가 SecurityConfig보다 먼저 돌면서 **토큰이 없으면 무조건 401**을 던진다. +따라서 `SecurityConfig.permitAll`만으로는 공개 엔드포인트가 뚫리지 않고, `JwtFilter.WHITELIST`에도 넣어야 한다. + +그런데 `isWhitelisted`가 `startsWith` 매칭이라 `"/"`를 넣으면 전체 인증이 무력화된다. +**정확히 일치할 때만 통과하는 `EXACT_WHITELIST`를 분리해 `/`와 `/error`를 넣는다.** + +`/error`가 빠져 있던 탓에 존재하지 않는 모든 경로가 404 대신 401로 나오고 있었다. 같이 고친다. + +### 3.8 Swagger 분리 + +광고 API는 별도 그룹 `3. 광고 API`로 띄운다. `/api/v1/ads/**`, `/api/v1/admin/ads/**`를 매칭하고, +기존 사용자·관리자 그룹에서는 제외해 섞이지 않게 한다. + +기존 `userApi` 그룹은 `FE_USED_OPERATIONS` 화이트리스트로 필터링되므로, 광고 API를 거기 넣으면 +어차피 보이지 않는다. 별도 그룹이 구조적으로 맞다. + +## 4. 운영 선행 작업 + +| 항목 | 상태 | +| --- | --- | +| `ad.picke.store` DNS + Railway 커스텀 도메인 + TLS | 완료 | +| 루트 공개 지면 배포 | 본 구현에 포함 | +| 광고 테이블 생성 | 별도 실행 불필요. `ddl-auto: update`라 배포 시 자동 생성된다 | +| 쿠팡 파트너스 가입·매체 등록 | ID 발급됨(`AF6830373`), 매체 등록 확인 필요 | +| 애드픽 파트너 가입 | 미착수. 계정이 필요해 코드로 대신할 수 없다 | +| 애드픽 `affId` 주입 | 가입 후 `ADPICK_AFF_ID` 설정. 비어 있으면 동기화를 건너뛴다 | +| 애드픽 서브아이디 파라미터 규격 확인 | 미확인. 확인되면 `ADPICK_SUB_ID_PARAM` 환경변수만 채우면 된다 | +| 애드픽 이용정책상 자체 앱 배너 노출 허용 여부 | 미확인 | +| 지면 목록 앱팀 확정 | 후보 5개 확정, 앱팀 확인 대기 | +| Play Console / App Store Connect "광고 포함" 신고 | 미착수 | +| 개인정보처리방침에 제휴 광고 문구 추가 | 미착수 | + +## 5. 지면 배치 트레이드오프 + +애드픽 캠페인 상당수가 CPI(앱 설치형)다. 단가는 커머스보다 높지만 클릭하면 사용자가 스토어로 나가 +다른 앱을 설치한다. 배틀 진행 중간 지면에 CPI를 깔면 이탈·리텐션에 직접 타격이 온다. + +세션이 자연스럽게 끝나는 지점(배틀 결과 화면)에 CPI를 두고, 피드 중간 인라인은 이탈 부담이 적은 +쿠팡 커머스로 채우는 배치를 권한다. 데이터가 쌓이면 지면별 CTR과 정산액을 보고 조정한다. + +## 6. 테스트 + +실제로 깨지기 쉬운 지점에 집중한다. + +- 쿼리스트링이 이미 붙은 제휴 링크에 `subId` 병합 +- 기간·상태 필터링 (미시작/만료/PAUSED 소재가 노출되지 않을 것) +- 가중 로테이션 분포 +- 없는 code / 만료 code 클릭 시 랜딩으로 302 +- 노출 집계 upsert가 같은 날 중복 호출에 누적될 것 + +## 7. 배포 체크리스트 + +- **광고 enum 을 바꿨으면 CHECK 제약을 직접 ALTER 한다.** `ddl-auto=update` 는 이미 있는 제약을 + 갱신하지 않아, `AdSlotCode`·`AdNetwork`·`AdStatus`·`AdSource`·`AdTargetOs` 에 값을 추가하고 배포하면 + 새 값의 INSERT 가 운영에서 조용히 막힌다. 구문은 `docs/db/20260901_create_ad_tables.sql` 주석에 있다. + `AdCheckConstraintTest` 가 CI 에서 먼저 깨주지만, 실제 ALTER 실행은 사람이 해야 한다. +- `COUPANG_PARTNERS_ID` 를 설정한다. 기본값이 없어 비어 있으면 기동 자체가 실패한다. +- `ADPICK_AFF_ID` 를 설정한다. 비어 있으면 애드픽 동기화만 건너뛰고 나머지는 정상 동작한다. diff --git a/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignClient.java b/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignClient.java new file mode 100644 index 00000000..7aba2de7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignClient.java @@ -0,0 +1,53 @@ +package com.swyp.picke.domain.ad.client; + +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 애드픽 캠페인 리스트 API 호출. + * 애드픽 가이드가 최대 1분에 1회 이하 호출을 요구하므로 요청마다 부르지 않고 스케줄러로만 부른다. + * 실제로 짧은 간격으로 연달아 호출하면 403 을 돌려준다. + */ +@Slf4j +@Component +public class AdpickCampaignClient { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Value("${picke.ad.adpick.base-url:https://adpick.co.kr/apis/offers.php}") + private String baseUrl; + + @Value("${picke.ad.adpick.aff-id:}") + private String affId; + + /** 가입 전에는 affId 가 비어 있다. 그때는 동기화를 건너뛴다. */ + public boolean isConfigured() { + return StringUtils.hasText(affId); + } + + public List fetchCampaigns() { + String url = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("affid", affId) + .queryParam("order", "rand") + .build() + .toUriString(); + + List campaigns = WebClient.create() + .get() + .uri(url) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .timeout(TIMEOUT) + .block(); + + return campaigns == null ? List.of() : campaigns; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponse.java b/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponse.java new file mode 100644 index 00000000..84a612e4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponse.java @@ -0,0 +1,42 @@ +package com.swyp.picke.domain.ad.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * 애드픽 캠페인 리스트 API(offers.php) 응답 한 건. + * 문서화되지 않은 필드가 늘어날 수 있어 모르는 필드는 무시한다. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record AdpickCampaignResponse( + + @JsonProperty("apOffer") String offerId, + @JsonProperty("apAppTitle") String appTitle, + @JsonProperty("apHeadline") String headline, + @JsonProperty("apAppPromoText") String promoText, + @JsonProperty("apImages") Map images, + @JsonProperty("apTrackingLink") String trackingLink, + @JsonProperty("apOS") String os, + @JsonProperty("apRemain") Integer remain +) { + + private static final String ICON_KEY = "icon"; + + public String iconUrl() { + return images == null ? null : images.get(ICON_KEY); + } + + /** 잔여 수량이 없는 캠페인은 클릭해도 전환이 잡히지 않으므로 노출하지 않는다. */ + public boolean hasRemaining() { + return remain != null && remain > 0; + } + + /** 이미지나 추적 링크가 비면 배너를 그릴 수 없다. */ + public boolean isRenderable() { + return offerId != null && !offerId.isBlank() + && appTitle != null && !appTitle.isBlank() + && trackingLink != null && !trackingLink.isBlank() + && iconUrl() != null; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java new file mode 100644 index 00000000..12ac991e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java @@ -0,0 +1,51 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.service.AdClickService; +import com.swyp.picke.domain.ad.service.AdClickService.AdClickTarget; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.servlet.view.RedirectView; + +/** + * 제휴 링크 클릭 진입점. + * /api/v1 아래에 두지 않는다. 외부 브라우저가 여는 공개 숏링크라 짧아야 하고, JSON API가 아니다. + */ +@Controller +@RequiredArgsConstructor +public class AdClickController { + + private static final String FORWARDED_FOR = "X-Forwarded-For"; + + private final AdClickService adClickService; + + @Value("${picke.ad.base-url:https://ad.picke.store}") + private String adBaseUrl; + + @GetMapping("/c/{code}") + public RedirectView click(@PathVariable String code, HttpServletRequest request) { + Optional target = adClickService.resolveTarget(code); + + if (target.isEmpty()) { + // 만료되었거나 없는 코드다. 404를 보여주는 대신 랜딩으로 흘려보낸다. + return new RedirectView(adBaseUrl + "/"); + } + + AdClickTarget clickTarget = target.get(); + adClickService.recordClick(clickTarget, resolveClientIp(request), request.getHeader("User-Agent")); + + return new RedirectView(clickTarget.redirectUrl()); + } + + private String resolveClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR); + if (forwardedFor == null || forwardedFor.isBlank()) { + return request.getRemoteAddr(); + } + return forwardedFor.split(",")[0].trim(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java new file mode 100644 index 00000000..3b0f4140 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java @@ -0,0 +1,52 @@ +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.enums.AdTargetOs; +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 = "요청 OS. 앱 설치형 캠페인이 OS별로 갈리므로 실제 OS를 보내야 한다.", + example = "ANDROID") + @RequestParam(defaultValue = "ALL") AdTargetOs os, + @Parameter(description = "받아갈 소재 개수", example = "1") + @RequestParam(defaultValue = "1") int size + ) { + return ApiResponse.onSuccess(adQueryService.findServableAds(slot, os, size)); + } + + @Operation(summary = "광고 노출 집계", + description = "조회가 아니라 실제로 화면에 그려진 시점에 호출한다. 조회를 노출로 세면 CTR이 왜곡된다.") + @PostMapping("/impressions") + public ApiResponse recordImpressions(@Valid @RequestBody AdImpressionRequest request) { + adQueryService.recordImpressions(request.codes()); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java new file mode 100644 index 00000000..461fbfb3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.service.AdQueryService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * ad.picke.store 루트 공개 지면. + * 쿠팡 파트너스 매체 심사에서 URL 접속 확인을 하므로 실제 콘텐츠가 있어야 한다. + * 광고 도메인이 아닌 Host로 들어오면 최소 응답만 준다. API 도메인 루트에 광고 페이지가 뜨면 안 된다. + */ +@Controller +@RequiredArgsConstructor +public class AdLandingController { + + private final AdQueryService adQueryService; + + @Value("${picke.ad.host:ad.picke.store}") + private String adHost; + + @GetMapping("/") + public Object landing(HttpServletRequest request, Model model) { + if (!adHost.equalsIgnoreCase(request.getServerName())) { + return ResponseEntity.ok("PICKE"); + } + + model.addAttribute("ads", adQueryService.findLandingAds()); + return "ad/landing"; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java b/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java new file mode 100644 index 00000000..3590aa42 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java @@ -0,0 +1,22 @@ +package com.swyp.picke.domain.ad.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * 노출 집계는 익명 공개 API다. 아무나 반복 호출해 노출수를 부풀릴 수 있다는 것을 감수하는 대신, + * 한 번에 밀어 넣을 수 있는 양은 막아 둔다. 한 화면에 그려지는 소재 수가 이보다 많을 일은 없다. + */ +@Schema(description = "광고 노출 집계 요청") +public record AdImpressionRequest( + + @Schema(description = "실제로 화면에 노출된 소재 코드 목록", example = "[\"a1b2c3d4\"]") + @NotEmpty(message = "노출된 소재 코드는 최소 1개 이상이어야 합니다.") + @Size(max = MAX_CODES, message = "한 번에 집계할 수 있는 소재는 최대 20개입니다.") + List codes +) { + + private static final int MAX_CODES = 20; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java b/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java new file mode 100644 index 00000000..a65e8b8b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java @@ -0,0 +1,50 @@ +package com.swyp.picke.domain.ad.dto.response; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "앱 지면에 노출할 제휴 광고 소재") +public record AdResponse( + + @Schema(description = "소재 코드", example = "a1b2c3d4") + String code, + + @Schema(description = "매체", example = "COUPANG") + AdNetwork network, + + @Schema(description = "배너 주 문구", example = "지금 인기 있는 무선 이어폰") + String title, + + @Schema(description = "배너 보조 문구", example = "리뷰 1만 개 이상") + String subtitle, + + @Schema(description = "소재 이미지 URL") + String imageUrl, + + @Schema(description = "버튼 문구", example = "구매하러 가기") + String ctaText, + + @Schema(description = "탭 시 이동할 URL. 외부 브라우저로 열어야 한다.", + example = "https://ad.picke.store/c/a1b2c3d4") + String clickUrl, + + @Schema(description = "광고 표기 라벨. 표시광고법 대응이므로 반드시 렌더해야 한다.", example = "광고") + String label +) { + + private static final String AD_LABEL = "광고"; + + public static AdResponse of(AdCreative creative, String clickUrl) { + return new AdResponse( + creative.getCode(), + creative.getNetwork(), + creative.getTitle(), + creative.getSubtitle(), + creative.getImageUrl(), + creative.getCtaText(), + clickUrl, + AD_LABEL + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java new file mode 100644 index 00000000..d791cbc9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java @@ -0,0 +1,53 @@ +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 org.hibernate.annotations.Check; +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") +}) +@Check(name = "ck_ad_click_logs_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', " + + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdClickLog extends BaseEntity { + + @Column(name = "creative_id", nullable = false) + private Long creativeId; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 40) + private AdSlotCode slot; + + /** 원본 IP는 저장하지 않는다. 중복 클릭 판별에 필요한 정도만 남긴다. */ + @Column(name = "ip_hash", length = 64) + private String ipHash; + + @Column(name = "user_agent", length = 500) + private String userAgent; + + @Builder + private AdClickLog(Long creativeId, AdSlotCode slot, String ipHash, String userAgent) { + this.creativeId = creativeId; + this.slot = slot; + this.ipHash = ipHash; + this.userAgent = userAgent; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java new file mode 100644 index 00000000..8803f7ef --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java @@ -0,0 +1,168 @@ +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.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +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 org.hibernate.annotations.Check; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 제휴 광고 소재. 각 매체 콘솔에서 발급한 완성형 제휴 링크를 어드민이 등록한다. + */ +@Entity +@Getter +@Table(name = "ad_creatives", uniqueConstraints = { + @UniqueConstraint(name = "uk_ad_creatives_external", columnNames = {"source", "external_id"}) +}) +@Check(name = "ck_ad_creatives_network", constraints = "network in ('COUPANG', 'ADPICK')") +@Check(name = "ck_ad_creatives_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', " + + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')") +@Check(name = "ck_ad_creatives_status", constraints = "status in ('DRAFT', 'ACTIVE', 'PAUSED')") +@Check(name = "ck_ad_creatives_source", constraints = "source in ('MANUAL', 'ADPICK_API')") +@Check(name = "ck_ad_creatives_target_os", constraints = "target_os in ('ALL', 'ANDROID', 'IOS')") +@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; + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 20) + private AdSource source; + + /** 매체 쪽 캠페인 식별자. 동기화 소재를 다시 찾을 때 쓴다. 수동 등록 소재는 비어 있다. */ + @Column(name = "external_id", length = 64) + private String externalId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_os", nullable = false, length = 20) + private AdTargetOs targetOs; + + @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, AdSource source, String externalId, + AdTargetOs targetOs) { + 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; + this.source = source != null ? source : AdSource.MANUAL; + this.externalId = externalId; + this.targetOs = targetOs != null ? targetOs : AdTargetOs.ALL; + } + + 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, AdTargetOs targetOs) { + 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; + this.targetOs = targetOs != null ? targetOs : AdTargetOs.ALL; + } + + /** + * 애드픽 동기화가 매 회차 내용을 덮어쓴다. + * 어드민이 꺼둔 소재는 다시 켜지 않는다. 별도 플래그 없이 PAUSED 를 끄기 스위치로 쓴다. + */ + public void syncFromAdpick(String title, String subtitle, String imageUrl, String ctaText, + String landingUrl, AdTargetOs targetOs, AdSlotCode slot, boolean servable) { + this.title = title; + this.subtitle = subtitle; + this.imageUrl = imageUrl; + this.ctaText = ctaText; + this.landingUrl = landingUrl; + this.targetOs = targetOs; + this.slot = slot; + if (this.status != AdStatus.PAUSED) { + this.status = servable ? AdStatus.ACTIVE : AdStatus.DRAFT; + } + } + + /** 동기화가 소유하는 소재는 어드민이 내용을 고치거나 지우지 않는다. */ + public boolean isManaged() { + return source == AdSource.ADPICK_API; + } + + public void changeStatus(AdStatus status) { + this.status = status; + } + + /** 게재 가능 여부. status와 기간을 함께 본다. */ + public boolean isServable(LocalDateTime now) { + if (status != AdStatus.ACTIVE) { + return false; + } + if (startsAt != null && now.isBefore(startsAt)) { + return false; + } + return endsAt == null || !now.isAfter(endsAt); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java new file mode 100644 index 00000000..7177c861 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java @@ -0,0 +1,53 @@ +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 org.hibernate.annotations.Check; +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"}) +}) +@Check(name = "ck_ad_impression_daily_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', " + + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdImpressionDaily extends BaseEntity { + + @Column(name = "creative_id", nullable = false) + private Long creativeId; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 40) + private AdSlotCode slot; + + @Column(name = "stat_date", nullable = false) + private LocalDate statDate; + + @Column(name = "impressions", nullable = false) + private long impressions; + + @Builder + private AdImpressionDaily(Long creativeId, AdSlotCode slot, LocalDate statDate, long impressions) { + this.creativeId = creativeId; + this.slot = slot; + this.statDate = statDate; + this.impressions = impressions; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java new file mode 100644 index 00000000..d22d4bf5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 제휴 광고 매체. AdMob 리워드 광고(reward 도메인)와는 무관하다. + */ +@Getter +@RequiredArgsConstructor +public enum AdNetwork { + + COUPANG("쿠팡 파트너스"), + ADPICK("애드픽"); + + private final String description; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java new file mode 100644 index 00000000..1799e776 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java @@ -0,0 +1,31 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 광고 노출 지면. + * 테이블이 아니라 enum인 이유는, 앱이 그릴 줄 모르는 지면을 어드민에서 만들어봐야 + * 아무 일도 일어나지 않기 때문이다. 지면 추가는 어차피 앱 배포와 묶인다. + * 목록은 iOS Presentation 모듈(Home/Battle/Chat/Profile)의 실제 화면을 기준으로 잡았다. + * 앱팀 확정 전이므로 실제로 붙이는 지면만 소재를 등록하면 된다. + * 소재가 없는 지면은 빈 배열을 반환하고 앱은 지면 자체를 숨긴다. + * {@code cpiFriendly}는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다. + * CPI는 단가가 높지만 클릭하면 사용자가 스토어로 나가 다른 앱을 설치한다. + * 세션이 자연스럽게 끝나는 지점이 아니면 이탈·리텐션에 그대로 타격이 온다. + */ +@Getter +@RequiredArgsConstructor +public enum AdSlotCode { + + HOME_FEED("홈 피드 인라인", false), + BATTLE_RESULT_BOTTOM("배틀 결과 하단", true), + CHAT_ROOM_INLINE("관점 목록 인라인", false), + ATTENDANCE_COMPLETE("출석 완료 후", true), + PROFILE_BOTTOM("프로필 하단", true); + + private final String description; + + /** 앱 설치형(CPI) 광고를 놓아도 되는 지면인지. */ + private final boolean cpiFriendly; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java new file mode 100644 index 00000000..ca796e09 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 소재를 누가 만들었는지. + * ADPICK_API 소재는 동기화가 내용을 덮어쓰므로 어드민에서 수정·삭제하지 않는다. + */ +@Getter +@RequiredArgsConstructor +public enum AdSource { + + MANUAL("어드민 수동 등록"), + ADPICK_API("애드픽 캠페인 API 동기화"); + + 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 00000000..42ccbb24 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.ad.enums; + +public enum AdStatus { + DRAFT, + ACTIVE, + PAUSED +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java new file mode 100644 index 00000000..52e82bc1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java @@ -0,0 +1,40 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 소재를 노출할 OS. + * 애드픽 앱 설치형 캠페인은 OS가 갈리므로, iOS 사용자에게 Android 캠페인을 보여주면 + * 클릭해도 전환이 일어나지 않는다. + */ +@Getter +@RequiredArgsConstructor +public enum AdTargetOs { + + ALL("전체"), + ANDROID("Android"), + IOS("iOS"); + + private final String description; + + /** 애드픽 apOS 값을 우리 enum으로 옮긴다. 모르는 값은 노출하지 않도록 비운다. */ + public static AdTargetOs fromAdpick(String apOs) { + if (apOs == null || apOs.isBlank()) { + return ALL; + } + String normalized = apOs.trim().toLowerCase(); + if (normalized.startsWith("and")) { + return ANDROID; + } + if (normalized.startsWith("ios") || normalized.startsWith("iphone")) { + return IOS; + } + return ALL; + } + + /** 요청 OS에 이 타깃을 노출해도 되는지. */ + public boolean matches(AdTargetOs requested) { + return this == ALL || requested == ALL || this == requested; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java new file mode 100644 index 00000000..97464760 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java @@ -0,0 +1,33 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 애드픽 서브아이디 파라미터명은 설정값으로 둔다. + * 파트너센터 링크생성 화면에서 규격을 확인하기 전까지는 값이 비어 있고, 그동안은 pass-through로 + * 원본 링크를 그대로 넘긴다. 지면별 성과 분리만 안 될 뿐 노출·클릭·리다이렉트는 정상 동작한다. + * 규격이 확인되면 배포 없이 {@code picke.ad.adpick.sub-id-param} 만 채우면 쿠팡과 같은 방식으로 붙는다. + */ +@Component +public class AdpickLinkBuilder implements AffiliateLinkBuilder { + + @Value("${picke.ad.adpick.sub-id-param:}") + private String subIdParam; + + @Override + public AdNetwork network() { + return AdNetwork.ADPICK; + } + + @Override + public String build(AdCreative creative) { + if (!StringUtils.hasText(subIdParam)) { + return creative.getLandingUrl(); + } + return AffiliateLinks.merge(creative.getLandingUrl(), subIdParam, AffiliateLinks.subIdOf(creative)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java new file mode 100644 index 00000000..89091aac --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; + +/** + * 매체별 최종 이동 URL을 만든다. + * 소재의 landingUrl은 각 매체 콘솔에서 발급한 완성형 링크라 이미 쿼리스트링을 갖고 있다. + * 추적 파라미터는 문자열 결합이 아니라 병합이어야 한다. + */ +public interface AffiliateLinkBuilder { + + AdNetwork network(); + + String build(AdCreative creative); +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java new file mode 100644 index 00000000..b3f9be88 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +public class AffiliateLinkResolver { + + private final Map builders = new EnumMap<>(AdNetwork.class); + + public AffiliateLinkResolver(List builderList) { + builderList.forEach(builder -> builders.put(builder.network(), builder)); + } + + public String resolve(AdCreative creative) { + AffiliateLinkBuilder builder = builders.get(creative.getNetwork()); + if (builder == null) { + return creative.getLandingUrl(); + } + return builder.build(creative); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java new file mode 100644 index 00000000..335d0198 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java @@ -0,0 +1,30 @@ +package com.swyp.picke.domain.ad.link; + +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 제휴 링크에 추적 파라미터를 끼워 넣는 공통 규칙. + */ +final class AffiliateLinks { + + private AffiliateLinks() { + } + + /** + * 이미 쿼리스트링이 붙어 있는 제휴 링크에 파라미터를 병합한다. + * 단순 문자열 결합이 아니다. 같은 이름의 파라미터가 이미 있으면 우리 값으로 덮는다. + * build(true)로 두는 이유는 landingUrl이 각 매체 콘솔에서 인코딩까지 끝난 상태로 오기 때문이다. + * 여기서 다시 인코딩하면 이중 인코딩된다. + */ + static String merge(String landingUrl, String paramName, String value) { + return UriComponentsBuilder.fromUriString(landingUrl) + .replaceQueryParam(paramName, value) + .build(true) + .toUriString(); + } + + /** 지면별 성과를 가르기 위한 추적값. 영문·숫자·밑줄만 쓰므로 인코딩이 필요 없다. */ + static String subIdOf(com.swyp.picke.domain.ad.entity.AdCreative creative) { + return creative.getSlot().name() + "_" + creative.getCode(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java new file mode 100644 index 00000000..601796b3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java @@ -0,0 +1,25 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import org.springframework.stereotype.Component; + +/** + * 쿠팡 파트너스는 subId를 지원한다. 지면별로 값을 달리 넣으면 파트너스 리포트에서 + * 지면별 실매출이 갈려, 어느 지면이 돈이 되는지 데이터로 볼 수 있다. + */ +@Component +public class CoupangLinkBuilder implements AffiliateLinkBuilder { + + private static final String SUB_ID_PARAM = "subId"; + + @Override + public AdNetwork network() { + return AdNetwork.COUPANG; + } + + @Override + public String build(AdCreative creative) { + return AffiliateLinks.merge(creative.getLandingUrl(), SUB_ID_PARAM, AffiliateLinks.subIdOf(creative)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java new file mode 100644 index 00000000..76c025a0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.ad.repository; + +import com.swyp.picke.domain.ad.entity.AdClickLog; +import com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AdClickLogRepository extends JpaRepository { + + @Query("select l.creativeId as creativeId, count(l) as total from AdClickLog l " + + "where l.createdAt >= :from and l.createdAt < :to " + + "group by l.creativeId") + List countByCreativeBetween(@Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + @Query("select new com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse(" + + "l.id, c.code, c.title, c.network, l.slot, l.createdAt) " + + "from AdClickLog l join AdCreative c on c.id = l.creativeId " + + "where l.createdAt >= :from and l.createdAt < :to " + + "order by l.id desc") + Page findClickLogs(@Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + Pageable pageable); + + interface CreativeCount { + Long getCreativeId(); + + long getTotal(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java new file mode 100644 index 00000000..252d30a2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java @@ -0,0 +1,38 @@ +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.AdSource; +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); + + List findAllBySource(AdSource source); +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java new file mode 100644 index 00000000..fae41652 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.ad.repository; + +import com.swyp.picke.domain.ad.entity.AdImpressionDaily; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AdImpressionDailyRepository extends JpaRepository { + + Optional findByCreativeIdAndSlotAndStatDate(Long creativeId, AdSlotCode slot, + LocalDate statDate); + + /** + * ON CONFLICT는 PostgreSQL 전용이라 테스트 H2에서 깨진다. 갱신 후 0건이면 삽입하는 방식으로 둔다. + */ + @Modifying + @Query("update AdImpressionDaily a set a.impressions = a.impressions + :delta " + + "where a.creativeId = :creativeId and a.slot = :slot and a.statDate = :statDate") + int increment(@Param("creativeId") Long creativeId, + @Param("slot") AdSlotCode slot, + @Param("statDate") LocalDate statDate, + @Param("delta") long delta); + + @Query("select a.creativeId as creativeId, sum(a.impressions) as total from AdImpressionDaily a " + + "where a.statDate >= :from and a.statDate <= :to " + + "group by a.creativeId") + List sumByCreativeBetween(@Param("from") LocalDate from, @Param("to") LocalDate to); + + interface CreativeCount { + Long getCreativeId(); + + long getTotal(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java b/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java new file mode 100644 index 00000000..30c2fe87 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java @@ -0,0 +1,31 @@ +package com.swyp.picke.domain.ad.scheduler; + +import com.swyp.picke.domain.ad.service.AdpickCampaignSyncService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 애드픽 캠페인 주기 동기화. + * 애드픽 가이드가 최대 1분에 1회 이하 호출을 요구해 넉넉한 간격으로 돈다. + * 동기화가 실패해도 이미 저장된 소재로 광고는 계속 나가야 하므로 예외를 흘리지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AdpickCampaignSyncScheduler { + + private final AdpickCampaignSyncService adpickCampaignSyncService; + + @Scheduled( + initialDelayString = "${picke.ad.adpick.sync-initial-delay-ms:60000}", + fixedDelayString = "${picke.ad.adpick.sync-interval-ms:600000}") + public void sync() { + try { + adpickCampaignSyncService.sync(); + } catch (Exception e) { + log.warn("[AdpickSync] 캠페인 동기화 실패: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java new file mode 100644 index 00000000..7b8193f8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java @@ -0,0 +1,92 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.entity.AdClickLog; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.link.AffiliateLinkResolver; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdClickService { + + private static final int USER_AGENT_MAX_LENGTH = 500; + + /** 게재 기간 판단은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AdCreativeRepository adCreativeRepository; + private final AdClickLogRepository adClickLogRepository; + private final AffiliateLinkResolver affiliateLinkResolver; + + /** + * 클릭 코드로 최종 이동 대상을 찾는다. 코드가 없거나 게재 기간이 지났으면 비어 있는 값을 준다. + */ + @Transactional(readOnly = true) + public Optional resolveTarget(String code) { + return adCreativeRepository.findByCode(code) + .filter(creative -> creative.isServable(LocalDateTime.now(KST))) + .map(creative -> new AdClickTarget( + creative.getId(), + creative.getSlot(), + affiliateLinkResolver.resolve(creative) + )); + } + + /** + * 클릭 적재가 리다이렉트를 붙잡으면 안 되므로 비동기로 둔다. + * 적재에 실패해도 사용자는 정상적으로 제휴처로 이동해야 한다. + */ + @Async + @Transactional + public void recordClick(AdClickTarget target, String clientIp, String userAgent) { + try { + adClickLogRepository.save(AdClickLog.builder() + .creativeId(target.creativeId()) + .slot(target.slot()) + .ipHash(hashIp(clientIp)) + .userAgent(truncate(userAgent)) + .build()); + } catch (Exception e) { + log.warn("[AdClick] 클릭 적재 실패 creativeId={}: {}", target.creativeId(), e.getMessage()); + } + } + + /** 원본 IP는 남기지 않는다. 중복 클릭 판별에 필요한 정도만 해시로 보관한다. */ + private String hashIp(String clientIp) { + if (clientIp == null || clientIp.isBlank()) { + return null; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(clientIp.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + return null; + } + } + + private String truncate(String userAgent) { + if (userAgent == null) { + return null; + } + return userAgent.length() > USER_AGENT_MAX_LENGTH + ? userAgent.substring(0, USER_AGENT_MAX_LENGTH) + : userAgent; + } + + public record AdClickTarget(Long creativeId, AdSlotCode slot, String redirectUrl) { + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java b/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java new file mode 100644 index 00000000..29e53142 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.security.SecureRandom; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 공개 클릭 URL(/c/{code})에 노출되는 짧은 코드를 만든다. + * 어드민이 눈으로 옮겨 적는 일이 있어 헷갈리기 쉬운 글자(l, o, 0, 1)를 뺀다. + */ +@Component +@RequiredArgsConstructor +public class AdCreativeCodeGenerator { + + private static final String ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789"; + private static final int LENGTH = 8; + private static final int MAX_ATTEMPTS = 10; + + private final AdCreativeRepository adCreativeRepository; + private final SecureRandom random = new SecureRandom(); + + public String generate() { + for (int attempt = 0; attempt < 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(LENGTH, 0, ALPHABET.length()) + .mapToObj(index -> String.valueOf(ALPHABET.charAt(index))) + .collect(Collectors.joining()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java b/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java new file mode 100644 index 00000000..137e33b7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java @@ -0,0 +1,48 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.entity.AdImpressionDaily; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 일별 노출 집계 행을 쓰는 지점. + * 갱신과 삽입을 각각 독립 트랜잭션으로 나눠 둔다. + * + *

PostgreSQL은 한 문장이 제약을 위반하면 그 트랜잭션 전체가 abort 상태로 들어가, + * 뒤따르는 쿼리가 모두 {@code current transaction is aborted}로 실패한다. + * 삽입 실패를 같은 트랜잭션 안에서 갱신으로 되돌리려 하면 운영에서 그 복구가 동작하지 않고, + * 예외가 위로 새면 호출부가 {@code UnexpectedRollbackException}을 맞아 배치 전체가 날아간다. + * 그래서 삽입만 별도 트랜잭션에 가둬 실패를 격리한다. + */ +@Component +@RequiredArgsConstructor +public class AdImpressionRecorder { + + private final AdImpressionDailyRepository adImpressionDailyRepository; + + /** 이미 있는 행을 올린다. 대상 행이 없으면 false. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean increment(Long creativeId, AdSlotCode slot, LocalDate statDate) { + return adImpressionDailyRepository.increment(creativeId, slot, statDate, 1L) > 0; + } + + /** + * 오늘 첫 노출이라 행을 만든다. + * 같은 (소재, 지면, 날짜)를 다른 요청이 먼저 만들었으면 제약 위반으로 실패하고, + * 실패는 이 트랜잭션 안에서 끝난다. 되돌리기는 호출부가 갱신으로 처리한다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void insert(Long creativeId, AdSlotCode slot, LocalDate statDate) { + adImpressionDailyRepository.saveAndFlush(AdImpressionDaily.builder() + .creativeId(creativeId) + .slot(slot) + .statDate(statDate) + .impressions(1L) + .build()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java new file mode 100644 index 00000000..c01b4290 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java @@ -0,0 +1,137 @@ +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.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdQueryService { + + /** 노출 집계 버킷과 게재 기간은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AdCreativeRepository adCreativeRepository; + private final AdImpressionRecorder adImpressionRecorder; + + @Value("${picke.ad.base-url:https://ad.picke.store}") + private String adBaseUrl; + + /** + * 지면에 노출할 소재를 가중 로테이션으로 고른다. 게재 가능한 소재가 없으면 빈 목록을 준다. + * 앱은 빈 목록을 받으면 지면 자체를 숨긴다. 광고가 없는 건 오류가 아니다. + * 애드픽 앱 설치형 캠페인은 OS가 갈리므로, 요청 OS와 맞지 않는 소재는 제외한다. + */ + @Transactional(readOnly = true) + public List findServableAds(AdSlotCode slot, AdTargetOs os, int size) { + LocalDateTime now = LocalDateTime.now(KST); + AdTargetOs requested = os != null ? os : AdTargetOs.ALL; + + List candidates = adCreativeRepository.findAllBySlotAndStatus(slot, AdStatus.ACTIVE).stream() + .filter(creative -> creative.isServable(now)) + .filter(creative -> creative.getTargetOs().matches(requested)) + .toList(); + + return weightedSample(candidates, size).stream() + .map(creative -> AdResponse.of(creative, buildClickUrl(creative))) + .toList(); + } + + /** + * ad.picke.store 루트 공개 지면에 나열할 소재. 매체 심사에서 실제 콘텐츠를 확인하므로 + * 로테이션 없이 게재 가능한 소재를 모두 보여준다. + */ + @Transactional(readOnly = true) + public List findLandingAds() { + LocalDateTime now = LocalDateTime.now(KST); + + return adCreativeRepository.findAllByStatusOrderByIdDesc(AdStatus.ACTIVE).stream() + .filter(creative -> creative.isServable(now)) + .map(creative -> AdResponse.of(creative, buildClickUrl(creative))) + .toList(); + } + + /** + * 조회 시점이 아니라 앱이 실제로 화면에 그린 시점에 호출된다. + * 조회를 노출로 세면 CTR이 실제보다 낮게 왜곡되기 때문이다. + * + *

여기에 트랜잭션을 걸지 않는다. 삽입이 제약 위반으로 실패했을 때의 되돌리기가 + * 실패한 트랜잭션 밖에서 일어나야 하고, 한 소재의 집계 실패가 나머지 소재까지 되돌리면 안 된다. + */ + public void recordImpressions(List codes) { + LocalDate today = LocalDate.now(KST); + + findImpressionTargets(codes).forEach(target -> increaseImpression(target, today)); + } + + private List findImpressionTargets(List codes) { + return adCreativeRepository.findAllByCodeIn(codes).stream() + .map(creative -> new ImpressionTarget(creative.getId(), creative.getSlot())) + .toList(); + } + + private void increaseImpression(ImpressionTarget target, LocalDate today) { + if (adImpressionRecorder.increment(target.creativeId(), target.slot(), today)) { + return; + } + + try { + adImpressionRecorder.insert(target.creativeId(), target.slot(), today); + } catch (DataIntegrityViolationException e) { + // 같은 (소재, 지면, 날짜) 행을 다른 요청이 먼저 만든 경우다. 갱신으로 되돌린다. + // 삽입이 독립 트랜잭션이라 여기서 도는 갱신은 정상 트랜잭션에서 실행된다. + adImpressionRecorder.increment(target.creativeId(), target.slot(), today); + } + } + + 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/ad/service/AdpickCampaignSyncService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java new file mode 100644 index 00000000..cf7b33d9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java @@ -0,0 +1,130 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.client.AdpickCampaignClient; +import com.swyp.picke.domain.ad.client.AdpickCampaignResponse; +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.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 애드픽 캠페인을 소재로 옮겨 담는다. + * 수동 등록 소재와 같은 테이블을 쓰므로 로테이션·노출 집계·클릭 추적 경로를 그대로 탄다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdpickCampaignSyncService { + + private static final int SUBTITLE_MAX_LENGTH = 200; + private static final int TITLE_MAX_LENGTH = 100; + + private final AdpickCampaignClient adpickCampaignClient; + private final AdCreativeRepository adCreativeRepository; + private final AdCreativeCodeGenerator adCreativeCodeGenerator; + + @Value("${picke.ad.adpick.slot:BATTLE_RESULT_BOTTOM}") + private AdSlotCode slot; + + @Value("${picke.ad.adpick.cta-text:설치하고 받기}") + private String ctaText; + + @Transactional + public int sync() { + if (!adpickCampaignClient.isConfigured()) { + log.info("[AdpickSync] affId 미설정으로 동기화를 건너뛴다."); + return 0; + } + + List campaigns = adpickCampaignClient.fetchCampaigns().stream() + .filter(AdpickCampaignResponse::isRenderable) + .toList(); + + Map existing = adCreativeRepository.findAllBySource(AdSource.ADPICK_API).stream() + .collect(Collectors.toMap(AdCreative::getExternalId, Function.identity(), (a, b) -> a)); + + Set seen = new HashSet<>(); + for (AdpickCampaignResponse campaign : campaigns) { + seen.add(campaign.offerId()); + upsert(existing.get(campaign.offerId()), campaign); + } + + retire(existing, seen); + log.info("[AdpickSync] 캠페인 {}건 동기화 완료", campaigns.size()); + return campaigns.size(); + } + + private void upsert(AdCreative found, AdpickCampaignResponse campaign) { + if (found != null) { + found.syncFromAdpick( + truncate(campaign.appTitle(), TITLE_MAX_LENGTH), + subtitleOf(campaign), + campaign.iconUrl(), + ctaText, + campaign.trackingLink(), + AdTargetOs.fromAdpick(campaign.os()), + slot, + campaign.hasRemaining()); + return; + } + + adCreativeRepository.save(AdCreative.builder() + .code(adCreativeCodeGenerator.generate()) + .network(AdNetwork.ADPICK) + .slot(slot) + .title(truncate(campaign.appTitle(), TITLE_MAX_LENGTH)) + .subtitle(subtitleOf(campaign)) + .imageUrl(campaign.iconUrl()) + .ctaText(ctaText) + .landingUrl(campaign.trackingLink()) + .status(campaign.hasRemaining() ? AdStatus.ACTIVE : AdStatus.DRAFT) + .weight(1) + .source(AdSource.ADPICK_API) + .externalId(campaign.offerId()) + .targetOs(AdTargetOs.fromAdpick(campaign.os())) + .build()); + } + + /** + * 피드에서 사라진 캠페인은 지우지 않고 내린다. + * 이미 쌓인 노출·클릭 집계가 어느 소재의 것인지 계속 읽을 수 있어야 한다. + */ + private void retire(Map existing, Set seen) { + existing.forEach((externalId, creative) -> { + if (!seen.contains(externalId) && creative.getStatus() == AdStatus.ACTIVE) { + creative.changeStatus(AdStatus.DRAFT); + } + }); + } + + private String subtitleOf(AdpickCampaignResponse campaign) { + String source = campaign.headline() != null && !campaign.headline().isBlank() + ? campaign.headline() + : campaign.promoText(); + if (source == null || source.isBlank()) { + return null; + } + return truncate(source.replaceAll("\\s+", " ").trim(), SUBTITLE_MAX_LENGTH); + } + + private String truncate(String value, int max) { + if (value == null) { + return null; + } + return value.length() > max ? value.substring(0, max) : value; + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java new file mode 100644 index 00000000..c25483e4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java @@ -0,0 +1,115 @@ +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.request.AdStatusRequest; +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.PatchMapping; +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 = "광고 소재 수정", description = "매체 API가 동기화하는 소재는 수정할 수 없다.") + @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 = "광고 소재 게재 상태 변경", + description = "동기화 소재도 끌 수 있다. PAUSED 는 동기화가 되돌리지 않는다.") + @PatchMapping("/{creativeId}/status") + public ApiResponse changeStatus( + @Parameter(description = "소재 ID", example = "1") + @PathVariable Long creativeId, + @Valid @RequestBody AdStatusRequest request + ) { + return ApiResponse.onSuccess(adminAdService.changeStatus(creativeId, request.status())); + } + + @Operation(summary = "광고 소재 삭제", description = "매체 API가 동기화하는 소재는 삭제할 수 없다.") + @DeleteMapping("/{creativeId}") + public ApiResponse delete( + @Parameter(description = "소재 ID", example = "1") + @PathVariable Long creativeId + ) { + adminAdService.delete(creativeId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "소재별 노출/클릭/CTR", + description = "우리 DB 기준 수치다. 제휴사 정산 리포트와 대조하는 용도로 쓴다.") + @GetMapping("/stats") + public ApiResponse> findStats( + @Parameter(description = "집계 시작일", example = "2026-09-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @Parameter(description = "집계 종료일(포함)", example = "2026-09-30") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminAdService.findStats(from, to)); + } + + @Operation(summary = "광고 클릭 내역", description = "언제 어떤 소재가 눌렸는지 최신순으로 본다.") + @GetMapping("/clicks") + public ApiResponse> findClickLogs( + @Parameter(description = "조회 시작일", example = "2026-09-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @Parameter(description = "조회 종료일(포함)", example = "2026-09-30") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @Parameter(description = "1부터 시작", example = "1") + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess(adminAdService.findClickLogs(from, to, page, size)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java new file mode 100644 index 00000000..a7ba14cb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java @@ -0,0 +1,81 @@ +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 com.swyp.picke.domain.ad.enums.AdTargetOs; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +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 = "노출할 OS. 앱 설치형은 OS가 갈리므로 맞춰야 한다.", example = "ALL") + @NotNull(message = "노출 OS는 필수입니다.") + AdTargetOs targetOs, + + @Schema(description = "가중 로테이션 가중치. 클수록 자주 노출된다.", example = "1") + @Positive(message = "가중치는 1 이상이어야 합니다.") + Integer weight, + + @Schema(description = "게재 시작 시각. 비우면 즉시 시작") + LocalDateTime startsAt, + + @Schema(description = "게재 종료 시각. 비우면 무제한") + LocalDateTime endsAt +) { + + /** + * 시작이 종료보다 뒤면 어떤 소재도 노출되지 않는다. + * 저장은 되고 광고만 조용히 안 나가는 상황이라 등록 시점에 막는다. + */ + @AssertTrue(message = "게재 종료 시각은 시작 시각보다 뒤여야 합니다.") + @Schema(hidden = true) + public boolean isPeriodValid() { + if (startsAt == null || endsAt == null) { + return true; + } + return !startsAt.isAfter(endsAt); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdStatusRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdStatusRequest.java new file mode 100644 index 00000000..95ba2099 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdStatusRequest.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.admin.dto.ad.request; + +import com.swyp.picke.domain.ad.enums.AdStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "광고 소재 게재 상태 변경 요청") +public record AdStatusRequest( + + @Schema(description = "변경할 게재 상태", example = "PAUSED") + @NotNull(message = "게재 상태는 필수입니다.") + AdStatus status +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java new file mode 100644 index 00000000..e9e4bee4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.admin.dto.ad.response; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "광고 클릭 내역 한 건") +public record AdClickLogResponse( + + Long clickId, + + @Schema(description = "소재 코드", example = "a1b2c3d4") + String code, + + @Schema(description = "소재 제목") + String title, + + AdNetwork network, + + AdSlotCode slot, + + @Schema(description = "클릭 시각") + LocalDateTime clickedAt +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java new file mode 100644 index 00000000..567970c1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java @@ -0,0 +1,50 @@ +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.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +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, + AdSource source, + AdTargetOs targetOs, + 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.getSource(), + creative.getTargetOs(), + creative.getWeight(), + creative.getStartsAt(), + creative.getEndsAt() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java new file mode 100644 index 00000000..26e0f912 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java @@ -0,0 +1,30 @@ +package com.swyp.picke.domain.admin.dto.ad.response; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "소재별 노출/클릭 집계") +public record AdStatsResponse( + Long creativeId, + String code, + AdNetwork network, + AdSlotCode slot, + String title, + + @Schema(description = "기간 내 노출 수") + long impressions, + + @Schema(description = "기간 내 클릭 수") + long clicks, + + @Schema(description = "클릭률(%). 노출이 0이면 0", example = "1.25") + double ctr +) { + + public static AdStatsResponse of(Long creativeId, String code, AdNetwork network, AdSlotCode slot, + String title, long impressions, long clicks) { + double ctr = impressions == 0 ? 0d : Math.round(clicks * 10000d / impressions) / 100d; + return new AdStatsResponse(creativeId, code, network, slot, title, impressions, clicks, ctr); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java new file mode 100644 index 00000000..d9884789 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java @@ -0,0 +1,206 @@ +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.enums.AdSource; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import com.swyp.picke.domain.ad.service.AdCreativeCodeGenerator; +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.net.URI; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@RequiredArgsConstructor +public class AdminAdService { + + private final AdCreativeRepository adCreativeRepository; + private final AdClickLogRepository adClickLogRepository; + private final AdImpressionDailyRepository adImpressionDailyRepository; + private final AdCreativeCodeGenerator adCreativeCodeGenerator; + + @Value("${coupang.partners.id:}") + private String coupangPartnersId; + + @Transactional + public AdCreativeResponse create(AdCreativeRequest request) { + validateLandingUrl(request.landingUrl()); + validateCoupangOwnership(request); + + AdCreative creative = AdCreative.builder() + .code(adCreativeCodeGenerator.generate()) + .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()) + .source(AdSource.MANUAL) + .targetOs(request.targetOs()) + .build(); + + return AdCreativeResponse.from(adCreativeRepository.save(creative)); + } + + @Transactional + public AdCreativeResponse update(Long creativeId, AdCreativeRequest request) { + validateLandingUrl(request.landingUrl()); + validateCoupangOwnership(request); + + AdCreative creative = findById(creativeId); + rejectIfManaged(creative); + + creative.update( + request.network(), + request.slot(), + request.title(), + request.subtitle(), + request.imageUrl(), + request.ctaText(), + request.landingUrl(), + request.status(), + request.weight(), + request.startsAt(), + request.endsAt(), + request.targetOs() + ); + + return AdCreativeResponse.from(creative); + } + + @Transactional + public void delete(Long creativeId) { + AdCreative creative = findById(creativeId); + rejectIfManaged(creative); + adCreativeRepository.delete(creative); + } + + /** + * 동기화 소재도 끌 수는 있어야 한다. + * PAUSED 는 동기화가 되돌리지 않으므로 어드민의 끄기 스위치가 된다. + */ + @Transactional + public AdCreativeResponse changeStatus(Long creativeId, AdStatus status) { + AdCreative creative = findById(creativeId); + creative.changeStatus(status); + return AdCreativeResponse.from(creative); + } + + @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))); + } + + /** + * 클릭 시점에 {@code AffiliateLinks.merge}가 인코딩이 끝난 URL을 가정하고 파싱한다. + * 등록 때 걸러 두지 않으면 잘못된 URL이 그대로 저장되고, 사용자가 배너를 누르는 순간에야 500이 난다. + * 매체·파트너스 아이디와 무관하게 모든 소재에 적용한다. 애드픽 수동 소재와 단축 링크도 여기를 탄다. + */ + private void validateLandingUrl(String landingUrl) { + try { + URI uri = UriComponentsBuilder.fromUriString(landingUrl).build(true).toUri(); + if (uri.getScheme() == null || uri.getHost() == null) { + throw new CustomException(ErrorCode.AD_INVALID_LANDING_URL); + } + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.AD_INVALID_LANDING_URL); + } + } + + /** + * 남의 파트너스 링크를 잘못 붙여넣으면 우리가 광고를 싣고 수수료는 남이 받는다. + * 다만 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)); + } + + /** 애드픽 동기화가 내용을 덮어쓰므로 어드민이 고치거나 지워도 다음 회차에 되돌아간다. */ + private void rejectIfManaged(AdCreative creative) { + if (creative.isManaged()) { + throw new CustomException(ErrorCode.AD_CREATIVE_MANAGED); + } + } +} 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 e3172bcc..7f3463ca 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 @@ -54,7 +55,20 @@ 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 @@ -144,6 +158,9 @@ private String resolveToken(HttpServletRequest request) { } private boolean isWhitelisted(String uri) { + if (EXACT_WHITELIST.contains(uri)) { + return true; + } return WHITELIST.stream().anyMatch(white -> uri.equals(white) || uri.startsWith(white)); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 415672dd..601fa424 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,13 @@ public enum ErrorCode { PHILOSOPHER_CALC_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "USER_500_PHIL", "철학자 유형을 계산할 수 없습니다."), RECAP_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_RECAP", "존재하지 않는 리캡입니다."), + // Ad (제휴 광고) + AD_CREATIVE_NOT_FOUND(HttpStatus.NOT_FOUND, "AD_404", "존재하지 않는 광고 소재입니다."), + AD_CODE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AD_500_CODE", "광고 소재 코드 생성에 실패했습니다."), + AD_COUPANG_PARTNER_MISMATCH(HttpStatus.BAD_REQUEST, "AD_400_LPTAG", "우리 쿠팡 파트너스 아이디가 아닌 제휴 링크입니다. 링크를 다시 확인해 주세요."), + AD_CREATIVE_MANAGED(HttpStatus.BAD_REQUEST, "AD_400_MANAGED", "매체 API가 동기화하는 소재는 수정하거나 삭제할 수 없습니다. 게재 상태만 변경할 수 있습니다."), + AD_INVALID_LANDING_URL(HttpStatus.BAD_REQUEST, "AD_400_URL", "제휴 링크 형식이 올바르지 않습니다. 인코딩된 전체 URL을 넣어 주세요."), + // 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 d48f9003..d2bf3f0e 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -58,7 +58,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/app-ads.txt", "/robots.txt", "/terms", - "/privacy-policy" + "/privacy-policy", + "/error", + "/c/**", + "/api/v1/ads", + "/api/v1/ads/**" ).permitAll() // 2. 관리자 HTML 화면 렌더링 요청 diff --git a/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java b/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java index 2aada9a9..743f5db8 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,20 @@ 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 faff467b..bff7a3b8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -132,6 +132,21 @@ admin: picke: baseUrl: ${PICKE_BASE_URL:https://picke.store} + ad: + host: ${AD_HOST:ad.picke.store} + base-url: ${AD_BASE_URL:https://ad.picke.store} + adpick: + # 애드픽 파트너센터 링크생성 화면에서 규격 확인 후 채운다. 비어 있으면 원본 링크를 그대로 넘긴다. + sub-id-param: ${ADPICK_SUB_ID_PARAM:} + # 애드픽 회원 아이디. 비어 있으면 캠페인 동기화를 건너뛴다. + aff-id: ${ADPICK_AFF_ID:} + base-url: ${ADPICK_BASE_URL:https://adpick.co.kr/apis/offers.php} + # 앱 설치형이 많아 세션이 끝나는 지면에 둔다. + slot: ${ADPICK_SLOT:BATTLE_RESULT_BOTTOM} + cta-text: ${ADPICK_CTA_TEXT:설치하고 받기} + # 애드픽 가이드가 최대 1분 1회 이하 호출을 요구한다. + sync-initial-delay-ms: ${ADPICK_SYNC_INITIAL_DELAY_MS:60000} + sync-interval-ms: ${ADPICK_SYNC_INTERVAL_MS:600000} s3: presigned-url: expiration-hours: 6 @@ -142,4 +157,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} diff --git a/src/main/resources/templates/ad/landing.html b/src/main/resources/templates/ad/landing.html new file mode 100644 index 00000000..0bce255c --- /dev/null +++ b/src/main/resources/templates/ad/landing.html @@ -0,0 +1,84 @@ + + + + + + PICKE 추천 - 오늘의 제휴 상품 + + + + +

+
+

PICKE 추천

+

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

+
+ +
+

추천 상품

+ + + +

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

+
+ +
+

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

+

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

+

© PICKE

+
+
+ + diff --git a/src/test/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponseTest.java b/src/test/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponseTest.java new file mode 100644 index 00000000..f0d0335c --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponseTest.java @@ -0,0 +1,94 @@ +package com.swyp.picke.domain.ad.client; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import java.io.InputStream; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 애드픽 offers.php 실제 응답으로 역직렬화를 확인한다. + * 픽스처는 실제 호출 결과를 그대로 옮긴 것이고, 잔여 0건과 아이콘 누락 건만 필터 경로 확인용으로 파생시켰다. + */ +class AdpickCampaignResponseTest { + + private static List campaigns; + + @BeforeAll + static void parseFixture() throws Exception { + try (InputStream in = AdpickCampaignResponseTest.class + .getResourceAsStream("/fixtures/ad/adpick-offers.json")) { + campaigns = new ObjectMapper().readValue(in, new TypeReference<>() { + }); + } + } + + @Test + @DisplayName("실제 응답의 필드가 우리가 쓰는 이름으로 모두 들어온다") + void deserializesFieldsWeUse() { + AdpickCampaignResponse first = campaigns.get(0); + + assertThat(first.offerId()).isEqualTo("9d3a9"); + assertThat(first.appTitle()).isNotBlank(); + assertThat(first.headline()).isNotBlank(); + assertThat(first.trackingLink()).startsWith("https://"); + assertThat(first.os()).isEqualTo("Both"); + assertThat(first.remain()).isPositive(); + assertThat(first.iconUrl()).startsWith("https://"); + } + + @Test + @DisplayName("문서에 없는 필드가 섞여 들어와도 파싱이 깨지지 않는다") + void ignoresUndocumentedFields() { + // 실제 응답에는 apKPI·apHook·apEvent 등 우리가 쓰지 않는 필드가 함께 온다. + assertThat(campaigns).hasSize(6); + } + + /** + * 실제 응답에 apOS 가 null 인 캠페인이 섞여 온다. OS 를 가리지 않는 것으로 보고 전체에 노출한다. + * iOS 전용 캠페인은 아직 실제 응답에서 관측하지 못해, iOS 매핑은 AdTargetOsTest 의 문자열 규칙으로만 덮여 있다. + */ + @Test + @DisplayName("apOS가 null인 실제 캠페인은 전체 노출로 본다") + void mapsNullOsToAll() { + AdpickCampaignResponse nullOs = campaigns.get(5); + + assertThat(nullOs.os()).isNull(); + assertThat(AdTargetOs.fromAdpick(nullOs.os())).isEqualTo(AdTargetOs.ALL); + } + + @Test + @DisplayName("apOS 실제 값 Both·Android가 타깃 OS로 옮겨진다") + void mapsRealOsValues() { + assertThat(AdTargetOs.fromAdpick(campaigns.get(0).os())).isEqualTo(AdTargetOs.ALL); + assertThat(AdTargetOs.fromAdpick(campaigns.get(1).os())).isEqualTo(AdTargetOs.ANDROID); + } + + @Test + @DisplayName("헤드라인·프로모션 문구가 모두 빈 캠페인도 렌더 대상이다. 보조 문구만 비게 둔다") + void treatsEmptyCopyAsRenderable() { + AdpickCampaignResponse noCopy = campaigns.get(2); + + assertThat(noCopy.headline()).isEmpty(); + assertThat(noCopy.promoText()).isEmpty(); + assertThat(noCopy.isRenderable()).isTrue(); + } + + @Test + @DisplayName("잔여가 0이면 게재 대상이 아니다") + void marksSoldOutCampaignAsNotServable() { + assertThat(campaigns.get(3).hasRemaining()).isFalse(); + } + + @Test + @DisplayName("아이콘이 없으면 배너를 그릴 수 없으므로 렌더 대상에서 뺀다") + void excludesCampaignWithoutIcon() { + assertThat(campaigns.get(4).iconUrl()).isNull(); + assertThat(campaigns.get(4).isRenderable()).isFalse(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/controller/AdApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/ad/controller/AdApiIntegrationTest.java new file mode 100644 index 00000000..7cc71318 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/controller/AdApiIntegrationTest.java @@ -0,0 +1,194 @@ +package com.swyp.picke.domain.ad.controller; + +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.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import java.time.LocalDate; +import java.time.ZoneId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 제휴 광고 배너가 앱까지 실제로 나가는지 확인한다. + * 조회 → 노출 집계 → 클릭 리다이렉트 → 공개 랜딩까지 한 흐름으로 본다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdApiIntegrationTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AdCreativeRepository adCreativeRepository; + + @Autowired + private AdImpressionDailyRepository adImpressionDailyRepository; + + @BeforeEach + void setUp() { + adImpressionDailyRepository.deleteAll(); + adCreativeRepository.deleteAll(); + } + + @Test + @DisplayName("게재 중인 쿠팡 소재는 배너 렌더에 필요한 값과 클릭 URL을 모두 담아 나간다") + void getAds_returnsServableCoupangCreative() throws Exception { + adCreativeRepository.save(coupang("cpg00001", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + mockMvc.perform(get("/api/v1/ads").param("slot", "HOME_FEED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].code").value("cpg00001")) + .andExpect(jsonPath("$.data[0].network").value("COUPANG")) + .andExpect(jsonPath("$.data[0].title").value("무선 이어폰")) + .andExpect(jsonPath("$.data[0].imageUrl").value("https://img.example.com/1.jpg")) + .andExpect(jsonPath("$.data[0].ctaText").value("구매하러 가기")) + .andExpect(jsonPath("$.data[0].clickUrl").value("https://ad.picke.store/c/cpg00001")) + .andExpect(jsonPath("$.data[0].label").value("광고")); + } + + @Test + @DisplayName("게재 가능한 소재가 없으면 빈 배열을 준다. 앱은 이때 지면을 숨긴다") + void getAds_returnsEmptyWhenNothingServable() throws Exception { + adCreativeRepository.save(coupang("cpg00002", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.PAUSED)); + + mockMvc.perform(get("/api/v1/ads").param("slot", "HOME_FEED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + + @Test + @DisplayName("iOS 요청에 Android 전용 애드픽 소재는 나가지 않는다") + void getAds_excludesCreativeOfOtherOs() throws Exception { + adCreativeRepository.save(coupang("apk00001", AdSlotCode.BATTLE_RESULT_BOTTOM, + AdTargetOs.ANDROID, AdStatus.ACTIVE)); + + mockMvc.perform(get("/api/v1/ads") + .param("slot", "BATTLE_RESULT_BOTTOM") + .param("os", "IOS")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + + mockMvc.perform(get("/api/v1/ads") + .param("slot", "BATTLE_RESULT_BOTTOM") + .param("os", "ANDROID")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)); + } + + @Test + @DisplayName("노출 집계는 같은 소재를 두 번 보내면 같은 날짜 행에 누적된다") + void recordImpressions_accumulatesIntoSameDailyRow() throws Exception { + AdCreative creative = adCreativeRepository.save( + coupang("cpg00003", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + for (int i = 0; i < 2; i++) { + mockMvc.perform(post("/api/v1/ads/impressions") + .contentType("application/json") + .content("{\"codes\":[\"cpg00003\"]}")) + .andExpect(status().isOk()); + } + + assertThat(adImpressionDailyRepository + .findByCreativeIdAndSlotAndStatDate(creative.getId(), AdSlotCode.HOME_FEED, LocalDate.now(KST))) + .isPresent() + .get() + .extracting(daily -> daily.getImpressions()) + .isEqualTo(2L); + } + + @Test + @DisplayName("노출 집계는 한 번에 밀어 넣을 수 있는 소재 수를 제한한다") + void recordImpressions_rejectsOversizedBatch() throws Exception { + String codes = java.util.stream.IntStream.range(0, 21) + .mapToObj(i -> "\"code%02d\"".formatted(i)) + .collect(java.util.stream.Collectors.joining(",")); + + mockMvc.perform(post("/api/v1/ads/impressions") + .contentType("application/json") + .content("{\"codes\":[" + codes + "]}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("클릭 숏링크는 지면별 subId를 붙인 제휴 링크로 리다이렉트한다") + void click_redirectsToAffiliateLinkWithSubId() throws Exception { + adCreativeRepository.save(coupang("cpg00004", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + mockMvc.perform(get("/c/cpg00004")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", + "https://link.coupang.com/a/ABCDE?lptag=AF6830373&subId=HOME_FEED_cpg00004")); + } + + @Test + @DisplayName("없는 코드로 들어오면 404 대신 광고 랜딩으로 흘려보낸다") + void click_fallsBackToLandingWhenCodeMissing() throws Exception { + mockMvc.perform(get("/c/nosuchcode")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", "https://ad.picke.store/")); + } + + @Test + @DisplayName("ad.picke.store 루트는 매체 심사용 공개 페이지에 소재를 그린다") + void landing_rendersCreativesOnAdHost() throws Exception { + adCreativeRepository.save(coupang("cpg00005", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + mockMvc.perform(get("/").with(request -> { + request.setServerName("ad.picke.store"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("무선 이어폰"))) + .andExpect(content().string( + org.hamcrest.Matchers.containsString("https://ad.picke.store/c/cpg00005"))); + } + + @Test + @DisplayName("광고 도메인이 아닌 Host의 루트에는 광고 페이지가 뜨지 않는다") + void landing_doesNotRenderOnApiHost() throws Exception { + adCreativeRepository.save(coupang("cpg00006", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().string("PICKE")); + } + + private AdCreative coupang(String code, AdSlotCode slot, AdTargetOs targetOs, AdStatus status) { + return AdCreative.builder() + .code(code) + .network(AdNetwork.COUPANG) + .slot(slot) + .title("무선 이어폰") + .subtitle("리뷰 1만 개 이상") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/ABCDE?lptag=AF6830373") + .status(status) + .weight(1) + .targetOs(targetOs) + .build(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/entity/AdCheckConstraintTest.java b/src/test/java/com/swyp/picke/domain/ad/entity/AdCheckConstraintTest.java new file mode 100644 index 00000000..2f638456 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/entity/AdCheckConstraintTest.java @@ -0,0 +1,84 @@ +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.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import java.util.Arrays; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.hibernate.annotations.Check; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * enum에 값을 추가하고 CHECK 제약 갱신을 잊으면 운영에서 INSERT가 조용히 막힌다. + * 제약 문자열과 enum 상수가 어긋나는 순간 여기서 먼저 깨지게 한다. + */ +class AdCheckConstraintTest { + + private static final Pattern QUOTED = Pattern.compile("'([^']+)'"); + + private Set valuesOf(Class entity, String constraintName) { + String constraints = Arrays.stream(entity.getAnnotationsByType(Check.class)) + .filter(check -> check.name().equals(constraintName)) + .map(Check::constraints) + .findFirst() + .orElseThrow(() -> new AssertionError( + entity.getSimpleName() + "에 " + constraintName + " CHECK 제약이 없습니다.")); + + Matcher matcher = QUOTED.matcher(constraints); + return matcher.results() + .map(result -> result.group(1)) + .collect(Collectors.toSet()); + } + + private Set namesOf(Class> type) { + return Arrays.stream(type.getEnumConstants()) + .map(Enum::name) + .collect(Collectors.toSet()); + } + + @Test + @DisplayName("ad_creatives의 network CHECK 제약이 AdNetwork 전체와 일치한다") + void adCreativesNetworkCheckMatchesEnum() { + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_network")) + .isEqualTo(namesOf(AdNetwork.class)); + } + + @Test + @DisplayName("ad_creatives의 status CHECK 제약이 AdStatus 전체와 일치한다") + void adCreativesStatusCheckMatchesEnum() { + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_status")) + .isEqualTo(namesOf(AdStatus.class)); + } + + @Test + @DisplayName("ad_creatives의 source CHECK 제약이 AdSource 전체와 일치한다") + void adCreativesSourceCheckMatchesEnum() { + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_source")) + .isEqualTo(namesOf(AdSource.class)); + } + + @Test + @DisplayName("ad_creatives의 target_os CHECK 제약이 AdTargetOs 전체와 일치한다") + void adCreativesTargetOsCheckMatchesEnum() { + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_target_os")) + .isEqualTo(namesOf(AdTargetOs.class)); + } + + @Test + @DisplayName("slot CHECK 제약이 세 테이블 모두 AdSlotCode 전체와 일치한다") + void slotChecksMatchEnum() { + Set slots = namesOf(AdSlotCode.class); + + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_slot")).isEqualTo(slots); + assertThat(valuesOf(AdClickLog.class, "ck_ad_click_logs_slot")).isEqualTo(slots); + assertThat(valuesOf(AdImpressionDaily.class, "ck_ad_impression_daily_slot")).isEqualTo(slots); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java b/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java new file mode 100644 index 00000000..42379efd --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java @@ -0,0 +1,62 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdCreativeTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 1, 12, 0); + + private AdCreative creative(AdStatus status, LocalDateTime startsAt, LocalDateTime endsAt) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/abcdef") + .status(status) + .weight(1) + .startsAt(startsAt) + .endsAt(endsAt) + .build(); + } + + @Test + @DisplayName("ACTIVE이고 기간이 열려 있으면 게재한다") + void isServable_true() { + assertThat(creative(AdStatus.ACTIVE, null, null).isServable(NOW)).isTrue(); + } + + @Test + @DisplayName("PAUSED와 DRAFT는 기간과 무관하게 게재하지 않는다") + void isServable_falseWhenNotActive() { + assertThat(creative(AdStatus.PAUSED, null, null).isServable(NOW)).isFalse(); + assertThat(creative(AdStatus.DRAFT, null, null).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("시작 전 소재는 게재하지 않는다") + void isServable_falseBeforeStart() { + assertThat(creative(AdStatus.ACTIVE, NOW.plusDays(1), null).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("종료된 소재는 게재하지 않는다") + void isServable_falseAfterEnd() { + assertThat(creative(AdStatus.ACTIVE, null, NOW.minusSeconds(1)).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("종료 시각과 정확히 같은 순간까지는 게재한다") + void isServable_trueAtExactEnd() { + assertThat(creative(AdStatus.ACTIVE, null, NOW).isServable(NOW)).isTrue(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/enums/AdTargetOsTest.java b/src/test/java/com/swyp/picke/domain/ad/enums/AdTargetOsTest.java new file mode 100644 index 00000000..e5f41f06 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/enums/AdTargetOsTest.java @@ -0,0 +1,42 @@ +package com.swyp.picke.domain.ad.enums; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdTargetOsTest { + + @Test + @DisplayName("애드픽 apOS 값을 우리 enum으로 옮긴다") + void fromAdpick() { + assertThat(AdTargetOs.fromAdpick("Android")).isEqualTo(AdTargetOs.ANDROID); + assertThat(AdTargetOs.fromAdpick("android")).isEqualTo(AdTargetOs.ANDROID); + assertThat(AdTargetOs.fromAdpick("iOS")).isEqualTo(AdTargetOs.IOS); + assertThat(AdTargetOs.fromAdpick("iPhone")).isEqualTo(AdTargetOs.IOS); + } + + @Test + @DisplayName("OS가 비었거나 모르는 값이면 전체 노출로 둔다") + void fromAdpick_unknown() { + assertThat(AdTargetOs.fromAdpick(null)).isEqualTo(AdTargetOs.ALL); + assertThat(AdTargetOs.fromAdpick("")).isEqualTo(AdTargetOs.ALL); + assertThat(AdTargetOs.fromAdpick("Tizen")).isEqualTo(AdTargetOs.ALL); + } + + @Test + @DisplayName("ALL 소재는 어느 OS에나 나가고, OS 지정 소재는 같은 OS에만 나간다") + void matches() { + assertThat(AdTargetOs.ALL.matches(AdTargetOs.IOS)).isTrue(); + assertThat(AdTargetOs.ANDROID.matches(AdTargetOs.ANDROID)).isTrue(); + assertThat(AdTargetOs.ANDROID.matches(AdTargetOs.IOS)).isFalse(); + assertThat(AdTargetOs.IOS.matches(AdTargetOs.ANDROID)).isFalse(); + } + + @Test + @DisplayName("요청 OS를 모르면(ALL) 모든 소재를 후보로 둔다") + void matches_requestAll() { + assertThat(AdTargetOs.ANDROID.matches(AdTargetOs.ALL)).isTrue(); + assertThat(AdTargetOs.IOS.matches(AdTargetOs.ALL)).isTrue(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java b/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java new file mode 100644 index 00000000..36d89c41 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java @@ -0,0 +1,53 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdpickLinkBuilderTest { + + private static final String LANDING = "https://adpick.co.kr/?ac=offer&tac=campaign&id=123"; + + private AdCreative creative() { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("앱 설치하고 포인트 받기") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("설치하고 받기") + .landingUrl(LANDING) + .status(AdStatus.ACTIVE) + .weight(1) + .build(); + } + + private AdpickLinkBuilder builder(String subIdParam) { + AdpickLinkBuilder builder = new AdpickLinkBuilder(); + ReflectionTestUtils.setField(builder, "subIdParam", subIdParam); + return builder; + } + + @Test + @DisplayName("파라미터명이 비어 있으면 원본 링크를 그대로 넘긴다") + void build_passThroughWhenParamNotConfigured() { + assertThat(builder("").build(creative())).isEqualTo(LANDING); + assertThat(builder(null).build(creative())).isEqualTo(LANDING); + } + + @Test + @DisplayName("파라미터명을 채우면 배포 없이 지면별 추적값이 붙는다") + void build_mergesSubIdWhenConfigured() { + String url = builder("subid").build(creative()); + + assertThat(url).contains("subid=BATTLE_RESULT_BOTTOM_abc12345"); + assertThat(url).contains("ac=offer"); + assertThat(url).contains("id=123"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java b/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java new file mode 100644 index 00000000..dea9512f --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java @@ -0,0 +1,66 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CoupangLinkBuilderTest { + + private final CoupangLinkBuilder builder = new CoupangLinkBuilder(); + + private AdCreative creative(String landingUrl) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl(landingUrl) + .status(AdStatus.ACTIVE) + .weight(1) + .build(); + } + + @Test + @DisplayName("쿼리스트링이 이미 있는 제휴 링크에도 subId를 병합한다") + void build_mergesSubIdIntoExistingQueryString() { + String url = builder.build(creative("https://link.coupang.com/re/AFF?lptag=AF6830373&pageKey=123")); + + assertThat(url).contains("lptag=AF6830373"); + assertThat(url).contains("pageKey=123"); + assertThat(url).contains("subId=HOME_FEED_abc12345"); + assertThat(url).doesNotContain("??"); + } + + @Test + @DisplayName("쿼리스트링이 없는 링크에는 subId를 새로 붙인다") + void build_appendsSubIdWhenNoQueryString() { + String url = builder.build(creative("https://link.coupang.com/a/abcdef")); + + assertThat(url).isEqualTo("https://link.coupang.com/a/abcdef?subId=HOME_FEED_abc12345"); + } + + @Test + @DisplayName("이미 subId가 있으면 우리 값으로 덮어쓴다") + void build_replacesExistingSubId() { + String url = builder.build(creative("https://link.coupang.com/a/abcdef?subId=old")); + + assertThat(url).contains("subId=HOME_FEED_abc12345"); + assertThat(url).doesNotContain("subId=old"); + } + + @Test + @DisplayName("인코딩된 파라미터를 이중 인코딩하지 않는다") + void build_doesNotDoubleEncode() { + String url = builder.build(creative("https://link.coupang.com/a/x?q=%EC%9D%B4%EC%96%B4%ED%8F%B0")); + + assertThat(url).contains("q=%EC%9D%B4%EC%96%B4%ED%8F%B0"); + assertThat(url).doesNotContain("%25"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java new file mode 100644 index 00000000..e542b577 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java @@ -0,0 +1,82 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.link.AffiliateLinkResolver; +import com.swyp.picke.domain.ad.link.CoupangLinkBuilder; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.service.AdClickService.AdClickTarget; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdClickServiceTest { + + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdClickLogRepository adClickLogRepository; + + private AdClickService adClickService() { + return new AdClickService( + adCreativeRepository, + adClickLogRepository, + new AffiliateLinkResolver(List.of(new CoupangLinkBuilder()))); + } + + private AdCreative creative(AdStatus status, LocalDateTime endsAt) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/abcdef") + .status(status) + .weight(1) + .endsAt(endsAt) + .build(); + } + + @Test + @DisplayName("없는 코드는 이동 대상을 주지 않는다") + void resolveTarget_emptyWhenCodeMissing() { + when(adCreativeRepository.findByCode("nope0000")).thenReturn(Optional.empty()); + + assertThat(adClickService().resolveTarget("nope0000")).isEmpty(); + } + + @Test + @DisplayName("게재가 끝난 소재는 이동 대상을 주지 않는다") + void resolveTarget_emptyWhenExpired() { + when(adCreativeRepository.findByCode("abc12345")) + .thenReturn(Optional.of(creative(AdStatus.ACTIVE, LocalDateTime.now().minusDays(1)))); + + assertThat(adClickService().resolveTarget("abc12345")).isEmpty(); + } + + @Test + @DisplayName("게재 중인 소재는 매체 규칙이 적용된 최종 URL을 준다") + void resolveTarget_returnsResolvedUrl() { + when(adCreativeRepository.findByCode("abc12345")) + .thenReturn(Optional.of(creative(AdStatus.ACTIVE, null))); + + AdClickTarget target = adClickService().resolveTarget("abc12345").orElseThrow(); + + assertThat(target.slot()).isEqualTo(AdSlotCode.HOME_FEED); + assertThat(target.redirectUrl()).contains("subId=HOME_FEED_abc12345"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java new file mode 100644 index 00000000..765cef91 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java @@ -0,0 +1,195 @@ +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.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +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.doThrow; +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 AdImpressionRecorder adImpressionRecorder; + + @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) + .targetOs(AdTargetOs.ALL) + .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, AdTargetOs.ALL, 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, AdTargetOs.ALL, 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, AdTargetOs.ALL, 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, AdTargetOs.ALL, 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, AdTargetOs.ALL, 1).get(0).code()) + .filter("heavy001"::equals) + .count(); + + assertThat(heavyPicks).isGreaterThan(400); + } + + @Test + @DisplayName("요청 OS와 맞지 않는 소재는 제외한다") + void findServableAds_filtersByTargetOs() { + AdCreative android = creative("aos00001", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(android, "targetOs", AdTargetOs.ANDROID); + AdCreative ios = creative("ios00001", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(ios, "targetOs", AdTargetOs.IOS); + AdCreative every = creative("all00001", AdStatus.ACTIVE, 1, null, null); + + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(android, ios, every)); + + List result = adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.IOS, 10); + + assertThat(result).extracting(AdResponse::code).containsExactlyInAnyOrder("ios00001", "all00001"); + } + + @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(adImpressionRecorder.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class))) + .thenReturn(true); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionRecorder, never()).insert(anyLong(), any(), 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(adImpressionRecorder.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class))) + .thenReturn(false); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionRecorder, times(1)) + .insert(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class)); + } + + @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(adImpressionRecorder.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class))) + .thenReturn(false, true); + doThrow(new DataIntegrityViolationException("duplicate")) + .when(adImpressionRecorder).insert(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class)); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionRecorder, times(2)) + .increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class)); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java new file mode 100644 index 00000000..25007fa8 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java @@ -0,0 +1,179 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.client.AdpickCampaignClient; +import com.swyp.picke.domain.ad.client.AdpickCampaignResponse; +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.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.util.List; +import java.util.Map; +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.ArgumentCaptor; +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.mockito.ArgumentMatchers.any; +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 AdpickCampaignSyncServiceTest { + + private static final String CTA = "설치하고 받기"; + + @Mock + private AdpickCampaignClient adpickCampaignClient; + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdCreativeCodeGenerator adCreativeCodeGenerator; + + @InjectMocks + private AdpickCampaignSyncService syncService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(syncService, "slot", AdSlotCode.BATTLE_RESULT_BOTTOM); + ReflectionTestUtils.setField(syncService, "ctaText", CTA); + when(adpickCampaignClient.isConfigured()).thenReturn(true); + when(adCreativeCodeGenerator.generate()).thenReturn("syn23456"); + when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of()); + } + + private AdpickCampaignResponse campaign(String offerId, String os, Integer remain) { + return new AdpickCampaignResponse( + offerId, + "리워디 월렛 - 금테크 지갑", + "금 모으기 앱테크", + "설명 텍스트", + Map.of("icon", "https://play-lh.googleusercontent.com/icon.png"), + "https://deg.kr/39e859f", + os, + remain + ); + } + + private AdCreative managed(String externalId, AdStatus status) { + return AdCreative.builder() + .code("syn00001") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("예전 제목") + .imageUrl("https://img.example.com/old.png") + .ctaText(CTA) + .landingUrl("https://deg.kr/old") + .status(status) + .weight(1) + .source(AdSource.ADPICK_API) + .externalId(externalId) + .targetOs(AdTargetOs.ANDROID) + .build(); + } + + @Test + @DisplayName("affId가 없으면 호출하지 않고 건너뛴다") + void sync_skipsWhenNotConfigured() { + when(adpickCampaignClient.isConfigured()).thenReturn(false); + + assertThat(syncService.sync()).isZero(); + verify(adpickCampaignClient, never()).fetchCampaigns(); + } + + @Test + @DisplayName("새 캠페인을 소재로 만들면서 매체·지면·OS를 채운다") + void sync_createsCreativeFromCampaign() { + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 120))); + + syncService.sync(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdCreative.class); + verify(adCreativeRepository).save(captor.capture()); + AdCreative saved = captor.getValue(); + + assertThat(saved.getNetwork()).isEqualTo(AdNetwork.ADPICK); + assertThat(saved.getSource()).isEqualTo(AdSource.ADPICK_API); + assertThat(saved.getExternalId()).isEqualTo("16b04"); + assertThat(saved.getSlot()).isEqualTo(AdSlotCode.BATTLE_RESULT_BOTTOM); + assertThat(saved.getTargetOs()).isEqualTo(AdTargetOs.ANDROID); + assertThat(saved.getStatus()).isEqualTo(AdStatus.ACTIVE); + assertThat(saved.getLandingUrl()).isEqualTo("https://deg.kr/39e859f"); + assertThat(saved.getImageUrl()).isEqualTo("https://play-lh.googleusercontent.com/icon.png"); + assertThat(saved.getCtaText()).isEqualTo(CTA); + } + + @Test + @DisplayName("잔여 수량이 없으면 게재하지 않는다") + void sync_doesNotServeExhaustedCampaign() { + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 0))); + + syncService.sync(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdCreative.class); + verify(adCreativeRepository).save(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(AdStatus.DRAFT); + } + + @Test + @DisplayName("이미지나 추적 링크가 없는 캠페인은 건너뛴다") + void sync_skipsUnrenderableCampaign() { + AdpickCampaignResponse broken = new AdpickCampaignResponse( + "16b04", "제목", null, null, Map.of(), null, "Android", 10); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(broken)); + + assertThat(syncService.sync()).isZero(); + verify(adCreativeRepository, never()).save(any()); + } + + @Test + @DisplayName("기존 소재는 내용을 갱신하고 새로 만들지 않는다") + void sync_updatesExistingCreative() { + AdCreative existing = managed("16b04", AdStatus.ACTIVE); + when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of(existing)); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 50))); + + syncService.sync(); + + verify(adCreativeRepository, never()).save(any()); + assertThat(existing.getTitle()).isEqualTo("리워디 월렛 - 금테크 지갑"); + assertThat(existing.getLandingUrl()).isEqualTo("https://deg.kr/39e859f"); + } + + @Test + @DisplayName("어드민이 꺼둔 소재는 동기화가 다시 켜지 않는다") + void sync_keepsAdminPause() { + AdCreative paused = managed("16b04", AdStatus.PAUSED); + when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of(paused)); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 120))); + + syncService.sync(); + + assertThat(paused.getStatus()).isEqualTo(AdStatus.PAUSED); + } + + @Test + @DisplayName("피드에서 사라진 캠페인은 지우지 않고 내린다") + void sync_retiresMissingCampaign() { + AdCreative gone = managed("old01", AdStatus.ACTIVE); + when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of(gone)); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 10))); + + syncService.sync(); + + assertThat(gone.getStatus()).isEqualTo(AdStatus.DRAFT); + verify(adCreativeRepository, never()).delete(any()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java new file mode 100644 index 00000000..18ff0342 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java @@ -0,0 +1,198 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdSource; +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.enums.AdTargetOs; +import com.swyp.picke.domain.ad.service.AdCreativeCodeGenerator; +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 java.util.Optional; +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.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; + @Mock + private AdCreativeCodeGenerator adCreativeCodeGenerator; + + @InjectMocks + private AdminAdService adminAdService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(adminAdService, "coupangPartnersId", OUR_PARTNERS_ID); + when(adCreativeCodeGenerator.generate()).thenReturn("abc23456"); + 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, + AdTargetOs.ALL, + 1, + null, + null + ); + } + + @Test + @DisplayName("형식이 깨진 제휴 링크는 등록 시점에 막는다. 클릭 시점 500을 앞당겨 잡는다") + void create_rejectsMalformedLandingUrl() { + // 인코딩되지 않은 공백이 들어간 URL. 클릭 시점 AffiliateLinks.merge 가 build(true) 로 파싱하다 터진다. + AdCreativeRequest request = request(AdNetwork.ADPICK, "https://deg.kr/a b c"); + + assertThatThrownBy(() -> adminAdService.create(request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AD_INVALID_LANDING_URL); + + verify(adCreativeRepository, never()).save(any(AdCreative.class)); + } + + @Test + @DisplayName("스킴이나 호스트가 없는 링크도 막는다") + void create_rejectsLandingUrlWithoutSchemeOrHost() { + AdCreativeRequest request = request(AdNetwork.ADPICK, "/c/relative-only"); + + assertThatThrownBy(() -> adminAdService.create(request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AD_INVALID_LANDING_URL); + } + + @Test + @DisplayName("애드픽 단축 링크처럼 파라미터 없는 정상 URL은 통과한다") + void create_acceptsShortAffiliateLink() { + AdCreativeRequest request = request(AdNetwork.ADPICK, "https://deg.kr/884a6d6"); + + assertThatCode(() -> adminAdService.create(request)).doesNotThrowAnyException(); + } + + @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 update_rejectsManagedCreative() { + AdCreative managed = AdCreative.builder() + .code("syn00001") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("리워디 월렛") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("설치하고 받기") + .landingUrl("https://deg.kr/39e859f") + .status(AdStatus.ACTIVE) + .weight(1) + .source(AdSource.ADPICK_API) + .externalId("16b04") + .targetOs(AdTargetOs.ANDROID) + .build(); + when(adCreativeRepository.findById(1L)).thenReturn(Optional.of(managed)); + + AdCreativeRequest request = request(AdNetwork.ADPICK, "https://deg.kr/39e859f"); + + assertThatThrownBy(() -> adminAdService.update(1L, request)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.AD_CREATIVE_MANAGED); + assertThatThrownBy(() -> adminAdService.delete(1L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("동기화 소재도 게재 상태는 바꿀 수 있다") + void changeStatus_allowedForManagedCreative() { + AdCreative managed = AdCreative.builder() + .code("syn00001") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("리워디 월렛") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("설치하고 받기") + .landingUrl("https://deg.kr/39e859f") + .status(AdStatus.ACTIVE) + .weight(1) + .source(AdSource.ADPICK_API) + .externalId("16b04") + .targetOs(AdTargetOs.ANDROID) + .build(); + when(adCreativeRepository.findById(1L)).thenReturn(Optional.of(managed)); + + assertThat(adminAdService.changeStatus(1L, AdStatus.PAUSED).status()).isEqualTo(AdStatus.PAUSED); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 437dfdd2..cd11c2a0 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -94,3 +94,7 @@ media: path: ffmpeg ffprobe: path: ffprobe + +coupang: + partners: + id: AF0000000 diff --git a/src/test/resources/fixtures/ad/adpick-offers.json b/src/test/resources/fixtures/ad/adpick-offers.json new file mode 100644 index 00000000..afdbe043 --- /dev/null +++ b/src/test/resources/fixtures/ad/adpick-offers.json @@ -0,0 +1,181 @@ +[ + { + "apOffer": "9d3a9", + "apType": "1", + "apCategory": "4", + "apPackage": "com.embrain.panelpower", + "apItemid": "1329447145", + "apAppTitle": "패널파워 - 설문조사로 돈 버는 습관", + "apHeadline": "최다 조사! 최대 적립금! 돈 버는 설문조사 엠브레인!", + "apVideo": "https://www.youtube.com/embed/v8jj_OrvxPs?vq=large&rel=0&autohide=1&showinfo=0", + "apDailyCap": "200", + "apRemain": 183, + "apOS": "Both", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon": "https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon256": "https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "banner640x100": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2026/fc2d8b26531786343404.jpg", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2026/d4c7df79c61786343405.jpg", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/65b9454", + "apKPI": "", + "apHook": "false", + "apEvent": "true" + }, + { + "apOffer": "16b04", + "apType": "1", + "apCategory": "4", + "apPackage": "com.chainwith.rewardy", + "apItemid": "", + "apAppTitle": "리워디 월렛 - 금테크 지갑, 즐거운 금모으기(금적립)", + "apHeadline": "", + "apVideo": "", + "apDailyCap": "0", + "apRemain": 119, + "apOS": "Android", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon": "https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon256": "https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "banner640x100": "", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/39e859f", + "apKPI": "", + "apHook": "false", + "apEvent": "true" + }, + { + "apOffer": "a7bea", + "apType": "1", + "apCategory": "4", + "apPackage": "com.reign.dokit", + "apItemid": "1472692146", + "apAppTitle": "도킷 - 채팅, 일본친구, 일본여자, 외국친구", + "apHeadline": "", + "apVideo": "", + "apDailyCap": "20", + "apRemain": 19, + "apOS": "Both", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon": "https://play-lh.googleusercontent.com/5Oka7krmmjpYC_W4DraT07RNM5eeOX0Tk7mM-7HethUjPyR029OZUHM-DpP8IWZQHio", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/5Oka7krmmjpYC_W4DraT07RNM5eeOX0Tk7mM-7HethUjPyR029OZUHM-DpP8IWZQHio", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/5Oka7krmmjpYC_W4DraT07RNM5eeOX0Tk7mM-7HethUjPyR029OZUHM-DpP8IWZQHio", + "icon256": "https://play-lh.googleusercontent.com/5Oka7krmmjpYC_W4DraT07RNM5eeOX0Tk7mM-7HethUjPyR029OZUHM-DpP8IWZQHio", + "banner640x100": "", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/884a6d6", + "apKPI": "", + "apHook": "true", + "apEvent": "true" + }, + { + "apOffer": "9d3a9x", + "apType": "1", + "apCategory": "4", + "apPackage": "com.embrain.panelpower", + "apItemid": "1329447145", + "apAppTitle": "패널파워 - 설문조사로 돈 버는 습관", + "apHeadline": "최다 조사! 최대 적립금! 돈 버는 설문조사 엠브레인!", + "apVideo": "https://www.youtube.com/embed/v8jj_OrvxPs?vq=large&rel=0&autohide=1&showinfo=0", + "apDailyCap": "200", + "apRemain": 0, + "apOS": "Both", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon": "https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon256": "https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "banner640x100": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2026/fc2d8b26531786343404.jpg", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2026/d4c7df79c61786343405.jpg", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/65b9454", + "apKPI": "", + "apHook": "false", + "apEvent": "true" + }, + { + "apOffer": "16b04x", + "apType": "1", + "apCategory": "4", + "apPackage": "com.chainwith.rewardy", + "apItemid": "", + "apAppTitle": "리워디 월렛 - 금테크 지갑, 즐거운 금모으기(금적립)", + "apHeadline": "", + "apVideo": "", + "apDailyCap": "0", + "apRemain": 119, + "apOS": "Android", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon256": "https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "banner640x100": "", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/39e859f", + "apKPI": "", + "apHook": "false", + "apEvent": "true" + }, + { + "apOffer": "a8753", + "apType": "3", + "apCategory": "4", + "apPackage": "", + "apItemid": "", + "apAppTitle": "애드픽 쇼핑메이트 회원가입", + "apHeadline": "쇼핑정보로 재테크하는 꿀팁! 애드픽 쇼핑메이트", + "apVideo": "", + "apDailyCap": "1000", + "apRemain": 1000, + "apOS": null, + "apAppPromoText": "소개하고 싶은 상품을 선택하고, 나만의 수익 링크를 각종 SNS, 커뮤니티 등에 홍보하면 끝! \r\n\r\n내가 원할 때 어디서든 간편하게 하는 앱테크, 애드픽 쇼핑메이트를 지금 시작해보세요 :)\r\n추천한 상품을 누군가 온라인에서 구매하면 구매금액의 일부를 수익으로 얻을 수 있어요.\r\n\r\n애드픽 쇼핑메이트는?\r\n- 스마트폰으로 언제든지 활동이 가능해요.\r\n- 자주 사는 물건을 홍보하면 활동이 쉬워져요.\r\n- 잠시 활동을 못해도 수익이 생길 수 있어요.\r\n- 11번가, 쿠팡 등 유명 쇼핑몰과 제휴되어 있어요.\r\n- 프리미엄 회원이 되어 초대링크를 활용하면 추가 수익을 얻을 수 있고 \r\n 프리미엄 회원은 3만 포인트 부터 출금 신청 가능해요.\r\n\r\n애드픽에서 더 많은 수익을 얻을 수 있는 꿀팁!\r\n- 하루 한 번 출석체크! 매일 1만 G포인트에 도전할 수 있는 '꽝 없는' 룰렛\r\n- 퀘스트만 잘 따라가도 애드픽 마스터! 목표 달성할 때마다 추가 보상까지!\r\n- 여러 사람들과 교류하며 보너스 포인트도 받을 수 있는 팸 활동\r\n- 포인트와 각종 선물을 받을 수 있는 게시판 이벤트 상시 진행!\r\n- 나만의 노하우를 판매하거나 다른 사람의 지식을 공유하며 수익을 얻는 지식마켓\r\n- 씨앗 수확하고 미션 수행하면 보너스 수익을 챙길 수 있는 출석 농장!", + "apPartner": "", + "apImages": { + "icon": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2020/939eb596f96176b2c54778939fa09f431586418837.png", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://s3.ap-northeast-2.amazonaws.com/oddm.img/2020/939eb596f96176b2c54778939fa09f431586418837.png", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://s3.ap-northeast-2.amazonaws.com/oddm.img/2020/939eb596f96176b2c54778939fa09f431586418837.png", + "icon256": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=256&f=https://d4jzqlmz11rgq.cloudfront.net/2020/939eb596f96176b2c54778939fa09f431586418837.png", + "banner640x100": "", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/26e8736", + "apKPI": "", + "apHook": "false", + "apEvent": "false" + } +] \ No newline at end of file