diff --git a/src/main/java/chaeso/zip/server/auth/infrastructure/security/SecurityConfig.java b/src/main/java/chaeso/zip/server/auth/infrastructure/security/SecurityConfig.java index 191be99..b496ab2 100644 --- a/src/main/java/chaeso/zip/server/auth/infrastructure/security/SecurityConfig.java +++ b/src/main/java/chaeso/zip/server/auth/infrastructure/security/SecurityConfig.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -48,10 +49,13 @@ public class SecurityConfig { "/api/v1/channels/*", "/api/v1/onboarding", "/api/v1/onboarding/ad-history/presigned-urls", - "/api/v1/recommendations", "/api/v1/simulations/estimate" }; + private static final String[] PUBLIC_GET_PATHS = { + "/api/v1/recommendations" + }; + private final JwtTokenProvider jwtTokenProvider; private final ObjectMapper objectMapper; private final CorsProperties corsProperties; @@ -80,6 +84,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize .requestMatchers(PUBLIC_PATHS).permitAll() + .requestMatchers(HttpMethod.GET, PUBLIC_GET_PATHS).permitAll() .anyRequest().authenticated()) .exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint())) diff --git a/src/main/java/chaeso/zip/server/common/config/ResponseWrapperSchemaCustomizer.java b/src/main/java/chaeso/zip/server/common/config/ResponseWrapperSchemaCustomizer.java new file mode 100644 index 0000000..c1bf865 --- /dev/null +++ b/src/main/java/chaeso/zip/server/common/config/ResponseWrapperSchemaCustomizer.java @@ -0,0 +1,48 @@ +package chaeso.zip.server.common.config; + +import chaeso.zip.server.common.response.ApiResponse; +import io.swagger.v3.oas.models.media.Schema; +import java.util.Map; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ResponseWrapperSchemaCustomizer { + + private static final String WRAPPER = ApiResponse.class.getSimpleName(); + private static final String VOID_WRAPPER = WRAPPER + "Void"; + private static final String DATA = "data"; + private static final String ERROR = "error"; + + @Bean + public OpenApiCustomizer responseWrapperRequiredFields() { + return openApi -> { + Map schemas = openApi.getComponents().getSchemas(); + if (schemas == null) { + return; + } + schemas.forEach((name, schema) -> { + if (carriesPayload(name)) { + require(schema, DATA); + } else if (name.equals(WRAPPER)) { + require(schema, ERROR); + } + }); + }; + } + + private boolean carriesPayload(String schemaName) { + return schemaName.startsWith(WRAPPER) + && !schemaName.equals(WRAPPER) + && !schemaName.equals(VOID_WRAPPER); + } + + private void require(Schema schema, String field) { + boolean present = schema.getProperties() != null && schema.getProperties().containsKey(field); + boolean alreadyRequired = schema.getRequired() != null && schema.getRequired().contains(field); + if (present && !alreadyRequired) { + schema.addRequiredItem(field); + } + } +} diff --git a/src/main/java/chaeso/zip/server/onboarding/domain/repository/OnboardingRepository.java b/src/main/java/chaeso/zip/server/onboarding/domain/repository/OnboardingRepository.java index 8847531..d7b189a 100644 --- a/src/main/java/chaeso/zip/server/onboarding/domain/repository/OnboardingRepository.java +++ b/src/main/java/chaeso/zip/server/onboarding/domain/repository/OnboardingRepository.java @@ -1,9 +1,14 @@ package chaeso.zip.server.onboarding.domain.repository; import chaeso.zip.server.onboarding.domain.entity.Onboarding; +import jakarta.persistence.LockModeType; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; /** * 온보딩 응답 리포지토리 인터페이스. @@ -11,4 +16,8 @@ public interface OnboardingRepository extends JpaRepository { List findByUserIdAndIsActiveTrue(UUID userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select o from Onboarding o where o.id = :onboardingId") + Optional findByIdForUpdate(@Param("onboardingId") UUID onboardingId); } diff --git a/src/main/java/chaeso/zip/server/recommendation/application/RecommendationService.java b/src/main/java/chaeso/zip/server/recommendation/application/RecommendationService.java index bac5067..229c741 100644 --- a/src/main/java/chaeso/zip/server/recommendation/application/RecommendationService.java +++ b/src/main/java/chaeso/zip/server/recommendation/application/RecommendationService.java @@ -1,6 +1,7 @@ package chaeso.zip.server.recommendation.application; import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse; +import chaeso.zip.server.recommendation.application.dto.SavedRecommendationResponse; import java.util.List; import java.util.UUID; @@ -10,4 +11,12 @@ public interface RecommendationService { * 온보딩 응답에 맞는 채널을 적합도 순으로 추천한다. */ List recommend(UUID onboardingId); + + /** + * 추천 결과를 추천 시점 값 그대로 저장한다. 같은 온보딩으로 다시 저장하면 이전 것을 덮어쓴다. + * + * @param userId 저장하는 사용자 + * @param onboardingId 추천의 근거가 된 온보딩. 저장된 추천 1건을 가리키는 키가 된다 + */ + SavedRecommendationResponse save(UUID userId, UUID onboardingId); } diff --git a/src/main/java/chaeso/zip/server/recommendation/application/RecommendationServiceImpl.java b/src/main/java/chaeso/zip/server/recommendation/application/RecommendationServiceImpl.java index 8d97a69..2c4119c 100644 --- a/src/main/java/chaeso/zip/server/recommendation/application/RecommendationServiceImpl.java +++ b/src/main/java/chaeso/zip/server/recommendation/application/RecommendationServiceImpl.java @@ -6,6 +6,7 @@ import chaeso.zip.server.channel.domain.repository.ChannelPricingRepository; import chaeso.zip.server.channel.domain.repository.ChannelProductRepository; import chaeso.zip.server.channel.domain.repository.ChannelRepository; +import chaeso.zip.server.channel.domain.vo.PricingModel; import chaeso.zip.server.estimation.application.DefaultCtrProvider; import chaeso.zip.server.estimation.domain.EstimationService; import chaeso.zip.server.estimation.domain.RepresentativeProduct; @@ -16,8 +17,12 @@ import chaeso.zip.server.onboarding.domain.entity.Onboarding; import chaeso.zip.server.onboarding.domain.repository.OnboardingRepository; import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse; +import chaeso.zip.server.recommendation.application.dto.SavedRecommendationResponse; import chaeso.zip.server.recommendation.domain.ChannelMatcher; import chaeso.zip.server.recommendation.domain.MatchScore; +import chaeso.zip.server.recommendation.domain.RecommendationSnapshot; +import chaeso.zip.server.recommendation.domain.entity.ChannelRecommendation; +import chaeso.zip.server.recommendation.domain.repository.ChannelRecommendationRepository; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Comparator; @@ -25,7 +30,9 @@ import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,28 +43,102 @@ public class RecommendationServiceImpl implements RecommendationService { static final int MAX_ITEMS = 8; + /** 추천 순위의 시작 번호 */ + private static final int FIRST_RANK = 1; + /** * 추천 순서 */ - private static final Comparator BEST_FIRST = Comparator - .comparingInt((Recommendation recommendation) -> recommendation.score().score()).reversed() - .thenComparing(recommendation -> recommendation.item().isExecutable(), - Comparator.reverseOrder()) - .thenComparing(recommendation -> recommendation.item().channelName()); + private static final Comparator BEST_FIRST = Comparator + .comparingInt((Scored scored) -> scored.score().score()).reversed() + .thenComparing(scored -> scored.snapshot().isExecutable(), Comparator.reverseOrder()) + .thenComparing(scored -> scored.snapshot().channelName()); private final OnboardingRepository onboardingRepository; private final ChannelRepository channelRepository; private final ChannelProductRepository channelProductRepository; private final ChannelPricingRepository channelPricingRepository; + private final ChannelRecommendationRepository channelRecommendationRepository; private final DefaultCtrProvider defaultCtrProvider; @Override public List recommend(UUID onboardingId) { - Onboarding onboarding = onboardingRepository.findById(onboardingId) + return calculate(findOnboarding(onboardingId)).stream() + .map(RecommendationItemResponse::from) + .toList(); + } + + /** + * 추천 결과를 스냅샷으로 저장한다. + */ + @Override + @Transactional + public SavedRecommendationResponse save(UUID userId, UUID onboardingId) { + Onboarding onboarding = findOwnedOnboarding(userId, onboardingId); + List snapshots = calculate(onboarding); + + onboardingRepository.findByIdForUpdate(onboardingId); + + channelRecommendationRepository.deleteByOnboardingId(onboardingId); + try { + channelRecommendationRepository.saveAll(IntStream.range(0, snapshots.size()) + .mapToObj(index -> toEntity(userId, onboardingId, FIRST_RANK + index, + snapshots.get(index))) + .toList()); + channelRecommendationRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new OnboardingBusinessException(OnboardingErrorCode.CONCURRENT_SUBMISSION); + } + + return SavedRecommendationResponse.of(onboardingId, snapshots); + } + + private Onboarding findOnboarding(UUID onboardingId) { + return onboardingRepository.findById(onboardingId) .orElseThrow(() -> new OnboardingBusinessException( OnboardingErrorCode.ONBOARDING_NOT_FOUND, "온보딩 정보가 없습니다. id=" + onboardingId)); + } + + private Onboarding findOwnedOnboarding(UUID userId, UUID onboardingId) { + Onboarding onboarding = findOnboarding(onboardingId); + if (!userId.equals(onboarding.getUserId())) { + throw new OnboardingBusinessException(OnboardingErrorCode.ONBOARDING_NOT_FOUND, + "온보딩 정보가 없습니다. id=" + onboardingId); + } + return onboarding; + } + private ChannelRecommendation toEntity(UUID userId, UUID onboardingId, int rank, + RecommendationSnapshot snapshot) { + return ChannelRecommendation.builder() + .userId(userId) + .onboardingId(onboardingId) + .channelId(snapshot.channelId()) + .rank(rank) + .score(snapshot.matchRate()) + .reason(snapshot.reason()) + .reasonTags(snapshot.reasonTags()) + .channelName(snapshot.channelName()) + .estPricingModel(snapshot.pricingModel()) + .estUnitPrice(snapshot.unitPrice()) + .estImpressionsMin(snapshot.impressions() == null ? null : snapshot.impressions().min()) + .estImpressionsMax(snapshot.impressions() == null ? null : snapshot.impressions().max()) + .estClicksMin(snapshot.clicks() == null ? null : snapshot.clicks().min()) + .estClicksMax(snapshot.clicks() == null ? null : snapshot.clicks().max()) + .cpcWon(snapshot.cpcWon()) + .pricingModelsAll(snapshot.pricingModelNames()) + .minBudgetWonSnap(snapshot.minBudgetWon()) + .audienceSummarySnap(snapshot.primaryTarget()) + .executable(snapshot.isExecutable()) + .shortfallWon(snapshot.shortfallWon()) + .build(); + } + + /** + * 온보딩 하나에 대한 채널별 추천을 적합도 순으로 계산한다. 조회와 저장이 같은 결과를 쓴다. + */ + private List calculate(Onboarding onboarding) { List channels = channelRepository.findByActiveTrue(); if (channels.isEmpty()) { return List.of(); @@ -85,7 +166,7 @@ public List recommend(UUID onboardingId) { defaultCtrPercent)) .sorted(BEST_FIRST) .limit(MAX_ITEMS) - .map(Recommendation::item) + .map(Scored::snapshot) .toList(); } @@ -97,18 +178,19 @@ private Candidate toCandidate(Onboarding onboarding, Channel channel, /** * 매칭된 채널 하나에 온보딩 예산·기간을 적용한다. */ - private Recommendation evaluate(Candidate candidate, Onboarding onboarding, + private Scored evaluate(Candidate candidate, Onboarding onboarding, Map> pricingsByProduct, long budgetWon, int periodDays, BigDecimal defaultCtrPercent) { Channel channel = candidate.channel(); MatchScore score = candidate.score(); + List pricingModels = pricingModels(candidate, pricingsByProduct); RepresentativeProduct representative = RepresentativeProduct .select(candidate.products(), pricingsByProduct, defaultCtrPercent) .orElse(null); if (representative == null) { - return new Recommendation(score, - RecommendationItemResponse.quoteRequired(channel, score, onboarding.getIndustry())); + return new Scored(score, RecommendationSnapshot.quoteRequired(channel, score, + onboarding.getIndustry(), pricingModels)); } long minBudgetWon = minBudgetWon(representative.pricing().value()); @@ -119,13 +201,29 @@ private Recommendation evaluate(Candidate candidate, Onboarding onboarding, EstimationResult result = EstimationService.estimate(representative.product(), estimationBudgetWon, periodDays); if (result == null) { - return new Recommendation(score, - RecommendationItemResponse.quoteRequired(channel, score, onboarding.getIndustry())); + return new Scored(score, RecommendationSnapshot.quoteRequired(channel, score, + onboarding.getIndustry(), pricingModels)); } - return new Recommendation(score, RecommendationItemResponse.estimated(channel, score, - onboarding.getIndustry(), representative.pricing(), result, minBudgetWon, executable, - shortfallWon, estimationBudgetWon)); + return new Scored(score, RecommendationSnapshot.estimated(channel, score, + onboarding.getIndustry(), representative.pricing(), pricingModels, result, minBudgetWon, + executable, shortfallWon, estimationBudgetWon)); + } + + /** + * 채널이 그 시점에 가지고 있던 과금 방식 전체. 저장 스냅샷에만 쓴다. + * + *

대표 단가로 고르지 못한 상품의 과금 방식도 포함한다. 열거 순서로 정렬해 같은 채널이면 같은 + * 배열이 되게 한다. + */ + private List pricingModels(Candidate candidate, + Map> pricingsByProduct) { + return candidate.products().stream() + .flatMap(product -> pricingsByProduct.getOrDefault(product.getId(), List.of()).stream()) + .map(ChannelPricing::getPricingModel) + .distinct() + .sorted() + .toList(); } private Map> productsByChannel(List channels) { @@ -154,6 +252,6 @@ private static long minBudgetWon(BigDecimal price) { private record Candidate(Channel channel, List products, MatchScore score) { } - private record Recommendation(MatchScore score, RecommendationItemResponse item) { + private record Scored(MatchScore score, RecommendationSnapshot snapshot) { } } diff --git a/src/main/java/chaeso/zip/server/recommendation/application/dto/RecommendationItemResponse.java b/src/main/java/chaeso/zip/server/recommendation/application/dto/RecommendationItemResponse.java index b8f9d91..440fe45 100644 --- a/src/main/java/chaeso/zip/server/recommendation/application/dto/RecommendationItemResponse.java +++ b/src/main/java/chaeso/zip/server/recommendation/application/dto/RecommendationItemResponse.java @@ -1,20 +1,16 @@ package chaeso.zip.server.recommendation.application.dto; -import chaeso.zip.server.channel.domain.entity.Channel; -import chaeso.zip.server.channel.domain.vo.Category; import chaeso.zip.server.channel.domain.vo.PricingModel; import chaeso.zip.server.estimation.application.dto.CountRangeResponse; -import chaeso.zip.server.estimation.domain.ClickCostPolicy; -import chaeso.zip.server.estimation.domain.vo.EstimationPricing; -import chaeso.zip.server.estimation.domain.vo.EstimationResult; -import chaeso.zip.server.recommendation.domain.MatchScore; -import chaeso.zip.server.recommendation.domain.PrimaryTarget; -import chaeso.zip.server.recommendation.domain.RecommendationReason; +import chaeso.zip.server.recommendation.domain.RecommendationSnapshot; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; import java.util.UUID; @Schema(description = "추천 채널") +@JsonInclude(Include.NON_NULL) public record RecommendationItemResponse( @Schema(description = "채널 식별자", example = "550e8400-e29b-41d4-a716-446655440000", requiredMode = Schema.RequiredMode.REQUIRED) @@ -30,63 +26,44 @@ public record RecommendationItemResponse( @Schema(description = "주요 타깃", example = "20~40대 여성", requiredMode = Schema.RequiredMode.REQUIRED) String primaryTarget, - @Schema(description = "클릭당 비용(원)", example = "150", nullable = true) + @Schema(description = "클릭당 비용(원). 예상 클릭이 없어 환산할 수 없으면 생략", example = "150", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) BigDecimal cpcWon, - @Schema(description = "대표 단가의 과금 방식", example = "CPM", nullable = true) + @Schema(description = "대표 단가의 과금 방식. 등록된 단가가 없으면 생략", example = "CPM", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) PricingModel pricingModel, - @Schema(description = "최소 집행 예산(원)", - example = "3000000", nullable = true) + @Schema(description = "최소 집행 예산(원). 등록된 단가가 없으면 생략", example = "3000000", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) Long minBudgetWon, - @Schema(description = "예상 노출 수 범위", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "예상 노출 수 범위. 추정 불가 시 생략", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) CountRangeResponse estImpressions, - @Schema(description = "예상 클릭 수 범위", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "예상 클릭 수 범위. 추정 불가 시 생략", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) CountRangeResponse estClicks, @Schema(description = "온보딩 예산(상한)으로 집행 가능한지 여부", requiredMode = Schema.RequiredMode.REQUIRED) boolean isExecutable, - @Schema(description = "집행에 부족한 금액(원)", example = "500000", nullable = true) + @Schema(description = "집행에 부족한 금액(원). 집행 가능하면 생략", example = "500000", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) Long shortfallWon) { /** - * 단가 정보가 있는 상품이 없어 집행 금액을 알 수 없는 매체 + * 계산 결과에서 화면에 쓰는 값만 골라 담는다. 근거 태그·대표 단가·과금 방식 전체는 저장에만 쓰므로 + * 응답에 넣지 않는다. */ - public static RecommendationItemResponse quoteRequired(Channel channel, MatchScore score, - Category industry) { + public static RecommendationItemResponse from(RecommendationSnapshot snapshot) { return new RecommendationItemResponse( - channel.getId(), - channel.getName(), - score.matchRate(), - RecommendationReason.of(score, industry, null, false), - PrimaryTarget.of(channel.getPrimaryAgeBand(), channel.getPrimaryGender()), - null, - null, - null, - null, - null, - false, - null); - } - - /** - * 대표 상품으로 추정을 마친 매체 - */ - public static RecommendationItemResponse estimated(Channel channel, MatchScore score, - Category industry, EstimationPricing pricing, EstimationResult result, long minBudgetWon, - boolean isExecutable, Long shortfallWon, long estimationBudgetWon) { - CountRangeResponse impressions = CountRangeResponse.from(result.impressions()); - CountRangeResponse clicks = CountRangeResponse.from(result.clicks()); - return new RecommendationItemResponse( - channel.getId(), - channel.getName(), - score.matchRate(), - RecommendationReason.of(score, industry, shortfallWon, true), - PrimaryTarget.of(channel.getPrimaryAgeBand(), channel.getPrimaryGender()), - ClickCostPolicy.cpcWon(pricing, estimationBudgetWon, - clicks == null ? null : clicks.midpoint()), - pricing.pricingModel(), - minBudgetWon, - impressions, - clicks, - isExecutable, - shortfallWon); + snapshot.channelId(), + snapshot.channelName(), + snapshot.matchRate(), + snapshot.reason(), + snapshot.primaryTarget(), + snapshot.cpcWon(), + snapshot.pricingModel(), + snapshot.minBudgetWon(), + CountRangeResponse.from(snapshot.impressions()), + CountRangeResponse.from(snapshot.clicks()), + snapshot.isExecutable(), + snapshot.shortfallWon()); } } diff --git a/src/main/java/chaeso/zip/server/recommendation/application/dto/SavedRecommendationResponse.java b/src/main/java/chaeso/zip/server/recommendation/application/dto/SavedRecommendationResponse.java new file mode 100644 index 0000000..6095663 --- /dev/null +++ b/src/main/java/chaeso/zip/server/recommendation/application/dto/SavedRecommendationResponse.java @@ -0,0 +1,26 @@ +package chaeso.zip.server.recommendation.application.dto; + +import chaeso.zip.server.recommendation.domain.RecommendationSnapshot; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.UUID; + +@Schema(description = "저장된 채널 추천") +public record SavedRecommendationResponse( + @Schema(description = "추천을 묶는 온보딩 응답 식별자. 이 값으로 저장된 추천 1건을 가리킨다", + example = "550e8400-e29b-41d4-a716-446655440000", + requiredMode = Schema.RequiredMode.REQUIRED) + UUID onboardingId, + @Schema(description = "저장된 채널 수. 추천된 채널이 없으면 0", + example = "8", requiredMode = Schema.RequiredMode.REQUIRED) + int channelCount, + @Schema(description = "저장된 추천 채널. 적합도 순이며 순위는 배열 순서와 같다. null 이 아닌 배열", + requiredMode = Schema.RequiredMode.REQUIRED) + List items) { + + public static SavedRecommendationResponse of(UUID onboardingId, + List snapshots) { + return new SavedRecommendationResponse(onboardingId, snapshots.size(), + snapshots.stream().map(RecommendationItemResponse::from).toList()); + } +} diff --git a/src/main/java/chaeso/zip/server/recommendation/domain/RecommendationSnapshot.java b/src/main/java/chaeso/zip/server/recommendation/domain/RecommendationSnapshot.java new file mode 100644 index 0000000..306729e --- /dev/null +++ b/src/main/java/chaeso/zip/server/recommendation/domain/RecommendationSnapshot.java @@ -0,0 +1,88 @@ +package chaeso.zip.server.recommendation.domain; + +import chaeso.zip.server.channel.domain.entity.Channel; +import chaeso.zip.server.channel.domain.vo.Category; +import chaeso.zip.server.channel.domain.vo.PricingModel; +import chaeso.zip.server.estimation.domain.ClickCostPolicy; +import chaeso.zip.server.estimation.domain.vo.ClickRange; +import chaeso.zip.server.estimation.domain.vo.EstimationPricing; +import chaeso.zip.server.estimation.domain.vo.EstimationResult; +import chaeso.zip.server.estimation.domain.vo.ImpressionRange; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public record RecommendationSnapshot( + UUID channelId, + String channelName, + int matchRate, + Set matchedAxes, + String reason, + String primaryTarget, + BigDecimal cpcWon, + PricingModel pricingModel, + BigDecimal unitPrice, + List pricingModels, + ImpressionRange impressions, + ClickRange clicks, + Long minBudgetWon, + boolean isExecutable, + Long shortfallWon) { + + /** 대표 단가가 없어 집행 금액을 알 수 없는 매체 */ + public static RecommendationSnapshot quoteRequired(Channel channel, MatchScore score, + Category industry, List pricingModels) { + return new RecommendationSnapshot( + channel.getId(), + channel.getName(), + score.matchRate(), + score.matchedAxes(), + RecommendationReason.of(score, industry, null, false), + PrimaryTarget.of(channel.getPrimaryAgeBand(), channel.getPrimaryGender()), + null, + null, + null, + pricingModels, + null, + null, + null, + false, + null); + } + + public static RecommendationSnapshot estimated(Channel channel, MatchScore score, + Category industry, EstimationPricing pricing, List pricingModels, + EstimationResult result, long minBudgetWon, boolean isExecutable, Long shortfallWon, + long estimationBudgetWon) { + ClickRange clicks = result.clicks(); + return new RecommendationSnapshot( + channel.getId(), + channel.getName(), + score.matchRate(), + score.matchedAxes(), + RecommendationReason.of(score, industry, shortfallWon, true), + PrimaryTarget.of(channel.getPrimaryAgeBand(), channel.getPrimaryGender()), + ClickCostPolicy.cpcWon(pricing, estimationBudgetWon, midpoint(clicks)), + pricing.pricingModel(), + pricing.value(), + pricingModels, + result.impressions(), + clicks, + minBudgetWon, + isExecutable, + shortfallWon); + } + + public List reasonTags() { + return matchedAxes.stream().map(MatchAxis::name).toList(); + } + + public List pricingModelNames() { + return pricingModels.stream().map(PricingModel::name).toList(); + } + + private static Long midpoint(ClickRange clicks) { + return clicks == null ? null : Math.round((clicks.min() + clicks.max()) / 2.0); + } +} diff --git a/src/main/java/chaeso/zip/server/recommendation/domain/entity/ChannelRecommendation.java b/src/main/java/chaeso/zip/server/recommendation/domain/entity/ChannelRecommendation.java new file mode 100644 index 0000000..3e61e05 --- /dev/null +++ b/src/main/java/chaeso/zip/server/recommendation/domain/entity/ChannelRecommendation.java @@ -0,0 +1,147 @@ +package chaeso.zip.server.recommendation.domain.entity; + +import chaeso.zip.server.channel.domain.vo.PricingModel; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@Entity +@EntityListeners(AuditingEntityListener.class) +@Table(name = "channel_recommendations", uniqueConstraints = { + @UniqueConstraint(name = "uq_channel_recommendation_onboarding_channel", + columnNames = {"onboarding_id", "channel_id"}), + @UniqueConstraint(name = "uq_channel_recommendation_onboarding_rank", + columnNames = {"onboarding_id", "rank"})}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChannelRecommendation { + + @Id + @UuidGenerator + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "onboarding_id", nullable = false) + private UUID onboardingId; + + @Column(name = "channel_id", nullable = false) + private UUID channelId; + + /** 추천 순위. 1 부터 시작한다 */ + @Column(name = "rank", nullable = false) + private int rank; + + /** 적합도(%) */ + @Column(name = "score", nullable = false) + private int score; + + @Column(name = "reason", nullable = false, length = 500) + private String reason; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "reason_tags") + private List reasonTags; + + @Column(name = "channel_name", nullable = false) + private String channelName; + + @Enumerated(EnumType.STRING) + @Column(name = "est_pricing_model", length = 20) + private PricingModel estPricingModel; + + @Column(name = "est_unit_price") + private BigDecimal estUnitPrice; + + @Column(name = "est_impressions_min") + private Long estImpressionsMin; + + @Column(name = "est_impressions_max") + private Long estImpressionsMax; + + @Column(name = "est_clicks_min") + private Long estClicksMin; + + @Column(name = "est_clicks_max") + private Long estClicksMax; + + @Column(name = "cpc_won") + private BigDecimal cpcWon; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "pricing_models_all") + private List pricingModelsAll; + + @Column(name = "min_budget_won_snap") + private Long minBudgetWonSnap; + + /** 추천이 만든 주요 타깃 문구 */ + @Column(name = "audience_summary_snap") + private String audienceSummarySnap; + + @Column(name = "is_executable", nullable = false) + private boolean isExecutable; + + @Column(name = "shortfall_won") + private Long shortfallWon; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + private ChannelRecommendation(UUID userId, UUID onboardingId, UUID channelId, int rank, int score, + String reason, List reasonTags, String channelName, PricingModel estPricingModel, + BigDecimal estUnitPrice, Long estImpressionsMin, Long estImpressionsMax, Long estClicksMin, + Long estClicksMax, BigDecimal cpcWon, List pricingModelsAll, Long minBudgetWonSnap, + String audienceSummarySnap, boolean executable, Long shortfallWon) { + if (userId == null) { + throw new IllegalArgumentException("ChannelRecommendation requires a userId."); + } + if (onboardingId == null) { + throw new IllegalArgumentException("ChannelRecommendation requires an onboardingId."); + } + if (channelId == null) { + throw new IllegalArgumentException("ChannelRecommendation requires a channelId."); + } + this.userId = userId; + this.onboardingId = onboardingId; + this.channelId = channelId; + this.rank = rank; + this.score = score; + this.reason = reason; + this.reasonTags = reasonTags; + this.channelName = channelName; + this.estPricingModel = estPricingModel; + this.estUnitPrice = estUnitPrice; + this.estImpressionsMin = estImpressionsMin; + this.estImpressionsMax = estImpressionsMax; + this.estClicksMin = estClicksMin; + this.estClicksMax = estClicksMax; + this.cpcWon = cpcWon; + this.pricingModelsAll = pricingModelsAll; + this.minBudgetWonSnap = minBudgetWonSnap; + this.audienceSummarySnap = audienceSummarySnap; + this.isExecutable = executable; + this.shortfallWon = shortfallWon; + } +} diff --git a/src/main/java/chaeso/zip/server/recommendation/domain/repository/ChannelRecommendationRepository.java b/src/main/java/chaeso/zip/server/recommendation/domain/repository/ChannelRecommendationRepository.java new file mode 100644 index 0000000..62f2062 --- /dev/null +++ b/src/main/java/chaeso/zip/server/recommendation/domain/repository/ChannelRecommendationRepository.java @@ -0,0 +1,22 @@ +package chaeso.zip.server.recommendation.domain.repository; + +import chaeso.zip.server.recommendation.domain.entity.ChannelRecommendation; +import java.util.List; +import java.util.UUID; +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 ChannelRecommendationRepository + extends JpaRepository { + + List findByOnboardingIdOrderByRankAsc(UUID onboardingId); + + /** + * 같은 온보딩으로 다시 저장할 때 이전 추천을 덮어씌운다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from ChannelRecommendation c where c.onboardingId = :onboardingId") + int deleteByOnboardingId(@Param("onboardingId") UUID onboardingId); +} diff --git a/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationApiDocs.java b/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationApiDocs.java index dad672f..ad882d9 100644 --- a/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationApiDocs.java +++ b/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationApiDocs.java @@ -1,15 +1,22 @@ package chaeso.zip.server.recommendation.presentation; +import chaeso.zip.server.auth.application.UserPrincipal; import chaeso.zip.server.common.response.ApiResponse; import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse; +import chaeso.zip.server.recommendation.application.dto.SavedRecommendationResponse; +import chaeso.zip.server.recommendation.presentation.dto.SaveRecommendationRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import java.util.List; import java.util.UUID; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "Recommendation", description = "채널 추천 API") public interface RecommendationApiDocs { @@ -29,8 +36,7 @@ public interface RecommendationApiDocs { "minBudgetWon": 3000, "estImpressions": { "min": 2833333, "max": 3833333 }, "estClicks": { "min": 70833, "max": 95833 }, - "isExecutable": true, - "shortfallWon": null + "isExecutable": true }, { "channelId": "9c1e8c2a-3f4d-4a5b-9c6d-7e8f9a0b1c2e", @@ -50,6 +56,31 @@ public interface RecommendationApiDocs { } """; + String RECOMMENDATION_SAVED_EXAMPLE = """ + { + "success": true, + "data": { + "onboardingId": "550e8400-e29b-41d4-a716-446655440000", + "channelCount": 1, + "items": [ + { + "channelId": "9c1e8c2a-3f4d-4a5b-9c6d-7e8f9a0b1c2e", + "channelName": "11번가 광고", + "matchRate": 100, + "recommendationReason": "쇼핑·커머스 업종, 설정한 광고 목적, 타깃 연령대에 적합하고 예산 내 집행이 가능해요", + "primaryTarget": "20~40대 여성", + "cpcWon": 120, + "pricingModel": "CPM", + "minBudgetWon": 3000, + "estImpressions": { "min": 2833333, "max": 3833333 }, + "estClicks": { "min": 70833, "max": 95833 }, + "isExecutable": true + } + ] + } + } + """; + String ONBOARDING_NOT_FOUND_EXAMPLE = """ { "success": false, @@ -61,6 +92,34 @@ public interface RecommendationApiDocs { } """; + String VALIDATION_ERROR_EXAMPLE = """ + { + "success": false, + "error": { + "code": "C-001", + "message": "입력값이 올바르지 않습니다.", + "fieldErrors": [ + { + "field": "onboardingId", + "value": "", + "reason": "온보딩 응답 식별자는 필수입니다" + } + ] + } + } + """; + + String CONCURRENT_SUBMISSION_EXAMPLE = """ + { + "success": false, + "error": { + "code": "ONB-006", + "message": "동시에 제출된 요청이 있어 처리할 수 없습니다. 다시 시도해주세요.", + "fieldErrors": [] + } + } + """; + @Operation(operationId = "getRecommendations", summary = "온보딩 기반 채널 추천", description = """ 온보딩 응답을 업종·광고 목적·타깃 연령대로 매칭해 적합한 채널을 적합도 순으로 \ @@ -86,4 +145,40 @@ ApiResponse> getRecommendations( @Parameter(description = "온보딩 응답 식별자", required = true, example = "550e8400-e29b-41d4-a716-446655440000") UUID onboardingId); + + @SecurityRequirement(name = "bearerAuth") + @Operation(operationId = "saveRecommendation", summary = "채널 추천 결과 저장", + description = """ + 추천을 다시 계산해 그 시점 값을 스냅샷으로 저장한다. 채널 1개가 1행이고, 요청한 \ + 온보딩이 저장된 추천 1건을 가리키는 키가 된다. + + 이후 채널의 단가·상품이 바뀌어도 저장된 추천은 변하지 않는다. 마이페이지는 이 저장분을 \ + 그대로 읽는다. + + 같은 온보딩으로 다시 저장하면 이전 추천을 지우고 다시 넣는다. 온보딩 응답은 불변이고 \ + 추천도 결정적이라 결과는 같으며, 재요청·재시도로 행이 쌓이지 않는다. + + 본인이 제출한 온보딩만 저장할 수 있다. 맞는 채널이 없으면 저장할 것도 없으므로 \ + channelCount 0 과 빈 배열을 반환한다.""") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "저장 성공", + useReturnTypeSchema = true, + content = @Content(examples = @ExampleObject(name = "RECOMMENDATION_SAVED", + value = RECOMMENDATION_SAVED_EXAMPLE))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", + description = "입력값 검증 실패(C-001)", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "VALIDATION_ERROR", value = VALIDATION_ERROR_EXAMPLE))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", + description = "존재하지 않거나 본인이 제출하지 않은 온보딩(ONB-007)", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "ONBOARDING_NOT_FOUND", + value = ONBOARDING_NOT_FOUND_EXAMPLE))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", + description = "같은 온보딩으로 동시에 저장 요청이 겹침(ONB-006). 다시 시도하면 된다", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "CONCURRENT_SUBMISSION", + value = CONCURRENT_SUBMISSION_EXAMPLE))) + ApiResponse saveRecommendation( + @Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody SaveRecommendationRequest request); } diff --git a/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationController.java b/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationController.java index 259b71c..b947728 100644 --- a/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationController.java +++ b/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationController.java @@ -1,14 +1,23 @@ package chaeso.zip.server.recommendation.presentation; +import chaeso.zip.server.auth.application.UserPrincipal; import chaeso.zip.server.common.response.ApiResponse; import chaeso.zip.server.recommendation.application.RecommendationService; import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse; +import chaeso.zip.server.recommendation.application.dto.SavedRecommendationResponse; +import chaeso.zip.server.recommendation.presentation.dto.SaveRecommendationRequest; +import jakarta.validation.Valid; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController @@ -24,4 +33,14 @@ public ApiResponse> getRecommendations( @RequestParam UUID onboardingId) { return ApiResponse.success(recommendationService.recommend(onboardingId)); } + + @Override + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse saveRecommendation( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody SaveRecommendationRequest request) { + return ApiResponse.success( + recommendationService.save(principal.userId(), request.onboardingId())); + } } diff --git a/src/main/java/chaeso/zip/server/recommendation/presentation/dto/SaveRecommendationRequest.java b/src/main/java/chaeso/zip/server/recommendation/presentation/dto/SaveRecommendationRequest.java new file mode 100644 index 0000000..0b5dec0 --- /dev/null +++ b/src/main/java/chaeso/zip/server/recommendation/presentation/dto/SaveRecommendationRequest.java @@ -0,0 +1,14 @@ +package chaeso.zip.server.recommendation.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +@Schema(description = "채널 추천 결과 저장 요청") +public record SaveRecommendationRequest( + @Schema(description = "추천의 근거가 된 온보딩 응답 식별자", + example = "550e8400-e29b-41d4-a716-446655440000", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "온보딩 응답 식별자는 필수입니다") + UUID onboardingId) { +} diff --git a/src/main/resources/db/migration/V15__create_channel_recommendations.sql b/src/main/resources/db/migration/V15__create_channel_recommendations.sql new file mode 100644 index 0000000..d8a65ea --- /dev/null +++ b/src/main/resources/db/migration/V15__create_channel_recommendations.sql @@ -0,0 +1,35 @@ +create table channel_recommendations ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users (id), + onboarding_id uuid not null references onboarding_responses (id), + channel_id uuid not null references channels (id), + rank integer not null, -- 추천 순위 + score integer not null, -- 적합도(%) + reason varchar(500) not null, -- 추천 근거 한 줄 + reason_tags text[], -- 근거가 된 매칭 축 (CATEGORY/OBJECTIVE/AGE_BAND) + + -- 아래는 모두 추천 시점 값의 복사본 + channel_name varchar(255) not null, + est_pricing_model varchar(20), + est_unit_price numeric, + est_impressions_min bigint, + est_impressions_max bigint, + est_clicks_min bigint, + est_clicks_max bigint, + cpc_won numeric, + pricing_models_all text[], -- 그 채널이 그 시점에 가지고 있던 과금 방식 전체 + min_budget_won_snap bigint, + audience_summary_snap varchar(255), -- 추천이 만든 주요 타깃 문구 + is_executable boolean not null, + shortfall_won bigint, + + created_at timestamp not null default now(), + + constraint uq_channel_recommendation_onboarding_channel unique (onboarding_id, channel_id) +); + +create index idx_channel_recommendation_user_created + on channel_recommendations (user_id, created_at desc); + +create index idx_channel_recommendation_onboarding + on channel_recommendations (onboarding_id, rank); diff --git a/src/main/resources/db/migration/V16__add_channel_recommendation_rank_unique.sql b/src/main/resources/db/migration/V16__add_channel_recommendation_rank_unique.sql new file mode 100644 index 0000000..581903f --- /dev/null +++ b/src/main/resources/db/migration/V16__add_channel_recommendation_rank_unique.sql @@ -0,0 +1,2 @@ +alter table channel_recommendations + add constraint uq_channel_recommendation_onboarding_rank unique (onboarding_id, rank); diff --git a/src/test/java/chaeso/zip/server/auth/infrastructure/security/SecurityConfigIntegrationTest.java b/src/test/java/chaeso/zip/server/auth/infrastructure/security/SecurityConfigIntegrationTest.java index e5ae5be..e8cd6d3 100644 --- a/src/test/java/chaeso/zip/server/auth/infrastructure/security/SecurityConfigIntegrationTest.java +++ b/src/test/java/chaeso/zip/server/auth/infrastructure/security/SecurityConfigIntegrationTest.java @@ -106,6 +106,16 @@ String googleLinkProbe() { String signupGoogleProbe() { return "ok"; } + + @GetMapping("/api/v1/recommendations") + String recommendationsProbe() { + return "ok"; + } + + @PostMapping("/api/v1/recommendations") + String saveRecommendationProbe() { + return "ok"; + } } @Nested @@ -134,6 +144,26 @@ void validAccessToken_passes() throws Exception { } } + @Nested + @DisplayName("메서드별로 갈리는 공개 경로") + class MethodScopedPublicPaths { + + @Test + @DisplayName("추천 조회는 토큰 없이 접근할 수 있다") + void getRecommendations_isPublic() throws Exception { + mockMvc.perform(get("/api/v1/recommendations")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("같은 경로라도 추천 저장은 토큰 없이 접근하면 401을 반환한다") + void saveRecommendation_requiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/recommendations")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error.code").value("C-004")); + } + } + @Nested @DisplayName("auth 공개 경로") class PublicAuthPaths { diff --git a/src/test/java/chaeso/zip/server/docs/OpenApiContractTest.java b/src/test/java/chaeso/zip/server/docs/OpenApiContractTest.java index 246a7a4..80d738f 100644 --- a/src/test/java/chaeso/zip/server/docs/OpenApiContractTest.java +++ b/src/test/java/chaeso/zip/server/docs/OpenApiContractTest.java @@ -68,7 +68,7 @@ void apiDocsContractIsValid() throws Exception { void pageableParametersAreExploded() throws Exception { JsonNode spec = loadOpenApiSpec(); - forEachOperation(spec, (path, operationId, operation) -> { + forEachOperation(spec, (path, method, operationId, operation) -> { JsonNode parameters = operation.path("parameters"); if (!parameters.isArray()) { return; @@ -166,13 +166,15 @@ class SecurityAndNoContentContracts { @DisplayName("SecurityConfig 인증 대상 API는 bearerAuth와 공통 401 오류 schema를 노출한다") void bearerOperationsPublishCommonUnauthorizedResponse() throws Exception { JsonNode spec = loadOpenApiSpec(); - String[] publicPaths = publicPaths(); + String[] publicPaths = publicPaths("PUBLIC_PATHS"); + String[] publicGetPaths = publicPaths("PUBLIC_GET_PATHS"); AntPathMatcher pathMatcher = new AntPathMatcher(); List bearerOperations = new ArrayList<>(); - forEachOperation(spec, (path, operationId, operation) -> { - boolean publicApi = List.of(publicPaths).stream() - .anyMatch(pattern -> pathMatcher.match(pattern, path)); + forEachOperation(spec, (path, method, operationId, operation) -> { + // PUBLIC_GET_PATHS 는 GET 만 열려 있으므로 같은 경로의 다른 메서드는 인증 대상이다 + boolean publicApi = matchesAny(pathMatcher, publicPaths, path) + || ("get".equals(method) && matchesAny(pathMatcher, publicGetPaths, path)); if (publicApi) { return; } @@ -212,6 +214,96 @@ void noContentResponsesHaveNoBody() throws Exception { .isTrue(); }); } + + @Test + @DisplayName("페이로드가 있는 성공 응답 래퍼는 data를 required로 노출한다") + void successWrappersRequireData() throws Exception { + JsonNode spec = loadOpenApiSpec(); + JsonNode schemas = spec.path("components").path("schemas"); + List checked = new ArrayList<>(); + + schemas.properties().forEach(schema -> { + String name = schema.getKey(); + // ApiResponse 는 제네릭이 지워진 오류 응답용, ApiResponseVoid 는 본문이 없는 성공 응답이다 + if (!name.startsWith("ApiResponse") || name.equals("ApiResponse") + || name.equals("ApiResponseVoid")) { + return; + } + checked.add(name); + List required = new ArrayList<>(); + schema.getValue().path("required").forEach(field -> required.add(field.asText())); + assertThat(required) + .as("%s 는 2xx 전용 래퍼이므로 data가 required 여야 합니다", name) + .contains("data"); + }); + + assertThat(checked) + .as("페이로드를 담는 성공 응답 래퍼가 하나 이상 있어야 합니다") + .isNotEmpty(); + } + + @Test + @DisplayName("data가 없는 래퍼는 data를 required로 올리지 않는다") + void wrappersWithoutDataDoNotRequireIt() throws Exception { + JsonNode spec = loadOpenApiSpec(); + JsonNode schemas = spec.path("components").path("schemas"); + + for (String name : List.of("ApiResponse", "ApiResponseVoid")) { + assertThat(requiredFields(schemas.path(name))) + .as("%s 는 data가 없는 응답에 쓰이므로 required 가 아니어야 합니다", name) + .doesNotContain("data"); + } + } + + @Test + @DisplayName("오류 응답 래퍼는 error를 required로 노출한다") + void errorWrapperRequiresError() throws Exception { + JsonNode spec = loadOpenApiSpec(); + JsonNode schemas = spec.path("components").path("schemas"); + + assertThat(requiredFields(schemas.path("ApiResponse"))) + .as("ApiResponse 는 오류 응답 전용이므로 error가 required 여야 합니다") + .contains("error"); + assertThat(requiredFields(schemas.path("ApiResponseVoid"))) + .as("ApiResponseVoid 는 성공 응답이므로 error가 required 가 아니어야 합니다") + .doesNotContain("error"); + } + + @Test + @DisplayName("성공 응답은 페이로드 래퍼를, 오류 응답은 공통 래퍼를 참조한다") + void wrapperSchemasSplitBySuccess() throws Exception { + JsonNode spec = loadOpenApiSpec(); + + // data/error 를 required 로 올릴 수 있는 근거가 이 갈림이다. 섞이면 둘 다 거짓이 된다 + forEachResponse(spec, (operationId, responseCode, response) -> { + JsonNode ref = + response.path("content").path("application/json").path("schema").path("$ref"); + if (ref.isMissingNode()) { + return; + } + String schema = ref.asText().substring(SCHEMA_REF_PREFIX.length()); + if (!schema.startsWith(API_RESPONSE_REF.substring(SCHEMA_REF_PREFIX.length()))) { + return; + } + if (responseCode.startsWith("2")) { + assertThat(schema) + .as("%s %s 성공 응답이 제네릭 없는 ApiResponse를 참조하면 data required가 깨집니다", + operationId, responseCode) + .isNotEqualTo("ApiResponse"); + } else { + assertThat(schema) + .as("%s %s 오류 응답이 타입 래퍼를 참조하면 error required가 깨집니다", + operationId, responseCode) + .isEqualTo("ApiResponse"); + } + }); + } + + private List requiredFields(JsonNode schema) { + List required = new ArrayList<>(); + schema.path("required").forEach(field -> required.add(field.asText())); + return required; + } } @Nested @@ -320,14 +412,18 @@ private boolean requiresBearerAuth(JsonNode operation) { return false; } - private String[] publicPaths() { - Object publicPaths = ReflectionTestUtils.getField(SecurityConfig.class, "PUBLIC_PATHS"); + private String[] publicPaths(String fieldName) { + Object publicPaths = ReflectionTestUtils.getField(SecurityConfig.class, fieldName); assertThat(publicPaths) - .as("SecurityConfig.PUBLIC_PATHS를 확인할 수 있어야 합니다") + .as("SecurityConfig.%s를 확인할 수 있어야 합니다", fieldName) .isInstanceOf(String[].class); return (String[]) publicPaths; } + private boolean matchesAny(AntPathMatcher pathMatcher, String[] patterns, String path) { + return List.of(patterns).stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + private JsonNode loadOpenApiSpec() throws Exception { MvcResult result = mockMvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()) @@ -337,23 +433,25 @@ private JsonNode loadOpenApiSpec() throws Exception { private List collectOperationIds(JsonNode spec) { List operationIds = new ArrayList<>(); - forEachOperation(spec, (path, operationId, operation) -> operationIds.add(operationId)); + forEachOperation(spec, + (path, method, operationId, operation) -> operationIds.add(operationId)); return operationIds; } private void forEachOperation(JsonNode spec, OperationConsumer consumer) { for (Map.Entry path : spec.path("paths").properties()) { - for (JsonNode operation : path.getValue()) { + for (Map.Entry method : path.getValue().properties()) { + JsonNode operation = method.getValue(); String operationId = operation.path("operationId").asText(); if (!operationId.isBlank()) { - consumer.accept(path.getKey(), operationId, operation); + consumer.accept(path.getKey(), method.getKey(), operationId, operation); } } } } private void forEachResponse(JsonNode spec, ResponseConsumer consumer) { - forEachOperation(spec, (path, operationId, operation) -> + forEachOperation(spec, (path, method, operationId, operation) -> operation.path("responses").properties().forEach(response -> consumer.accept(operationId, response.getKey(), response.getValue()))); } @@ -361,7 +459,7 @@ private void forEachResponse(JsonNode spec, ResponseConsumer consumer) { @FunctionalInterface private interface OperationConsumer { - void accept(String path, String operationId, JsonNode operation); + void accept(String path, String method, String operationId, JsonNode operation); } @FunctionalInterface diff --git a/src/test/java/chaeso/zip/server/recommendation/application/RecommendationServiceImplTest.java b/src/test/java/chaeso/zip/server/recommendation/application/RecommendationServiceImplTest.java index 87be104..8f83e09 100644 --- a/src/test/java/chaeso/zip/server/recommendation/application/RecommendationServiceImplTest.java +++ b/src/test/java/chaeso/zip/server/recommendation/application/RecommendationServiceImplTest.java @@ -6,8 +6,11 @@ import static chaeso.zip.server.support.ChannelCatalogFixture.withObjectives; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -26,10 +29,14 @@ import chaeso.zip.server.estimation.application.DefaultCtrProvider; import chaeso.zip.server.estimation.application.dto.CountRangeResponse; import chaeso.zip.server.onboarding.domain.OnboardingBusinessException; +import chaeso.zip.server.onboarding.domain.OnboardingErrorCode; import chaeso.zip.server.onboarding.domain.entity.Onboarding; import chaeso.zip.server.onboarding.domain.repository.OnboardingRepository; import chaeso.zip.server.onboarding.domain.vo.CampaignPeriod; import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse; +import chaeso.zip.server.recommendation.application.dto.SavedRecommendationResponse; +import chaeso.zip.server.recommendation.domain.entity.ChannelRecommendation; +import chaeso.zip.server.recommendation.domain.repository.ChannelRecommendationRepository; import chaeso.zip.server.support.OnboardingFixture; import java.math.BigDecimal; import java.util.ArrayList; @@ -44,14 +51,17 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; @ExtendWith(MockitoExtension.class) class RecommendationServiceImplTest { private static final UUID ONBOARDING_ID = UUID.randomUUID(); + private static final UUID USER_ID = UUID.randomUUID(); private static final Category INDUSTRY = Category.MEDICAL_HEALTHCARE; private static final CampaignObjective OBJECTIVE = CampaignObjective.AWARENESS; @@ -71,6 +81,8 @@ class RecommendationServiceImplTest { @Mock private ChannelPricingRepository channelPricingRepository; @Mock + private ChannelRecommendationRepository channelRecommendationRepository; + @Mock private DefaultCtrProvider defaultCtrProvider; @InjectMocks @@ -79,6 +91,9 @@ class RecommendationServiceImplTest { @Captor private ArgumentCaptor> productIdsCaptor; + @Captor + private ArgumentCaptor> savedCaptor; + @Nested @DisplayName("적합도 계산과 순서") class Ranking { @@ -319,6 +334,208 @@ void rejectsUnknownOnboarding() { verifyNoInteractions(channelRepository, channelProductRepository, channelPricingRepository); } + @Nested + @DisplayName("추천 결과 저장 (POST /recommendations)") + class Save { + + @Test + @DisplayName("추천된 채널을 각각 한 행으로 저장하고 순위를 1부터 매긴다") + void savesOneRowPerChannelWithRank() { + Channel best = matchingChannel("가매체", List.of(INDUSTRY), List.of(TARGET_AGE_BAND)); + Channel second = matchingChannel("나매체", List.of(INDUSTRY), List.of(OTHER_AGE_BAND)); + givenCatalog( + entry(best, OBJECTIVE, PricingModel.CPM, "3000"), + entry(second, OBJECTIVE, PricingModel.CPM, "3000")); + + SavedRecommendationResponse response = save(ownedOnboarding()); + + assertThat(response.onboardingId()).isEqualTo(ONBOARDING_ID); + assertThat(response.channelCount()).isEqualTo(2); + + verify(channelRecommendationRepository).saveAll(savedCaptor.capture()); + assertThat(savedCaptor.getValue()) + .extracting(ChannelRecommendation::getChannelName, ChannelRecommendation::getRank) + .containsExactly(tuple("가매체", 1), tuple("나매체", 2)); + assertThat(savedCaptor.getValue()) + .allSatisfy(row -> { + assertThat(row.getUserId()).isEqualTo(USER_ID); + assertThat(row.getOnboardingId()).isEqualTo(ONBOARDING_ID); + }); + } + + @Test + @DisplayName("추천 시점의 계산 결과를 그대로 박제한다") + void storesCalculatedValuesAsSnapshot() { + Channel channel = matchingChannel("가매체", List.of(INDUSTRY), List.of(TARGET_AGE_BAND)); + givenCatalog(entry(channel, OBJECTIVE, PricingModel.CPM, "3000")); + + RecommendationItemResponse calculated = save(ownedOnboarding()).items().getFirst(); + + verify(channelRecommendationRepository).saveAll(savedCaptor.capture()); + ChannelRecommendation saved = savedCaptor.getValue().getFirst(); + + // 응답으로 나간 값과 저장된 값이 같아야 재계산 없이 그때 화면을 복원할 수 있다 + assertThat(saved.getChannelId()).isEqualTo(calculated.channelId()); + assertThat(saved.getChannelName()).isEqualTo(calculated.channelName()); + assertThat(saved.getScore()).isEqualTo(calculated.matchRate()); + assertThat(saved.getReason()).isEqualTo(calculated.recommendationReason()); + assertThat(saved.getAudienceSummarySnap()).isEqualTo(calculated.primaryTarget()); + assertThat(saved.getEstPricingModel()).isEqualTo(calculated.pricingModel()); + assertThat(saved.getMinBudgetWonSnap()).isEqualTo(calculated.minBudgetWon()); + assertThat(saved.getCpcWon()).isEqualByComparingTo(calculated.cpcWon()); + assertThat(saved.getEstImpressionsMin()).isEqualTo(calculated.estImpressions().min()); + assertThat(saved.getEstImpressionsMax()).isEqualTo(calculated.estImpressions().max()); + assertThat(saved.getEstClicksMin()).isEqualTo(calculated.estClicks().min()); + assertThat(saved.getEstClicksMax()).isEqualTo(calculated.estClicks().max()); + assertThat(saved.isExecutable()).isEqualTo(calculated.isExecutable()); + assertThat(saved.getShortfallWon()).isEqualTo(calculated.shortfallWon()); + + // 응답에는 없고 저장에만 있는 값 + assertThat(saved.getEstUnitPrice()).isEqualByComparingTo("3000"); + assertThat(saved.getReasonTags()).containsExactly("CATEGORY", "OBJECTIVE", "AGE_BAND"); + assertThat(saved.getPricingModelsAll()).containsExactly("CPM"); + } + + @Test + @DisplayName("대표 단가가 없는 매체도 저장하고, 추정값 자리는 비워 둔다") + void savesQuoteRequiredChannelWithoutEstimates() { + Channel channel = matchingChannel("단가없는매체", List.of(INDUSTRY), List.of(TARGET_AGE_BAND)); + ChannelProduct product = + withObjectives(product(UUID.randomUUID(), channel.getId()), OBJECTIVE); + given(channelRepository.findByActiveTrue()).willReturn(List.of(channel)); + given(channelProductRepository.findByChannelIdIn(anyCollection())) + .willReturn(List.of(product)); + given(channelPricingRepository.findByChannelProductIdIn(anyCollection())) + .willReturn(List.of()); + given(defaultCtrProvider.averageCtrPercent()).willReturn(AVERAGE_CTR); + + save(ownedOnboarding()); + + verify(channelRecommendationRepository).saveAll(savedCaptor.capture()); + ChannelRecommendation saved = savedCaptor.getValue().getFirst(); + + assertThat(saved.getEstPricingModel()).isNull(); + assertThat(saved.getEstUnitPrice()).isNull(); + assertThat(saved.getEstImpressionsMin()).isNull(); + assertThat(saved.getEstClicksMax()).isNull(); + assertThat(saved.getMinBudgetWonSnap()).isNull(); + assertThat(saved.isExecutable()).isFalse(); + assertThat(saved.getPricingModelsAll()).isEmpty(); + // 근거는 단가와 무관하게 매칭 축으로 만들어지므로 남는다 + assertThat(saved.getReason()).isNotBlank(); + assertThat(saved.getReasonTags()).isNotEmpty(); + } + + @Test + @DisplayName("같은 온보딩으로 다시 저장하면 이전 추천을 지우고 다시 넣는다") + void overwritesPreviousRecommendation() { + Channel channel = matchingChannel("가매체", List.of(INDUSTRY), List.of(TARGET_AGE_BAND)); + givenCatalog(entry(channel, OBJECTIVE, PricingModel.CPM, "3000")); + + save(ownedOnboarding()); + + InOrder inOrder = inOrder(channelRecommendationRepository); + inOrder.verify(channelRecommendationRepository).deleteByOnboardingId(ONBOARDING_ID); + inOrder.verify(channelRecommendationRepository).saveAll(anyList()); + } + + @Test + @DisplayName("지우고 넣기 전에 온보딩 행을 잠가 같은 온보딩의 저장을 직렬화한다") + void locksOnboardingBeforeRewriting() { + Channel channel = matchingChannel("가매체", List.of(INDUSTRY), List.of(TARGET_AGE_BAND)); + givenCatalog(entry(channel, OBJECTIVE, PricingModel.CPM, "3000")); + + save(ownedOnboarding()); + + InOrder inOrder = inOrder(channelRepository, onboardingRepository, + channelRecommendationRepository); + inOrder.verify(channelRepository).findByActiveTrue(); + inOrder.verify(onboardingRepository).findByIdForUpdate(ONBOARDING_ID); + inOrder.verify(channelRecommendationRepository).deleteByOnboardingId(ONBOARDING_ID); + inOrder.verify(channelRecommendationRepository).saveAll(anyList()); + } + + @Test + @DisplayName("맞는 채널이 없으면 저장할 것도 없어 빈 결과를 반환한다") + void savesNothingWhenNothingMatches() { + Channel unmatched = matchingChannel("미매칭 채널", List.of(Category.GAME), + List.of(OTHER_AGE_BAND)); + given(channelRepository.findByActiveTrue()).willReturn(List.of(unmatched)); + given(channelProductRepository.findByChannelIdIn(anyCollection())).willReturn(List.of()); + + SavedRecommendationResponse response = save(ownedOnboarding()); + + assertThat(response.channelCount()).isZero(); + assertThat(response.items()).isEmpty(); + // 덮어쓰기는 그대로 수행한다. 이번 추천이 비었다면 이전 저장분도 남아 있으면 안 된다 + verify(channelRecommendationRepository).deleteByOnboardingId(ONBOARDING_ID); + verify(channelRecommendationRepository).saveAll(List.of()); + } + + @Test + @DisplayName("다른 사용자가 제출한 온보딩은 없는 것과 같은 404 로 숨기고 저장하지 않는다") + void rejectsOtherUsersOnboarding() { + Onboarding othersOnboarding = onboarding(UUID.randomUUID()); + + assertThatThrownBy(() -> save(othersOnboarding)) + .isInstanceOf(OnboardingBusinessException.class) + .hasMessageContaining(ONBOARDING_ID.toString()); + + verifyNoInteractions(channelRecommendationRepository, channelRepository); + } + + @Test + @DisplayName("비로그인으로 제출해 주인이 없는 온보딩도 저장할 수 없다") + void rejectsAnonymousOnboarding() { + Onboarding anonymous = onboarding(null); + + assertThatThrownBy(() -> save(anonymous)) + .isInstanceOf(OnboardingBusinessException.class); + + verifyNoInteractions(channelRecommendationRepository, channelRepository); + } + + @Test + @DisplayName("존재하지 않는 온보딩은 404 로 거부한다") + void rejectsUnknownOnboarding() { + given(onboardingRepository.findById(ONBOARDING_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> recommendationService.save(USER_ID, ONBOARDING_ID)) + .isInstanceOf(OnboardingBusinessException.class); + + verifyNoInteractions(channelRecommendationRepository, channelRepository); + } + + @Test + @DisplayName("같은 온보딩으로 저장이 동시에 겹치면 유니크 제약 위반을 409 로 바꿔 준다") + void translatesConcurrentSaveToConflict() { + Channel channel = matchingChannel("가매체", List.of(INDUSTRY), List.of(TARGET_AGE_BAND)); + givenCatalog(entry(channel, OBJECTIVE, PricingModel.CPM, "3000")); + given(channelRecommendationRepository.saveAll(anyList())) + .willThrow(new DataIntegrityViolationException("duplicate key")); + + assertThatThrownBy(() -> save(ownedOnboarding())) + .isInstanceOf(OnboardingBusinessException.class) + .extracting(e -> ((OnboardingBusinessException) e).getErrorCode()) + .isEqualTo(OnboardingErrorCode.CONCURRENT_SUBMISSION); + } + + private SavedRecommendationResponse save(Onboarding onboarding) { + given(onboardingRepository.findById(ONBOARDING_ID)).willReturn(Optional.of(onboarding)); + return recommendationService.save(USER_ID, ONBOARDING_ID); + } + + /** 저장하는 사용자가 직접 제출한 온보딩. */ + private Onboarding ownedOnboarding() { + return onboarding(USER_ID); + } + + private Onboarding onboarding(UUID userId) { + return OnboardingFixture.onboarding(userId, INDUSTRY, OBJECTIVE, List.of(TARGET_AGE_BAND), + 1_000_000L, BUDGET_MAX, CampaignPeriod.M1); + } + } + private List recommend(Onboarding onboarding) { given(onboardingRepository.findById(ONBOARDING_ID)).willReturn(Optional.of(onboarding)); return recommendationService.recommend(ONBOARDING_ID); diff --git a/src/test/java/chaeso/zip/server/recommendation/domain/ChannelRecommendationRepositoryTest.java b/src/test/java/chaeso/zip/server/recommendation/domain/ChannelRecommendationRepositoryTest.java new file mode 100644 index 0000000..83a183b --- /dev/null +++ b/src/test/java/chaeso/zip/server/recommendation/domain/ChannelRecommendationRepositoryTest.java @@ -0,0 +1,124 @@ +package chaeso.zip.server.recommendation.domain; + +import static chaeso.zip.server.support.ChannelCatalogFixture.persistableChannel; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import chaeso.zip.server.channel.domain.vo.PricingModel; +import chaeso.zip.server.recommendation.domain.entity.ChannelRecommendation; +import chaeso.zip.server.recommendation.domain.repository.ChannelRecommendationRepository; +import chaeso.zip.server.support.OnboardingFixture; +import chaeso.zip.server.support.PostgresDataJpaTest; +import chaeso.zip.server.support.UserFixture; +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.dao.DataIntegrityViolationException; + +@PostgresDataJpaTest +class ChannelRecommendationRepositoryTest { + + @Autowired + private ChannelRecommendationRepository channelRecommendationRepository; + + @Autowired + private TestEntityManager entityManager; + + private UUID userId; + private UUID onboardingId; + private UUID channelId; + + @BeforeEach + void persistReferences() { + userId = entityManager.persistAndFlush( + UserFixture.user(UUID.randomUUID() + "@example.com")).getId(); + onboardingId = entityManager.persistAndFlush( + OnboardingFixture.onboarding(userId)).getId(); + channelId = entityManager.persistAndFlush(persistableChannel("11번가 광고")).getId(); + } + + @Test + @DisplayName("근거 태그와 과금 방식 배열을 PostgreSQL text[]로 저장하고 다시 읽어온다") + void savesArrayColumns() { + channelRecommendationRepository.saveAndFlush(recommendation(1)); + entityManager.clear(); + + ChannelRecommendation found = + channelRecommendationRepository.findByOnboardingIdOrderByRankAsc(onboardingId).getFirst(); + + assertThat(found.getReasonTags()).containsExactly("CATEGORY", "OBJECTIVE"); + assertThat(found.getPricingModelsAll()).containsExactly("CPM", "CPC"); + assertThat(found.getEstUnitPrice()).isEqualByComparingTo("3000"); + assertThat(found.getEstPricingModel()).isEqualTo(PricingModel.CPM); + assertThat(found.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("같은 온보딩에 같은 채널을 두 번 저장하면 유니크 제약으로 막는다") + void rejectsDuplicateChannelInOneRecommendation() { + channelRecommendationRepository.saveAndFlush(recommendation(1)); + + assertThatThrownBy(() -> channelRecommendationRepository.saveAndFlush(recommendation(2))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("같은 온보딩에서 순위가 겹치면 유니크 제약으로 막는다") + void rejectsDuplicateRankInOneRecommendation() { + channelRecommendationRepository.saveAndFlush(recommendation(1)); + UUID otherChannelId = entityManager.persistAndFlush(persistableChannel("당근마켓 광고")).getId(); + + assertThatThrownBy(() -> channelRecommendationRepository + .saveAndFlush(recommendation(1, otherChannelId))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("한 트랜잭션에서 지우고 다시 넣어도 유니크 제약에 걸리지 않는다") + void deleteThenInsertInSameTransaction() { + // 파생 삭제였다면 INSERT 가 DELETE 보다 먼저 나가 여기서 유니크 제약에 걸린다 + channelRecommendationRepository.saveAndFlush(recommendation(1)); + + int deleted = channelRecommendationRepository.deleteByOnboardingId(onboardingId); + channelRecommendationRepository.saveAndFlush(recommendation(1)); + entityManager.clear(); + + assertThat(deleted).isEqualTo(1); + assertThat(channelRecommendationRepository.findByOnboardingIdOrderByRankAsc(onboardingId)) + .hasSize(1); + } + + private ChannelRecommendation recommendation(int rank) { + return recommendation(rank, channelId); + } + + private ChannelRecommendation recommendation(int rank, UUID channel) { + return ChannelRecommendation.builder() + .userId(userId) + .onboardingId(onboardingId) + .channelId(channel) + .rank(rank) + .score(78) + .reason("쇼핑·커머스 업종, 설정한 광고 목적에 적합하고 예산 내 집행이 가능해요") + .reasonTags(List.of("CATEGORY", "OBJECTIVE")) + .channelName("11번가 광고") + .estPricingModel(PricingModel.CPM) + .estUnitPrice(new BigDecimal("3000")) + .estImpressionsMin(850_000L) + .estImpressionsMax(1_150_000L) + .estClicksMin(21_250L) + .estClicksMax(28_750L) + .cpcWon(new BigDecimal("120")) + .pricingModelsAll(List.of("CPM", "CPC")) + .minBudgetWonSnap(3_000L) + .audienceSummarySnap("30대 여성") + .executable(true) + .shortfallWon(null) + .build(); + } +} diff --git a/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java b/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java index 8b95239..25a256c 100644 --- a/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java +++ b/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java @@ -1,10 +1,15 @@ package chaeso.zip.server.recommendation.presentation; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import chaeso.zip.server.auth.application.UserPrincipal; import chaeso.zip.server.channel.domain.vo.PricingModel; import chaeso.zip.server.common.ratelimit.RateLimiter; import chaeso.zip.server.estimation.application.dto.CountRangeResponse; @@ -12,15 +17,23 @@ import chaeso.zip.server.onboarding.domain.OnboardingErrorCode; import chaeso.zip.server.recommendation.application.RecommendationService; import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse; +import chaeso.zip.server.recommendation.application.dto.SavedRecommendationResponse; +import chaeso.zip.server.recommendation.presentation.dto.SaveRecommendationRequest; +import com.fasterxml.jackson.databind.ObjectMapper; import java.math.BigDecimal; import java.util.List; import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.BDDMockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -29,16 +42,31 @@ class RecommendationControllerTest { private static final UUID ONBOARDING_ID = UUID.randomUUID(); + private static final UUID USER_ID = UUID.randomUUID(); @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockitoBean private RecommendationService recommendationService; @MockitoBean private RateLimiter rateLimiter; + @BeforeEach + void authenticate() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new UserPrincipal(USER_ID), null, List.of())); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + @Test @DisplayName("추천 조회가 성공하면 200 과 적합도·추정값을 반환하고 enum 은 코드값으로 직렬화된다") void getRecommendations_success() throws Exception { @@ -63,7 +91,7 @@ void getRecommendations_success() throws Exception { .andExpect(jsonPath("$.data[0].estImpressions.min").value(850000)) .andExpect(jsonPath("$.data[0].estClicks.max").value(28750)) .andExpect(jsonPath("$.data[0].isExecutable").value(true)) - .andExpect(jsonPath("$.data[0].shortfallWon").doesNotExist()); + .andExpect(content().string(not(containsString("shortfallWon")))); } @Test @@ -96,4 +124,83 @@ void getRecommendations_missingOnboardingId() throws Exception { mockMvc.perform(get("/api/v1/recommendations")) .andExpect(status().isBadRequest()); } + + @Test + @DisplayName("저장 요청이 성공하면 201 과 저장된 추천을 반환한다") + void saveRecommendation_created() throws Exception { + UUID channelId = UUID.randomUUID(); + RecommendationItemResponse item = new RecommendationItemResponse( + channelId, "11번가 광고", 78, "쇼핑·커머스 업종, 설정한 광고 목적에 적합하고 예산 내 집행이 가능해요", + "20~40대 여성", new BigDecimal("120"), PricingModel.CPM, 3_000L, + new CountRangeResponse(850_000, 1_150_000), new CountRangeResponse(21_250, 28_750), + true, null); + given(recommendationService.save(USER_ID, ONBOARDING_ID)) + .willReturn(new SavedRecommendationResponse(ONBOARDING_ID, 1, List.of(item))); + + mockMvc.perform(post("/api/v1/recommendations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new SaveRecommendationRequest(ONBOARDING_ID)))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.onboardingId").value(ONBOARDING_ID.toString())) + .andExpect(jsonPath("$.data.channelCount").value(1)) + .andExpect(jsonPath("$.data.items[0].channelId").value(channelId.toString())); + } + + @Test + @DisplayName("맞는 채널이 없으면 201 과 빈 배열을 반환한다") + void saveRecommendation_savesNothing() throws Exception { + given(recommendationService.save(USER_ID, ONBOARDING_ID)) + .willReturn(new SavedRecommendationResponse(ONBOARDING_ID, 0, List.of())); + + mockMvc.perform(post("/api/v1/recommendations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new SaveRecommendationRequest(ONBOARDING_ID)))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.channelCount").value(0)) + .andExpect(jsonPath("$.data.items").isEmpty()); + } + + @Test + @DisplayName("onboardingId 없이 저장하면 400 C-001 과 필드 에러를 반환한다") + void saveRecommendation_missingOnboardingId() throws Exception { + mockMvc.perform(post("/api/v1/recommendations") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("C-001")) + .andExpect(jsonPath("$.error.fieldErrors[0].field").value("onboardingId")); + } + + @Test + @DisplayName("없는 온보딩이나 남의 온보딩으로 저장하면 404 ONB-007 을 반환한다") + void saveRecommendation_onboardingNotFound() throws Exception { + BDDMockito.willThrow(new OnboardingBusinessException( + OnboardingErrorCode.ONBOARDING_NOT_FOUND, "온보딩 정보가 없습니다. id=" + ONBOARDING_ID)) + .given(recommendationService).save(USER_ID, ONBOARDING_ID); + + mockMvc.perform(post("/api/v1/recommendations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new SaveRecommendationRequest(ONBOARDING_ID)))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("ONB-007")); + } + + @Test + @DisplayName("저장이 동시에 겹치면 409 ONB-006 을 반환한다") + void saveRecommendation_concurrent() throws Exception { + BDDMockito.willThrow(new OnboardingBusinessException( + OnboardingErrorCode.CONCURRENT_SUBMISSION)) + .given(recommendationService).save(USER_ID, ONBOARDING_ID); + + mockMvc.perform(post("/api/v1/recommendations") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new SaveRecommendationRequest(ONBOARDING_ID)))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("ONB-006")); + } } diff --git a/src/test/java/chaeso/zip/server/support/ChannelCatalogFixture.java b/src/test/java/chaeso/zip/server/support/ChannelCatalogFixture.java index 77f8a8f..b745908 100644 --- a/src/test/java/chaeso/zip/server/support/ChannelCatalogFixture.java +++ b/src/test/java/chaeso/zip/server/support/ChannelCatalogFixture.java @@ -20,6 +20,12 @@ public final class ChannelCatalogFixture { private ChannelCatalogFixture() { } + public static Channel persistableChannel(String name) { + Channel channel = BeanUtils.instantiateClass(Channel.class); + set(channel, "name", name); + return channel; + } + public static Channel channel(UUID id, String name) { Channel channel = BeanUtils.instantiateClass(Channel.class); set(channel, "id", id); diff --git a/src/test/java/chaeso/zip/server/support/OnboardingFixture.java b/src/test/java/chaeso/zip/server/support/OnboardingFixture.java index e476a0b..ac34bad 100644 --- a/src/test/java/chaeso/zip/server/support/OnboardingFixture.java +++ b/src/test/java/chaeso/zip/server/support/OnboardingFixture.java @@ -35,7 +35,15 @@ public static Onboarding onboarding(UUID userId) { */ public static Onboarding onboarding(Category industry, CampaignObjective campaignObjective, List targetAgeBands, Long budgetMin, Long budgetMax, CampaignPeriod period) { - return Onboarding.create(null, SERVICE_NAME, industry, ServiceType.MOBILE_APP, targetAgeBands, + return onboarding(null, industry, campaignObjective, targetAgeBands, budgetMin, budgetMax, + period); + } + + /** 매칭 축·예산·기간에 제출자까지 지정하는 온보딩. 소유권을 따지는 테스트에 쓴다. */ + public static Onboarding onboarding(UUID userId, Category industry, + CampaignObjective campaignObjective, List targetAgeBands, Long budgetMin, + Long budgetMax, CampaignPeriod period) { + return Onboarding.create(userId, SERVICE_NAME, industry, ServiceType.MOBILE_APP, targetAgeBands, campaignObjective, budgetMin, budgetMax, period, AdExperience.NONE, List.of()); }