Skip to content

Commit 452e3e5

Browse files
authored
운영 관리자에서 AdFit 수익과 ROI를 확인하도록 지원
AdFit 수익과 비용을 기록해 관리자 ROI를 확인하도록 지원 Constraint: AdFit 수익은 콘솔 수동 입력값이며 자동 동기화가 아니다 Tested: AdFit 계산 및 권한과 기존 광고 회귀 테스트 28건 Confidence: high Scope-risk: narrow
1 parent 0551d9d commit 452e3e5

12 files changed

Lines changed: 360 additions & 0 deletions

File tree

docs/api-specs/adfit-api.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# 관리자 AdFit 광고와 ROI
2+
3+
- `GET /api/v1/admin/adfit?from=YYYY-MM-DD&to=YYYY-MM-DD`: ADMIN 전용. 최대 366일.
4+
- `PUT /api/v1/admin/adfit/daily`: ADMIN 전용. `{date, unit, revenue, cost, costBasis}`.
5+
- `unit`: NATIVE_WIDE(홈·큐레이션·마이페이지 공유), BANNER(탐색), APP_TRANSITION(앱 시작).
6+
- `costBasis`: AD_OPERATIONS(광고 운영비), ACQUISITION(유입 광고비), SERVICE_OPERATIONS(서비스 운영비).
7+
- KRW 금액, 소수 둘째 자리까지, 음수 불가. 같은 날짜·단위는 수정. 미래 날짜 입력 불가.
8+
- source=MANUAL_CONSOLE: AdFit 콘솔에서 확인한 예상 수익을 관리자가 입력한다. 자동 연동·확정 정산액이 아니다.
9+
- 광고 단위는 로컬 Picke-iOS의 SDK 연결 기준이다. 개별 사용자에게 SDK가 선택한 이미지·광고주 소재를 재현하거나 실시간 노출을 보증하지 않는다.
10+
- 같은 단위를 여러 화면에서 사용하더라도 수익은 한 번만 합산한다. 비용도 단위별 배분액으로 입력하며 전체 운영비를 각 단위에 중복 입력하지 않는다.
11+
- 수익과 비용은 입력된 날짜들의 합계다. reportedDays/expectedDays로 부분 입력을 표시한다. 미입력은 null이며 0원이 아니다.
12+
- ROI = (수익 - 비용) / 비용 × 100. 모든 날짜가 입력되고 같은 비용 기준이며 비용이 양수일 때만 계산한다. 그 외 null.
13+
- 수익 0, 비용 양수인 정상 입력은 ROI -100%다. 미입력과 구분한다.
14+
- 공식 공개 보고서 REST API는 확인하지 못했으므로 비공개 API를 추측하거나 관리자 브라우저에 인증 정보를 저장하지 않는다.
15+
- 공식 참고: https://adfit.kakao.com/ , https://adfit.github.io/
16+
- DB: `docs/db/20260910_create_adfit_daily_reports.sql`. 현재 프로젝트는 Hibernate ddl-auto=update를 사용한다.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
CREATE TABLE IF NOT EXISTS adfit_daily_reports (
2+
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
3+
created_at TIMESTAMP,
4+
updated_at TIMESTAMP,
5+
report_date DATE NOT NULL,
6+
ad_unit VARCHAR(30) NOT NULL,
7+
revenue NUMERIC(16, 2) NOT NULL CHECK (revenue >= 0),
8+
cost NUMERIC(16, 2) NOT NULL CHECK (cost >= 0),
9+
cost_basis VARCHAR(30) NOT NULL,
10+
CONSTRAINT uk_adfit_daily_date_unit UNIQUE (report_date, ad_unit)
11+
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
public enum AdfitCostBasis {
4+
AD_OPERATIONS, ACQUISITION, SERVICE_OPERATIONS
5+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import com.swyp.picke.global.common.BaseEntity;
4+
import jakarta.persistence.*;
5+
import java.math.BigDecimal;
6+
import java.time.LocalDate;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
import lombok.AccessLevel;
10+
11+
@Entity
12+
@Getter
13+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
14+
@Table(name = "adfit_daily_reports", uniqueConstraints = @UniqueConstraint(
15+
name = "uk_adfit_daily_date_unit", columnNames = {"report_date", "ad_unit"}))
16+
public class AdfitDaily extends BaseEntity {
17+
@Column(name = "report_date", nullable = false)
18+
private LocalDate date;
19+
@Enumerated(EnumType.STRING)
20+
@Column(name = "ad_unit", nullable = false, length = 30)
21+
private AdfitUnit unit;
22+
@Column(nullable = false, precision = 16, scale = 2)
23+
private BigDecimal revenue;
24+
@Column(nullable = false, precision = 16, scale = 2)
25+
private BigDecimal cost;
26+
@Enumerated(EnumType.STRING)
27+
@Column(nullable = false, length = 30)
28+
private AdfitCostBasis costBasis;
29+
30+
public AdfitDaily(LocalDate date, AdfitUnit unit) {
31+
this.date = date;
32+
this.unit = unit;
33+
}
34+
35+
public void update(BigDecimal revenue, BigDecimal cost, AdfitCostBasis costBasis) {
36+
this.revenue = revenue;
37+
this.cost = cost;
38+
this.costBasis = costBasis;
39+
}
40+
}
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 java.time.LocalDate;
4+
import java.util.List;
5+
import java.util.Optional;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
8+
public interface AdfitDailyRepository extends JpaRepository<AdfitDaily, Long> {
9+
Optional<AdfitDaily> findByDateAndUnit(LocalDate date, AdfitUnit unit);
10+
List<AdfitDaily> findAllByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to);
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import jakarta.validation.constraints.*;
4+
import java.math.BigDecimal;
5+
import java.time.LocalDate;
6+
7+
public record AdfitDailyRequest(
8+
@NotNull LocalDate date,
9+
@NotNull AdfitUnit unit,
10+
@NotNull @DecimalMin("0") @Digits(integer = 14, fraction = 2) BigDecimal revenue,
11+
@NotNull @DecimalMin("0") @Digits(integer = 14, fraction = 2) BigDecimal cost,
12+
@NotNull AdfitCostBasis costBasis
13+
) {}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import java.math.BigDecimal;
4+
import java.time.LocalDate;
5+
import java.util.List;
6+
7+
public record AdfitReport(LocalDate from, LocalDate to, String source,
8+
List<UnitReport> units, List<Day> days) {
9+
public record UnitReport(AdfitUnit unit, String name, List<String> placements, String format,
10+
long reportedDays, long expectedDays, BigDecimal revenue,
11+
BigDecimal cost, BigDecimal roi) {}
12+
public record Day(LocalDate date, AdfitUnit unit, BigDecimal revenue, BigDecimal cost,
13+
AdfitCostBasis costBasis) {}
14+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import java.math.BigDecimal;
4+
import java.math.RoundingMode;
5+
import java.time.LocalDate;
6+
import java.time.ZoneId;
7+
import java.time.temporal.ChronoUnit;
8+
import java.util.Arrays;
9+
import java.util.List;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
@Service
15+
@RequiredArgsConstructor
16+
public class AdfitReportService {
17+
private final AdfitDailyRepository repository;
18+
19+
@Transactional
20+
public void save(AdfitDailyRequest request) {
21+
if (request.date().isAfter(LocalDate.now(ZoneId.of("Asia/Seoul")))) {
22+
throw new IllegalArgumentException("미래 날짜의 수익은 입력할 수 없습니다.");
23+
}
24+
AdfitDaily daily = repository.findByDateAndUnit(request.date(), request.unit())
25+
.orElseGet(() -> new AdfitDaily(request.date(), request.unit()));
26+
daily.update(request.revenue(), request.cost(), request.costBasis());
27+
repository.save(daily);
28+
}
29+
30+
@Transactional(readOnly = true)
31+
public AdfitReport report(LocalDate from, LocalDate to) {
32+
long expected = ChronoUnit.DAYS.between(from, to) + 1;
33+
if (expected < 1 || expected > 366) {
34+
throw new IllegalArgumentException("조회 기간은 1일부터 366일까지입니다.");
35+
}
36+
List<AdfitDaily> days = repository.findAllByDateBetweenOrderByDateDesc(from, to);
37+
List<AdfitReport.UnitReport> units = Arrays.stream(AdfitUnit.values()).map(unit -> {
38+
List<AdfitDaily> entries = days.stream().filter(day -> day.getUnit() == unit).toList();
39+
BigDecimal revenue = entries.isEmpty() ? null : entries.stream().map(AdfitDaily::getRevenue)
40+
.reduce(BigDecimal.ZERO, BigDecimal::add);
41+
BigDecimal cost = entries.isEmpty() ? null : entries.stream().map(AdfitDaily::getCost)
42+
.reduce(BigDecimal.ZERO, BigDecimal::add);
43+
// 기간이 빠졌거나 비용 기준이 섞이면 비교 가능한 ROI가 아니다.
44+
boolean complete = entries.size() == expected
45+
&& entries.stream().map(AdfitDaily::getCostBasis).distinct().count() == 1;
46+
BigDecimal roi = complete && cost != null && cost.signum() > 0
47+
? revenue.subtract(cost).multiply(BigDecimal.valueOf(100))
48+
.divide(cost, 2, RoundingMode.HALF_UP) : null;
49+
return new AdfitReport.UnitReport(unit, unit.getDisplayName(), unit.getPlacements(),
50+
unit.getFormat(), entries.size(), expected, revenue, cost, roi);
51+
}).toList();
52+
return new AdfitReport(from, to, "MANUAL_CONSOLE", units, days.stream().map(day ->
53+
new AdfitReport.Day(day.getDate(), day.getUnit(), day.getRevenue(), day.getCost(),
54+
day.getCostBasis())).toList());
55+
}
56+
}
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 lombok.Getter;
5+
import lombok.RequiredArgsConstructor;
6+
7+
@Getter
8+
@RequiredArgsConstructor
9+
public enum AdfitUnit {
10+
NATIVE_WIDE("네이티브 광고", List.of("홈", "큐레이션", "마이페이지"), "네이티브 2:1"),
11+
BANNER("탐색 배너", List.of("탐색"), "배너 320×100"),
12+
APP_TRANSITION("앱 시작 팝업", List.of("앱 시작"), "전면 팝업");
13+
14+
private final String displayName;
15+
private final List<String> placements;
16+
private final String format;
17+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.swyp.picke.domain.admin.adfit;
2+
3+
import com.swyp.picke.global.common.response.ApiResponse;
4+
import io.swagger.v3.oas.annotations.Operation;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
6+
import jakarta.validation.Valid;
7+
import java.time.LocalDate;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.format.annotation.DateTimeFormat;
10+
import org.springframework.security.access.prepost.PreAuthorize;
11+
import org.springframework.web.bind.annotation.*;
12+
13+
@RestController
14+
@RequiredArgsConstructor
15+
@RequestMapping("/api/v1/admin/adfit")
16+
@PreAuthorize("hasRole('ADMIN')")
17+
@Tag(name = "관리자 AdFit", description = "iOS 광고 단위 및 콘솔 수동 입력 수익·비용·ROI")
18+
public class AdminAdfitController {
19+
private final AdfitReportService service;
20+
21+
@GetMapping
22+
@Operation(summary = "AdFit 광고 단위와 기간별 수익·ROI", description = "미입력 금액 및 계산 불가 ROI는 null")
23+
public ApiResponse<AdfitReport> report(
24+
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
25+
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
26+
return ApiResponse.onSuccess(service.report(from, to));
27+
}
28+
29+
@PutMapping("/daily")
30+
@Operation(summary = "광고 단위별 일일 수익·배분 비용 저장", description = "같은 날짜·광고 단위는 수정한다. 비용을 여러 단위에 중복 입력하지 않는다.")
31+
public ApiResponse<Void> save(@Valid @RequestBody AdfitDailyRequest request) {
32+
service.save(request);
33+
return ApiResponse.onSuccess(null);
34+
}
35+
}

0 commit comments

Comments
 (0)