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 0000000..0551b45
--- /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 0000000..b3c95dc
--- /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/service/AdpickCampaignSyncService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java
index cf7b33d..888d6b7 100644
--- a/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java
+++ b/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java
@@ -2,6 +2,8 @@
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;
@@ -9,6 +11,9 @@
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;
@@ -33,7 +38,11 @@ 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;
@@ -43,6 +52,16 @@ public class AdpickCampaignSyncService {
@Value("${picke.ad.adpick.cta-text:설치하고 받기}")
private String ctaText;
+ /**
+ * 쇼핑·핫딜 상품을 흩어 놓을 지면.
+ * 앱 캠페인은 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()) {
@@ -63,9 +82,97 @@ public int sync() {
upsert(existing.get(campaign.offerId()), campaign);
}
+ int products = syncShopping(existing, seen);
+
retire(existing, seen);
- log.info("[AdpickSync] 캠페인 {}건 동기화 완료", campaigns.size());
- return campaigns.size();
+ log.info("[AdpickSync] 캠페인 {}건, 쇼핑 {}건 동기화 완료", campaigns.size(), products);
+ return campaigns.size() + products;
+ }
+
+ /**
+ * 쇼핑·핫딜 상품을 소재로 옮겨 담는다.
+ *
+ * 앱 캠페인과 같은 {@code ADPICK_API} 소스를 쓰되 식별자에 접두사를 둬서 섞이지 않게 한다.
+ * 소스를 새로 만들면 {@code ck_ad_creatives_source} 제약을 배포 때 사람이 직접 ALTER 해야 한다.
+ * 상품 하나 늘리자고 그 절차를 얹지 않는다.
+ */
+ private int syncShopping(Map existing, Set seen) {
+ if (!adpickShoppingClient.isConfigured() || shoppingSlots.isEmpty()) {
+ return 0;
+ }
+
+ List products = adpickShoppingClient.fetchProducts().stream()
+ .filter(AdpickShoppingResponse::isRenderable)
+ .toList();
+
+ for (AdpickShoppingResponse product : products) {
+ String externalId = shoppingIdOf(product.buyUrl());
+ if (!seen.add(externalId)) {
+ // 쇼핑과 핫딜에 같은 상품이 함께 실릴 수 있다. 먼저 담은 쪽만 남긴다.
+ continue;
+ }
+ upsertShopping(existing.get(externalId), externalId, product);
+ }
+ return products.size();
+ }
+
+ private void upsertShopping(AdCreative found, String externalId, AdpickShoppingResponse product) {
+ AdSlotCode target = slotOf(externalId);
+ 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,
+ 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(1)
+ .source(AdSource.ADPICK_API)
+ .externalId(externalId)
+ .targetOs(AdTargetOs.ALL)
+ .build());
+ }
+
+ /**
+ * 상품에는 애드픽이 주는 코드가 없어 구매 링크에서 식별자를 만든다.
+ * 링크가 그대로면 같은 소재로 갱신되고, 바뀌면 새 소재가 된다.
+ */
+ private String shoppingIdOf(String buyUrl) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(buyUrl.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 AdSlotCode slotOf(String externalId) {
+ int index = Math.floorMod(externalId.hashCode(), shoppingSlots.size());
+ return shoppingSlots.get(index);
}
private void upsert(AdCreative found, AdpickCampaignResponse campaign) {
diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java
index 25007fa..4415622 100644
--- a/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java
+++ b/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java
@@ -1,6 +1,8 @@
package com.swyp.picke.domain.ad.service;
import com.swyp.picke.domain.ad.client.AdpickCampaignClient;
+import com.swyp.picke.domain.ad.client.AdpickShoppingClient;
+import com.swyp.picke.domain.ad.client.AdpickShoppingResponse;
import com.swyp.picke.domain.ad.client.AdpickCampaignResponse;
import com.swyp.picke.domain.ad.entity.AdCreative;
import com.swyp.picke.domain.ad.enums.AdNetwork;
@@ -26,6 +28,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -38,6 +41,8 @@ class AdpickCampaignSyncServiceTest {
@Mock
private AdpickCampaignClient adpickCampaignClient;
@Mock
+ private AdpickShoppingClient adpickShoppingClient;
+ @Mock
private AdCreativeRepository adCreativeRepository;
@Mock
private AdCreativeCodeGenerator adCreativeCodeGenerator;
@@ -49,6 +54,8 @@ class AdpickCampaignSyncServiceTest {
void setUp() {
ReflectionTestUtils.setField(syncService, "slot", AdSlotCode.BATTLE_RESULT_BOTTOM);
ReflectionTestUtils.setField(syncService, "ctaText", CTA);
+ ReflectionTestUtils.setField(syncService, "shoppingSlots", List.of(AdSlotCode.HOME_FEED));
+ ReflectionTestUtils.setField(syncService, "shoppingCtaText", "구매하러 가기");
when(adpickCampaignClient.isConfigured()).thenReturn(true);
when(adCreativeCodeGenerator.generate()).thenReturn("syn23456");
when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of());
@@ -176,4 +183,57 @@ void sync_retiresMissingCampaign() {
assertThat(gone.getStatus()).isEqualTo(AdStatus.DRAFT);
verify(adCreativeRepository, never()).delete(any());
}
+
+ private AdpickShoppingResponse product(String name, String buyUrl) {
+ return new AdpickShoppingResponse(name, "https://img.example.com/p.jpg", "트립닷컴", "최대 4.2%", buyUrl);
+ }
+
+ @Test
+ @DisplayName("쇼핑·핫딜 상품도 소재로 담는다. 앱 캠페인이 7건뿐이라 지면을 채우려면 이쪽이 필요하다")
+ void sync_bringsShoppingProducts() {
+ when(adpickCampaignClient.isConfigured()).thenReturn(true);
+ when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of());
+ when(adpickShoppingClient.isConfigured()).thenReturn(true);
+ when(adpickShoppingClient.fetchProducts()).thenReturn(List.of(
+ product("호텔 A", "https://adpick.co.kr/apis/goshopping.php?affid=ab2c41&offer=1&url=a"),
+ product("호텔 B", "https://adpick.co.kr/apis/goshopping.php?affid=ab2c41&offer=1&url=b")));
+
+ assertThat(syncService.sync()).isEqualTo(2);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(AdCreative.class);
+ verify(adCreativeRepository, times(2)).save(captor.capture());
+ assertThat(captor.getAllValues()).extracting(AdCreative::getCtaText)
+ .containsOnly("구매하러 가기");
+ assertThat(captor.getAllValues()).extracting(AdCreative::getExternalId)
+ .allMatch(id -> id.startsWith("sh"));
+ assertThat(captor.getAllValues()).extracting(AdCreative::getTargetOs)
+ .containsOnly(AdTargetOs.ALL);
+ }
+
+ @Test
+ @DisplayName("같은 상품이 쇼핑과 핫딜에 겹쳐 실려도 한 번만 담는다")
+ void sync_deduplicatesProductsAcrossFeeds() {
+ String buyUrl = "https://adpick.co.kr/apis/goshopping.php?affid=ab2c41&offer=1&url=same";
+ when(adpickCampaignClient.isConfigured()).thenReturn(true);
+ when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of());
+ when(adpickShoppingClient.isConfigured()).thenReturn(true);
+ when(adpickShoppingClient.fetchProducts()).thenReturn(List.of(
+ product("같은 상품", buyUrl), product("같은 상품", buyUrl)));
+
+ syncService.sync();
+
+ verify(adCreativeRepository, times(1)).save(any(AdCreative.class));
+ }
+
+ @Test
+ @DisplayName("애드픽 설정이 없으면 쇼핑도 건너뛴다")
+ void sync_skipsShoppingWhenNotConfigured() {
+ when(adpickCampaignClient.isConfigured()).thenReturn(true);
+ when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of());
+ when(adpickShoppingClient.isConfigured()).thenReturn(false);
+
+ assertThat(syncService.sync()).isZero();
+
+ verify(adpickShoppingClient, never()).fetchProducts();
+ }
}