images,
+ @JsonProperty("apTrackingLink") String trackingLink,
+ @JsonProperty("apOS") String os,
+ @JsonProperty("apRemain") Integer remain
+) {
+
+ private static final String ICON_KEY = "icon";
+
+ public String iconUrl() {
+ return images == null ? null : images.get(ICON_KEY);
+ }
+
+ /** 잔여 수량이 없는 캠페인은 클릭해도 전환이 잡히지 않으므로 노출하지 않는다. */
+ public boolean hasRemaining() {
+ return remain != null && remain > 0;
+ }
+
+ /** 이미지나 추적 링크가 비면 배너를 그릴 수 없다. */
+ public boolean isRenderable() {
+ return offerId != null && !offerId.isBlank()
+ && appTitle != null && !appTitle.isBlank()
+ && trackingLink != null && !trackingLink.isBlank()
+ && iconUrl() != null;
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingClient.java b/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingClient.java
new file mode 100644
index 00000000..0551b45a
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingClient.java
@@ -0,0 +1,92 @@
+package com.swyp.picke.domain.ad.client;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * 애드픽 쇼핑·핫딜 상품을 가져온다.
+ *
+ * 캠페인 리스트 API(offers.php)와 별도 재고다. 이 계정 기준으로 앱 캠페인은 7건뿐이지만
+ * 쇼핑과 핫딜에서 각각 30건이 더 내려온다. 지면을 채우려면 이쪽이 필요하다.
+ *
+ *
두 API는 응답이 {@code {title, description, list:[...]}} 로 한 겹 싸여 있다.
+ * 형태가 같아 같은 파서를 쓴다.
+ */
+@Slf4j
+@Component
+public class AdpickShoppingClient {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(10);
+
+ private final String shoppingUrl;
+ private final String hotdealUrl;
+ private final String affId;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ public AdpickShoppingClient(
+ @Value("${picke.ad.adpick.shopping-url:https://adpick.co.kr/apis/sdk_shopping.php}") String shoppingUrl,
+ @Value("${picke.ad.adpick.hotdeal-url:https://adpick.co.kr/apis/sdk_shopping_hotdeal.php}") String hotdealUrl,
+ @Value("${picke.ad.adpick.aff-id:}") String affId) {
+ this.shoppingUrl = shoppingUrl;
+ this.hotdealUrl = hotdealUrl;
+ this.affId = affId;
+ }
+
+ public boolean isConfigured() {
+ return StringUtils.hasText(affId);
+ }
+
+ /** 쇼핑과 핫딜을 합쳐 준다. 한쪽이 실패해도 다른 쪽은 살린다. */
+ public List fetchProducts() {
+ List products = new ArrayList<>();
+ products.addAll(fetchFrom(shoppingUrl));
+ products.addAll(fetchFrom(hotdealUrl));
+ return products;
+ }
+
+ private List fetchFrom(String baseUrl) {
+ String url = UriComponentsBuilder.fromUriString(baseUrl)
+ .queryParam("affid", affId)
+ .build()
+ .toUriString();
+ try {
+ // 이 API 는 JSON 을 text/html 로 내려준다. 컨텐츠 타입만 믿고 디코딩하면 전부 실패한다.
+ String body = WebClient.create()
+ .get()
+ .uri(url)
+ .retrieve()
+ .bodyToMono(String.class)
+ .timeout(TIMEOUT)
+ .block();
+
+ if (body == null || body.isBlank()) {
+ return List.of();
+ }
+ List groups = objectMapper.readValue(body, new TypeReference>() {
+ });
+ return groups.stream()
+ .filter(group -> group.list() != null)
+ .flatMap(group -> group.list().stream())
+ .toList();
+ } catch (Exception e) {
+ // 재고를 늘리려고 붙인 부가 피드다. 여기서 터져도 앱 캠페인 동기화까지 막지 않는다.
+ log.warn("[AdpickShopping] {} 조회 실패: {}", baseUrl, e.getMessage());
+ return List.of();
+ }
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private record Group(@JsonProperty("list") List list) {
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingResponse.java b/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingResponse.java
new file mode 100644
index 00000000..b3c95dcf
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingResponse.java
@@ -0,0 +1,38 @@
+package com.swyp.picke.domain.ad.client;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * 애드픽 쇼핑·핫딜 API(sdk_shopping.php, sdk_shopping_hotdeal.php)의 상품 한 건.
+ *
+ * 앱 설치형 캠페인과 달리 상품에는 애드픽이 주는 고유 코드가 없다.
+ * {@code buyurl} 이 이미 affid 가 박힌 완성된 추적 링크라, 이 값을 식별자의 근거로 삼는다.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record AdpickShoppingResponse(
+
+ @JsonProperty("product_name") String productName,
+ @JsonProperty("photo") String photo,
+ @JsonProperty("mall_name") String mallName,
+ @JsonProperty("commission") String commission,
+ @JsonProperty("buyurl") String buyUrl
+) {
+
+ /** 이미지나 구매 링크가 비면 배너를 그릴 수 없다. */
+ public boolean isRenderable() {
+ return productName != null && !productName.isBlank()
+ && photo != null && !photo.isBlank()
+ && buyUrl != null && !buyUrl.isBlank();
+ }
+
+ /** 판매처와 수수료를 보조 문구로 쓴다. 둘 다 없으면 보조 문구를 비운다. */
+ public String subtitle() {
+ if (mallName == null || mallName.isBlank()) {
+ return commission == null || commission.isBlank() ? null : commission;
+ }
+ return commission == null || commission.isBlank()
+ ? mallName
+ : mallName + " · " + commission;
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java
new file mode 100644
index 00000000..12ac991e
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java
@@ -0,0 +1,51 @@
+package com.swyp.picke.domain.ad.controller;
+
+import com.swyp.picke.domain.ad.service.AdClickService;
+import com.swyp.picke.domain.ad.service.AdClickService.AdClickTarget;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.servlet.view.RedirectView;
+
+/**
+ * 제휴 링크 클릭 진입점.
+ * /api/v1 아래에 두지 않는다. 외부 브라우저가 여는 공개 숏링크라 짧아야 하고, JSON API가 아니다.
+ */
+@Controller
+@RequiredArgsConstructor
+public class AdClickController {
+
+ private static final String FORWARDED_FOR = "X-Forwarded-For";
+
+ private final AdClickService adClickService;
+
+ @Value("${picke.ad.base-url:https://ad.picke.store}")
+ private String adBaseUrl;
+
+ @GetMapping("/c/{code}")
+ public RedirectView click(@PathVariable String code, HttpServletRequest request) {
+ Optional target = adClickService.resolveTarget(code);
+
+ if (target.isEmpty()) {
+ // 만료되었거나 없는 코드다. 404를 보여주는 대신 랜딩으로 흘려보낸다.
+ return new RedirectView(adBaseUrl + "/");
+ }
+
+ AdClickTarget clickTarget = target.get();
+ adClickService.recordClick(clickTarget, resolveClientIp(request), request.getHeader("User-Agent"));
+
+ return new RedirectView(clickTarget.redirectUrl());
+ }
+
+ private String resolveClientIp(HttpServletRequest request) {
+ String forwardedFor = request.getHeader(FORWARDED_FOR);
+ if (forwardedFor == null || forwardedFor.isBlank()) {
+ return request.getRemoteAddr();
+ }
+ return forwardedFor.split(",")[0].trim();
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java
new file mode 100644
index 00000000..3b0f4140
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java
@@ -0,0 +1,52 @@
+package com.swyp.picke.domain.ad.controller;
+
+import com.swyp.picke.domain.ad.dto.request.AdImpressionRequest;
+import com.swyp.picke.domain.ad.dto.response.AdResponse;
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.domain.ad.enums.AdTargetOs;
+import com.swyp.picke.domain.ad.service.AdQueryService;
+import com.swyp.picke.global.common.response.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "제휴 광고 API", description = "앱 지면에 노출할 제휴 광고 조회 및 노출 집계")
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/ads")
+public class AdController {
+
+ private final AdQueryService adQueryService;
+
+ @Operation(summary = "지면별 광고 조회",
+ description = "게재 가능한 소재가 없으면 빈 배열을 준다. 앱은 이때 지면 자체를 숨긴다.")
+ @GetMapping
+ public ApiResponse> getAds(
+ @Parameter(description = "노출 지면", example = "HOME_FEED")
+ @RequestParam AdSlotCode slot,
+ @Parameter(description = "요청 OS. 앱 설치형 캠페인이 OS별로 갈리므로 실제 OS를 보내야 한다.",
+ example = "ANDROID")
+ @RequestParam(defaultValue = "ALL") AdTargetOs os,
+ @Parameter(description = "받아갈 소재 개수", example = "1")
+ @RequestParam(defaultValue = "1") int size
+ ) {
+ return ApiResponse.onSuccess(adQueryService.findServableAds(slot, os, size));
+ }
+
+ @Operation(summary = "광고 노출 집계",
+ description = "조회가 아니라 실제로 화면에 그려진 시점에 호출한다. 조회를 노출로 세면 CTR이 왜곡된다.")
+ @PostMapping("/impressions")
+ public ApiResponse recordImpressions(@Valid @RequestBody AdImpressionRequest request) {
+ adQueryService.recordImpressions(request.codes());
+ return ApiResponse.onSuccess(null);
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java
new file mode 100644
index 00000000..461fbfb3
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java
@@ -0,0 +1,35 @@
+package com.swyp.picke.domain.ad.controller;
+
+import com.swyp.picke.domain.ad.service.AdQueryService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+
+/**
+ * ad.picke.store 루트 공개 지면.
+ * 쿠팡 파트너스 매체 심사에서 URL 접속 확인을 하므로 실제 콘텐츠가 있어야 한다.
+ * 광고 도메인이 아닌 Host로 들어오면 최소 응답만 준다. API 도메인 루트에 광고 페이지가 뜨면 안 된다.
+ */
+@Controller
+@RequiredArgsConstructor
+public class AdLandingController {
+
+ private final AdQueryService adQueryService;
+
+ @Value("${picke.ad.host:ad.picke.store}")
+ private String adHost;
+
+ @GetMapping("/")
+ public Object landing(HttpServletRequest request, Model model) {
+ if (!adHost.equalsIgnoreCase(request.getServerName())) {
+ return ResponseEntity.ok("PICKE");
+ }
+
+ model.addAttribute("ads", adQueryService.findLandingAds());
+ return "ad/landing";
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java b/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java
new file mode 100644
index 00000000..3590aa42
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java
@@ -0,0 +1,22 @@
+package com.swyp.picke.domain.ad.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import java.util.List;
+
+/**
+ * 노출 집계는 익명 공개 API다. 아무나 반복 호출해 노출수를 부풀릴 수 있다는 것을 감수하는 대신,
+ * 한 번에 밀어 넣을 수 있는 양은 막아 둔다. 한 화면에 그려지는 소재 수가 이보다 많을 일은 없다.
+ */
+@Schema(description = "광고 노출 집계 요청")
+public record AdImpressionRequest(
+
+ @Schema(description = "실제로 화면에 노출된 소재 코드 목록", example = "[\"a1b2c3d4\"]")
+ @NotEmpty(message = "노출된 소재 코드는 최소 1개 이상이어야 합니다.")
+ @Size(max = MAX_CODES, message = "한 번에 집계할 수 있는 소재는 최대 20개입니다.")
+ List codes
+) {
+
+ private static final int MAX_CODES = 20;
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java b/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java
new file mode 100644
index 00000000..a65e8b8b
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java
@@ -0,0 +1,50 @@
+package com.swyp.picke.domain.ad.dto.response;
+
+import com.swyp.picke.domain.ad.entity.AdCreative;
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "앱 지면에 노출할 제휴 광고 소재")
+public record AdResponse(
+
+ @Schema(description = "소재 코드", example = "a1b2c3d4")
+ String code,
+
+ @Schema(description = "매체", example = "COUPANG")
+ AdNetwork network,
+
+ @Schema(description = "배너 주 문구", example = "지금 인기 있는 무선 이어폰")
+ String title,
+
+ @Schema(description = "배너 보조 문구", example = "리뷰 1만 개 이상")
+ String subtitle,
+
+ @Schema(description = "소재 이미지 URL")
+ String imageUrl,
+
+ @Schema(description = "버튼 문구", example = "구매하러 가기")
+ String ctaText,
+
+ @Schema(description = "탭 시 이동할 URL. 외부 브라우저로 열어야 한다.",
+ example = "https://ad.picke.store/c/a1b2c3d4")
+ String clickUrl,
+
+ @Schema(description = "광고 표기 라벨. 표시광고법 대응이므로 반드시 렌더해야 한다.", example = "광고")
+ String label
+) {
+
+ private static final String AD_LABEL = "광고";
+
+ public static AdResponse of(AdCreative creative, String clickUrl) {
+ return new AdResponse(
+ creative.getCode(),
+ creative.getNetwork(),
+ creative.getTitle(),
+ creative.getSubtitle(),
+ creative.getImageUrl(),
+ creative.getCtaText(),
+ clickUrl,
+ AD_LABEL
+ );
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java
new file mode 100644
index 00000000..d791cbc9
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java
@@ -0,0 +1,53 @@
+package com.swyp.picke.domain.ad.entity;
+
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.global.common.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import org.hibernate.annotations.Check;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 클릭 원장. 제휴사 리포트와 대조하는 용도다.
+ * 클릭 시각은 {@code BaseEntity.createdAt}이다. /c/{code}는 외부 브라우저에서 열려
+ * Authorization 헤더가 없으므로 사용자를 특정하지 않는다.
+ */
+@Entity
+@Getter
+@Table(name = "ad_click_logs", indexes = {
+ @Index(name = "idx_ad_click_logs_creative", columnList = "creative_id")
+})
+@Check(name = "ck_ad_click_logs_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', "
+ + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class AdClickLog extends BaseEntity {
+
+ @Column(name = "creative_id", nullable = false)
+ private Long creativeId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "slot", nullable = false, length = 40)
+ private AdSlotCode slot;
+
+ /** 원본 IP는 저장하지 않는다. 중복 클릭 판별에 필요한 정도만 남긴다. */
+ @Column(name = "ip_hash", length = 64)
+ private String ipHash;
+
+ @Column(name = "user_agent", length = 500)
+ private String userAgent;
+
+ @Builder
+ private AdClickLog(Long creativeId, AdSlotCode slot, String ipHash, String userAgent) {
+ this.creativeId = creativeId;
+ this.slot = slot;
+ this.ipHash = ipHash;
+ this.userAgent = userAgent;
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java
new file mode 100644
index 00000000..2eb1a5d0
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java
@@ -0,0 +1,170 @@
+package com.swyp.picke.domain.ad.entity;
+
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.domain.ad.enums.AdSource;
+import com.swyp.picke.domain.ad.enums.AdStatus;
+import com.swyp.picke.domain.ad.enums.AdTargetOs;
+import com.swyp.picke.global.common.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import org.hibernate.annotations.Check;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 제휴 광고 소재. 각 매체 콘솔에서 발급한 완성형 제휴 링크를 어드민이 등록한다.
+ */
+@Entity
+@Getter
+@Table(name = "ad_creatives", uniqueConstraints = {
+ @UniqueConstraint(name = "uk_ad_creatives_external", columnNames = {"source", "external_id"})
+})
+@Check(name = "ck_ad_creatives_network", constraints = "network in ('COUPANG', 'ADPICK')")
+@Check(name = "ck_ad_creatives_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', "
+ + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')")
+@Check(name = "ck_ad_creatives_status", constraints = "status in ('DRAFT', 'ACTIVE', 'PAUSED')")
+@Check(name = "ck_ad_creatives_source", constraints = "source in ('MANUAL', 'ADPICK_API')")
+@Check(name = "ck_ad_creatives_target_os", constraints = "target_os in ('ALL', 'ANDROID', 'IOS')")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class AdCreative extends BaseEntity {
+
+ /** 공개 클릭 URL(/c/{code})에 노출되는 짧은 코드. PK를 그대로 드러내지 않기 위해 둔다. */
+ @Column(name = "code", nullable = false, unique = true, length = 16)
+ private String code;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "network", nullable = false, length = 20)
+ private AdNetwork network;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "slot", nullable = false, length = 40)
+ private AdSlotCode slot;
+
+ @Column(name = "title", nullable = false, length = 100)
+ private String title;
+
+ @Column(name = "subtitle", length = 200)
+ private String subtitle;
+
+ @Column(name = "image_url", nullable = false, length = 500)
+ private String imageUrl;
+
+ /** 쿠팡은 "구매하러 가기", 애드픽 CPI는 "설치하고 받기" 식으로 성격이 달라 소재 단위로 둔다. */
+ @Column(name = "cta_text", nullable = false, length = 30)
+ private String ctaText;
+
+ @Column(name = "landing_url", nullable = false, length = 1000)
+ private String landingUrl;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ private AdStatus status;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "source", nullable = false, length = 20)
+ private AdSource source;
+
+ /** 매체 쪽 캠페인 식별자. 동기화 소재를 다시 찾을 때 쓴다. 수동 등록 소재는 비어 있다. */
+ @Column(name = "external_id", length = 64)
+ private String externalId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "target_os", nullable = false, length = 20)
+ private AdTargetOs targetOs;
+
+ @Column(name = "weight", nullable = false)
+ private int weight;
+
+ @Column(name = "starts_at")
+ private LocalDateTime startsAt;
+
+ @Column(name = "ends_at")
+ private LocalDateTime endsAt;
+
+ @Builder
+ private AdCreative(String code, AdNetwork network, AdSlotCode slot, String title, String subtitle,
+ String imageUrl, String ctaText, String landingUrl, AdStatus status, Integer weight,
+ LocalDateTime startsAt, LocalDateTime endsAt, AdSource source, String externalId,
+ AdTargetOs targetOs) {
+ this.code = code;
+ this.network = network;
+ this.slot = slot;
+ this.title = title;
+ this.subtitle = subtitle;
+ this.imageUrl = imageUrl;
+ this.ctaText = ctaText;
+ this.landingUrl = landingUrl;
+ this.status = status != null ? status : AdStatus.DRAFT;
+ this.weight = weight != null ? weight : 1;
+ this.startsAt = startsAt;
+ this.endsAt = endsAt;
+ this.source = source != null ? source : AdSource.MANUAL;
+ this.externalId = externalId;
+ this.targetOs = targetOs != null ? targetOs : AdTargetOs.ALL;
+ }
+
+ public void update(AdNetwork network, AdSlotCode slot, String title, String subtitle, String imageUrl,
+ String ctaText, String landingUrl, AdStatus status, Integer weight,
+ LocalDateTime startsAt, LocalDateTime endsAt, AdTargetOs targetOs) {
+ this.network = network;
+ this.slot = slot;
+ this.title = title;
+ this.subtitle = subtitle;
+ this.imageUrl = imageUrl;
+ this.ctaText = ctaText;
+ this.landingUrl = landingUrl;
+ this.status = status;
+ this.weight = weight != null ? weight : 1;
+ this.startsAt = startsAt;
+ this.endsAt = endsAt;
+ this.targetOs = targetOs != null ? targetOs : AdTargetOs.ALL;
+ }
+
+ /**
+ * 애드픽 동기화가 매 회차 내용을 덮어쓴다.
+ * 어드민이 꺼둔 소재는 다시 켜지 않는다. 별도 플래그 없이 PAUSED 를 끄기 스위치로 쓴다.
+ */
+ public void syncFromAdpick(String title, String subtitle, String imageUrl, String ctaText,
+ String landingUrl, AdTargetOs targetOs, AdSlotCode slot, int weight,
+ boolean servable) {
+ this.title = title;
+ this.subtitle = subtitle;
+ this.imageUrl = imageUrl;
+ this.ctaText = ctaText;
+ this.landingUrl = landingUrl;
+ this.targetOs = targetOs;
+ this.slot = slot;
+ this.weight = weight;
+ if (this.status != AdStatus.PAUSED) {
+ this.status = servable ? AdStatus.ACTIVE : AdStatus.DRAFT;
+ }
+ }
+
+ /** 동기화가 소유하는 소재는 어드민이 내용을 고치거나 지우지 않는다. */
+ public boolean isManaged() {
+ return source == AdSource.ADPICK_API;
+ }
+
+ public void changeStatus(AdStatus status) {
+ this.status = status;
+ }
+
+ /** 게재 가능 여부. status와 기간을 함께 본다. */
+ public boolean isServable(LocalDateTime now) {
+ if (status != AdStatus.ACTIVE) {
+ return false;
+ }
+ if (startsAt != null && now.isBefore(startsAt)) {
+ return false;
+ }
+ return endsAt == null || !now.isAfter(endsAt);
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java
new file mode 100644
index 00000000..7177c861
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java
@@ -0,0 +1,53 @@
+package com.swyp.picke.domain.ad.entity;
+
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.global.common.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import org.hibernate.annotations.Check;
+import java.time.LocalDate;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 일별 노출 집계.
+ * 노출을 raw 로그로 쌓으면 배너가 스크롤에 걸릴 때마다 행이 생겨 금방 수천만 건이 된다.
+ * CTR 산출에는 일별 카운터로 충분하다.
+ */
+@Entity
+@Getter
+@Table(name = "ad_impression_daily", uniqueConstraints = {
+ @UniqueConstraint(name = "uk_ad_impression_daily", columnNames = {"creative_id", "slot", "stat_date"})
+})
+@Check(name = "ck_ad_impression_daily_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', "
+ + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class AdImpressionDaily extends BaseEntity {
+
+ @Column(name = "creative_id", nullable = false)
+ private Long creativeId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "slot", nullable = false, length = 40)
+ private AdSlotCode slot;
+
+ @Column(name = "stat_date", nullable = false)
+ private LocalDate statDate;
+
+ @Column(name = "impressions", nullable = false)
+ private long impressions;
+
+ @Builder
+ private AdImpressionDaily(Long creativeId, AdSlotCode slot, LocalDate statDate, long impressions) {
+ this.creativeId = creativeId;
+ this.slot = slot;
+ this.statDate = statDate;
+ this.impressions = impressions;
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java
new file mode 100644
index 00000000..d22d4bf5
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java
@@ -0,0 +1,17 @@
+package com.swyp.picke.domain.ad.enums;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 제휴 광고 매체. AdMob 리워드 광고(reward 도메인)와는 무관하다.
+ */
+@Getter
+@RequiredArgsConstructor
+public enum AdNetwork {
+
+ COUPANG("쿠팡 파트너스"),
+ ADPICK("애드픽");
+
+ private final String description;
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java
new file mode 100644
index 00000000..1799e776
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java
@@ -0,0 +1,31 @@
+package com.swyp.picke.domain.ad.enums;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 광고 노출 지면.
+ * 테이블이 아니라 enum인 이유는, 앱이 그릴 줄 모르는 지면을 어드민에서 만들어봐야
+ * 아무 일도 일어나지 않기 때문이다. 지면 추가는 어차피 앱 배포와 묶인다.
+ * 목록은 iOS Presentation 모듈(Home/Battle/Chat/Profile)의 실제 화면을 기준으로 잡았다.
+ * 앱팀 확정 전이므로 실제로 붙이는 지면만 소재를 등록하면 된다.
+ * 소재가 없는 지면은 빈 배열을 반환하고 앱은 지면 자체를 숨긴다.
+ * {@code cpiFriendly}는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다.
+ * CPI는 단가가 높지만 클릭하면 사용자가 스토어로 나가 다른 앱을 설치한다.
+ * 세션이 자연스럽게 끝나는 지점이 아니면 이탈·리텐션에 그대로 타격이 온다.
+ */
+@Getter
+@RequiredArgsConstructor
+public enum AdSlotCode {
+
+ HOME_FEED("홈 피드 인라인", false),
+ BATTLE_RESULT_BOTTOM("배틀 결과 하단", true),
+ CHAT_ROOM_INLINE("관점 목록 인라인", false),
+ ATTENDANCE_COMPLETE("출석 완료 후", true),
+ PROFILE_BOTTOM("프로필 하단", true);
+
+ private final String description;
+
+ /** 앱 설치형(CPI) 광고를 놓아도 되는 지면인지. */
+ private final boolean cpiFriendly;
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java
new file mode 100644
index 00000000..ca796e09
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java
@@ -0,0 +1,18 @@
+package com.swyp.picke.domain.ad.enums;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 소재를 누가 만들었는지.
+ * ADPICK_API 소재는 동기화가 내용을 덮어쓰므로 어드민에서 수정·삭제하지 않는다.
+ */
+@Getter
+@RequiredArgsConstructor
+public enum AdSource {
+
+ MANUAL("어드민 수동 등록"),
+ ADPICK_API("애드픽 캠페인 API 동기화");
+
+ private final String description;
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java
new file mode 100644
index 00000000..42ccbb24
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java
@@ -0,0 +1,7 @@
+package com.swyp.picke.domain.ad.enums;
+
+public enum AdStatus {
+ DRAFT,
+ ACTIVE,
+ PAUSED
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java
new file mode 100644
index 00000000..52e82bc1
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java
@@ -0,0 +1,40 @@
+package com.swyp.picke.domain.ad.enums;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 소재를 노출할 OS.
+ * 애드픽 앱 설치형 캠페인은 OS가 갈리므로, iOS 사용자에게 Android 캠페인을 보여주면
+ * 클릭해도 전환이 일어나지 않는다.
+ */
+@Getter
+@RequiredArgsConstructor
+public enum AdTargetOs {
+
+ ALL("전체"),
+ ANDROID("Android"),
+ IOS("iOS");
+
+ private final String description;
+
+ /** 애드픽 apOS 값을 우리 enum으로 옮긴다. 모르는 값은 노출하지 않도록 비운다. */
+ public static AdTargetOs fromAdpick(String apOs) {
+ if (apOs == null || apOs.isBlank()) {
+ return ALL;
+ }
+ String normalized = apOs.trim().toLowerCase();
+ if (normalized.startsWith("and")) {
+ return ANDROID;
+ }
+ if (normalized.startsWith("ios") || normalized.startsWith("iphone")) {
+ return IOS;
+ }
+ return ALL;
+ }
+
+ /** 요청 OS에 이 타깃을 노출해도 되는지. */
+ public boolean matches(AdTargetOs requested) {
+ return this == ALL || requested == ALL || this == requested;
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java
new file mode 100644
index 00000000..97464760
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java
@@ -0,0 +1,33 @@
+package com.swyp.picke.domain.ad.link;
+
+import com.swyp.picke.domain.ad.entity.AdCreative;
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 애드픽 서브아이디 파라미터명은 설정값으로 둔다.
+ * 파트너센터 링크생성 화면에서 규격을 확인하기 전까지는 값이 비어 있고, 그동안은 pass-through로
+ * 원본 링크를 그대로 넘긴다. 지면별 성과 분리만 안 될 뿐 노출·클릭·리다이렉트는 정상 동작한다.
+ * 규격이 확인되면 배포 없이 {@code picke.ad.adpick.sub-id-param} 만 채우면 쿠팡과 같은 방식으로 붙는다.
+ */
+@Component
+public class AdpickLinkBuilder implements AffiliateLinkBuilder {
+
+ @Value("${picke.ad.adpick.sub-id-param:}")
+ private String subIdParam;
+
+ @Override
+ public AdNetwork network() {
+ return AdNetwork.ADPICK;
+ }
+
+ @Override
+ public String build(AdCreative creative) {
+ if (!StringUtils.hasText(subIdParam)) {
+ return creative.getLandingUrl();
+ }
+ return AffiliateLinks.merge(creative.getLandingUrl(), subIdParam, AffiliateLinks.subIdOf(creative));
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java
new file mode 100644
index 00000000..89091aac
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java
@@ -0,0 +1,16 @@
+package com.swyp.picke.domain.ad.link;
+
+import com.swyp.picke.domain.ad.entity.AdCreative;
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+
+/**
+ * 매체별 최종 이동 URL을 만든다.
+ * 소재의 landingUrl은 각 매체 콘솔에서 발급한 완성형 링크라 이미 쿼리스트링을 갖고 있다.
+ * 추적 파라미터는 문자열 결합이 아니라 병합이어야 한다.
+ */
+public interface AffiliateLinkBuilder {
+
+ AdNetwork network();
+
+ String build(AdCreative creative);
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java
new file mode 100644
index 00000000..b3f9be88
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java
@@ -0,0 +1,26 @@
+package com.swyp.picke.domain.ad.link;
+
+import com.swyp.picke.domain.ad.entity.AdCreative;
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import org.springframework.stereotype.Component;
+
+@Component
+public class AffiliateLinkResolver {
+
+ private final Map builders = new EnumMap<>(AdNetwork.class);
+
+ public AffiliateLinkResolver(List builderList) {
+ builderList.forEach(builder -> builders.put(builder.network(), builder));
+ }
+
+ public String resolve(AdCreative creative) {
+ AffiliateLinkBuilder builder = builders.get(creative.getNetwork());
+ if (builder == null) {
+ return creative.getLandingUrl();
+ }
+ return builder.build(creative);
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java
new file mode 100644
index 00000000..335d0198
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java
@@ -0,0 +1,30 @@
+package com.swyp.picke.domain.ad.link;
+
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * 제휴 링크에 추적 파라미터를 끼워 넣는 공통 규칙.
+ */
+final class AffiliateLinks {
+
+ private AffiliateLinks() {
+ }
+
+ /**
+ * 이미 쿼리스트링이 붙어 있는 제휴 링크에 파라미터를 병합한다.
+ * 단순 문자열 결합이 아니다. 같은 이름의 파라미터가 이미 있으면 우리 값으로 덮는다.
+ * build(true)로 두는 이유는 landingUrl이 각 매체 콘솔에서 인코딩까지 끝난 상태로 오기 때문이다.
+ * 여기서 다시 인코딩하면 이중 인코딩된다.
+ */
+ static String merge(String landingUrl, String paramName, String value) {
+ return UriComponentsBuilder.fromUriString(landingUrl)
+ .replaceQueryParam(paramName, value)
+ .build(true)
+ .toUriString();
+ }
+
+ /** 지면별 성과를 가르기 위한 추적값. 영문·숫자·밑줄만 쓰므로 인코딩이 필요 없다. */
+ static String subIdOf(com.swyp.picke.domain.ad.entity.AdCreative creative) {
+ return creative.getSlot().name() + "_" + creative.getCode();
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java
new file mode 100644
index 00000000..601796b3
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java
@@ -0,0 +1,25 @@
+package com.swyp.picke.domain.ad.link;
+
+import com.swyp.picke.domain.ad.entity.AdCreative;
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+import org.springframework.stereotype.Component;
+
+/**
+ * 쿠팡 파트너스는 subId를 지원한다. 지면별로 값을 달리 넣으면 파트너스 리포트에서
+ * 지면별 실매출이 갈려, 어느 지면이 돈이 되는지 데이터로 볼 수 있다.
+ */
+@Component
+public class CoupangLinkBuilder implements AffiliateLinkBuilder {
+
+ private static final String SUB_ID_PARAM = "subId";
+
+ @Override
+ public AdNetwork network() {
+ return AdNetwork.COUPANG;
+ }
+
+ @Override
+ public String build(AdCreative creative) {
+ return AffiliateLinks.merge(creative.getLandingUrl(), SUB_ID_PARAM, AffiliateLinks.subIdOf(creative));
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java
new file mode 100644
index 00000000..76c025a0
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java
@@ -0,0 +1,35 @@
+package com.swyp.picke.domain.ad.repository;
+
+import com.swyp.picke.domain.ad.entity.AdClickLog;
+import com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface AdClickLogRepository extends JpaRepository {
+
+ @Query("select l.creativeId as creativeId, count(l) as total from AdClickLog l "
+ + "where l.createdAt >= :from and l.createdAt < :to "
+ + "group by l.creativeId")
+ List countByCreativeBetween(@Param("from") LocalDateTime from,
+ @Param("to") LocalDateTime to);
+
+ @Query("select new com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse("
+ + "l.id, c.code, c.title, c.network, l.slot, l.createdAt) "
+ + "from AdClickLog l join AdCreative c on c.id = l.creativeId "
+ + "where l.createdAt >= :from and l.createdAt < :to "
+ + "order by l.id desc")
+ Page findClickLogs(@Param("from") LocalDateTime from,
+ @Param("to") LocalDateTime to,
+ Pageable pageable);
+
+ interface CreativeCount {
+ Long getCreativeId();
+
+ long getTotal();
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java
new file mode 100644
index 00000000..252d30a2
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java
@@ -0,0 +1,38 @@
+package com.swyp.picke.domain.ad.repository;
+
+import com.swyp.picke.domain.ad.entity.AdCreative;
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.domain.ad.enums.AdSource;
+import com.swyp.picke.domain.ad.enums.AdStatus;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface AdCreativeRepository extends JpaRepository {
+
+ Optional findByCode(String code);
+
+ boolean existsByCode(String code);
+
+ List findAllBySlotAndStatus(AdSlotCode slot, AdStatus status);
+
+ List findAllByStatusOrderByIdDesc(AdStatus status);
+
+ List findAllByOrderByIdDesc();
+
+ @Query("select c from AdCreative c "
+ + "where (:network is null or c.network = :network) "
+ + "and (:slot is null or c.slot = :slot) "
+ + "and (:status is null or c.status = :status) "
+ + "order by c.id desc")
+ List search(@Param("network") AdNetwork network,
+ @Param("slot") AdSlotCode slot,
+ @Param("status") AdStatus status);
+
+ List findAllByCodeIn(List codes);
+
+ List findAllBySource(AdSource source);
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java
new file mode 100644
index 00000000..fae41652
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java
@@ -0,0 +1,39 @@
+package com.swyp.picke.domain.ad.repository;
+
+import com.swyp.picke.domain.ad.entity.AdImpressionDaily;
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface AdImpressionDailyRepository extends JpaRepository {
+
+ Optional findByCreativeIdAndSlotAndStatDate(Long creativeId, AdSlotCode slot,
+ LocalDate statDate);
+
+ /**
+ * ON CONFLICT는 PostgreSQL 전용이라 테스트 H2에서 깨진다. 갱신 후 0건이면 삽입하는 방식으로 둔다.
+ */
+ @Modifying
+ @Query("update AdImpressionDaily a set a.impressions = a.impressions + :delta "
+ + "where a.creativeId = :creativeId and a.slot = :slot and a.statDate = :statDate")
+ int increment(@Param("creativeId") Long creativeId,
+ @Param("slot") AdSlotCode slot,
+ @Param("statDate") LocalDate statDate,
+ @Param("delta") long delta);
+
+ @Query("select a.creativeId as creativeId, sum(a.impressions) as total from AdImpressionDaily a "
+ + "where a.statDate >= :from and a.statDate <= :to "
+ + "group by a.creativeId")
+ List sumByCreativeBetween(@Param("from") LocalDate from, @Param("to") LocalDate to);
+
+ interface CreativeCount {
+ Long getCreativeId();
+
+ long getTotal();
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java b/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java
new file mode 100644
index 00000000..30c2fe87
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java
@@ -0,0 +1,31 @@
+package com.swyp.picke.domain.ad.scheduler;
+
+import com.swyp.picke.domain.ad.service.AdpickCampaignSyncService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 애드픽 캠페인 주기 동기화.
+ * 애드픽 가이드가 최대 1분에 1회 이하 호출을 요구해 넉넉한 간격으로 돈다.
+ * 동기화가 실패해도 이미 저장된 소재로 광고는 계속 나가야 하므로 예외를 흘리지 않는다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AdpickCampaignSyncScheduler {
+
+ private final AdpickCampaignSyncService adpickCampaignSyncService;
+
+ @Scheduled(
+ initialDelayString = "${picke.ad.adpick.sync-initial-delay-ms:60000}",
+ fixedDelayString = "${picke.ad.adpick.sync-interval-ms:600000}")
+ public void sync() {
+ try {
+ adpickCampaignSyncService.sync();
+ } catch (Exception e) {
+ log.warn("[AdpickSync] 캠페인 동기화 실패: {}", e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java
new file mode 100644
index 00000000..7b8193f8
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java
@@ -0,0 +1,92 @@
+package com.swyp.picke.domain.ad.service;
+
+import com.swyp.picke.domain.ad.entity.AdClickLog;
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.domain.ad.link.AffiliateLinkResolver;
+import com.swyp.picke.domain.ad.repository.AdClickLogRepository;
+import com.swyp.picke.domain.ad.repository.AdCreativeRepository;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.HexFormat;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AdClickService {
+
+ private static final int USER_AGENT_MAX_LENGTH = 500;
+
+ /** 게재 기간 판단은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */
+ private static final ZoneId KST = ZoneId.of("Asia/Seoul");
+
+ private final AdCreativeRepository adCreativeRepository;
+ private final AdClickLogRepository adClickLogRepository;
+ private final AffiliateLinkResolver affiliateLinkResolver;
+
+ /**
+ * 클릭 코드로 최종 이동 대상을 찾는다. 코드가 없거나 게재 기간이 지났으면 비어 있는 값을 준다.
+ */
+ @Transactional(readOnly = true)
+ public Optional resolveTarget(String code) {
+ return adCreativeRepository.findByCode(code)
+ .filter(creative -> creative.isServable(LocalDateTime.now(KST)))
+ .map(creative -> new AdClickTarget(
+ creative.getId(),
+ creative.getSlot(),
+ affiliateLinkResolver.resolve(creative)
+ ));
+ }
+
+ /**
+ * 클릭 적재가 리다이렉트를 붙잡으면 안 되므로 비동기로 둔다.
+ * 적재에 실패해도 사용자는 정상적으로 제휴처로 이동해야 한다.
+ */
+ @Async
+ @Transactional
+ public void recordClick(AdClickTarget target, String clientIp, String userAgent) {
+ try {
+ adClickLogRepository.save(AdClickLog.builder()
+ .creativeId(target.creativeId())
+ .slot(target.slot())
+ .ipHash(hashIp(clientIp))
+ .userAgent(truncate(userAgent))
+ .build());
+ } catch (Exception e) {
+ log.warn("[AdClick] 클릭 적재 실패 creativeId={}: {}", target.creativeId(), e.getMessage());
+ }
+ }
+
+ /** 원본 IP는 남기지 않는다. 중복 클릭 판별에 필요한 정도만 해시로 보관한다. */
+ private String hashIp(String clientIp) {
+ if (clientIp == null || clientIp.isBlank()) {
+ return null;
+ }
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(clientIp.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ return null;
+ }
+ }
+
+ private String truncate(String userAgent) {
+ if (userAgent == null) {
+ return null;
+ }
+ return userAgent.length() > USER_AGENT_MAX_LENGTH
+ ? userAgent.substring(0, USER_AGENT_MAX_LENGTH)
+ : userAgent;
+ }
+
+ public record AdClickTarget(Long creativeId, AdSlotCode slot, String redirectUrl) {
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java b/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java
new file mode 100644
index 00000000..29e53142
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java
@@ -0,0 +1,41 @@
+package com.swyp.picke.domain.ad.service;
+
+import com.swyp.picke.domain.ad.repository.AdCreativeRepository;
+import com.swyp.picke.global.common.exception.CustomException;
+import com.swyp.picke.global.common.exception.ErrorCode;
+import java.security.SecureRandom;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 공개 클릭 URL(/c/{code})에 노출되는 짧은 코드를 만든다.
+ * 어드민이 눈으로 옮겨 적는 일이 있어 헷갈리기 쉬운 글자(l, o, 0, 1)를 뺀다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AdCreativeCodeGenerator {
+
+ private static final String ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
+ private static final int LENGTH = 8;
+ private static final int MAX_ATTEMPTS = 10;
+
+ private final AdCreativeRepository adCreativeRepository;
+ private final SecureRandom random = new SecureRandom();
+
+ public String generate() {
+ for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
+ String code = randomCode();
+ if (!adCreativeRepository.existsByCode(code)) {
+ return code;
+ }
+ }
+ throw new CustomException(ErrorCode.AD_CODE_GENERATION_FAILED);
+ }
+
+ private String randomCode() {
+ return random.ints(LENGTH, 0, ALPHABET.length())
+ .mapToObj(index -> String.valueOf(ALPHABET.charAt(index)))
+ .collect(Collectors.joining());
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java b/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java
new file mode 100644
index 00000000..137e33b7
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java
@@ -0,0 +1,48 @@
+package com.swyp.picke.domain.ad.service;
+
+import com.swyp.picke.domain.ad.entity.AdImpressionDaily;
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository;
+import java.time.LocalDate;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 일별 노출 집계 행을 쓰는 지점.
+ * 갱신과 삽입을 각각 독립 트랜잭션으로 나눠 둔다.
+ *
+ * PostgreSQL은 한 문장이 제약을 위반하면 그 트랜잭션 전체가 abort 상태로 들어가,
+ * 뒤따르는 쿼리가 모두 {@code current transaction is aborted}로 실패한다.
+ * 삽입 실패를 같은 트랜잭션 안에서 갱신으로 되돌리려 하면 운영에서 그 복구가 동작하지 않고,
+ * 예외가 위로 새면 호출부가 {@code UnexpectedRollbackException}을 맞아 배치 전체가 날아간다.
+ * 그래서 삽입만 별도 트랜잭션에 가둬 실패를 격리한다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AdImpressionRecorder {
+
+ private final AdImpressionDailyRepository adImpressionDailyRepository;
+
+ /** 이미 있는 행을 올린다. 대상 행이 없으면 false. */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public boolean increment(Long creativeId, AdSlotCode slot, LocalDate statDate) {
+ return adImpressionDailyRepository.increment(creativeId, slot, statDate, 1L) > 0;
+ }
+
+ /**
+ * 오늘 첫 노출이라 행을 만든다.
+ * 같은 (소재, 지면, 날짜)를 다른 요청이 먼저 만들었으면 제약 위반으로 실패하고,
+ * 실패는 이 트랜잭션 안에서 끝난다. 되돌리기는 호출부가 갱신으로 처리한다.
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void insert(Long creativeId, AdSlotCode slot, LocalDate statDate) {
+ adImpressionDailyRepository.saveAndFlush(AdImpressionDaily.builder()
+ .creativeId(creativeId)
+ .slot(slot)
+ .statDate(statDate)
+ .impressions(1L)
+ .build());
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java
new file mode 100644
index 00000000..5f04b72a
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java
@@ -0,0 +1,149 @@
+package com.swyp.picke.domain.ad.service;
+
+import com.swyp.picke.domain.ad.dto.response.AdResponse;
+import com.swyp.picke.domain.ad.entity.AdCreative;
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+import com.swyp.picke.domain.ad.enums.AdSource;
+import com.swyp.picke.domain.ad.enums.AdStatus;
+import com.swyp.picke.domain.ad.enums.AdTargetOs;
+import com.swyp.picke.domain.ad.repository.AdCreativeRepository;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AdQueryService {
+
+ /** 노출 집계 버킷과 게재 기간은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */
+ private static final ZoneId KST = ZoneId.of("Asia/Seoul");
+
+ private final AdCreativeRepository adCreativeRepository;
+ private final AdImpressionRecorder adImpressionRecorder;
+
+ @Value("${picke.ad.base-url:https://ad.picke.store}")
+ private String adBaseUrl;
+
+ /**
+ * 지면에 노출할 소재를 가중 로테이션으로 고른다. 게재 가능한 소재가 없으면 빈 목록을 준다.
+ * 앱은 빈 목록을 받으면 지면 자체를 숨긴다. 광고가 없는 건 오류가 아니다.
+ * 애드픽 앱 설치형 캠페인은 OS가 갈리므로, 요청 OS와 맞지 않는 소재는 제외한다.
+ */
+ @Transactional(readOnly = true)
+ public List findServableAds(AdSlotCode slot, AdTargetOs os, int size) {
+ LocalDateTime now = LocalDateTime.now(KST);
+ AdTargetOs requested = os != null ? os : AdTargetOs.ALL;
+
+ List candidates = adCreativeRepository.findAllBySlotAndStatus(slot, AdStatus.ACTIVE).stream()
+ .filter(creative -> creative.isServable(now))
+ .filter(this::isShoppingOrManualAd)
+ .filter(creative -> creative.getTargetOs().matches(requested))
+ .toList();
+
+ return weightedSample(candidates, size).stream()
+ .map(creative -> AdResponse.of(creative, buildClickUrl(creative)))
+ .toList();
+ }
+
+ /**
+ * ad.picke.store 루트 공개 지면에 나열할 소재. 매체 심사에서 실제 콘텐츠를 확인하므로
+ * 로테이션 없이 게재 가능한 소재를 모두 보여준다.
+ */
+ @Transactional(readOnly = true)
+ public List findLandingAds() {
+ LocalDateTime now = LocalDateTime.now(KST);
+
+ return adCreativeRepository.findAllByStatusOrderByIdDesc(AdStatus.ACTIVE).stream()
+ .filter(creative -> creative.isServable(now))
+ .filter(this::isShoppingOrManualAd)
+ .map(creative -> AdResponse.of(creative, buildClickUrl(creative)))
+ .toList();
+ }
+
+ /**
+ * 조회 시점이 아니라 앱이 실제로 화면에 그린 시점에 호출된다.
+ * 조회를 노출로 세면 CTR이 실제보다 낮게 왜곡되기 때문이다.
+ *
+ * 여기에 트랜잭션을 걸지 않는다. 삽입이 제약 위반으로 실패했을 때의 되돌리기가
+ * 실패한 트랜잭션 밖에서 일어나야 하고, 한 소재의 집계 실패가 나머지 소재까지 되돌리면 안 된다.
+ */
+ public void recordImpressions(List codes) {
+ LocalDate today = LocalDate.now(KST);
+
+ findImpressionTargets(codes).forEach(target -> increaseImpression(target, today));
+ }
+
+ private List findImpressionTargets(List codes) {
+ return adCreativeRepository.findAllByCodeIn(codes).stream()
+ .map(creative -> new ImpressionTarget(creative.getId(), creative.getSlot()))
+ .toList();
+ }
+
+ private void increaseImpression(ImpressionTarget target, LocalDate today) {
+ if (adImpressionRecorder.increment(target.creativeId(), target.slot(), today)) {
+ return;
+ }
+
+ try {
+ adImpressionRecorder.insert(target.creativeId(), target.slot(), today);
+ } catch (DataIntegrityViolationException e) {
+ // 같은 (소재, 지면, 날짜) 행을 다른 요청이 먼저 만든 경우다. 갱신으로 되돌린다.
+ // 삽입이 독립 트랜잭션이라 여기서 도는 갱신은 정상 트랜잭션에서 실행된다.
+ adImpressionRecorder.increment(target.creativeId(), target.slot(), today);
+ }
+ }
+
+ /** 동기화된 애드픽 설치·가입 캠페인은 제외한다. 쇼핑 식별자는 수집 시 생성한 sh + 해시다. */
+ private boolean isShoppingOrManualAd(AdCreative creative) {
+ if (creative.getNetwork() != AdNetwork.ADPICK || creative.getSource() != AdSource.ADPICK_API) {
+ return true;
+ }
+ return creative.getExternalId() != null && creative.getExternalId().matches("sh[0-9a-f]{10}");
+ }
+
+ private String buildClickUrl(AdCreative creative) {
+ return adBaseUrl + "/c/" + creative.getCode();
+ }
+
+ private List weightedSample(List candidates, int size) {
+ List pool = new ArrayList<>(candidates);
+ List picked = new ArrayList<>();
+
+ while (!pool.isEmpty() && picked.size() < size) {
+ picked.add(pickOne(pool));
+ }
+ return picked;
+ }
+
+ /** 가중치에 비례해 하나를 뽑고 풀에서 제거한다. 같은 소재가 한 응답에 두 번 담기지 않게 한다. */
+ private AdCreative pickOne(List pool) {
+ int totalWeight = pool.stream().mapToInt(creative -> Math.max(1, creative.getWeight())).sum();
+ int threshold = ThreadLocalRandom.current().nextInt(totalWeight);
+
+ int accumulated = 0;
+ for (Iterator iterator = pool.iterator(); iterator.hasNext(); ) {
+ AdCreative creative = iterator.next();
+ accumulated += Math.max(1, creative.getWeight());
+ if (threshold < accumulated) {
+ iterator.remove();
+ return creative;
+ }
+ }
+ return pool.remove(pool.size() - 1);
+ }
+
+ private record ImpressionTarget(Long creativeId, AdSlotCode slot) {
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java
new file mode 100644
index 00000000..286f6a69
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java
@@ -0,0 +1,253 @@
+package com.swyp.picke.domain.ad.service;
+
+import com.swyp.picke.domain.ad.client.AdpickCampaignClient;
+import com.swyp.picke.domain.ad.client.AdpickCampaignResponse;
+import com.swyp.picke.domain.ad.client.AdpickShoppingClient;
+import com.swyp.picke.domain.ad.client.AdpickShoppingResponse;
+import com.swyp.picke.domain.ad.entity.AdCreative;
+import com.swyp.picke.domain.ad.enums.AdNetwork;
+import com.swyp.picke.domain.ad.enums.AdSlotCode;
+import com.swyp.picke.domain.ad.enums.AdSource;
+import com.swyp.picke.domain.ad.enums.AdStatus;
+import com.swyp.picke.domain.ad.enums.AdTargetOs;
+import com.swyp.picke.domain.ad.repository.AdCreativeRepository;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 애드픽 캠페인을 소재로 옮겨 담는다.
+ * 수동 등록 소재와 같은 테이블을 쓰므로 로테이션·노출 집계·클릭 추적 경로를 그대로 탄다.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AdpickCampaignSyncService {
+
+ private static final int SUBTITLE_MAX_LENGTH = 200;
+ private static final int TITLE_MAX_LENGTH = 100;
+
+ private static final String SHOPPING_ID_PREFIX = "sh";
+ private static final int SHOPPING_ID_LENGTH = 10;
+
+ private final AdpickCampaignClient adpickCampaignClient;
+ private final AdpickShoppingClient adpickShoppingClient;
+ private final AdCreativeRepository adCreativeRepository;
+ private final AdCreativeCodeGenerator adCreativeCodeGenerator;
+
+ @Value("${picke.ad.adpick.slot:BATTLE_RESULT_BOTTOM}")
+ private AdSlotCode slot;
+
+ @Value("${picke.ad.adpick.cta-text:설치하고 받기}")
+ private String ctaText;
+
+ /**
+ * 로테이션 가중치. 쇼핑 상품을 앱 설치형보다 자주 띄우려고 나눠 둔다.
+ * 앱 캠페인은 7건뿐이라 같은 가중치면 지면마다 같은 소재가 반복해서 뽑힌다.
+ */
+ @Value("${picke.ad.adpick.weight:1}")
+ private int campaignWeight;
+
+ @Value("${picke.ad.adpick.shopping-weight:3}")
+ private int shoppingWeight;
+
+ /**
+ * 쇼핑·핫딜 상품을 흩어 놓을 지면.
+ * 앱 캠페인은 7건뿐이라 한 지면밖에 못 채운다. 상품 60건을 나눠 담아 나머지 지면을 채운다.
+ */
+ @Value("${picke.ad.adpick.shopping-slots:HOME_FEED,CHAT_ROOM_INLINE,ATTENDANCE_COMPLETE,PROFILE_BOTTOM,BATTLE_RESULT_BOTTOM}")
+ private List shoppingSlots;
+
+ @Value("${picke.ad.adpick.shopping-cta-text:구매하러 가기}")
+ private String shoppingCtaText;
+
+ @Transactional
+ public int sync() {
+ if (!adpickCampaignClient.isConfigured()) {
+ log.info("[AdpickSync] affId 미설정으로 동기화를 건너뛴다.");
+ return 0;
+ }
+
+ List campaigns = adpickCampaignClient.fetchCampaigns().stream()
+ .filter(AdpickCampaignResponse::isRenderable)
+ .toList();
+
+ Map existing = adCreativeRepository.findAllBySource(AdSource.ADPICK_API).stream()
+ .collect(Collectors.toMap(AdCreative::getExternalId, Function.identity(), (a, b) -> a));
+
+ Set seen = new HashSet<>();
+ for (AdpickCampaignResponse campaign : campaigns) {
+ seen.add(campaign.offerId());
+ upsert(existing.get(campaign.offerId()), campaign);
+ }
+
+ int products = syncShopping(existing, seen);
+
+ retire(existing, seen);
+ log.info("[AdpickSync] 캠페인 {}건, 쇼핑 {}건 동기화 완료", campaigns.size(), products);
+ return campaigns.size() + products;
+ }
+
+ /**
+ * 쇼핑·핫딜 상품을 소재로 옮겨 담는다.
+ *
+ * 앱 캠페인과 같은 {@code ADPICK_API} 소스를 쓰되 식별자에 접두사를 둬서 섞이지 않게 한다.
+ * 소스를 새로 만들면 {@code ck_ad_creatives_source} 제약을 배포 때 사람이 직접 ALTER 해야 한다.
+ * 상품 하나 늘리자고 그 절차를 얹지 않는다.
+ *
+ *
상품 하나를 지면 하나에만 두면 지면당 재고가 전체의 1/지면수로 쪼개진다.
+ * 쇼핑 상품은 지면을 가릴 이유가 없으므로 모든 쇼핑 지면에 각각 등록해 지면마다 전량을 쓴다.
+ * 노출·클릭 집계는 소재 단위라, 지면별로 따로 세려면 소재도 지면별로 나뉘어 있어야 한다.
+ */
+ private int syncShopping(Map existing, Set seen) {
+ if (!adpickShoppingClient.isConfigured() || shoppingSlots.isEmpty()) {
+ return 0;
+ }
+
+ List products = adpickShoppingClient.fetchProducts().stream()
+ .filter(AdpickShoppingResponse::isRenderable)
+ .toList();
+
+ Set buyUrls = new HashSet<>();
+ int synced = 0;
+ for (AdpickShoppingResponse product : products) {
+ if (!buyUrls.add(product.buyUrl())) {
+ // 쇼핑과 핫딜에 같은 상품이 함께 실릴 수 있다. 먼저 담은 쪽만 남긴다.
+ continue;
+ }
+ for (AdSlotCode target : shoppingSlots) {
+ String externalId = shoppingIdOf(product.buyUrl(), target);
+ seen.add(externalId);
+ upsertShopping(existing.get(externalId), externalId, target, product);
+ synced++;
+ }
+ }
+ return synced;
+ }
+
+ private void upsertShopping(
+ AdCreative found, String externalId, AdSlotCode target, AdpickShoppingResponse product) {
+ if (found != null) {
+ found.syncFromAdpick(
+ truncate(product.productName(), TITLE_MAX_LENGTH),
+ truncate(product.subtitle(), SUBTITLE_MAX_LENGTH),
+ product.photo(),
+ shoppingCtaText,
+ product.buyUrl(),
+ AdTargetOs.ALL,
+ target,
+ shoppingWeight,
+ true);
+ return;
+ }
+
+ adCreativeRepository.save(AdCreative.builder()
+ .code(adCreativeCodeGenerator.generate())
+ .network(AdNetwork.ADPICK)
+ .slot(target)
+ .title(truncate(product.productName(), TITLE_MAX_LENGTH))
+ .subtitle(truncate(product.subtitle(), SUBTITLE_MAX_LENGTH))
+ .imageUrl(product.photo())
+ .ctaText(shoppingCtaText)
+ .landingUrl(product.buyUrl())
+ .status(AdStatus.ACTIVE)
+ .weight(shoppingWeight)
+ .source(AdSource.ADPICK_API)
+ .externalId(externalId)
+ .targetOs(AdTargetOs.ALL)
+ .build());
+ }
+
+ /**
+ * 상품에는 애드픽이 주는 코드가 없어 구매 링크에서 식별자를 만든다.
+ * 링크가 그대로면 같은 소재로 갱신되고, 바뀌면 새 소재가 된다.
+ *
+ * 같은 상품이 지면마다 따로 등록되므로 지면까지 해시에 넣는다.
+ * 길이와 모양({@code sh} + 16진수 10자)은 그대로 둔다. 조회 쪽이 이 형태로 쇼핑 소재를 가려낸다.
+ */
+ private String shoppingIdOf(String buyUrl, AdSlotCode slot) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest((buyUrl + "|" + slot.name()).getBytes(StandardCharsets.UTF_8));
+ StringBuilder hex = new StringBuilder(SHOPPING_ID_PREFIX);
+ for (int i = 0; hex.length() < SHOPPING_ID_PREFIX.length() + SHOPPING_ID_LENGTH; i++) {
+ hex.append(String.format("%02x", digest[i]));
+ }
+ return hex.substring(0, SHOPPING_ID_PREFIX.length() + SHOPPING_ID_LENGTH);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 을 쓸 수 없다", e);
+ }
+ }
+
+ private void upsert(AdCreative found, AdpickCampaignResponse campaign) {
+ if (found != null) {
+ found.syncFromAdpick(
+ truncate(campaign.appTitle(), TITLE_MAX_LENGTH),
+ subtitleOf(campaign),
+ campaign.iconUrl(),
+ ctaText,
+ campaign.trackingLink(),
+ AdTargetOs.fromAdpick(campaign.os()),
+ slot,
+ campaignWeight,
+ campaign.hasRemaining());
+ return;
+ }
+
+ adCreativeRepository.save(AdCreative.builder()
+ .code(adCreativeCodeGenerator.generate())
+ .network(AdNetwork.ADPICK)
+ .slot(slot)
+ .title(truncate(campaign.appTitle(), TITLE_MAX_LENGTH))
+ .subtitle(subtitleOf(campaign))
+ .imageUrl(campaign.iconUrl())
+ .ctaText(ctaText)
+ .landingUrl(campaign.trackingLink())
+ .status(campaign.hasRemaining() ? AdStatus.ACTIVE : AdStatus.DRAFT)
+ .weight(campaignWeight)
+ .source(AdSource.ADPICK_API)
+ .externalId(campaign.offerId())
+ .targetOs(AdTargetOs.fromAdpick(campaign.os()))
+ .build());
+ }
+
+ /**
+ * 피드에서 사라진 캠페인은 지우지 않고 내린다.
+ * 이미 쌓인 노출·클릭 집계가 어느 소재의 것인지 계속 읽을 수 있어야 한다.
+ */
+ private void retire(Map existing, Set seen) {
+ existing.forEach((externalId, creative) -> {
+ if (!seen.contains(externalId) && creative.getStatus() == AdStatus.ACTIVE) {
+ creative.changeStatus(AdStatus.DRAFT);
+ }
+ });
+ }
+
+ private String subtitleOf(AdpickCampaignResponse campaign) {
+ String source = campaign.headline() != null && !campaign.headline().isBlank()
+ ? campaign.headline()
+ : campaign.promoText();
+ if (source == null || source.isBlank()) {
+ return null;
+ }
+ return truncate(source.replaceAll("\\s+", " ").trim(), SUBTITLE_MAX_LENGTH);
+ }
+
+ private String truncate(String value, int max) {
+ if (value == null) {
+ return null;
+ }
+ return value.length() > max ? value.substring(0, max) : value;
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java
new file mode 100644
index 00000000..30a1a10c
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java
@@ -0,0 +1,164 @@
+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 java.util.function.Supplier;
+import lombok.extern.slf4j.Slf4j;
+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 Supplier sessionCookieSupplier;
+ private final AdfitHttpTransport transport;
+ private final ObjectMapper objectMapper;
+
+ @Autowired
+ public AdfitAccountReportClient(AdfitSessionCookieStore cookieStore, AdfitHttpTransport transport) {
+ this(cookieStore::current, transport, new ObjectMapper());
+ }
+
+ AdfitAccountReportClient(Supplier sessionCookieSupplier, AdfitHttpTransport transport,
+ ObjectMapper objectMapper) {
+ this.sessionCookieSupplier = sessionCookieSupplier;
+ this.transport = transport;
+ this.objectMapper = objectMapper;
+ }
+
+ public AdfitReport.AccountReport fetch(LocalDate from, LocalDate to, long expectedDays) {
+ validateRange(from, to);
+ List emptyDays = nullDays(from, to);
+ String sessionCookie = sessionCookieSupplier.get();
+ 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 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 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 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 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 nullDays(LocalDate from, LocalDate to) {
+ List 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 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("<");
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java
new file mode 100644
index 00000000..63aa7ec6
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java
@@ -0,0 +1,8 @@
+package com.swyp.picke.domain.admin.adfit;
+
+public enum AdfitAccountReportStatus {
+ NOT_CONFIGURED,
+ CONNECTED,
+ RECONNECT_REQUIRED,
+ UNAVAILABLE
+}
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/AdfitHttpResponse.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpResponse.java
new file mode 100644
index 00000000..5fa96ade
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpResponse.java
@@ -0,0 +1,17 @@
+package com.swyp.picke.domain.admin.adfit;
+
+import java.util.List;
+import java.util.Map;
+
+record AdfitHttpResponse(int statusCode, Map> 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"));
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java
new file mode 100644
index 00000000..c32c4d19
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java
@@ -0,0 +1,7 @@
+package com.swyp.picke.domain.admin.adfit;
+
+import java.net.URI;
+
+interface AdfitHttpTransport {
+ AdfitHttpResponse get(URI uri, String sessionCookie);
+}
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..acbe2c02
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java
@@ -0,0 +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 units, List days, AccountReport account) {
+ 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) {}
+ public record AccountReport(AdfitAccountReportStatus status, Instant fetchedAt,
+ long reportedDays, long expectedDays, BigDecimal revenue,
+ BigDecimal cost, BigDecimal roi, List days) {}
+ public record AccountDay(LocalDate date, BigDecimal revenue, BigDecimal ctr, BigDecimal ecpm,
+ BigDecimal fillRate, BigDecimal winFillRate) {}
+}
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..844657c7
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java
@@ -0,0 +1,66 @@
+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;
+ private final AdfitAccountReportClient accountReportClient;
+ private final AdfitSessionCookieStore sessionCookieStore;
+
+ public AdfitSessionCookieStatus sessionCookieStatus() {
+ return sessionCookieStore.status();
+ }
+
+ public void updateSessionCookie(AdfitSessionCookieRequest request) {
+ sessionCookieStore.update(request.cookie());
+ }
+
+ @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);
+ }
+
+ 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();
+ 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(), account);
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookie.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookie.java
new file mode 100644
index 00000000..1af390a1
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookie.java
@@ -0,0 +1,34 @@
+package com.swyp.picke.domain.admin.adfit;
+
+import com.swyp.picke.global.common.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * AdFit 콘솔 세션 쿠키.
+ *
+ * AdFit은 매체주 수익을 읽는 공개 REST API가 없어 콘솔 내부 API를 세션 쿠키로 호출한다.
+ * 이 쿠키는 수시로 만료되는데 값이 환경변수에만 있으면 만료마다 재배포를 해야 한다.
+ * 관리자가 화면에서 갈아끼울 수 있도록 한 행으로 보관한다.
+ */
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "adfit_session_cookies")
+public class AdfitSessionCookie extends BaseEntity {
+
+ @Column(name = "cookie_value", nullable = false, length = 4096)
+ private String value;
+
+ public AdfitSessionCookie(String value) {
+ this.value = value;
+ }
+
+ public void update(String value) {
+ this.value = value;
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRepository.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRepository.java
new file mode 100644
index 00000000..8346a918
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRepository.java
@@ -0,0 +1,9 @@
+package com.swyp.picke.domain.admin.adfit;
+
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface AdfitSessionCookieRepository extends JpaRepository {
+ /** 한 행만 두고 쓰지만, 저장이 겹쳐 행이 늘어도 마지막 값이 이기게 한다. */
+ Optional findTopByOrderByIdDesc();
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRequest.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRequest.java
new file mode 100644
index 00000000..c8c8b5ed
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRequest.java
@@ -0,0 +1,11 @@
+package com.swyp.picke.domain.admin.adfit;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/**
+ * @param cookie AdFit 콘솔에 로그인한 브라우저의 Cookie 헤더 전체. {@code name=value; name2=value2} 형태.
+ */
+public record AdfitSessionCookieRequest(
+ @NotBlank @Size(max = 4096) String cookie) {
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieSource.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieSource.java
new file mode 100644
index 00000000..f98e1ae7
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieSource.java
@@ -0,0 +1,10 @@
+package com.swyp.picke.domain.admin.adfit;
+
+public enum AdfitSessionCookieSource {
+ /** 관리자가 화면에서 넣은 값. 환경변수보다 앞선다. */
+ ADMIN_CONSOLE,
+ /** 배포 시 주입한 환경변수 값. */
+ ENVIRONMENT,
+ /** 양쪽 모두 없음. 자동 수익 조회가 NOT_CONFIGURED 가 된다. */
+ NONE
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStatus.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStatus.java
new file mode 100644
index 00000000..8647acf4
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStatus.java
@@ -0,0 +1,14 @@
+package com.swyp.picke.domain.admin.adfit;
+
+import java.time.LocalDateTime;
+
+/**
+ * 쿠키 보유 여부와 마지막 갱신 시각. 쿠키 값 자체는 담지 않는다.
+ *
+ * @param updatedAt 관리자가 마지막으로 넣은 시각. 환경변수 값을 쓰는 중이면 null.
+ */
+public record AdfitSessionCookieStatus(
+ boolean configured,
+ AdfitSessionCookieSource source,
+ LocalDateTime updatedAt) {
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStore.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStore.java
new file mode 100644
index 00000000..ccd289e1
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStore.java
@@ -0,0 +1,60 @@
+package com.swyp.picke.domain.admin.adfit;
+
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+/**
+ * 자동 수익 조회에 쓸 AdFit 세션 쿠키를 고른다.
+ *
+ * 관리자가 화면에서 넣은 값이 환경변수보다 앞선다. 만료될 때 재배포 없이 갈아끼우는 게 목적이다.
+ * 값은 어떤 응답에도 담지 않는다. 콘솔 로그인 자격이라 읽히면 계정이 그대로 열린다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AdfitSessionCookieStore {
+
+ private final AdfitSessionCookieRepository repository;
+
+ @Value("${picke.adfit.session-cookie:${ADFIT_SESSION_COOKIE:}}")
+ private String environmentCookie;
+
+ @Transactional(readOnly = true)
+ public String current() {
+ return stored()
+ .map(AdfitSessionCookie::getValue)
+ .orElse(environmentCookie);
+ }
+
+ @Transactional
+ public void update(String cookie) {
+ String trimmed = cookie == null ? "" : cookie.trim();
+ if (!trimmed.contains("=")) {
+ // 브라우저에서 Cookie 헤더가 아닌 걸 붙여넣은 경우다. 조회가 조용히 실패하게 두지 않는다.
+ throw new IllegalArgumentException("세션 쿠키는 name=value 형태여야 합니다.");
+ }
+ AdfitSessionCookie target = repository.findTopByOrderByIdDesc()
+ .orElseGet(() -> new AdfitSessionCookie(trimmed));
+ target.update(trimmed);
+ repository.save(target);
+ }
+
+ /** 화면에 쿠키가 있는지와 마지막 갱신 시각만 준다. 값은 주지 않는다. */
+ @Transactional(readOnly = true)
+ public AdfitSessionCookieStatus status() {
+ return stored()
+ .map(cookie -> new AdfitSessionCookieStatus(
+ true, AdfitSessionCookieSource.ADMIN_CONSOLE, cookie.getUpdatedAt()))
+ .orElseGet(() -> StringUtils.hasText(environmentCookie)
+ ? new AdfitSessionCookieStatus(true, AdfitSessionCookieSource.ENVIRONMENT, null)
+ : new AdfitSessionCookieStatus(false, AdfitSessionCookieSource.NONE, null));
+ }
+
+ private Optional stored() {
+ return repository.findTopByOrderByIdDesc()
+ .filter(cookie -> StringUtils.hasText(cookie.getValue()));
+ }
+}
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..f6c8b4ab
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java
@@ -0,0 +1,49 @@
+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 = "AdFit 계정 자동 수익 및 수동 입력 비용·ROI")
+public class AdminAdfitController {
+ private final AdfitReportService service;
+
+ @GetMapping
+ @Operation(summary = "AdFit 기간별 수익·ROI", description = "account는 AdFit 계정 자동 수익, units/days는 수동 입력 비용·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));
+ }
+
+ @GetMapping("/session-cookie")
+ @Operation(summary = "AdFit 세션 쿠키 상태", description = "쿠키 보유 여부와 마지막 갱신 시각만 준다. 쿠키 값은 응답에 담지 않는다")
+ public ApiResponse sessionCookieStatus() {
+ return ApiResponse.onSuccess(service.sessionCookieStatus());
+ }
+
+ @PutMapping("/session-cookie")
+ @Operation(summary = "AdFit 세션 쿠키 갱신",
+ description = "AdFit 콘솔에 로그인한 브라우저의 Cookie 헤더 전체를 넣는다. 재배포 없이 자동 수익 조회가 다시 붙는다")
+ public ApiResponse updateSessionCookie(@Valid @RequestBody AdfitSessionCookieRequest request) {
+ service.updateSessionCookie(request);
+ return ApiResponse.onSuccess(null);
+ }
+
+ @PutMapping("/daily")
+ @Operation(summary = "광고 단위별 일일 수익·배분 비용 저장", description = "같은 날짜·광고 단위는 수정한다. 비용을 여러 단위에 중복 입력하지 않는다.")
+ public ApiResponse save(@Valid @RequestBody AdfitDailyRequest request) {
+ service.save(request);
+ return ApiResponse.onSuccess(null);
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java
new file mode 100644
index 00000000..02c74050
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java
@@ -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 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")), "");
+ }
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java
new file mode 100644
index 00000000..a2948316
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java
@@ -0,0 +1,56 @@
+package com.swyp.picke.domain.admin.analytics;
+
+import com.swyp.picke.global.common.response.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/admin/analytics")
+@PreAuthorize("hasRole('ADMIN')")
+@Tag(name = "관리자 지표", description = "Mixpanel 이벤트와 Sentry 사용자 이벤트·오류를 관리자 화면에서 함께 본다")
+public class AdminAnalyticsController {
+
+ private static final long MAX_DAYS = 366;
+
+ private final MixpanelClient mixpanelClient;
+ private final SentryClient sentryClient;
+
+ @GetMapping("/mixpanel")
+ @Operation(summary = "Mixpanel 이벤트 일별 발생 수",
+ description = "events 를 비우면 기간에 나타난 이벤트 전부를 센다. 이벤트·사용자·가입 집계를 함께 반환한다. 원본 이벤트를 받아 세므로 기간은 31일까지")
+ public ApiResponse mixpanel(
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
+ @RequestParam(required = false) List events) {
+ validateRange(from, to, MixpanelClient.MAX_DAYS);
+ return ApiResponse.onSuccess(mixpanelClient.fetchDailyCounts(events, from, to));
+ }
+
+ @GetMapping("/sentry")
+ @Operation(summary = "Sentry 사용자 이벤트와 프로젝트 관측 데이터",
+ description = "analytics_event 태그의 모든 사용자 이벤트와 오류·로그·성능·세션·릴리즈를 프로젝트별로 제공한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED")
+ public ApiResponse sentry(
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
+ validateRange(from, to, MAX_DAYS);
+ return ApiResponse.onSuccess(sentryClient.fetchUnresolvedIssues(from, to));
+ }
+
+ private void validateRange(LocalDate from, LocalDate to, long maxDays) {
+ long days = ChronoUnit.DAYS.between(from, to) + 1;
+ if (days < 1 || days > maxDays) {
+ throw new IllegalArgumentException("조회 기간은 1일부터 " + maxDays + "일까지입니다.");
+ }
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpResponse.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpResponse.java
new file mode 100644
index 00000000..6d8b58c1
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpResponse.java
@@ -0,0 +1,17 @@
+package com.swyp.picke.domain.admin.analytics;
+
+import java.util.List;
+import java.util.Map;
+
+record AnalyticsHttpResponse(int statusCode, Map> headers, String body) {
+ boolean isSuccess() {
+ return statusCode >= 200 && statusCode < 300;
+ }
+
+ 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"));
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpTransport.java
new file mode 100644
index 00000000..ce87a145
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpTransport.java
@@ -0,0 +1,8 @@
+package com.swyp.picke.domain.admin.analytics;
+
+import java.net.URI;
+import java.util.Map;
+
+interface AnalyticsHttpTransport {
+ AnalyticsHttpResponse get(URI uri, Map headers);
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsStatus.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsStatus.java
new file mode 100644
index 00000000..71d2c31b
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsStatus.java
@@ -0,0 +1,9 @@
+package com.swyp.picke.domain.admin.analytics;
+
+public enum AnalyticsStatus {
+ /** 토큰·프로젝트 설정이 없다. 지표를 비워 보여주고 오류로 다루지 않는다. */
+ NOT_CONFIGURED,
+ CONNECTED,
+ /** 토큰 거절, 외부 장애, 응답 스키마 불일치. 0으로 대체하지 않는다. */
+ UNAVAILABLE
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/JavaNetAnalyticsHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/JavaNetAnalyticsHttpTransport.java
new file mode 100644
index 00000000..e9d14cf5
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/analytics/JavaNetAnalyticsHttpTransport.java
@@ -0,0 +1,41 @@
+package com.swyp.picke.domain.admin.analytics;
+
+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 JavaNetAnalyticsHttpTransport implements AnalyticsHttpTransport {
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
+ /** Mixpanel 원본 이벤트는 한 달치가 수십 MB 가 된다. 내려받는 시간을 감당할 만큼 둔다. */
+ private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
+
+ private final HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(CONNECT_TIMEOUT)
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ @Override
+ public AnalyticsHttpResponse get(URI uri, Map headers) {
+ try {
+ HttpRequest.Builder request = HttpRequest.newBuilder(uri)
+ .timeout(REQUEST_TIMEOUT)
+ .header("Accept", "application/json");
+ headers.forEach(request::header);
+ HttpResponse response = client.send(request.GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+ return new AnalyticsHttpResponse(response.statusCode(), response.headers().map(), response.body());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return new AnalyticsHttpResponse(0, Map.of("x-analytics-error", List.of("interrupted")), "");
+ } catch (Exception e) {
+ // 관리자 지표 조회다. 외부 장애가 관리자 화면 전체를 500 으로 만들지 않게 한다.
+ return new AnalyticsHttpResponse(0, Map.of("x-analytics-error", List.of("request_failed")), "");
+ }
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java
new file mode 100644
index 00000000..e5fb9491
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java
@@ -0,0 +1,248 @@
+package com.swyp.picke.domain.admin.analytics;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * Mixpanel 원본 이벤트를 내려받아 서버가 일별로 센다.
+ *
+ * 집계 API(segmentation·insights)를 쓰지 않는다. 현재 프로젝트 플랜이 그쪽을 막아
+ * {@code HTTP 402 Your plan does not allow API calls} 를 준다. 반면 Raw Export
+ * ({@code data.mixpanel.com/api/2.0/export}) 는 같은 플랜에서 열려 있어 여기서 직접 센다.
+ *
+ *
인증은 프로젝트 API 비밀을 Basic 사용자명 자리에 넣는 레거시 방식이다.
+ * 이 방식에는 {@code project_id} 파라미터를 넣으면 400 이 되므로 넣지 않는다.
+ * 비밀이 이미 프로젝트를 특정한다.
+ *
+ *
응답은 한 줄에 이벤트 하나인 NDJSON 이다. {@code properties.time} 은 프로젝트 타임존
+ * 기준 epoch 초이며, 요청의 {@code from_date}·{@code to_date} 경계도 같은 타임존을 따른다.
+ * 둘을 같은 타임존으로 묶어야 날짜 버킷이 Mixpanel 화면 숫자와 맞는다.
+ */
+@Slf4j
+@Component
+public class MixpanelClient {
+
+ /** 원본 이벤트를 전부 받으므로 기간이 넓으면 응답이 급격히 커진다. */
+ static final long MAX_DAYS = 31;
+
+ private final String baseUrl;
+ private final String apiSecret;
+ private final ZoneId projectZone;
+ private final AnalyticsHttpTransport transport;
+ private final ObjectMapper objectMapper;
+
+ @Autowired
+ public MixpanelClient(
+ @Value("${picke.analytics.mixpanel.base-url:https://data.mixpanel.com}") String baseUrl,
+ @Value("${picke.analytics.mixpanel.api-secret:${MIXPANEL_API_SECRET:}}") String apiSecret,
+ @Value("${picke.analytics.mixpanel.project-zone:UTC}") String projectZone,
+ @Value("${picke.analytics.mixpanel.default-events:}") List defaultEvents,
+ AnalyticsHttpTransport transport) {
+ this(baseUrl, apiSecret, ZoneId.of(projectZone), defaultEvents, transport, new ObjectMapper());
+ }
+
+ MixpanelClient(String baseUrl, String apiSecret, ZoneId projectZone, List defaultEvents,
+ AnalyticsHttpTransport transport, ObjectMapper objectMapper) {
+ this.baseUrl = baseUrl;
+ this.apiSecret = apiSecret;
+ this.projectZone = projectZone;
+ this.transport = transport;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * @param requestedEvents 빈 값이면 기간에 실제로 나타난 이벤트 전부를 센다.
+ */
+ public MixpanelEventReport fetchDailyCounts(List requestedEvents, LocalDate from, LocalDate to) {
+ if (!StringUtils.hasText(apiSecret)) {
+ return MixpanelEventReport.empty(AnalyticsStatus.NOT_CONFIGURED, from, to);
+ }
+
+ AnalyticsHttpResponse response = transport.get(uri(from, to),
+ Map.of("Authorization", "Basic " + basicCredentials()));
+ if (!response.isSuccess()) {
+ log.warn("[Mixpanel] 원본 이벤트 조회 실패: status={}", response.statusCode());
+ return MixpanelEventReport.empty(AnalyticsStatus.UNAVAILABLE, from, to);
+ }
+
+ try {
+ Aggregation aggregation = aggregate(response.body(), targetEvents(requestedEvents), from, to);
+ return new MixpanelEventReport(AnalyticsStatus.CONNECTED, Instant.now(), from, to,
+ aggregation.totalEvents(), aggregation.uniqueUsers(), to,
+ aggregation.activeUsers(), aggregation.signUps(), aggregation.signUpDays(),
+ aggregation.availableEvents(), aggregation.events());
+ } catch (Exception e) {
+ log.warn("[Mixpanel] 원본 이벤트 파싱 실패: {}", e.getClass().getSimpleName());
+ return MixpanelEventReport.empty(AnalyticsStatus.UNAVAILABLE, from, to);
+ }
+ }
+
+ private List targetEvents(List requestedEvents) {
+ if (requestedEvents != null && !requestedEvents.isEmpty()) {
+ return requestedEvents;
+ }
+ return List.of();
+ }
+
+ /**
+ * NDJSON 을 한 줄씩 세어 이벤트×날짜 표를 만든다.
+ *
+ * 지정한 이벤트가 없으면 기간에 나타난 이벤트를 전부 센다. 원본을 받았으므로
+ * 어떤 이벤트가 있었는지 서버가 알 수 있다. 이름을 미리 설정해 둘 필요가 없다.
+ */
+ private Aggregation aggregate(
+ String body, List targets, LocalDate from, LocalDate to) throws Exception {
+ Set wanted = targets.isEmpty() ? null : new LinkedHashSet<>(targets);
+ Map stats = new HashMap<>();
+ Set availableEvents = new HashSet<>();
+ Set reportUsers = new HashSet<>();
+ Set activeUsers = new HashSet<>();
+ Map signUpsByDate = new HashMap<>();
+ long signUps = 0;
+
+ for (String line : body.split("\n")) {
+ if (line.isBlank()) {
+ continue;
+ }
+ JsonNode node = objectMapper.readTree(line);
+ String event = node.path("event").asText(null);
+ JsonNode time = node.path("properties").path("time");
+ if (event == null || !time.isNumber()) {
+ throw new IllegalArgumentException("Mixpanel export line is missing event or time.");
+ }
+ availableEvents.add(event);
+ Instant occurredAt = Instant.ofEpochSecond(time.asLong());
+ LocalDate date = occurredAt.atZone(projectZone).toLocalDate();
+ if (date.isBefore(from) || date.isAfter(to)) {
+ // 경계 하루가 타임존 차이로 걸쳐 들어올 수 있다. 요청 기간 밖은 버린다.
+ continue;
+ }
+ String distinctId = text(node.path("properties").get("distinct_id"));
+ if (date.equals(to)) {
+ if (distinctId != null) {
+ activeUsers.add(distinctId);
+ }
+ if ("sign_up".equals(event)) {
+ signUps++;
+ }
+ }
+ if ("sign_up".equals(event)) {
+ signUpsByDate.merge(date, 1L, Long::sum);
+ }
+ if (wanted != null && !wanted.contains(event)) {
+ continue;
+ }
+ EventStats eventStats = stats.computeIfAbsent(event, key -> new EventStats());
+ eventStats.add(date, occurredAt, distinctId);
+ if (distinctId != null) {
+ reportUsers.add(distinctId);
+ }
+ }
+
+ List events = wanted != null ? List.copyOf(wanted) : new ArrayList<>(stats.keySet());
+ List series = events.stream()
+ .map(event -> series(event, stats.getOrDefault(event, new EventStats()), from, to))
+ .sorted(Comparator.comparing(MixpanelEventReport.EventSeries::total).reversed())
+ .toList();
+ long total = series.stream().mapToLong(MixpanelEventReport.EventSeries::total).sum();
+ return new Aggregation(total, (long) reportUsers.size(), (long) activeUsers.size(), signUps,
+ signUpDays(signUpsByDate, from, to),
+ availableEvents.stream().sorted().toList(), series);
+ }
+
+ private List signUpDays(
+ Map counts, LocalDate from, LocalDate to) {
+ List days = new ArrayList<>();
+ for (LocalDate cursor = from; !cursor.isAfter(to); cursor = cursor.plusDays(1)) {
+ days.add(new MixpanelEventReport.SignUpDay(cursor, counts.getOrDefault(cursor, 0L)));
+ }
+ return days;
+ }
+
+ /** 원본을 전부 받았으므로 이벤트가 없던 날짜는 미집계가 아니라 0 이다. */
+ private MixpanelEventReport.EventSeries series(
+ String event, EventStats stats, LocalDate from, LocalDate to) {
+ List days = new ArrayList<>();
+ for (LocalDate cursor = to; !cursor.isBefore(from); cursor = cursor.minusDays(1)) {
+ days.add(new MixpanelEventReport.Day(
+ cursor,
+ stats.counts.getOrDefault(cursor, 0L),
+ (long) stats.users.getOrDefault(cursor, Set.of()).size()));
+ }
+ return new MixpanelEventReport.EventSeries(
+ event, stats.total, (long) stats.allUsers.size(), stats.firstSeen, stats.lastSeen, days);
+ }
+
+ private String text(JsonNode value) {
+ return value == null || value.isNull() || value.isContainerNode() ? null : value.asText();
+ }
+
+ private record Aggregation(
+ Long totalEvents,
+ Long uniqueUsers,
+ Long activeUsers,
+ Long signUps,
+ List signUpDays,
+ List availableEvents,
+ List events) {
+ }
+
+ private static final class EventStats {
+ private final Map counts = new HashMap<>();
+ private final Map> users = new HashMap<>();
+ private final Set allUsers = new HashSet<>();
+ private long total;
+ private Instant firstSeen;
+ private Instant lastSeen;
+
+ private void add(LocalDate date, Instant occurredAt, String distinctId) {
+ counts.merge(date, 1L, Long::sum);
+ total++;
+ if (distinctId != null) {
+ allUsers.add(distinctId);
+ users.computeIfAbsent(date, key -> new HashSet<>()).add(distinctId);
+ }
+ if (firstSeen == null || occurredAt.isBefore(firstSeen)) {
+ firstSeen = occurredAt;
+ }
+ if (lastSeen == null || occurredAt.isAfter(lastSeen)) {
+ lastSeen = occurredAt;
+ }
+ }
+ }
+
+ private String basicCredentials() {
+ // 레거시 방식은 API 비밀을 사용자명 자리에 두고 비밀번호를 비운다.
+ return Base64.getEncoder()
+ .encodeToString((apiSecret + ":").getBytes(StandardCharsets.UTF_8));
+ }
+
+ private URI uri(LocalDate from, LocalDate to) {
+ return UriComponentsBuilder.fromUriString(baseUrl)
+ .path("/api/2.0/export")
+ .queryParam("from_date", from)
+ .queryParam("to_date", to)
+ .build()
+ .toUri();
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java
new file mode 100644
index 00000000..d446e071
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java
@@ -0,0 +1,49 @@
+package com.swyp.picke.domain.admin.analytics;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * Mixpanel 이벤트 일별 발생 수. 원본 이벤트를 내려받아 서버가 센 값이다.
+ *
+ * @param availableEvents 조회 기간 원본에 나타난 전체 이벤트 이름.
+ * @param events 발생 수 내림차순. 조회할 이벤트를 지정하지 않으면 기간에 나타난 이벤트 전부다.
+ */
+public record MixpanelEventReport(
+ AnalyticsStatus status,
+ Instant fetchedAt,
+ LocalDate from,
+ LocalDate to,
+ Long totalEvents,
+ Long uniqueUsers,
+ LocalDate summaryDate,
+ Long activeUsers,
+ Long signUps,
+ List signUpDays,
+ List availableEvents,
+ List events) {
+
+ public record EventSeries(
+ String event,
+ Long total,
+ Long uniqueUsers,
+ Instant firstSeen,
+ Instant lastSeen,
+ List days) {
+ }
+
+ /**
+ * @param count 해당 날짜 발생 수. 원본을 전부 받아 세므로 이벤트가 없던 날짜는 미집계가 아니라 0 이다.
+ */
+ public record Day(LocalDate date, Long count, Long uniqueUsers) {
+ }
+
+ public record SignUpDay(LocalDate date, Long count) {
+ }
+
+ static MixpanelEventReport empty(AnalyticsStatus status, LocalDate from, LocalDate to) {
+ return new MixpanelEventReport(
+ status, null, from, to, null, null, to, null, null, List.of(), List.of(), List.of());
+ }
+}
diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java
new file mode 100644
index 00000000..aa279d47
--- /dev/null
+++ b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java
@@ -0,0 +1,690 @@
+package com.swyp.picke.domain.admin.analytics;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.net.URI;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * Sentry 프로젝트의 오류·로그·성능·프로파일·메트릭·세션·릴리즈 데이터를 읽는다.
+ *
+ * 인증은 조직 인증 토큰(Bearer)이다. 토큰이 없으면 조회하지 않고 NOT_CONFIGURED 를 준다.
+ * 기간을 절대 날짜로 넘기려면 statsPeriod 를 빈 값으로 함께 보내야 한다.
+ */
+@Slf4j
+@Component
+public class SentryClient {
+
+ private static final int ISSUE_LIMIT = 20;
+ /** full=true 는 Sentry가 페이지 크기를 최대 10건으로 제한한다. */
+ private static final int RECENT_EVENT_LIMIT = 10;
+ private static final int ANALYTICS_EVENT_BATCH_SIZE = 10;
+ private static final String ANALYTICS_EVENT_FIELD = "tag[analytics_event,string]";
+ private static final List DATASETS = List.of(
+ "errors", "logs", "spans", "profile_functions", "tracemetrics");
+
+ private final String baseUrl;
+ private final String organization;
+ private final List projects;
+ private final String authToken;
+ private final AnalyticsHttpTransport transport;
+ private final ObjectMapper objectMapper;
+
+ @Autowired
+ public SentryClient(
+ @Value("${picke.analytics.sentry.base-url:https://sentry.io}") String baseUrl,
+ @Value("${picke.analytics.sentry.organization:${SENTRY_ORG:picke}}") String organization,
+ @Value("${picke.analytics.sentry.projects:${SENTRY_PROJECTS:picke-ios,picke-android}}")
+ List projects,
+ @Value("${picke.analytics.sentry.auth-token:${SENTRY_AUTH_TOKEN:}}") String authToken,
+ AnalyticsHttpTransport transport) {
+ this(baseUrl, organization, projects, authToken, transport, new ObjectMapper());
+ }
+
+ SentryClient(String baseUrl, String organization, List projects, String authToken,
+ AnalyticsHttpTransport transport, ObjectMapper objectMapper) {
+ this.baseUrl = baseUrl;
+ this.organization = organization;
+ this.projects = projects;
+ this.authToken = authToken;
+ this.transport = transport;
+ this.objectMapper = objectMapper;
+ }
+
+ public SentryIssueReport fetchUnresolvedIssues(LocalDate from, LocalDate to) {
+ if (!isConfigured()) {
+ return SentryIssueReport.empty(AnalyticsStatus.NOT_CONFIGURED, from, to);
+ }
+
+ List fetched = projects.stream()
+ .map(project -> fetchProject(project, from, to))
+ .toList();
+ boolean allConnected = fetched.stream()
+ .allMatch(project -> project.status() == AnalyticsStatus.CONNECTED);
+ Long total = allConnected
+ ? fetched.stream().mapToLong(SentryIssueReport.ProjectIssues::totalEvents).sum()
+ : null;
+ return new SentryIssueReport(
+ allConnected ? AnalyticsStatus.CONNECTED : AnalyticsStatus.UNAVAILABLE,
+ Instant.now(), from, to, total, fetched);
+ }
+
+ /** 한 프로젝트가 막혀도 다른 프로젝트는 살린다. iOS 가 죽었다고 Android 지표까지 감추지 않는다. */
+ private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate from, LocalDate to) {
+ AnalyticsHttpResponse issueResponse = transport.get(issueUri(project, from, to),
+ Map.of("Authorization", "Bearer " + authToken));
+ AnalyticsHttpResponse statsResponse = transport.get(statsUri(project, from, to),
+ Map.of("Authorization", "Bearer " + authToken));
+ AnalyticsHttpResponse eventResponse = transport.get(eventUri(project, from, to),
+ Map.of("Authorization", "Bearer " + authToken));
+ if (!issueResponse.isSuccess() || !issueResponse.isJson()
+ || !statsResponse.isSuccess() || !statsResponse.isJson()
+ || !eventResponse.isSuccess() || !eventResponse.isJson()) {
+ log.warn("[Sentry] 조회 실패: project={}, issuesStatus={}, statsStatus={}, eventsStatus={}",
+ project, issueResponse.statusCode(), statsResponse.statusCode(), eventResponse.statusCode());
+ return SentryIssueReport.emptyProject(project, AnalyticsStatus.UNAVAILABLE);
+ }
+
+ try {
+ List issues = parseIssues(issueResponse.body());
+ List days = parseDays(statsResponse.body(), from, to);
+ List recentEvents = parseEvents(eventResponse.body());
+ List datasets = new ArrayList<>(DATASETS.stream()
+ .map(dataset -> fetchDataset(project, dataset, from, to))
+ .toList());
+ datasets.add(fetchSignUps(project, from, to));
+ SentryIssueReport.AnalyticsEventCatalog analyticsEvents = fetchAnalyticsEvents(project, from, to);
+ SentryIssueReport.MetricCatalog metricCatalog = fetchMetricCatalog(project, from, to);
+ SentryIssueReport.SessionHealth sessionHealth = fetchSessionHealth(project, from, to);
+ SentryIssueReport.ResourceCatalog releases = fetchReleases(project);
+ long unresolvedTotal = issues.stream()
+ .map(SentryIssueReport.Issue::events)
+ .filter(events -> events != null)
+ .mapToLong(Long::longValue)
+ .sum();
+ long total = days.stream().mapToLong(SentryIssueReport.Day::events).sum();
+ return new SentryIssueReport.ProjectIssues(
+ project, AnalyticsStatus.CONNECTED, total, unresolvedTotal, days, issues, recentEvents,
+ datasets, analyticsEvents, metricCatalog, sessionHealth, releases);
+ } catch (Exception e) {
+ log.warn("[Sentry] 핵심 응답 파싱 실패: project={}, {}", project, e.getClass().getSimpleName());
+ return SentryIssueReport.emptyProject(project, AnalyticsStatus.UNAVAILABLE);
+ }
+ }
+
+ private boolean isConfigured() {
+ return StringUtils.hasText(authToken)
+ && StringUtils.hasText(organization)
+ && projects != null
+ && !projects.isEmpty();
+ }
+
+ private URI issueUri(String project, LocalDate from, LocalDate to) {
+ return UriComponentsBuilder.fromUriString(baseUrl)
+ .path("/api/0/projects/{organization}/{project}/issues/")
+ .queryParam("query", "is:unresolved")
+ .queryParam("sort", "freq")
+ .queryParam("limit", ISSUE_LIMIT)
+ // 절대 기간을 쓰려면 statsPeriod 를 빈 값으로 넘겨야 한다. 생략하면 기본 기간이 적용된다.
+ .queryParam("statsPeriod", "")
+ .queryParam("start", from.atStartOfDay())
+ .queryParam("end", to.atTime(LocalTime.MAX).withNano(0))
+ .queryParam("utc", "true")
+ .buildAndExpand(organization, project)
+ .toUri();
+ }
+
+ private URI statsUri(String project, LocalDate from, LocalDate to) {
+ long since = from.atStartOfDay(ZoneOffset.UTC).toEpochSecond();
+ long until = to.plusDays(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond();
+ return UriComponentsBuilder.fromUriString(baseUrl)
+ .path("/api/0/projects/{organization}/{project}/stats/")
+ .queryParam("stat", "received")
+ .queryParam("since", since)
+ .queryParam("until", until)
+ .queryParam("resolution", "1d")
+ .buildAndExpand(organization, project)
+ .toUri();
+ }
+
+ private URI eventUri(String project, LocalDate from, LocalDate to) {
+ return UriComponentsBuilder.fromUriString(baseUrl)
+ .path("/api/0/projects/{organization}/{project}/events/")
+ .queryParam("start", from.atStartOfDay())
+ .queryParam("end", to.atTime(LocalTime.MAX).withNano(0))
+ .queryParam("full", "true")
+ .buildAndExpand(organization, project)
+ .toUri();
+ }
+
+ private SentryIssueReport.DatasetSeries fetchDataset(
+ String project, String dataset, LocalDate from, LocalDate to) {
+ String yAxis = dataset.equals("tracemetrics") ? "sum(value)" : "count()";
+ return fetchDataset(project, dataset, dataset, yAxis, null, from, to);
+ }
+
+ private SentryIssueReport.DatasetSeries fetchSignUps(
+ String project, LocalDate from, LocalDate to) {
+ return fetchDataset(project, "sign_up", "tracemetrics", "sum(value)",
+ "metric.name:\"user.action.count\" event:sign_up", from, to);
+ }
+
+ private SentryIssueReport.DatasetSeries fetchDataset(
+ String project,
+ String responseName,
+ String sentryDataset,
+ String yAxis,
+ String query,
+ LocalDate from,
+ LocalDate to) {
+ AnalyticsHttpResponse response = transport.get(
+ datasetUri(project, sentryDataset, yAxis, query, from, to),
+ Map.of("Authorization", "Bearer " + authToken));
+ if (!response.isSuccess() || !response.isJson()) {
+ log.warn("[Sentry] 데이터셋 조회 실패: project={}, dataset={}, status={}",
+ project, responseName, response.statusCode());
+ return new SentryIssueReport.DatasetSeries(
+ responseName, AnalyticsStatus.UNAVAILABLE, null, List.of());
+ }
+ try {
+ List days = parseDatasetDays(response.body(), from, to);
+ long total = days.stream().mapToLong(SentryIssueReport.Day::events).sum();
+ return new SentryIssueReport.DatasetSeries(responseName, AnalyticsStatus.CONNECTED, total, days);
+ } catch (Exception e) {
+ log.warn("[Sentry] 데이터셋 파싱 실패: project={}, dataset={}, {}",
+ project, responseName, e.getClass().getSimpleName());
+ return new SentryIssueReport.DatasetSeries(
+ responseName, AnalyticsStatus.UNAVAILABLE, null, List.of());
+ }
+ }
+
+ private URI datasetUri(
+ String project,
+ String dataset,
+ String yAxis,
+ String query,
+ LocalDate from,
+ LocalDate to) {
+ UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(baseUrl)
+ .path("/api/0/organizations/{organization}/events-timeseries/")
+ .queryParam("project", project)
+ .queryParam("dataset", dataset)
+ .queryParam("start", from.atStartOfDay())
+ .queryParam("end", to.atTime(LocalTime.MAX).withNano(0))
+ .queryParam("interval", 86400)
+ .queryParam("yAxis", yAxis);
+ if (StringUtils.hasText(query)) {
+ builder.queryParam("query", query);
+ }
+ return builder.buildAndExpand(organization).toUri();
+ }
+
+ private SentryIssueReport.MetricCatalog fetchMetricCatalog(String project, LocalDate from, LocalDate to) {
+ AnalyticsHttpResponse response = transport.get(metricCatalogUri(project, from, to),
+ Map.of("Authorization", "Bearer " + authToken));
+ if (!response.isSuccess() || !response.isJson()) {
+ return new SentryIssueReport.MetricCatalog(AnalyticsStatus.UNAVAILABLE, List.of());
+ }
+ try {
+ JsonNode root = objectMapper.readTree(response.body());
+ if (!root.isArray()) {
+ throw new IllegalArgumentException("Sentry metric catalog response must be an array.");
+ }
+ List