Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/**
* 애드픽 쇼핑·핫딜 상품을 가져온다.
*
* <p>캠페인 리스트 API(offers.php)와 별도 재고다. 이 계정 기준으로 앱 캠페인은 7건뿐이지만
* 쇼핑과 핫딜에서 각각 30건이 더 내려온다. 지면을 채우려면 이쪽이 필요하다.
*
* <p>두 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<AdpickShoppingResponse> fetchProducts() {
List<AdpickShoppingResponse> products = new ArrayList<>();
products.addAll(fetchFrom(shoppingUrl));
products.addAll(fetchFrom(hotdealUrl));
return products;
}

private List<AdpickShoppingResponse> 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<Group> groups = objectMapper.readValue(body, new TypeReference<List<Group>>() {
});
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<AdpickShoppingResponse> list) {
}
}
Original file line number Diff line number Diff line change
@@ -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)의 상품 한 건.
*
* <p>앱 설치형 캠페인과 달리 상품에는 애드픽이 주는 고유 코드가 없다.
* {@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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@

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;
Expand All @@ -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;

Expand All @@ -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<AdSlotCode> shoppingSlots;

@Value("${picke.ad.adpick.shopping-cta-text:구매하러 가기}")
private String shoppingCtaText;

@Transactional
public int sync() {
if (!adpickCampaignClient.isConfigured()) {
Expand All @@ -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;
}

/**
* 쇼핑·핫딜 상품을 소재로 옮겨 담는다.
*
* <p>앱 캠페인과 같은 {@code ADPICK_API} 소스를 쓰되 식별자에 접두사를 둬서 섞이지 않게 한다.
* 소스를 새로 만들면 {@code ck_ad_creatives_source} 제약을 배포 때 사람이 직접 ALTER 해야 한다.
* 상품 하나 늘리자고 그 절차를 얹지 않는다.
*/
private int syncShopping(Map<String, AdCreative> existing, Set<String> seen) {
if (!adpickShoppingClient.isConfigured() || shoppingSlots.isEmpty()) {
return 0;
}

List<AdpickShoppingResponse> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -38,6 +41,8 @@ class AdpickCampaignSyncServiceTest {
@Mock
private AdpickCampaignClient adpickCampaignClient;
@Mock
private AdpickShoppingClient adpickShoppingClient;
@Mock
private AdCreativeRepository adCreativeRepository;
@Mock
private AdCreativeCodeGenerator adCreativeCodeGenerator;
Expand All @@ -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());
Expand Down Expand Up @@ -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<AdCreative> 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();
}
}
Loading