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(); + } }