Skip to content

Commit e3e5bd8

Browse files
authored
feat: 애드픽 쇼핑 재고 확대 + AdFit 세션 갱신 + Mixpanel·Sentry 지표 (#487)
1 parent a7b0c3b commit e3e5bd8

30 files changed

Lines changed: 1340 additions & 29 deletions

‎docs/api-specs/adfit-api.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22

33
- `GET /api/v1/admin/adfit?from=YYYY-MM-DD&to=YYYY-MM-DD`: ADMIN 전용. 최대 366일.
44
- `PUT /api/v1/admin/adfit/daily`: ADMIN 전용. `{date, unit, revenue, cost, costBasis}`.
5+
- `GET /api/v1/admin/adfit/session-cookie`: ADMIN 전용. `{configured, source, updatedAt}`. 쿠키 값은 응답에 담지 않는다.
6+
- `PUT /api/v1/admin/adfit/session-cookie`: ADMIN 전용. `{cookie}`. AdFit 콘솔에 로그인한 브라우저의 Cookie 헤더 전체를 넣는다.
57
- `GET` 응답에는 기존 수동 입력 단위 리포트(`source`, `units`, `days`)와 별도로 `account`가 포함된다.
68
- `account.status`: `NOT_CONFIGURED`, `CONNECTED`, `RECONNECT_REQUIRED`, `UNAVAILABLE`.
79
- `account.days[]`: `{date, revenue, ctr, ecpm, fillRate, winFillRate}`. AdFit 콘솔에 값이 없거나 누락된 날짜는 `null`이며 0으로 대체하지 않는다.
810
- `account.fetchedAt`: `CONNECTED`일 때 이번 관리자 조회에서 AdFit 응답을 성공적으로 파싱한 시각이다. 원천 데이터의 최종 집계 시각이나 배치 동기화 시각이 아니다.
911
- `account.revenue`: 조회 기간 중 실제 내려온 일별 수익 합계. 수익 데이터가 전부 `null`이면 `null`.
1012
- `account.cost`, `account.roi`: AdFit 계정 자동 보고서가 광고 비용을 제공하지 않으므로 항상 `null`.
11-
- 자동 보고서는 `ADFIT_SESSION_COOKIE` 또는 `picke.adfit.session-cookie`가 있을 때 AdFit 콘솔 계정 종합 일별 API를 조회한다.
13+
- 자동 보고서는 세션 쿠키가 있을 때 AdFit 콘솔 계정 종합 일별 API를 조회한다. 쿠키는 매 조회 시점에 다시 읽는다.
14+
- 쿠키 우선순위: 관리자 화면 입력값(`adfit_session_cookies` 테이블, `source=ADMIN_CONSOLE`) > 환경변수 `ADFIT_SESSION_COOKIE`·`picke.adfit.session-cookie`(`source=ENVIRONMENT`). 둘 다 없으면 `source=NONE`.
15+
- 세션이 만료되면 `RECONNECT_REQUIRED`가 된다. 재배포 없이 `PUT /session-cookie`로 새 쿠키를 넣어 복구한다.
1216
- 세션 쿠키가 없으면 `NOT_CONFIGURED`, 로그인 만료·리다이렉트·HTML 로그인 응답이면 `RECONNECT_REQUIRED`, API 장애·스키마 불일치면 `UNAVAILABLE`.
1317
- `unit`: NATIVE_WIDE(홈·큐레이션·마이페이지 공유), BANNER(탐색), APP_TRANSITION(앱 시작).
1418
- `costBasis`: AD_OPERATIONS(광고 운영비), ACQUISITION(유입 광고비), SERVICE_OPERATIONS(서비스 운영비).
@@ -21,4 +25,5 @@
2125
- 수익 0, 비용 양수인 정상 입력은 ROI -100%다. 미입력과 구분한다.
2226
- AdFit 계정 자동 보고서는 콘솔 내부 API(`accountTotal/periodicIndicators`)를 사용한다. 공개 파트너 REST API가 아니므로 세션 만료 시 재연결이 필요하다.
2327
- 공식 참고: https://adfit.kakao.com/ , https://adfit.github.io/
24-
- DB: `docs/db/20260910_create_adfit_daily_reports.sql`. 현재 프로젝트는 Hibernate ddl-auto=update를 사용한다.
28+
- 카카오 REST API 키(`Authorization: KakaoAK ...`)로는 조회할 수 없다. 애드핏 매체주 수익용 공개 REST API가 없다. 카카오 디벨로퍼스 REST API 레퍼런스에 해당 엔드포인트가 없고, AdX Report API는 RTB 연동 DSP 전용, 카카오모먼트 리포트 API는 광고주 집행 측이다.
29+
- DB: `docs/db/20260910_create_adfit_daily_reports.sql`, `docs/db/20260913_create_adfit_session_cookies.sql`. 현재 프로젝트는 Hibernate ddl-auto=update를 사용한다.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# 관리자 지표 (Mixpanel · Sentry)
2+
3+
- `GET /api/v1/admin/analytics/mixpanel?from=YYYY-MM-DD&to=YYYY-MM-DD&events=A,B`: ADMIN 전용. 최대 366일.
4+
- `GET /api/v1/admin/analytics/sentry?from=YYYY-MM-DD&to=YYYY-MM-DD`: ADMIN 전용. 최대 366일.
5+
- 두 응답의 `status`: `NOT_CONFIGURED`, `CONNECTED`, `UNAVAILABLE`. 조회 실패를 0으로 대체하지 않는다.
6+
- `NOT_CONFIGURED`는 오류가 아니다. 토큰을 넣기 전 상태이므로 화면은 지면을 비우고 안내만 띄운다.
7+
8+
## Mixpanel
9+
10+
집계 API가 아니라 **원본 이벤트를 내려받아 서버가 직접 센다.**
11+
12+
- `GET https://data.mixpanel.com/api/2.0/export?from_date&to_date`. 응답은 한 줄에 이벤트 하나인 NDJSON.
13+
- 인증은 프로젝트 API 비밀을 Basic 사용자명 자리에 넣고 비밀번호를 비우는 레거시 방식이다. 이 방식에 `project_id`를 넣으면 400이 된다. 비밀이 이미 프로젝트를 특정한다.
14+
- `events[].days[]`: `{date, count}`. 최신 날짜부터. 원본을 전부 받아 세므로 이벤트가 없던 날짜는 미집계가 아니라 **0**이다.
15+
- `events`를 비우면 기간에 나타난 이벤트 전부를 발생 수 내림차순으로 준다. 이벤트 이름을 미리 설정해 둘 필요가 없다.
16+
- 원본을 전부 받으므로 기간은 **31일까지**다(`MixpanelClient.MAX_DAYS`). 4일치가 약 2MB다.
17+
- `properties.time`은 프로젝트 타임존 기준 epoch 초이고 `from_date`·`to_date` 경계도 같은 타임존을 따른다. 둘을 같은 타임존으로 묶어야 Mixpanel 화면 숫자와 맞는다. `picke.analytics.mixpanel.project-zone`(기본 `UTC`)로 맞춘다. 이 프로젝트는 UTC로 실측 확인했다.
18+
- 경계 하루가 타임존 차이로 걸쳐 들어올 수 있어 요청 기간 밖 이벤트는 버린다.
19+
20+
### 왜 집계 API를 안 쓰는가
21+
22+
- **현재 Picke의 Mixpanel 플랜은 Query API를 허용하지 않는다.** 2026-09-14 실측: `/api/query/segmentation`·`/api/query/insights` 모두 `HTTP 402 Your plan does not allow API calls`. 인증은 통과하므로 자격 문제가 아니다.
23+
- 같은 플랜에서 Raw Export는 **200으로 열려 있다.** 그래서 이쪽으로 붙였다. Mixpanel MCP나 다른 클라이언트를 붙여도 Query API를 호출하는 한 같은 402를 받는다.
24+
- 서비스 계정 방식(`username:secret`)은 이 프로젝트에서 401이다. 프로젝트 토큰(`project_token`)은 이벤트 수집용이라 조회 인증에 쓰이지 않는다(401).
25+
- 실측(2026-09-14, 09-10~09-13): 16종 이벤트, `screen_view` 644 · `network_request` 561 · `ui_action` 172 · `onboarding_step` 65 · `battle_step` 44 · `sign_up` 3 등.
26+
27+
### 환경변수
28+
29+
- `MIXPANEL_API_SECRET` 하나면 된다. 설정 키는 `picke.analytics.mixpanel.*`.
30+
- EU·인도 데이터 거주 프로젝트는 호스트가 다르다. `picke.analytics.mixpanel.base-url`로 바꾼다.
31+
- `ad_click` 이벤트에 `unit`·`placement`·`format` 속성이 붙어 온다. AdFit 클릭은 `unit` 없이 `format=popup`·`placement=app_start`로 들어온다. 광고 클릭을 매체별로 나누려면 이 속성을 쓴다.
32+
33+
## Sentry
34+
35+
- `GET /api/0/projects/{org}/{project}/issues/?query=is:unresolved&sort=freq`. 조직 인증 토큰 `Authorization: Bearer`.
36+
- 절대 기간을 쓰려면 `statsPeriod`를 빈 값으로 함께 보낸다. 생략하면 Sentry 기본 기간이 적용된다.
37+
- iOS·Android를 각각 호출해 `projects[]`로 나눠 준다. `projects[]`: `{project, status, totalEvents, issues[]}`.
38+
- 한 프로젝트가 막혀도 다른 프로젝트는 살린다. 대신 최상위 `status`는 `UNAVAILABLE`, 최상위 `totalEvents`는 `null`이다. 일부만 더한 값을 전체 합계처럼 보여주지 않는다.
39+
- 프로젝트별 최대 20건. `projects[].totalEvents`는 그 20건의 합계이며 프로젝트 전체 이벤트 수가 아니다.
40+
- `sort=freq`는 절대 기간에서 이벤트 수 내림차순을 보장하지 않는다(실측 확인). 응답 순서를 믿지 않고 서버가 다시 정렬한다.
41+
- `issues[].events`는 Sentry가 문자열로 주기도 한다. 숫자·문자열 모두 읽는다.
42+
- 환경변수: `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`(기본 `picke`), `SENTRY_PROJECTS`(기본 `picke-ios,picke-android`). 설정 키는 `picke.analytics.sentry.*`.
43+
- 앱은 이미 같은 org·project로 Sentry에 리포트한다. 토큰만 서버에 넣으면 같은 데이터를 읽는다.
44+
- 실측(2026-09-14, 최근 30일): `picke-ios` 미해결 2건·1,765 이벤트, `picke-android` 9건·181 이벤트.
45+
46+
## 공통
47+
48+
- 외부 호출 실패가 관리자 화면 전체를 500으로 만들지 않는다. 타임아웃은 연결 3초, 요청 10초다.
49+
- 토큰은 응답에 담지 않는다. 서버 설정에만 둔다.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- AdFit 콘솔 세션 쿠키 보관용. 관리자 화면에서 갱신해 재배포 없이 자동 수익 조회를 복구한다.
2+
-- 한 행만 두고 쓴다. 조회는 id 역순 첫 행을 읽으므로 행이 늘어도 마지막 값이 이긴다.
3+
CREATE TABLE IF NOT EXISTS adfit_session_cookies (
4+
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
5+
created_at TIMESTAMP,
6+
updated_at TIMESTAMP,
7+
cookie_value VARCHAR(4096) NOT NULL
8+
);

‎src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java‎

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ public int sync() {
105105
* <p>앱 캠페인과 같은 {@code ADPICK_API} 소스를 쓰되 식별자에 접두사를 둬서 섞이지 않게 한다.
106106
* 소스를 새로 만들면 {@code ck_ad_creatives_source} 제약을 배포 때 사람이 직접 ALTER 해야 한다.
107107
* 상품 하나 늘리자고 그 절차를 얹지 않는다.
108+
*
109+
* <p>상품 하나를 지면 하나에만 두면 지면당 재고가 전체의 1/지면수로 쪼개진다.
110+
* 쇼핑 상품은 지면을 가릴 이유가 없으므로 모든 쇼핑 지면에 각각 등록해 지면마다 전량을 쓴다.
111+
* 노출·클릭 집계는 소재 단위라, 지면별로 따로 세려면 소재도 지면별로 나뉘어 있어야 한다.
108112
*/
109113
private int syncShopping(Map<String, AdCreative> existing, Set<String> seen) {
110114
if (!adpickShoppingClient.isConfigured() || shoppingSlots.isEmpty()) {
@@ -115,19 +119,25 @@ private int syncShopping(Map<String, AdCreative> existing, Set<String> seen) {
115119
.filter(AdpickShoppingResponse::isRenderable)
116120
.toList();
117121

122+
Set<String> buyUrls = new HashSet<>();
123+
int synced = 0;
118124
for (AdpickShoppingResponse product : products) {
119-
String externalId = shoppingIdOf(product.buyUrl());
120-
if (!seen.add(externalId)) {
125+
if (!buyUrls.add(product.buyUrl())) {
121126
// 쇼핑과 핫딜에 같은 상품이 함께 실릴 수 있다. 먼저 담은 쪽만 남긴다.
122127
continue;
123128
}
124-
upsertShopping(existing.get(externalId), externalId, product);
129+
for (AdSlotCode target : shoppingSlots) {
130+
String externalId = shoppingIdOf(product.buyUrl(), target);
131+
seen.add(externalId);
132+
upsertShopping(existing.get(externalId), externalId, target, product);
133+
synced++;
134+
}
125135
}
126-
return products.size();
136+
return synced;
127137
}
128138

129-
private void upsertShopping(AdCreative found, String externalId, AdpickShoppingResponse product) {
130-
AdSlotCode target = slotOf(externalId);
139+
private void upsertShopping(
140+
AdCreative found, String externalId, AdSlotCode target, AdpickShoppingResponse product) {
131141
if (found != null) {
132142
found.syncFromAdpick(
133143
truncate(product.productName(), TITLE_MAX_LENGTH),
@@ -162,11 +172,14 @@ private void upsertShopping(AdCreative found, String externalId, AdpickShoppingR
162172
/**
163173
* 상품에는 애드픽이 주는 코드가 없어 구매 링크에서 식별자를 만든다.
164174
* 링크가 그대로면 같은 소재로 갱신되고, 바뀌면 새 소재가 된다.
175+
*
176+
* <p>같은 상품이 지면마다 따로 등록되므로 지면까지 해시에 넣는다.
177+
* 길이와 모양({@code sh} + 16진수 10자)은 그대로 둔다. 조회 쪽이 이 형태로 쇼핑 소재를 가려낸다.
165178
*/
166-
private String shoppingIdOf(String buyUrl) {
179+
private String shoppingIdOf(String buyUrl, AdSlotCode slot) {
167180
try {
168181
byte[] digest = MessageDigest.getInstance("SHA-256")
169-
.digest(buyUrl.getBytes(StandardCharsets.UTF_8));
182+
.digest((buyUrl + "|" + slot.name()).getBytes(StandardCharsets.UTF_8));
170183
StringBuilder hex = new StringBuilder(SHOPPING_ID_PREFIX);
171184
for (int i = 0; hex.length() < SHOPPING_ID_PREFIX.length() + SHOPPING_ID_LENGTH; i++) {
172185
hex.append(String.format("%02x", digest[i]));
@@ -177,15 +190,6 @@ private String shoppingIdOf(String buyUrl) {
177190
}
178191
}
179192

180-
/**
181-
* 지면은 식별자로 정한다. 매 동기화마다 다시 뽑으면 같은 상품이 지면을 옮겨 다녀
182-
* 노출 집계가 지면별로 흩어진다.
183-
*/
184-
private AdSlotCode slotOf(String externalId) {
185-
int index = Math.floorMod(externalId.hashCode(), shoppingSlots.size());
186-
return shoppingSlots.get(index);
187-
}
188-
189193
private void upsert(AdCreative found, AdpickCampaignResponse campaign) {
190194
if (found != null) {
191195
found.syncFromAdpick(

‎src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
import java.util.HashMap;
1212
import java.util.List;
1313
import java.util.Map;
14+
import java.util.function.Supplier;
1415
import lombok.extern.slf4j.Slf4j;
15-
import org.springframework.beans.factory.annotation.Value;
1616
import org.springframework.beans.factory.annotation.Autowired;
1717
import org.springframework.stereotype.Component;
1818
import org.springframework.util.StringUtils;
@@ -24,26 +24,27 @@ public class AdfitAccountReportClient {
2424
private static final String REPORT_URL =
2525
"https://adfit.kakao.com/api/v2/report/accountTotal/periodicIndicators";
2626

27-
private final String sessionCookie;
27+
/** 쿠키는 조회할 때마다 다시 읽는다. 관리자가 만료된 값을 갈아끼우면 재배포 없이 바로 반영돼야 한다. */
28+
private final Supplier<String> sessionCookieSupplier;
2829
private final AdfitHttpTransport transport;
2930
private final ObjectMapper objectMapper;
3031

3132
@Autowired
32-
public AdfitAccountReportClient(
33-
@Value("${picke.adfit.session-cookie:${ADFIT_SESSION_COOKIE:}}") String sessionCookie,
34-
AdfitHttpTransport transport) {
35-
this(sessionCookie, transport, new ObjectMapper());
33+
public AdfitAccountReportClient(AdfitSessionCookieStore cookieStore, AdfitHttpTransport transport) {
34+
this(cookieStore::current, transport, new ObjectMapper());
3635
}
3736

38-
AdfitAccountReportClient(String sessionCookie, AdfitHttpTransport transport, ObjectMapper objectMapper) {
39-
this.sessionCookie = sessionCookie;
37+
AdfitAccountReportClient(Supplier<String> sessionCookieSupplier, AdfitHttpTransport transport,
38+
ObjectMapper objectMapper) {
39+
this.sessionCookieSupplier = sessionCookieSupplier;
4040
this.transport = transport;
4141
this.objectMapper = objectMapper;
4242
}
4343

4444
public AdfitReport.AccountReport fetch(LocalDate from, LocalDate to, long expectedDays) {
4545
validateRange(from, to);
4646
List<AdfitReport.AccountDay> emptyDays = nullDays(from, to);
47+
String sessionCookie = sessionCookieSupplier.get();
4748
if (!StringUtils.hasText(sessionCookie)) {
4849
return report(AdfitAccountReportStatus.NOT_CONFIGURED, null, expectedDays, null, emptyDays);
4950
}

‎src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616
public class AdfitReportService {
1717
private final AdfitDailyRepository repository;
1818
private final AdfitAccountReportClient accountReportClient;
19+
private final AdfitSessionCookieStore sessionCookieStore;
20+
21+
public AdfitSessionCookieStatus sessionCookieStatus() {
22+
return sessionCookieStore.status();
23+
}
24+
25+
public void updateSessionCookie(AdfitSessionCookieRequest request) {
26+
sessionCookieStore.update(request.cookie());
27+
}
1928

2029
@Transactional
2130
public void save(AdfitDailyRequest request) {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import com.swyp.picke.global.common.BaseEntity;
4+
import jakarta.persistence.Column;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.Table;
7+
import lombok.AccessLevel;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
11+
/**
12+
* AdFit 콘솔 세션 쿠키.
13+
*
14+
* <p>AdFit은 매체주 수익을 읽는 공개 REST API가 없어 콘솔 내부 API를 세션 쿠키로 호출한다.
15+
* 이 쿠키는 수시로 만료되는데 값이 환경변수에만 있으면 만료마다 재배포를 해야 한다.
16+
* 관리자가 화면에서 갈아끼울 수 있도록 한 행으로 보관한다.
17+
*/
18+
@Entity
19+
@Getter
20+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
21+
@Table(name = "adfit_session_cookies")
22+
public class AdfitSessionCookie extends BaseEntity {
23+
24+
@Column(name = "cookie_value", nullable = false, length = 4096)
25+
private String value;
26+
27+
public AdfitSessionCookie(String value) {
28+
this.value = value;
29+
}
30+
31+
public void update(String value) {
32+
this.value = value;
33+
}
34+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import java.util.Optional;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
public interface AdfitSessionCookieRepository extends JpaRepository<AdfitSessionCookie, Long> {
7+
/** 한 행만 두고 쓰지만, 저장이 겹쳐 행이 늘어도 마지막 값이 이기게 한다. */
8+
Optional<AdfitSessionCookie> findTopByOrderByIdDesc();
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Size;
5+
6+
/**
7+
* @param cookie AdFit 콘솔에 로그인한 브라우저의 Cookie 헤더 전체. {@code name=value; name2=value2} 형태.
8+
*/
9+
public record AdfitSessionCookieRequest(
10+
@NotBlank @Size(max = 4096) String cookie) {
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
public enum AdfitSessionCookieSource {
4+
/** 관리자가 화면에서 넣은 값. 환경변수보다 앞선다. */
5+
ADMIN_CONSOLE,
6+
/** 배포 시 주입한 환경변수 값. */
7+
ENVIRONMENT,
8+
/** 양쪽 모두 없음. 자동 수익 조회가 NOT_CONFIGURED 가 된다. */
9+
NONE
10+
}

0 commit comments

Comments
 (0)