From 8ae21041175467bb9f49563310b6c66ee2ab23a2 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 10 Sep 2026 14:10:44 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B4=91?= =?UTF-8?q?=EA=B3=A0=EC=97=90=EC=84=9C=20=EC=95=A0=EB=93=9C=ED=94=BD=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98=C2=B7=EA=B0=80=EC=9E=85=20=EC=BA=A0=ED=8E=98?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constraint: 쇼핑 재고가 부족해도 설치형 광고로 채우지 않는다. Confidence: high Scope-risk: narrow Tested: 광고 조회·동기화 회귀 테스트 통과 --- .../domain/ad/service/AdQueryService.java | 12 ++++++ .../domain/ad/service/AdQueryServiceTest.java | 39 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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 index c01b429..5f04b72 100644 --- a/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java @@ -3,6 +3,8 @@ 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; @@ -46,6 +48,7 @@ public List findServableAds(AdSlotCode slot, AdTargetOs os, int size List candidates = adCreativeRepository.findAllBySlotAndStatus(slot, AdStatus.ACTIVE).stream() .filter(creative -> creative.isServable(now)) + .filter(this::isShoppingOrManualAd) .filter(creative -> creative.getTargetOs().matches(requested)) .toList(); @@ -64,6 +67,7 @@ public List findLandingAds() { return adCreativeRepository.findAllByStatusOrderByIdDesc(AdStatus.ACTIVE).stream() .filter(creative -> creative.isServable(now)) + .filter(this::isShoppingOrManualAd) .map(creative -> AdResponse.of(creative, buildClickUrl(creative))) .toList(); } @@ -101,6 +105,14 @@ private void increaseImpression(ImpressionTarget target, LocalDate 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(); } diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java index 765cef9..ace9f9a 100644 --- a/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java @@ -5,6 +5,7 @@ import com.swyp.picke.domain.ad.enums.AdNetwork; import com.swyp.picke.domain.ad.enums.AdSlotCode; import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdSource; import com.swyp.picke.domain.ad.enums.AdTargetOs; import com.swyp.picke.domain.ad.repository.AdCreativeRepository; import java.time.LocalDate; @@ -147,6 +148,44 @@ void findServableAds_filtersByTargetOs() { assertThat(result).extracting(AdResponse::code).containsExactlyInAnyOrder("ios00001", "all00001"); } + private AdCreative syncedAdpick(String code, String externalId) { + AdCreative ad = creative(code, AdStatus.ACTIVE, 100, null, null); + ReflectionTestUtils.setField(ad, "network", AdNetwork.ADPICK); + ReflectionTestUtils.setField(ad, "source", AdSource.ADPICK_API); + ReflectionTestUtils.setField(ad, "externalId", externalId); + return ad; + } + + @Test + void excludesInstallCampaignsEvenWhenShoppingCannotFillRequestedSize() { + var shopping = syncedAdpick("shop0001", "sh012345abcd"); + var install = syncedAdpick("app00001", "ukjd9nru"); + var signup = syncedAdpick("join0001", "hmvdx3t3"); + var coupang = creative("coup0001", AdStatus.ACTIVE, 1, null, null); + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(shopping, install, signup, coupang)); + + assertThat(adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ALL, 10)) + .extracting(AdResponse::code).containsExactlyInAnyOrder("shop0001", "coup0001"); + } + + @Test + void doesNotFallbackToInstallAdsWhenThereAreNoShoppingProducts() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(syncedAdpick("app00001", "ukjd9nru"), + syncedAdpick("bad00001", null))); + assertThat(adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ANDROID, 10)).isEmpty(); + } + + @Test + void landingAlsoExcludesInstallCampaigns() { + when(adCreativeRepository.findAllByStatusOrderByIdDesc(AdStatus.ACTIVE)) + .thenReturn(List.of(syncedAdpick("shop0001", "sh012345abcd"), + syncedAdpick("app00001", "ukjd9nru"))); + assertThat(adQueryService.findLandingAds()).extracting(AdResponse::code) + .containsExactly("shop0001"); + } + @Test @DisplayName("노출 집계는 같은 날 반복 호출하면 기존 행을 누적한다") void recordImpressions_incrementsExistingRow() {