diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/application/mapper/AdvertisementConverter.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/application/mapper/AdvertisementConverter.java index 9a73388c..9098f152 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/application/mapper/AdvertisementConverter.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/application/mapper/AdvertisementConverter.java @@ -1,18 +1,19 @@ package com.whereyouad.WhereYouAd.domains.advertisement.application.mapper; import com.whereyouad.WhereYouAd.domains.advertisement.application.dto.response.AdvertisementResponse; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.BudgetFieldType; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Grain; import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdContent; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdGroup; - -import java.util.List; -import java.time.LocalDateTime; - -import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Grain; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.BudgetHistory; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.MetricFact; import com.whereyouad.WhereYouAd.domains.project.persistence.entity.Project; +import java.time.LocalDateTime; +import java.util.List; + public class AdvertisementConverter { public static AdvertisementResponse.AdContentInfoResponse toAdContentInfo(AdContent adContent, @@ -64,4 +65,37 @@ public static MetricFact createMetricFact(AdContent adContent, LocalDateTime tim .revenue(null) .build(); } + + public static BudgetHistory toCampaignBudgetHistory(AdCampaign campaign, Long previousValue, Long newValue, Long changedBy, Provider provider) { + return BudgetHistory.builder() + .adCampaign(campaign) + .fieldType(BudgetFieldType.CAMPAIGN_BUDGET) + .previousValue(previousValue) + .newValue(newValue) + .changedBy(changedBy) + .provider(provider) + .build(); + } + + public static BudgetHistory toAdGroupBudgetHistory(AdGroup adGroup, Long previousValue, Long newValue, Long changedBy, Provider provider) { + return BudgetHistory.builder() + .adGroup(adGroup) + .fieldType(BudgetFieldType.AD_GROUP_BUDGET) + .previousValue(previousValue) + .newValue(newValue) + .changedBy(changedBy) + .provider(provider) + .build(); + } + + public static BudgetHistory toBidAmountHistory(AdGroup adGroup, Long previousValue, Long newValue, Long changedBy, Provider provider) { + return BudgetHistory.builder() + .adGroup(adGroup) + .fieldType(BudgetFieldType.BID_AMOUNT) + .previousValue(previousValue) + .newValue(newValue) + .changedBy(changedBy) + .provider(provider) + .build(); + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/constant/BudgetFieldType.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/constant/BudgetFieldType.java new file mode 100644 index 00000000..9f817956 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/constant/BudgetFieldType.java @@ -0,0 +1,7 @@ +package com.whereyouad.WhereYouAd.domains.advertisement.domain.constant; + +public enum BudgetFieldType { + CAMPAIGN_BUDGET, + AD_GROUP_BUDGET, + BID_AMOUNT +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java index 9525a41d..e994e90b 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/domain/service/NaverAdApiService.java @@ -1,9 +1,12 @@ package com.whereyouad.WhereYouAd.domains.advertisement.domain.service; +import com.whereyouad.WhereYouAd.domains.advertisement.application.mapper.AdvertisementConverter; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdGroup; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.AdCampaignRepository; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.AdGroupRepository; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.BudgetHistoryRepository; import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgRole; import com.whereyouad.WhereYouAd.domains.organization.exception.handler.OrgHandler; import com.whereyouad.WhereYouAd.domains.organization.exception.code.OrgErrorCode; @@ -28,6 +31,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Optional; @Slf4j @Service @@ -40,6 +44,7 @@ public class NaverAdApiService { private final NaverClient naverClient; private final AdGroupRepository adGroupRepository; private final AdCampaignRepository adCampaignRepository; + private final BudgetHistoryRepository budgetHistoryRepository; // 캠페인 목록 조회 @Transactional(readOnly = true) @@ -191,6 +196,13 @@ public NaverDTO.CampaignResponse updateCampaignBudget(Long userId, Long connecti throw new AdvertisementHandler(NaverAdErrorCode.NAVER_INVALID_BUDGET_RANGE); } } + Optional campaignOpt = adCampaignRepository + .findByPlatformAccountAndExternalCampaignId(connection.getPlatformAccount(), campaignId); + campaignOpt.ifPresent(campaign -> { + if (request.dailyBudget() != null && request.dailyBudget().equals(campaign.getBudget())) { + throw new AdvertisementHandler(NaverAdErrorCode.NAVER_SAME_BUDGET_VALUE); + } + }); try { Map headers = adApiAuthUtil.generateAuthHeaders( connectionId, AdAuthRequest.forMethodAndPath("PUT", "/ncc/campaigns/" + campaignId)); @@ -198,9 +210,12 @@ public NaverDTO.CampaignResponse updateCampaignBudget(Long userId, Long connecti new NaverDTO.UpdateCampaignBudgetBody(campaignId, request.useDailyBudget(), request.dailyBudget()); NaverDTO.CampaignResponse result = naverClient.updateCampaignBudget(headers, campaignId, "budget", body); - adCampaignRepository - .findByPlatformAccountAndExternalCampaignId(connection.getPlatformAccount(), campaignId) - .ifPresent(campaign -> campaign.updateBudget(request.dailyBudget())); + campaignOpt.ifPresent(campaign -> { + Long previousBudget = campaign.getBudget(); + campaign.updateBudget(request.dailyBudget()); + budgetHistoryRepository.save(AdvertisementConverter.toCampaignBudgetHistory( + campaign, previousBudget, request.dailyBudget(), userId, Provider.NAVER)); + }); return result; } catch (Exception e) { @@ -229,6 +244,17 @@ public NaverDTO.AdGroupResponse updateAdGroupBudget(Long userId, Long connection throw new AdvertisementHandler(NaverAdErrorCode.NAVER_INVALID_BID_AMOUNT_RANGE); } } + Optional adGroupOpt = adGroupRepository + .findByAdCampaign_PlatformAccountAndExternalGroupId(connection.getPlatformAccount(), adgroupId); + adGroupOpt.ifPresent(adGroup -> { + boolean budgetUnchanged = request.dailyBudget() == null + || request.dailyBudget().equals(adGroup.getBudget()); + boolean bidAmtUnchanged = request.bidAmt() == null + || request.bidAmt().equals(adGroup.getBidAmount()); + if (budgetUnchanged && bidAmtUnchanged) { + throw new AdvertisementHandler(NaverAdErrorCode.NAVER_SAME_BUDGET_VALUE); + } + }); try { Map headers = adApiAuthUtil.generateAuthHeaders( connectionId, AdAuthRequest.forMethodAndPath("PUT", "/ncc/adgroups/" + adgroupId)); @@ -243,9 +269,19 @@ public NaverDTO.AdGroupResponse updateAdGroupBudget(Long userId, Long connection new NaverDTO.UpdateAdGroupBudgetBody(adgroupId, null, null, request.bidAmt())); } - adGroupRepository - .findByAdCampaign_PlatformAccountAndExternalGroupId(connection.getPlatformAccount(), adgroupId) - .ifPresent(adGroup -> adGroup.updateBudget(request.dailyBudget(), request.bidAmt())); + adGroupOpt.ifPresent(adGroup -> { + if (request.dailyBudget() != null) { + Long previousBudget = adGroup.getBudget(); + budgetHistoryRepository.save(AdvertisementConverter.toAdGroupBudgetHistory( + adGroup, previousBudget, request.dailyBudget(), userId, Provider.NAVER)); + } + if (request.bidAmt() != null) { + Long previousBidAmount = adGroup.getBidAmount(); + budgetHistoryRepository.save(AdvertisementConverter.toBidAmountHistory( + adGroup, previousBidAmount, request.bidAmt(), userId, Provider.NAVER)); + } + adGroup.updateBudget(request.dailyBudget(), request.bidAmt()); + }); return result; } catch (Exception e) { diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/exception/code/NaverAdErrorCode.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/exception/code/NaverAdErrorCode.java index fe633ee2..c7fb4781 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/exception/code/NaverAdErrorCode.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/exception/code/NaverAdErrorCode.java @@ -14,6 +14,7 @@ public enum NaverAdErrorCode implements BaseErrorCode { NAVER_INVALID_BUDGET_VALUE(HttpStatus.BAD_REQUEST, "NAVER_400_2", "예산 및 입찰가는 10원 단위로 입력해야 합니다."), NAVER_INVALID_BUDGET_RANGE(HttpStatus.BAD_REQUEST, "NAVER_400_3", "예산은 50원 이상 1,000,000,000원 이하로 입력해야 합니다."), NAVER_INVALID_BID_AMOUNT_RANGE(HttpStatus.BAD_REQUEST, "NAVER_400_4", "입찰가는 70원 이상 100,000원 이하로 입력해야 합니다."), + NAVER_SAME_BUDGET_VALUE(HttpStatus.BAD_REQUEST, "NAVER_400_5", "이전 예산과 동일한 값으로 수정할 수 없습니다."), // 404 NAVER_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "NAVER_404_1", "플랫폼 연결 정보를 찾을 수 없습니다."), diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java new file mode 100644 index 00000000..19c3388c --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/BudgetHistory.java @@ -0,0 +1,51 @@ +package com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity; + +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.BudgetFieldType; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; +import com.whereyouad.WhereYouAd.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +@Entity +@Table(name = "budget_history") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class BudgetHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "budget_history_id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "field_type", nullable = false) + private BudgetFieldType fieldType; + + @Column(name = "previous_value") + private Long previousValue; + + @Column(name = "new_value") + private Long newValue; + + @Column(name = "changed_by", nullable = false) + private Long changedBy; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false) + private Provider provider; + + // 연관 관계 (nullable로 2개 중에 1개만 연결 가능) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ad_campaign_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private AdCampaign adCampaign; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ad_group_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private AdGroup adGroup; +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java new file mode 100644 index 00000000..44055597 --- /dev/null +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/BudgetHistoryRepository.java @@ -0,0 +1,26 @@ +package com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository; + +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.BudgetHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface BudgetHistoryRepository extends JpaRepository { + + @Query(""" + SELECT bh FROM BudgetHistory bh + LEFT JOIN bh.adCampaign ac + LEFT JOIN bh.adGroup ag + LEFT JOIN ag.adCampaign agc + WHERE (ac.organization.id = :orgId OR agc.organization.id = :orgId) + AND bh.createdAt BETWEEN :start AND :end + ORDER BY bh.createdAt DESC + """) + List findByOrgAndPeriod( + @Param("orgId") Long orgId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); +} diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/dto/response/DashboardResponse.java b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/dto/response/DashboardResponse.java index e80e8859..907d82c2 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/dto/response/DashboardResponse.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/dto/response/DashboardResponse.java @@ -1,9 +1,11 @@ package com.whereyouad.WhereYouAd.domains.dashboard.application.dto.response; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.BudgetFieldType; import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; import com.whereyouad.WhereYouAd.domains.click.application.dto.response.ClickResponse; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; public class DashboardResponse { @@ -97,4 +99,19 @@ public record PlatformMetricFactSummaryResponse( DailyMetricFactResponse total, // 기간 전체 합계 지표 List dailyMetrics // 일자별 지표 리스트 ) {} + + public record BudgetHistoryItem( + BudgetFieldType fieldType, + String targetName, + Long previousValue, + Long newValue, + LocalDateTime changedAt, + Provider provider + ) {} + + public record BudgetHistoryListResponse( + LocalDate startDate, + LocalDate endDate, + List histories + ) {} } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java index 7ee99d5a..08db9fb7 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/application/mapper/DashboardConverter.java @@ -1,14 +1,14 @@ package com.whereyouad.WhereYouAd.domains.dashboard.application.mapper; import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.BudgetHistory; import com.whereyouad.WhereYouAd.domains.click.application.dto.response.ClickResponse; import com.whereyouad.WhereYouAd.domains.dashboard.application.dto.response.DashboardResponse; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; -import java.math.BigDecimal; - public class DashboardConverter { public static DashboardResponse.BudgetSummaryResponse toBudgetSummary( @@ -53,6 +53,22 @@ public static DashboardResponse.OngoingPlatformAdCountResponse toOngoingPlatform return new DashboardResponse.OngoingPlatformAdCountResponse(startDate, endDate, totalCount, providerCount); } + public static List toBudgetHistoryItems(List histories) { + return histories.stream().map(bh -> { + String targetName = bh.getAdCampaign() != null + ? bh.getAdCampaign().getName() + : bh.getAdGroup().getName(); + return new DashboardResponse.BudgetHistoryItem( + bh.getFieldType(), + targetName, + bh.getPreviousValue(), + bh.getNewValue(), + bh.getCreatedAt(), + bh.getProvider() + ); + }).toList(); + } + //Data -> DTO public static DashboardResponse.RealTimeGraphResponse toRealTimeGraphResponse( List timeSeriesData, String mode, Boolean hasSuspect, DashboardResponse.SuspectDetail suspectDetail) diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardService.java b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardService.java index 46659e95..8d3dae4e 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardService.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardService.java @@ -20,4 +20,8 @@ DashboardResponse.OngoingPlatformAdCountResponse getOngoingAdCountByProvider( // 플랫폼 대시보드의 일자별 지표(MetricFact) 조회 DashboardResponse.PlatformMetricFactSummaryResponse getPlatformMetricFacts( Long userId, Long orgId, String providerType, Integer days); + + // 기간별 예산 변경 이력 조회 + DashboardResponse.BudgetHistoryListResponse getBudgetHistory( + Long userId, Long orgId, LocalDate startDate, LocalDate endDate); } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java index 7f4ababd..49e63ac9 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/domain/service/DashboardServiceImpl.java @@ -3,8 +3,11 @@ import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Status; import com.whereyouad.WhereYouAd.domains.advertisement.exception.AdvertisementHandler; import com.whereyouad.WhereYouAd.domains.advertisement.exception.code.AdvertisementErrorCode; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.BudgetFieldType; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.BudgetHistory; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.AdCampaignRepository; import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.BudgetHistoryRepository; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.projection.MetricSumProjection; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.projection.RoasProjection; @@ -41,6 +44,7 @@ public class DashboardServiceImpl implements DashboardService { private final AdCampaignRepository adCampaignRepository; private final MetricFactRepository metricFactRepository; + private final BudgetHistoryRepository budgetHistoryRepository; private final OrgMemberRepository orgMemberRepository; private final OrgRepository orgRepository; private final BudgetCalculator budgetCalculator; @@ -404,4 +408,25 @@ public DashboardResponse.PlatformMetricFactSummaryResponse getPlatformMetricFact dailyMetrics ); } + + @Override + @Transactional(readOnly = true) + public DashboardResponse.BudgetHistoryListResponse getBudgetHistory( + Long userId, Long orgId, LocalDate startDate, LocalDate endDate) { + + orgRepository.findById(orgId) + .orElseThrow(() -> new DashboardException(OrgErrorCode.ORG_NOT_FOUND)); + orgMemberRepository.findByUserIdAndOrgId(userId, orgId) + .orElseThrow(() -> new DashboardException(DashboardErrorCode.ACCESS_FORBIDDEN)); + + List histories = budgetHistoryRepository.findByOrgAndPeriod( + orgId, + startDate.atStartOfDay(), + endDate.plusDays(1).atStartOfDay() + ); + + List items = DashboardConverter.toBudgetHistoryItems(histories); + + return new DashboardResponse.BudgetHistoryListResponse(startDate, endDate, items); + } } \ No newline at end of file diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/DashboardController.java b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/DashboardController.java index 7a13a813..16f0a97e 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/DashboardController.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/DashboardController.java @@ -96,6 +96,16 @@ public ResponseEntity> getBudgetHistory( + @AuthenticationPrincipal(expression = "userId") Long userId, + @PathVariable Long orgId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + return ResponseEntity.ok(DataResponse.from( + dashboardService.getBudgetHistory(userId, orgId, startDate, endDate))); + } + @GetMapping(value = "/{orgId}/clicks/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntity streamRealClicks( @AuthenticationPrincipal(expression = "userId") Long userId, diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java index 66f8970f..3e052dca 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/dashboard/presentation/docs/DashboardControllerDocs.java @@ -115,6 +115,23 @@ ResponseEntity @Parameter(description = "조회 기간(일 단위, 기본값 7)", example = "7") @RequestParam(required = false, defaultValue = "7") Integer days ); + @Operation( + summary = "대시보드 - 예산 변경 이력 조회 API", + description = "조직 내 캠페인 및 광고그룹의 예산/입찰가 변경 이력을 기간별로 조회합니다.\n\n" + + "fieldType: CAMPAIGN_BUDGET(캠페인 예산), AD_GROUP_BUDGET(광고그룹 예산), BID_AMOUNT(입찰가)" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "403", description = "해당 조직에 대한 접근 권한이 없는 경우"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 조직") + }) + ResponseEntity> getBudgetHistory( + @AuthenticationPrincipal(expression = "userId") Long userId, + @Parameter(description = "조직 ID", required = true, example = "1") @PathVariable Long orgId, + @Parameter(description = "조회 시작일 (YYYY-MM-DD)", required = true, example = "2025-01-01") @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @Parameter(description = "조회 종료일 (YYYY-MM-DD)", required = true, example = "2025-12-31") @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate + ); + @Operation( summary = "대시보드 - 실시간 클릭수 스트림 출력 API", description = "해당 조직의 최근 60분간 실시간 클릭수 추이와 이상 징후(봇) 감지 여부를 스트림으로 보내주는 API입니다.\n\n" + diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java index 86c9de3f..5608bbdd 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/dto/response/TimelineResponse.java @@ -1,5 +1,7 @@ package com.whereyouad.WhereYouAd.domains.timeline.application.dto.response; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.BudgetFieldType; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Provider; import com.whereyouad.WhereYouAd.domains.timeline.domain.constant.MetricType; import com.whereyouad.WhereYouAd.domains.timeline.domain.constant.PerformanceStatus; @@ -39,7 +41,17 @@ public record TimelineDetailDTO( List metrics, String summary, List dailyTrend, - List platformContributions + List platformContributions, + List budgetHistories + ) {} + + public record BudgetHistoryItem( + BudgetFieldType fieldType, + String targetName, + Long previousValue, + Long newValue, + LocalDateTime changedAt, + Provider provider ) {} public record DailyMetricDTO( diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java index 071810a9..4ecf88cf 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/application/mapper/TimelineConverter.java @@ -1,5 +1,6 @@ package com.whereyouad.WhereYouAd.domains.timeline.application.mapper; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.BudgetHistory; import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.Organization; import com.whereyouad.WhereYouAd.domains.timeline.application.dto.request.TimelineRequest.TimelineCreateDto; import com.whereyouad.WhereYouAd.domains.timeline.application.dto.response.TimelineResponse; @@ -57,7 +58,8 @@ public static TimelineResponse.TimelineDetailDTO toTimelineDetailDTO( Timeline timeline, List metrics, List dailyTrend, - List platformContributions + List platformContributions, + List budgetHistories ) { return new TimelineResponse.TimelineDetailDTO( timeline.getId(), @@ -68,10 +70,27 @@ public static TimelineResponse.TimelineDetailDTO toTimelineDetailDTO( metrics, timeline.getSummary(), dailyTrend, - platformContributions + platformContributions, + budgetHistories ); } + public static List toBudgetHistoryItems(List histories) { + return histories.stream().map(bh -> { + String targetName = bh.getAdCampaign() != null + ? bh.getAdCampaign().getName() + : bh.getAdGroup().getName(); + return new TimelineResponse.BudgetHistoryItem( + bh.getFieldType(), + targetName, + bh.getPreviousValue(), + bh.getNewValue(), + bh.getCreatedAt(), + bh.getProvider() + ); + }).toList(); + } + // entity -> dto public static TimelineResponse.CreateResponseDTO toCreateResponse(Timeline timeline) { List metrics = new ArrayList<>(); diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java index e5d6bf27..d442eae5 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java @@ -20,6 +20,8 @@ import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Grain; import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Status; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.MetricFact; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.BudgetHistory; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.BudgetHistoryRepository; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository; import com.whereyouad.WhereYouAd.domains.timeline.persistence.repository.TimelineRepository; import lombok.AccessLevel; @@ -46,6 +48,7 @@ public class TimelineServiceImpl implements TimelineService { private final TimelineRepository timelineRepository; private final MetricFactRepository metricFactRepository; + private final BudgetHistoryRepository budgetHistoryRepository; private final OrgRepository orgRepository; private final OrgMemberRepository orgMemberRepository; private final TimelineUtil timelineUtil; @@ -254,7 +257,15 @@ public TimelineResponse.TimelineDetailDTO getTimelineDetail(Long userId, Long or // 플랫폼별 기여도 반환 List platformContributions = buildPlatformContributions(facts, timeline); - return TimelineConverter.toTimelineDetailDTO(timeline, metrics, dailyTrend, platformContributions); + // 타임라인 기간 내 예산 변경 이력 조회 + List histories = budgetHistoryRepository.findByOrgAndPeriod( + orgId, + timeline.getStartDate().atStartOfDay(), + timeline.getEndDate().plusDays(1).atStartOfDay() + ); + List budgetHistories = TimelineConverter.toBudgetHistoryItems(histories); + + return TimelineConverter.toTimelineDetailDTO(timeline, metrics, dailyTrend, platformContributions, budgetHistories); } @Override