diff --git a/docs/api-specs/adfit-api.md b/docs/api-specs/adfit-api.md new file mode 100644 index 00000000..46bc6b8e --- /dev/null +++ b/docs/api-specs/adfit-api.md @@ -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를 사용한다. diff --git a/docs/db/20260910_create_adfit_daily_reports.sql b/docs/db/20260910_create_adfit_daily_reports.sql new file mode 100644 index 00000000..46e168b9 --- /dev/null +++ b/docs/db/20260910_create_adfit_daily_reports.sql @@ -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) +); diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitCostBasis.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitCostBasis.java new file mode 100644 index 00000000..33162dcd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitCostBasis.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.admin.adfit; + +public enum AdfitCostBasis { + AD_OPERATIONS, ACQUISITION, SERVICE_OPERATIONS +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDaily.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDaily.java new file mode 100644 index 00000000..f80b299c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDaily.java @@ -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; + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRepository.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRepository.java new file mode 100644 index 00000000..23cbfa6c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRepository.java @@ -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 { + Optional findByDateAndUnit(LocalDate date, AdfitUnit unit); + List findAllByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to); +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRequest.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRequest.java new file mode 100644 index 00000000..a4b5cf25 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRequest.java @@ -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 +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java new file mode 100644 index 00000000..5b0f1cee --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java @@ -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 units, List days) { + public record UnitReport(AdfitUnit unit, String name, List 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) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java new file mode 100644 index 00000000..8e510a70 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java @@ -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 days = repository.findAllByDateBetweenOrderByDateDesc(from, to); + List units = Arrays.stream(AdfitUnit.values()).map(unit -> { + List 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()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitUnit.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitUnit.java new file mode 100644 index 00000000..a94df626 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitUnit.java @@ -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 placements; + private final String format; +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java new file mode 100644 index 00000000..31d258c0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java @@ -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 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 save(@Valid @RequestBody AdfitDailyRequest request) { + service.save(request); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java new file mode 100644 index 00000000..3c1d0b9c --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java @@ -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); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/adfit/AdminAdfitSecurityTest.java b/src/test/java/com/swyp/picke/domain/admin/adfit/AdminAdfitSecurityTest.java new file mode 100644 index 00000000..8116447c --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/adfit/AdminAdfitSecurityTest.java @@ -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"); + } + } +}