Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/api-specs/adfit-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,23 @@

- `GET /api/v1/admin/adfit?from=YYYY-MM-DD&to=YYYY-MM-DD`: ADMIN 전용. 최대 366일.
- `PUT /api/v1/admin/adfit/daily`: ADMIN 전용. `{date, unit, revenue, cost, costBasis}`.
- `GET` 응답에는 기존 수동 입력 단위 리포트(`source`, `units`, `days`)와 별도로 `account`가 포함된다.
- `account.status`: `NOT_CONFIGURED`, `CONNECTED`, `RECONNECT_REQUIRED`, `UNAVAILABLE`.
- `account.days[]`: `{date, revenue, ctr, ecpm, fillRate, winFillRate}`. AdFit 콘솔에 값이 없거나 누락된 날짜는 `null`이며 0으로 대체하지 않는다.
- `account.fetchedAt`: `CONNECTED`일 때 이번 관리자 조회에서 AdFit 응답을 성공적으로 파싱한 시각이다. 원천 데이터의 최종 집계 시각이나 배치 동기화 시각이 아니다.
- `account.revenue`: 조회 기간 중 실제 내려온 일별 수익 합계. 수익 데이터가 전부 `null`이면 `null`.
- `account.cost`, `account.roi`: AdFit 계정 자동 보고서가 광고 비용을 제공하지 않으므로 항상 `null`.
- 자동 보고서는 `ADFIT_SESSION_COOKIE` 또는 `picke.adfit.session-cookie`가 있을 때 AdFit 콘솔 계정 종합 일별 API를 조회한다.
- 세션 쿠키가 없으면 `NOT_CONFIGURED`, 로그인 만료·리다이렉트·HTML 로그인 응답이면 `RECONNECT_REQUIRED`, API 장애·스키마 불일치면 `UNAVAILABLE`.
- `unit`: NATIVE_WIDE(홈·큐레이션·마이페이지 공유), BANNER(탐색), APP_TRANSITION(앱 시작).
- `costBasis`: AD_OPERATIONS(광고 운영비), ACQUISITION(유입 광고비), SERVICE_OPERATIONS(서비스 운영비).
- KRW 금액, 소수 둘째 자리까지, 음수 불가. 같은 날짜·단위는 수정. 미래 날짜 입력 불가.
- source=MANUAL_CONSOLE: AdFit 콘솔에서 확인한 예상 수익을 관리자가 입력한다. 자동 연동·확정 정산액이 아니다.
- source=MANUAL_CONSOLE: `units`와 `days`는 수동 입력 데이터다. 자동 계정 수익은 `account`만 사용한다.
- 광고 단위는 로컬 Picke-iOS의 SDK 연결 기준이다. 개별 사용자에게 SDK가 선택한 이미지·광고주 소재를 재현하거나 실시간 노출을 보증하지 않는다.
- 같은 단위를 여러 화면에서 사용하더라도 수익은 한 번만 합산한다. 비용도 단위별 배분액으로 입력하며 전체 운영비를 각 단위에 중복 입력하지 않는다.
- 수익과 비용은 입력된 날짜들의 합계다. reportedDays/expectedDays로 부분 입력을 표시한다. 미입력은 null이며 0원이 아니다.
- ROI = (수익 - 비용) / 비용 × 100. 모든 날짜가 입력되고 같은 비용 기준이며 비용이 양수일 때만 계산한다. 그 외 null.
- 수익 0, 비용 양수인 정상 입력은 ROI -100%다. 미입력과 구분한다.
- 공식 공개 보고서 REST API는 확인하지 못했으므로 비공개 API를 추측하거나 관리자 브라우저에 인증 정보를 저장하지 않는다.
- AdFit 계정 자동 보고서는 콘솔 내부 API(`accountTotal/periodicIndicators`)를 사용한다. 공개 파트너 REST API가 아니므로 세션 만료 시 재연결이 필요하다.
- 공식 참고: https://adfit.kakao.com/ , https://adfit.github.io/
- DB: `docs/db/20260910_create_adfit_daily_reports.sql`. 현재 프로젝트는 Hibernate ddl-auto=update를 사용한다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.swyp.picke.domain.admin.adfit;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;

@Slf4j
@Component
public class AdfitAccountReportClient {
private static final String REPORT_URL =
"https://adfit.kakao.com/api/v2/report/accountTotal/periodicIndicators";

private final String sessionCookie;
private final AdfitHttpTransport transport;
private final ObjectMapper objectMapper;

@Autowired
public AdfitAccountReportClient(
@Value("${picke.adfit.session-cookie:${ADFIT_SESSION_COOKIE:}}") String sessionCookie,
AdfitHttpTransport transport) {
this(sessionCookie, transport, new ObjectMapper());
}

AdfitAccountReportClient(String sessionCookie, AdfitHttpTransport transport, ObjectMapper objectMapper) {
this.sessionCookie = sessionCookie;
this.transport = transport;
this.objectMapper = objectMapper;
}

public AdfitReport.AccountReport fetch(LocalDate from, LocalDate to, long expectedDays) {
validateRange(from, to);
List<AdfitReport.AccountDay> emptyDays = nullDays(from, to);
if (!StringUtils.hasText(sessionCookie)) {
return report(AdfitAccountReportStatus.NOT_CONFIGURED, null, expectedDays, null, emptyDays);
}

AdfitHttpResponse response = transport.get(uri(from, to), sessionCookie);
if (response.statusCode() == 401 || response.statusCode() == 403 || response.statusCode() == 419
|| response.isRedirect()
|| looksLikeHtml(response.body())) {
return report(AdfitAccountReportStatus.RECONNECT_REQUIRED, null, expectedDays, null, emptyDays);
}
if (response.statusCode() < 200 || response.statusCode() >= 300 || !response.isJson()) {
return report(AdfitAccountReportStatus.UNAVAILABLE, null, expectedDays, null, emptyDays);
}

try {
List<AdfitReport.AccountDay> days = parseDays(response.body(), from, to);
BigDecimal revenue = days.stream()
.map(AdfitReport.AccountDay::revenue)
.filter(value -> value != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
long reportedDays = days.stream().filter(day -> day.revenue() != null).count();
return new AdfitReport.AccountReport(AdfitAccountReportStatus.CONNECTED, Instant.now(),
reportedDays, expectedDays, reportedDays == 0 ? null : revenue, null, null, days);
} catch (Exception e) {
log.warn("[AdFit] 계정 수익 보고서 응답 파싱 실패: {}", e.getClass().getSimpleName());
return report(AdfitAccountReportStatus.UNAVAILABLE, null, expectedDays, null, emptyDays);
}
}

private URI uri(LocalDate from, LocalDate to) {
return UriComponentsBuilder.fromUriString(REPORT_URL)
.queryParam("dayType", "DAY")
.queryParam("startDate", from)
.queryParam("endDate", to)
.build()
.toUri();
}

private List<AdfitReport.AccountDay> parseDays(String body, LocalDate from, LocalDate to) throws Exception {
JsonNode root = objectMapper.readTree(body);
if (!root.isArray()) {
throw new IllegalArgumentException("AdFit report root must be an array.");
}

Map<LocalDate, AdfitReport.AccountDay> byDate = new HashMap<>();
for (JsonNode node : root) {
LocalDate date = LocalDate.parse(requiredText(node, "reportDate"));
if (date.isBefore(from) || date.isAfter(to)) {
throw new IllegalArgumentException("AdFit report date is out of range.");
}
if (!node.has("profit")) {
throw new IllegalArgumentException("AdFit report profit is missing.");
}
AdfitReport.AccountDay day = new AdfitReport.AccountDay(date, decimalOrNull(node, "profit"),
decimalOrNull(node, "ctr"), decimalOrNull(node, "ecpm"),
decimalOrNull(node, "fillRate"), decimalOrNull(node, "winFillRate"));
if (byDate.putIfAbsent(date, day) != null) {
throw new IllegalArgumentException("AdFit report date is duplicated.");
}
}

List<AdfitReport.AccountDay> days = new ArrayList<>();
LocalDate cursor = to;
while (!cursor.isBefore(from)) {
days.add(byDate.getOrDefault(cursor,
new AdfitReport.AccountDay(cursor, null, null, null, null, null)));
cursor = cursor.minusDays(1);
}
return days;
}

private String requiredText(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || value.isNull() || !value.isTextual()) {
throw new IllegalArgumentException("AdFit report " + field + " is invalid.");
}
return value.asText();
}

private BigDecimal decimalOrNull(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
if (!value.isNumber()) {
throw new IllegalArgumentException("AdFit report " + field + " is invalid.");
}
return value.decimalValue();
}

private List<AdfitReport.AccountDay> nullDays(LocalDate from, LocalDate to) {
List<AdfitReport.AccountDay> days = new ArrayList<>();
LocalDate cursor = to;
while (!cursor.isBefore(from)) {
days.add(new AdfitReport.AccountDay(cursor, null, null, null, null, null));
cursor = cursor.minusDays(1);
}
return days;
}

private AdfitReport.AccountReport report(AdfitAccountReportStatus status, Instant fetchedAt,
long expectedDays, BigDecimal revenue,
List<AdfitReport.AccountDay> days) {
return new AdfitReport.AccountReport(status, fetchedAt, 0, expectedDays, revenue, null, null, days);
}

private void validateRange(LocalDate from, LocalDate to) {
long expected = ChronoUnit.DAYS.between(from, to) + 1;
if (expected < 1 || expected > 366) {
throw new IllegalArgumentException("조회 기간은 1일부터 366일까지입니다.");
}
}

private boolean looksLikeHtml(String body) {
return body != null && body.stripLeading().startsWith("<");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.swyp.picke.domain.admin.adfit;

public enum AdfitAccountReportStatus {
NOT_CONFIGURED,
CONNECTED,
RECONNECT_REQUIRED,
UNAVAILABLE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.swyp.picke.domain.admin.adfit;

import java.util.List;
import java.util.Map;

record AdfitHttpResponse(int statusCode, Map<String, List<String>> headers, String body) {
boolean isRedirect() {
return statusCode >= 300 && statusCode < 400;
}

boolean isJson() {
return headers.entrySet().stream()
.filter(entry -> "content-type".equalsIgnoreCase(entry.getKey()))
.flatMap(entry -> entry.getValue().stream())
.anyMatch(value -> value.toLowerCase().contains("application/json"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swyp.picke.domain.admin.adfit;

import java.net.URI;

interface AdfitHttpTransport {
AdfitHttpResponse get(URI uri, String sessionCookie);
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
package com.swyp.picke.domain.admin.adfit;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;

public record AdfitReport(LocalDate from, LocalDate to, String source,
List<UnitReport> units, List<Day> days) {
List<UnitReport> units, List<Day> days, AccountReport account) {
public record UnitReport(AdfitUnit unit, String name, List<String> placements, String format,
long reportedDays, long expectedDays, BigDecimal revenue,
BigDecimal cost, BigDecimal roi) {}
public record Day(LocalDate date, AdfitUnit unit, BigDecimal revenue, BigDecimal cost,
AdfitCostBasis costBasis) {}
public record AccountReport(AdfitAccountReportStatus status, Instant fetchedAt,
long reportedDays, long expectedDays, BigDecimal revenue,
BigDecimal cost, BigDecimal roi, List<AccountDay> days) {}
public record AccountDay(LocalDate date, BigDecimal revenue, BigDecimal ctr, BigDecimal ecpm,
BigDecimal fillRate, BigDecimal winFillRate) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
@RequiredArgsConstructor
public class AdfitReportService {
private final AdfitDailyRepository repository;
private final AdfitAccountReportClient accountReportClient;

@Transactional
public void save(AdfitDailyRequest request) {
Expand All @@ -27,7 +28,6 @@ public void save(AdfitDailyRequest request) {
repository.save(daily);
}

@Transactional(readOnly = true)
public AdfitReport report(LocalDate from, LocalDate to) {
long expected = ChronoUnit.DAYS.between(from, to) + 1;
if (expected < 1 || expected > 366) {
Expand All @@ -49,8 +49,9 @@ public AdfitReport report(LocalDate from, LocalDate to) {
return new AdfitReport.UnitReport(unit, unit.getDisplayName(), unit.getPlacements(),
unit.getFormat(), entries.size(), expected, revenue, cost, roi);
}).toList();
AdfitReport.AccountReport account = accountReportClient.fetch(from, to, expected);
return new AdfitReport(from, to, "MANUAL_CONSOLE", units, days.stream().map(day ->
new AdfitReport.Day(day.getDate(), day.getUnit(), day.getRevenue(), day.getCost(),
day.getCostBasis())).toList());
day.getCostBasis())).toList(), account);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
@RequiredArgsConstructor
@RequestMapping("/api/v1/admin/adfit")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "관리자 AdFit", description = "iOS 광고 단위 및 콘솔 수동 입력 수익·비용·ROI")
@Tag(name = "관리자 AdFit", description = "AdFit 계정 자동 수익 및 수동 입력 비용·ROI")
public class AdminAdfitController {
private final AdfitReportService service;

@GetMapping
@Operation(summary = "AdFit 광고 단위와 기간별 수익·ROI", description = "미입력 금액 및 계산 불가 ROI는 null")
@Operation(summary = "AdFit 기간별 수익·ROI", description = "account는 AdFit 계정 자동 수익, units/days는 수동 입력 비용·ROI. 미입력·계산 불가는 null")
public ApiResponse<AdfitReport> report(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.swyp.picke.domain.admin.adfit;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;

@Component
class JavaNetAdfitHttpTransport implements AdfitHttpTransport {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(8);

private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();

@Override
public AdfitHttpResponse get(URI uri, String sessionCookie) {
try {
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(REQUEST_TIMEOUT)
.header("Accept", "application/json")
.header("Referer", "https://adfit.kakao.com/report")
.header("Cookie", sessionCookie)
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return new AdfitHttpResponse(response.statusCode(), response.headers().map(), response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new AdfitHttpResponse(0, Map.of("x-adfit-error", List.of("interrupted")), "");
} catch (Exception e) {
return new AdfitHttpResponse(0, Map.of("x-adfit-error", List.of("request_failed")), "");
}
}
}
Loading
Loading