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
16 changes: 16 additions & 0 deletions docs/api-specs/adfit-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 관리자 AdFit 광고와 ROI

- `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}`.
- `unit`: NATIVE_WIDE(홈·큐레이션·마이페이지 공유), BANNER(탐색), APP_TRANSITION(앱 시작).
- `costBasis`: AD_OPERATIONS(광고 운영비), ACQUISITION(유입 광고비), SERVICE_OPERATIONS(서비스 운영비).
- KRW 금액, 소수 둘째 자리까지, 음수 불가. 같은 날짜·단위는 수정. 미래 날짜 입력 불가.
- source=MANUAL_CONSOLE: AdFit 콘솔에서 확인한 예상 수익을 관리자가 입력한다. 자동 연동·확정 정산액이 아니다.
- 광고 단위는 로컬 Picke-iOS의 SDK 연결 기준이다. 개별 사용자에게 SDK가 선택한 이미지·광고주 소재를 재현하거나 실시간 노출을 보증하지 않는다.
- 같은 단위를 여러 화면에서 사용하더라도 수익은 한 번만 합산한다. 비용도 단위별 배분액으로 입력하며 전체 운영비를 각 단위에 중복 입력하지 않는다.
- 수익과 비용은 입력된 날짜들의 합계다. reportedDays/expectedDays로 부분 입력을 표시한다. 미입력은 null이며 0원이 아니다.
- ROI = (수익 - 비용) / 비용 × 100. 모든 날짜가 입력되고 같은 비용 기준이며 비용이 양수일 때만 계산한다. 그 외 null.
- 수익 0, 비용 양수인 정상 입력은 ROI -100%다. 미입력과 구분한다.
- 공식 공개 보고서 REST API는 확인하지 못했으므로 비공개 API를 추측하거나 관리자 브라우저에 인증 정보를 저장하지 않는다.
- 공식 참고: https://adfit.kakao.com/ , https://adfit.github.io/
- DB: `docs/db/20260910_create_adfit_daily_reports.sql`. 현재 프로젝트는 Hibernate ddl-auto=update를 사용한다.
11 changes: 11 additions & 0 deletions docs/db/20260910_create_adfit_daily_reports.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS adfit_daily_reports (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP,
updated_at TIMESTAMP,
report_date DATE NOT NULL,
ad_unit VARCHAR(30) NOT NULL,
revenue NUMERIC(16, 2) NOT NULL CHECK (revenue >= 0),
cost NUMERIC(16, 2) NOT NULL CHECK (cost >= 0),
cost_basis VARCHAR(30) NOT NULL,
CONSTRAINT uk_adfit_daily_date_unit UNIQUE (report_date, ad_unit)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.swyp.picke.domain.admin.adfit;

public enum AdfitCostBasis {
AD_OPERATIONS, ACQUISITION, SERVICE_OPERATIONS
}
40 changes: 40 additions & 0 deletions src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDaily.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.swyp.picke.domain.admin.adfit;

import com.swyp.picke.global.common.BaseEntity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.AccessLevel;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "adfit_daily_reports", uniqueConstraints = @UniqueConstraint(
name = "uk_adfit_daily_date_unit", columnNames = {"report_date", "ad_unit"}))
public class AdfitDaily extends BaseEntity {
@Column(name = "report_date", nullable = false)
private LocalDate date;
@Enumerated(EnumType.STRING)
@Column(name = "ad_unit", nullable = false, length = 30)
private AdfitUnit unit;
@Column(nullable = false, precision = 16, scale = 2)
private BigDecimal revenue;
@Column(nullable = false, precision = 16, scale = 2)
private BigDecimal cost;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private AdfitCostBasis costBasis;

public AdfitDaily(LocalDate date, AdfitUnit unit) {
this.date = date;
this.unit = unit;
}

public void update(BigDecimal revenue, BigDecimal cost, AdfitCostBasis costBasis) {
this.revenue = revenue;
this.cost = cost;
this.costBasis = costBasis;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.swyp.picke.domain.admin.adfit;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AdfitDailyRepository extends JpaRepository<AdfitDaily, Long> {
Optional<AdfitDaily> findByDateAndUnit(LocalDate date, AdfitUnit unit);
List<AdfitDaily> findAllByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.swyp.picke.domain.admin.adfit;

import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;

public record AdfitDailyRequest(
@NotNull LocalDate date,
@NotNull AdfitUnit unit,
@NotNull @DecimalMin("0") @Digits(integer = 14, fraction = 2) BigDecimal revenue,
@NotNull @DecimalMin("0") @Digits(integer = 14, fraction = 2) BigDecimal cost,
@NotNull AdfitCostBasis costBasis
) {}
14 changes: 14 additions & 0 deletions src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.swyp.picke.domain.admin.adfit;

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

public record AdfitReport(LocalDate from, LocalDate to, String source,
List<UnitReport> units, List<Day> days) {
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) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.swyp.picke.domain.admin.adfit;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AdfitReportService {
private final AdfitDailyRepository repository;

@Transactional
public void save(AdfitDailyRequest request) {
if (request.date().isAfter(LocalDate.now(ZoneId.of("Asia/Seoul")))) {
throw new IllegalArgumentException("미래 날짜의 수익은 입력할 수 없습니다.");
}
AdfitDaily daily = repository.findByDateAndUnit(request.date(), request.unit())
.orElseGet(() -> new AdfitDaily(request.date(), request.unit()));
daily.update(request.revenue(), request.cost(), request.costBasis());
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) {
throw new IllegalArgumentException("조회 기간은 1일부터 366일까지입니다.");
}
List<AdfitDaily> days = repository.findAllByDateBetweenOrderByDateDesc(from, to);
List<AdfitReport.UnitReport> units = Arrays.stream(AdfitUnit.values()).map(unit -> {
List<AdfitDaily> entries = days.stream().filter(day -> day.getUnit() == unit).toList();
BigDecimal revenue = entries.isEmpty() ? null : entries.stream().map(AdfitDaily::getRevenue)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal cost = entries.isEmpty() ? null : entries.stream().map(AdfitDaily::getCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 기간이 빠졌거나 비용 기준이 섞이면 비교 가능한 ROI가 아니다.
boolean complete = entries.size() == expected
&& entries.stream().map(AdfitDaily::getCostBasis).distinct().count() == 1;
BigDecimal roi = complete && cost != null && cost.signum() > 0
? revenue.subtract(cost).multiply(BigDecimal.valueOf(100))
.divide(cost, 2, RoundingMode.HALF_UP) : null;
return new AdfitReport.UnitReport(unit, unit.getDisplayName(), unit.getPlacements(),
unit.getFormat(), entries.size(), expected, revenue, cost, roi);
}).toList();
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());
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/swyp/picke/domain/admin/adfit/AdfitUnit.java
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 lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum AdfitUnit {
NATIVE_WIDE("네이티브 광고", List.of("홈", "큐레이션", "마이페이지"), "네이티브 2:1"),
BANNER("탐색 배너", List.of("탐색"), "배너 320×100"),
APP_TRANSITION("앱 시작 팝업", List.of("앱 시작"), "전면 팝업");

private final String displayName;
private final List<String> placements;
private final String format;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.swyp.picke.domain.admin.adfit;

import com.swyp.picke.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/admin/adfit")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "관리자 AdFit", description = "iOS 광고 단위 및 콘솔 수동 입력 수익·비용·ROI")
public class AdminAdfitController {
private final AdfitReportService service;

@GetMapping
@Operation(summary = "AdFit 광고 단위와 기간별 수익·ROI", description = "미입력 금액 및 계산 불가 ROI는 null")
public ApiResponse<AdfitReport> report(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
return ApiResponse.onSuccess(service.report(from, to));
}

@PutMapping("/daily")
@Operation(summary = "광고 단위별 일일 수익·배분 비용 저장", description = "같은 날짜·광고 단위는 수정한다. 비용을 여러 단위에 중복 입력하지 않는다.")
public ApiResponse<Void> save(@Valid @RequestBody AdfitDailyRequest request) {
service.save(request);
return ApiResponse.onSuccess(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.swyp.picke.domain.admin.adfit;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
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 static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class AdfitReportServiceTest {
@Mock AdfitDailyRepository repository;
@InjectMocks AdfitReportService service;
private final LocalDate date = LocalDate.of(2026, 9, 1);

private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBasis basis) {
AdfitDaily day = new AdfitDaily(date, AdfitUnit.NATIVE_WIDE);
day.update(new BigDecimal(revenue), new BigDecimal(cost), basis);
return day;
}

@Test void calculatesProfitBasedRoiOnceForSharedUnit() {
when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1))).thenReturn(List.of(
day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS),
day(date.plusDays(1), "100", "50", AdfitCostBasis.AD_OPERATIONS)));
var result = service.report(date, date.plusDays(1));
assertThat(result.units()).hasSize(3);
var unit = result.units().getFirst();
assertThat(unit.placements()).hasSize(3);
assertThat(unit.revenue()).isEqualByComparingTo("300");
assertThat(unit.cost()).isEqualByComparingTo("150");
assertThat(unit.roi()).isEqualByComparingTo("100");
}

@Test void missingRevenueIsNotZero() {
when(repository.findAllByDateBetweenOrderByDateDesc(date, date)).thenReturn(List.of());
var unit = service.report(date, date).units().getFirst();
assertThat(unit.revenue()).isNull();
assertThat(unit.cost()).isNull();
assertThat(unit.roi()).isNull();
assertThat(unit.reportedDays()).isZero();
}

@Test void partialPeriodDoesNotReportRoi() {
when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1)))
.thenReturn(List.of(day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS)));
var unit = service.report(date, date.plusDays(1)).units().getFirst();
assertThat(unit.revenue()).isEqualByComparingTo("200");
assertThat(unit.roi()).isNull();
assertThat(unit.expectedDays()).isEqualTo(2);
}

@Test void zeroCostHasNoRoiButLossIsNegative() {
when(repository.findAllByDateBetweenOrderByDateDesc(date, date))
.thenReturn(List.of(day(date, "100", "0", AdfitCostBasis.AD_OPERATIONS)))
.thenReturn(List.of(day(date, "50", "100", AdfitCostBasis.AD_OPERATIONS)));
assertThat(service.report(date, date).units().getFirst().roi()).isNull();
assertThat(service.report(date, date).units().getFirst().roi()).isEqualByComparingTo("-50");
}

@Test void mixedCostBasesHaveNoRoi() {
when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1))).thenReturn(List.of(
day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS),
day(date.plusDays(1), "100", "50", AdfitCostBasis.ACQUISITION)));
assertThat(service.report(date, date.plusDays(1)).units().getFirst().roi()).isNull();
}

@Test void repeatedSaveUpdatesExistingDay() {
var existing = day(date, "100", "10", AdfitCostBasis.AD_OPERATIONS);
when(repository.findByDateAndUnit(date, AdfitUnit.NATIVE_WIDE)).thenReturn(Optional.of(existing));
service.save(new AdfitDailyRequest(date, AdfitUnit.NATIVE_WIDE,
new BigDecimal("200"), new BigDecimal("20"), AdfitCostBasis.ACQUISITION));
verify(repository).save(existing);
assertThat(existing.getRevenue()).isEqualByComparingTo("200");
assertThat(existing.getCostBasis()).isEqualTo(AdfitCostBasis.ACQUISITION);
}

@Test void rejectsInvalidPeriodsAndFutureEntries() {
assertThatThrownBy(() -> service.report(date.plusDays(1), date)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> service.report(date, date.plusDays(366))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> service.save(new AdfitDailyRequest(LocalDate.now().plusDays(2),
AdfitUnit.BANNER, BigDecimal.ONE, BigDecimal.ONE, AdfitCostBasis.AD_OPERATIONS)))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(repository);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.swyp.picke.domain.admin.adfit;

import java.math.BigDecimal;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@SpringJUnitConfig(AdminAdfitSecurityTest.Config.class)
class AdminAdfitSecurityTest {
@Configuration
@EnableMethodSecurity
static class Config {
@Bean AdfitReportService service() { return mock(AdfitReportService.class); }
@Bean AdminAdfitController controller(AdfitReportService service) { return new AdminAdfitController(service); }
}
@Autowired AdminAdfitController controller;

@Test @WithMockUser(roles = "USER")
void regularUserCannotReadOrWriteIncome() {
var date = LocalDate.of(2026, 9, 1);
assertThatThrownBy(() -> controller.report(date, date)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.save(new AdfitDailyRequest(date, AdfitUnit.BANNER,
BigDecimal.ONE, BigDecimal.ONE, AdfitCostBasis.AD_OPERATIONS)))
.isInstanceOf(AccessDeniedException.class);
}

@Test @WithMockUser(roles = "ADMIN")
void adminCanReadAndWriteIncome() {
var date = LocalDate.of(2026, 9, 1);
assertThatCode(() -> controller.report(date, date)).doesNotThrowAnyException();
assertThatCode(() -> controller.save(new AdfitDailyRequest(date, AdfitUnit.BANNER,
BigDecimal.ONE, BigDecimal.ONE, AdfitCostBasis.AD_OPERATIONS))).doesNotThrowAnyException();
}

@Test void rejectsNegativeMoneyAndMissingBasis() {
try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var errors = factory.getValidator().validate(new AdfitDailyRequest(LocalDate.now(),
AdfitUnit.BANNER, new BigDecimal("-1"), new BigDecimal("1.001"), null));
assertThat(errors).extracting(error -> error.getPropertyPath().toString())
.containsExactlyInAnyOrder("revenue", "cost", "costBasis");
}
}
}
Loading