Skip to content

Commit ce4da98

Browse files
authored
feat: 운영 AdFit 일별 수익 자동 조회 연결 (#466)
Tested: AdFit tests and CI passed Not-tested: Authenticated production revenue query; ADFIT_SESSION_COOKIE required Scope-risk: moderate
1 parent 47d8809 commit ce4da98

11 files changed

Lines changed: 400 additions & 7 deletions

File tree

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@
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` 응답에는 기존 수동 입력 단위 리포트(`source`, `units`, `days`)와 별도로 `account`가 포함된다.
6+
- `account.status`: `NOT_CONFIGURED`, `CONNECTED`, `RECONNECT_REQUIRED`, `UNAVAILABLE`.
7+
- `account.days[]`: `{date, revenue, ctr, ecpm, fillRate, winFillRate}`. AdFit 콘솔에 값이 없거나 누락된 날짜는 `null`이며 0으로 대체하지 않는다.
8+
- `account.fetchedAt`: `CONNECTED`일 때 이번 관리자 조회에서 AdFit 응답을 성공적으로 파싱한 시각이다. 원천 데이터의 최종 집계 시각이나 배치 동기화 시각이 아니다.
9+
- `account.revenue`: 조회 기간 중 실제 내려온 일별 수익 합계. 수익 데이터가 전부 `null`이면 `null`.
10+
- `account.cost`, `account.roi`: AdFit 계정 자동 보고서가 광고 비용을 제공하지 않으므로 항상 `null`.
11+
- 자동 보고서는 `ADFIT_SESSION_COOKIE` 또는 `picke.adfit.session-cookie`가 있을 때 AdFit 콘솔 계정 종합 일별 API를 조회한다.
12+
- 세션 쿠키가 없으면 `NOT_CONFIGURED`, 로그인 만료·리다이렉트·HTML 로그인 응답이면 `RECONNECT_REQUIRED`, API 장애·스키마 불일치면 `UNAVAILABLE`.
513
- `unit`: NATIVE_WIDE(홈·큐레이션·마이페이지 공유), BANNER(탐색), APP_TRANSITION(앱 시작).
614
- `costBasis`: AD_OPERATIONS(광고 운영비), ACQUISITION(유입 광고비), SERVICE_OPERATIONS(서비스 운영비).
715
- KRW 금액, 소수 둘째 자리까지, 음수 불가. 같은 날짜·단위는 수정. 미래 날짜 입력 불가.
8-
- source=MANUAL_CONSOLE: AdFit 콘솔에서 확인한 예상 수익을 관리자가 입력한다. 자동 연동·확정 정산액이 아니다.
16+
- source=MANUAL_CONSOLE: `units`와 `days`는 수동 입력 데이터다. 자동 계정 수익은 `account`만 사용한다.
917
- 광고 단위는 로컬 Picke-iOS의 SDK 연결 기준이다. 개별 사용자에게 SDK가 선택한 이미지·광고주 소재를 재현하거나 실시간 노출을 보증하지 않는다.
1018
- 같은 단위를 여러 화면에서 사용하더라도 수익은 한 번만 합산한다. 비용도 단위별 배분액으로 입력하며 전체 운영비를 각 단위에 중복 입력하지 않는다.
1119
- 수익과 비용은 입력된 날짜들의 합계다. reportedDays/expectedDays로 부분 입력을 표시한다. 미입력은 null이며 0원이 아니다.
1220
- ROI = (수익 - 비용) / 비용 × 100. 모든 날짜가 입력되고 같은 비용 기준이며 비용이 양수일 때만 계산한다. 그 외 null.
1321
- 수익 0, 비용 양수인 정상 입력은 ROI -100%다. 미입력과 구분한다.
14-
- 공식 공개 보고서 REST API는 확인하지 못했으므로 비공개 API를 추측하거나 관리자 브라우저에 인증 정보를 저장하지 않는다.
22+
- AdFit 계정 자동 보고서는 콘솔 내부 API(`accountTotal/periodicIndicators`)를 사용한다. 공개 파트너 REST API가 아니므로 세션 만료 시 재연결이 필요하다.
1523
- 공식 참고: https://adfit.kakao.com/ , https://adfit.github.io/
1624
- DB: `docs/db/20260910_create_adfit_daily_reports.sql`. 현재 프로젝트는 Hibernate ddl-auto=update를 사용한다.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import java.math.BigDecimal;
6+
import java.net.URI;
7+
import java.time.Instant;
8+
import java.time.LocalDate;
9+
import java.time.temporal.ChronoUnit;
10+
import java.util.ArrayList;
11+
import java.util.HashMap;
12+
import java.util.List;
13+
import java.util.Map;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.beans.factory.annotation.Value;
16+
import org.springframework.beans.factory.annotation.Autowired;
17+
import org.springframework.stereotype.Component;
18+
import org.springframework.util.StringUtils;
19+
import org.springframework.web.util.UriComponentsBuilder;
20+
21+
@Slf4j
22+
@Component
23+
public class AdfitAccountReportClient {
24+
private static final String REPORT_URL =
25+
"https://adfit.kakao.com/api/v2/report/accountTotal/periodicIndicators";
26+
27+
private final String sessionCookie;
28+
private final AdfitHttpTransport transport;
29+
private final ObjectMapper objectMapper;
30+
31+
@Autowired
32+
public AdfitAccountReportClient(
33+
@Value("${picke.adfit.session-cookie:${ADFIT_SESSION_COOKIE:}}") String sessionCookie,
34+
AdfitHttpTransport transport) {
35+
this(sessionCookie, transport, new ObjectMapper());
36+
}
37+
38+
AdfitAccountReportClient(String sessionCookie, AdfitHttpTransport transport, ObjectMapper objectMapper) {
39+
this.sessionCookie = sessionCookie;
40+
this.transport = transport;
41+
this.objectMapper = objectMapper;
42+
}
43+
44+
public AdfitReport.AccountReport fetch(LocalDate from, LocalDate to, long expectedDays) {
45+
validateRange(from, to);
46+
List<AdfitReport.AccountDay> emptyDays = nullDays(from, to);
47+
if (!StringUtils.hasText(sessionCookie)) {
48+
return report(AdfitAccountReportStatus.NOT_CONFIGURED, null, expectedDays, null, emptyDays);
49+
}
50+
51+
AdfitHttpResponse response = transport.get(uri(from, to), sessionCookie);
52+
if (response.statusCode() == 401 || response.statusCode() == 403 || response.statusCode() == 419
53+
|| response.isRedirect()
54+
|| looksLikeHtml(response.body())) {
55+
return report(AdfitAccountReportStatus.RECONNECT_REQUIRED, null, expectedDays, null, emptyDays);
56+
}
57+
if (response.statusCode() < 200 || response.statusCode() >= 300 || !response.isJson()) {
58+
return report(AdfitAccountReportStatus.UNAVAILABLE, null, expectedDays, null, emptyDays);
59+
}
60+
61+
try {
62+
List<AdfitReport.AccountDay> days = parseDays(response.body(), from, to);
63+
BigDecimal revenue = days.stream()
64+
.map(AdfitReport.AccountDay::revenue)
65+
.filter(value -> value != null)
66+
.reduce(BigDecimal.ZERO, BigDecimal::add);
67+
long reportedDays = days.stream().filter(day -> day.revenue() != null).count();
68+
return new AdfitReport.AccountReport(AdfitAccountReportStatus.CONNECTED, Instant.now(),
69+
reportedDays, expectedDays, reportedDays == 0 ? null : revenue, null, null, days);
70+
} catch (Exception e) {
71+
log.warn("[AdFit] 계정 수익 보고서 응답 파싱 실패: {}", e.getClass().getSimpleName());
72+
return report(AdfitAccountReportStatus.UNAVAILABLE, null, expectedDays, null, emptyDays);
73+
}
74+
}
75+
76+
private URI uri(LocalDate from, LocalDate to) {
77+
return UriComponentsBuilder.fromUriString(REPORT_URL)
78+
.queryParam("dayType", "DAY")
79+
.queryParam("startDate", from)
80+
.queryParam("endDate", to)
81+
.build()
82+
.toUri();
83+
}
84+
85+
private List<AdfitReport.AccountDay> parseDays(String body, LocalDate from, LocalDate to) throws Exception {
86+
JsonNode root = objectMapper.readTree(body);
87+
if (!root.isArray()) {
88+
throw new IllegalArgumentException("AdFit report root must be an array.");
89+
}
90+
91+
Map<LocalDate, AdfitReport.AccountDay> byDate = new HashMap<>();
92+
for (JsonNode node : root) {
93+
LocalDate date = LocalDate.parse(requiredText(node, "reportDate"));
94+
if (date.isBefore(from) || date.isAfter(to)) {
95+
throw new IllegalArgumentException("AdFit report date is out of range.");
96+
}
97+
if (!node.has("profit")) {
98+
throw new IllegalArgumentException("AdFit report profit is missing.");
99+
}
100+
AdfitReport.AccountDay day = new AdfitReport.AccountDay(date, decimalOrNull(node, "profit"),
101+
decimalOrNull(node, "ctr"), decimalOrNull(node, "ecpm"),
102+
decimalOrNull(node, "fillRate"), decimalOrNull(node, "winFillRate"));
103+
if (byDate.putIfAbsent(date, day) != null) {
104+
throw new IllegalArgumentException("AdFit report date is duplicated.");
105+
}
106+
}
107+
108+
List<AdfitReport.AccountDay> days = new ArrayList<>();
109+
LocalDate cursor = to;
110+
while (!cursor.isBefore(from)) {
111+
days.add(byDate.getOrDefault(cursor,
112+
new AdfitReport.AccountDay(cursor, null, null, null, null, null)));
113+
cursor = cursor.minusDays(1);
114+
}
115+
return days;
116+
}
117+
118+
private String requiredText(JsonNode node, String field) {
119+
JsonNode value = node.get(field);
120+
if (value == null || value.isNull() || !value.isTextual()) {
121+
throw new IllegalArgumentException("AdFit report " + field + " is invalid.");
122+
}
123+
return value.asText();
124+
}
125+
126+
private BigDecimal decimalOrNull(JsonNode node, String field) {
127+
JsonNode value = node.get(field);
128+
if (value == null || value.isNull()) {
129+
return null;
130+
}
131+
if (!value.isNumber()) {
132+
throw new IllegalArgumentException("AdFit report " + field + " is invalid.");
133+
}
134+
return value.decimalValue();
135+
}
136+
137+
private List<AdfitReport.AccountDay> nullDays(LocalDate from, LocalDate to) {
138+
List<AdfitReport.AccountDay> days = new ArrayList<>();
139+
LocalDate cursor = to;
140+
while (!cursor.isBefore(from)) {
141+
days.add(new AdfitReport.AccountDay(cursor, null, null, null, null, null));
142+
cursor = cursor.minusDays(1);
143+
}
144+
return days;
145+
}
146+
147+
private AdfitReport.AccountReport report(AdfitAccountReportStatus status, Instant fetchedAt,
148+
long expectedDays, BigDecimal revenue,
149+
List<AdfitReport.AccountDay> days) {
150+
return new AdfitReport.AccountReport(status, fetchedAt, 0, expectedDays, revenue, null, null, days);
151+
}
152+
153+
private void validateRange(LocalDate from, LocalDate to) {
154+
long expected = ChronoUnit.DAYS.between(from, to) + 1;
155+
if (expected < 1 || expected > 366) {
156+
throw new IllegalArgumentException("조회 기간은 1일부터 366일까지입니다.");
157+
}
158+
}
159+
160+
private boolean looksLikeHtml(String body) {
161+
return body != null && body.stripLeading().startsWith("<");
162+
}
163+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
public enum AdfitAccountReportStatus {
4+
NOT_CONFIGURED,
5+
CONNECTED,
6+
RECONNECT_REQUIRED,
7+
UNAVAILABLE
8+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
record AdfitHttpResponse(int statusCode, Map<String, List<String>> headers, String body) {
7+
boolean isRedirect() {
8+
return statusCode >= 300 && statusCode < 400;
9+
}
10+
11+
boolean isJson() {
12+
return headers.entrySet().stream()
13+
.filter(entry -> "content-type".equalsIgnoreCase(entry.getKey()))
14+
.flatMap(entry -> entry.getValue().stream())
15+
.anyMatch(value -> value.toLowerCase().contains("application/json"));
16+
}
17+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import java.net.URI;
4+
5+
interface AdfitHttpTransport {
6+
AdfitHttpResponse get(URI uri, String sessionCookie);
7+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
package com.swyp.picke.domain.admin.adfit;
22

33
import java.math.BigDecimal;
4+
import java.time.Instant;
45
import java.time.LocalDate;
56
import java.util.List;
67

78
public record AdfitReport(LocalDate from, LocalDate to, String source,
8-
List<UnitReport> units, List<Day> days) {
9+
List<UnitReport> units, List<Day> days, AccountReport account) {
910
public record UnitReport(AdfitUnit unit, String name, List<String> placements, String format,
1011
long reportedDays, long expectedDays, BigDecimal revenue,
1112
BigDecimal cost, BigDecimal roi) {}
1213
public record Day(LocalDate date, AdfitUnit unit, BigDecimal revenue, BigDecimal cost,
1314
AdfitCostBasis costBasis) {}
15+
public record AccountReport(AdfitAccountReportStatus status, Instant fetchedAt,
16+
long reportedDays, long expectedDays, BigDecimal revenue,
17+
BigDecimal cost, BigDecimal roi, List<AccountDay> days) {}
18+
public record AccountDay(LocalDate date, BigDecimal revenue, BigDecimal ctr, BigDecimal ecpm,
19+
BigDecimal fillRate, BigDecimal winFillRate) {}
1420
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
@RequiredArgsConstructor
1616
public class AdfitReportService {
1717
private final AdfitDailyRepository repository;
18+
private final AdfitAccountReportClient accountReportClient;
1819

1920
@Transactional
2021
public void save(AdfitDailyRequest request) {
@@ -27,7 +28,6 @@ public void save(AdfitDailyRequest request) {
2728
repository.save(daily);
2829
}
2930

30-
@Transactional(readOnly = true)
3131
public AdfitReport report(LocalDate from, LocalDate to) {
3232
long expected = ChronoUnit.DAYS.between(from, to) + 1;
3333
if (expected < 1 || expected > 366) {
@@ -49,8 +49,9 @@ public AdfitReport report(LocalDate from, LocalDate to) {
4949
return new AdfitReport.UnitReport(unit, unit.getDisplayName(), unit.getPlacements(),
5050
unit.getFormat(), entries.size(), expected, revenue, cost, roi);
5151
}).toList();
52+
AdfitReport.AccountReport account = accountReportClient.fetch(from, to, expected);
5253
return new AdfitReport(from, to, "MANUAL_CONSOLE", units, days.stream().map(day ->
5354
new AdfitReport.Day(day.getDate(), day.getUnit(), day.getRevenue(), day.getCost(),
54-
day.getCostBasis())).toList());
55+
day.getCostBasis())).toList(), account);
5556
}
5657
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
@RequiredArgsConstructor
1515
@RequestMapping("/api/v1/admin/adfit")
1616
@PreAuthorize("hasRole('ADMIN')")
17-
@Tag(name = "관리자 AdFit", description = "iOS 광고 단위 및 콘솔 수동 입력 수익·비용·ROI")
17+
@Tag(name = "관리자 AdFit", description = "AdFit 계정 자동 수익 및 수동 입력 비용·ROI")
1818
public class AdminAdfitController {
1919
private final AdfitReportService service;
2020

2121
@GetMapping
22-
@Operation(summary = "AdFit 광고 단위와 기간별 수익·ROI", description = "미입력 금액 및 계산 불가 ROI는 null")
22+
@Operation(summary = "AdFit 기간별 수익·ROI", description = "account는 AdFit 계정 자동 수익, units/days는 수동 입력 비용·ROI. 미입력·계산 불가는 null")
2323
public ApiResponse<AdfitReport> report(
2424
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
2525
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import java.net.URI;
4+
import java.net.http.HttpClient;
5+
import java.net.http.HttpRequest;
6+
import java.net.http.HttpResponse;
7+
import java.time.Duration;
8+
import java.util.List;
9+
import java.util.Map;
10+
import org.springframework.stereotype.Component;
11+
12+
@Component
13+
class JavaNetAdfitHttpTransport implements AdfitHttpTransport {
14+
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
15+
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(8);
16+
17+
private final HttpClient client = HttpClient.newBuilder()
18+
.connectTimeout(CONNECT_TIMEOUT)
19+
.followRedirects(HttpClient.Redirect.NEVER)
20+
.build();
21+
22+
@Override
23+
public AdfitHttpResponse get(URI uri, String sessionCookie) {
24+
try {
25+
HttpRequest request = HttpRequest.newBuilder(uri)
26+
.timeout(REQUEST_TIMEOUT)
27+
.header("Accept", "application/json")
28+
.header("Referer", "https://adfit.kakao.com/report")
29+
.header("Cookie", sessionCookie)
30+
.GET()
31+
.build();
32+
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
33+
return new AdfitHttpResponse(response.statusCode(), response.headers().map(), response.body());
34+
} catch (InterruptedException e) {
35+
Thread.currentThread().interrupt();
36+
return new AdfitHttpResponse(0, Map.of("x-adfit-error", List.of("interrupted")), "");
37+
} catch (Exception e) {
38+
return new AdfitHttpResponse(0, Map.of("x-adfit-error", List.of("request_failed")), "");
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)