Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cfa281a
:sparkles: feat: 네이버 광고 캠페인, 광고그룹 예산 수정 추가
kingmingyu May 29, 2026
492ab63
:sparkles: feat: 네이버 광고 캠페인, 광고그룹 예산 수정 요청 값 추가
kingmingyu May 29, 2026
56a263a
:sparkles: feat: 네이버 광고 캠페인, 광고그룹 예산 수정 관련 에러코드 추가
kingmingyu May 29, 2026
5de43b0
:sparkles: feat: 네이버 광고 캠페인, 광고그룹 예산 수정 서비스 로직 구현
kingmingyu May 29, 2026
9036cd5
:sparkles: feat: 네이버 광고 캠페인, 광고그룹 예산 수정 Controller 및 Docs 작성
kingmingyu May 29, 2026
56b549b
:memo: docs: 네이버 광고 캠페인, 광고그룹 예산 수정 Docs 예외 응답 추가
kingmingyu May 29, 2026
f76e671
:memo: docs: 네이버 API test용 엔드포인트 Swagger Hidden 처리
kingmingyu May 29, 2026
3a30ecf
:sparkles: feat: 네이버 광고 예산 수정 API 조직원/ADMIN 권한 검증 추가
kingmingyu May 29, 2026
fc5ad9b
:bug: fix: 네이버 광고그룹 bidAmt 수정 시 budget과 별도 API 호출로 분리
kingmingyu May 29, 2026
935207e
:recycle: refactor: 네이버 예산 수정 트랜잭션 범위 축소 및 커넥션 미조회 시 404 반환
kingmingyu May 29, 2026
5fddf60
:bug: fix: 예산 및 입찰가 음수/0 입력 값 거부 검증 추가
kingmingyu May 29, 2026
db6ab11
Merge branch 'develop' into feat/#141
kingmingyu Jun 3, 2026
3b57c36
:sparkles: feat: AdGroup 엔티티에 예산 관련 필드 추가
kingmingyu Jun 5, 2026
35ec12e
:sparkles: feat: 예산 update 메서드 추가
kingmingyu Jun 5, 2026
3376938
:sparkles: feat: campaign, adGroup 예산 수정 시 db 업데이트 로직 추가
kingmingyu Jun 8, 2026
a2e19df
:sparkles: feat: 예산, 입찰가 입력값 범위 및 단위 검증 강화
kingmingyu Jun 8, 2026
86177c3
:sparkles: feat: AdGroup 동기화 시 예산 및 입찰가 필드 반영
kingmingyu Jun 8, 2026
f9e7a55
:bug: fix: 캠페인 예산 비활성화 시 DB budget 필드 미반영 버그 수정
kingmingyu Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
package com.whereyouad.WhereYouAd.domains.advertisement.domain.service;

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.organization.domain.constant.OrgRole;
import com.whereyouad.WhereYouAd.domains.organization.exception.handler.OrgHandler;
import com.whereyouad.WhereYouAd.domains.organization.exception.code.OrgErrorCode;
import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.OrgMember;
import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository;
import com.whereyouad.WhereYouAd.domains.platform.persistence.entity.PlatformConnection;
import com.whereyouad.WhereYouAd.domains.platform.persistence.repository.PlatformConnectionRepository;
import com.whereyouad.WhereYouAd.global.utils.AdApiAuthUtil;
import com.whereyouad.WhereYouAd.global.adapi.dto.AdAuthRequest;
Expand All @@ -25,8 +35,11 @@
public class NaverAdApiService {

private final PlatformConnectionRepository connectionRepository;
private final OrgMemberRepository orgMemberRepository;
private final AdApiAuthUtil adApiAuthUtil;
private final NaverClient naverClient;
private final AdGroupRepository adGroupRepository;
private final AdCampaignRepository adCampaignRepository;

// 캠페인 목록 조회
@Transactional(readOnly = true)
Expand Down Expand Up @@ -166,6 +179,94 @@ public NaverDTO.RawReportResponse downloadReport(Long connectionId, String downl
}
}

// 캠페인 예산 수정
@Transactional
public NaverDTO.CampaignResponse updateCampaignBudget(Long userId, Long connectionId, String campaignId, NaverDTO.UpdateCampaignBudgetRequest request) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
PlatformConnection connection = validateAdminOwnership(userId, connectionId);
if (request.dailyBudget() != null) {
if (request.dailyBudget() % 10 != 0) {
throw new AdvertisementHandler(NaverAdErrorCode.NAVER_INVALID_BUDGET_VALUE);
}
if (request.dailyBudget() < 50 || request.dailyBudget() > 1_000_000_000) {
throw new AdvertisementHandler(NaverAdErrorCode.NAVER_INVALID_BUDGET_RANGE);
}
}
try {
Map<String, String> headers = adApiAuthUtil.generateAuthHeaders(
connectionId, AdAuthRequest.forMethodAndPath("PUT", "/ncc/campaigns/" + campaignId));
NaverDTO.UpdateCampaignBudgetBody body =
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()));

return result;
} catch (Exception e) {
log.error("[NAVER] 캠페인 예산 수정 실패 - connectionId={}, campaignId={}", connectionId, campaignId, e);
throw new AdvertisementHandler(NaverAdErrorCode.NAVER_CAMPAIGN_BUDGET_UPDATE_FAILED);
}
}

// 광고그룹 예산 수정
@Transactional
public NaverDTO.AdGroupResponse updateAdGroupBudget(Long userId, Long connectionId, String adgroupId, NaverDTO.UpdateAdGroupBudgetRequest request) {
PlatformConnection connection = validateAdminOwnership(userId, connectionId);
if (request.dailyBudget() != null) {
if (request.dailyBudget() % 10 != 0) {
throw new AdvertisementHandler(NaverAdErrorCode.NAVER_INVALID_BUDGET_VALUE);
}
if (request.dailyBudget() < 50 || request.dailyBudget() > 1_000_000_000) {
throw new AdvertisementHandler(NaverAdErrorCode.NAVER_INVALID_BUDGET_RANGE);
}
}
if (request.bidAmt() != null) {
if (request.bidAmt() % 10 != 0) {
throw new AdvertisementHandler(NaverAdErrorCode.NAVER_INVALID_BUDGET_VALUE);
}
if (request.bidAmt() < 70 || request.bidAmt() > 100_000) {
throw new AdvertisementHandler(NaverAdErrorCode.NAVER_INVALID_BID_AMOUNT_RANGE);
}
}
try {
Map<String, String> headers = adApiAuthUtil.generateAuthHeaders(
connectionId, AdAuthRequest.forMethodAndPath("PUT", "/ncc/adgroups/" + adgroupId));
// 네이버 API 제약: budget과 bidAmt는 fields에 함께 넣어도 bidAmt가 무시됨 → 각각 별도 호출 필요
NaverDTO.AdGroupResponse result = null;
if (request.dailyBudget() != null || request.useDailyBudget() != null) {
result = naverClient.updateAdGroupBudget(headers, adgroupId, "budget",
new NaverDTO.UpdateAdGroupBudgetBody(adgroupId, request.useDailyBudget(), request.dailyBudget(), null));
}
if (request.bidAmt() != null) {
result = naverClient.updateAdGroupBudget(headers, adgroupId, "bidAmt",
new NaverDTO.UpdateAdGroupBudgetBody(adgroupId, null, null, request.bidAmt()));
}

adGroupRepository
.findByAdCampaign_PlatformAccountAndExternalGroupId(connection.getPlatformAccount(), adgroupId)
.ifPresent(adGroup -> adGroup.updateBudget(request.dailyBudget(), request.bidAmt()));

return result;
} catch (Exception e) {
log.error("[NAVER] 광고그룹 예산 수정 실패 - connectionId={}, adgroupId={}", connectionId, adgroupId, e);
throw new AdvertisementHandler(NaverAdErrorCode.NAVER_AD_GROUP_BUDGET_UPDATE_FAILED);
}
}

@Transactional(readOnly = true)
private PlatformConnection validateAdminOwnership(Long userId, Long connectionId) {
PlatformConnection connection = connectionRepository.findWithAccountAndOrgById(connectionId)
.orElseThrow(() -> new AdvertisementHandler(NaverAdErrorCode.NAVER_CONNECTION_NOT_FOUND));
Long orgId = connection.getPlatformAccount().getOrganization().getId();
OrgMember orgMember = orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
.orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_MEMBER_NOT_FOUND));
if (orgMember.getRole() != OrgRole.ADMIN) {
throw new OrgHandler(OrgErrorCode.ORG_MEMBER_FORBIDDEN);
}
return connection;
}

// 일별 기본 지표 조회 (/stats, 기본 일 단위)
@Transactional(readOnly = true)
public List<NaverDTO.StatResponse> getDailyStats(Long connectionId, String id, String since, String until) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ public enum NaverAdErrorCode implements BaseErrorCode {

// 400
NAVER_INVALID_DOWNLOAD_URL(HttpStatus.BAD_REQUEST, "NAVER_400_1", "유효하지 않은 다운로드 URL입니다."),
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원 이하로 입력해야 합니다."),

// 404
NAVER_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "NAVER_404_1", "플랫폼 연결 정보를 찾을 수 없습니다."),

// 500
NAVER_CAMPAIGN_FETCH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NAVER_500_1", "네이버 캠페인 목록 조회에 실패했습니다."),
Expand All @@ -21,6 +27,8 @@ public enum NaverAdErrorCode implements BaseErrorCode {
NAVER_REPORT_STATUS_CHECK_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NAVER_500_6", "네이버 성과 보고서 상태 조회에 실패했습니다."),
NAVER_REPORT_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NAVER_500_7", "네이버 성과 보고서 원문 다운로드에 실패했습니다."),
NAVER_HOURLY_STAT_FETCH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NAVER_500_8", "네이버 시간대별 통계 조회에 실패했습니다."),
NAVER_CAMPAIGN_BUDGET_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NAVER_500_9", "네이버 캠페인 예산 수정에 실패했습니다."),
NAVER_AD_GROUP_BUDGET_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NAVER_500_10", "네이버 광고 그룹 예산 수정에 실패했습니다."),
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,8 @@ public void update(String name, Status status, Long budget, Goal goal,
this.description = description;
}
}

public void updateBudget(Long budget) {
this.budget = budget;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ public class AdGroup extends BaseEntity {
@OneToMany(mappedBy = "adGroup", cascade = CascadeType.ALL)
private List<AdContent> adContents = new ArrayList<>();

@Column(name = "budget")
private Long budget;

@Column(name = "bid_amount")
private Long bidAmount;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ad_campaign_id")
private AdCampaign adCampaign;
Expand All @@ -55,4 +61,9 @@ public void update(String name, Status status, String targetingInfo) {
this.targetingInfo = targetingInfo;
}
}

public void updateBudget(Long budget, Long bidAmount) {
if (budget != null) this.budget = budget;
if (bidAmount != null) this.bidAmount = bidAmount;
}
Comment on lines +65 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

budget은 null을 “미수정”이 아니라 “해제”로 해석해야 하는 케이스가 있습니다.

Line 66 조건식 때문에 useDailyBudget=false 흐름에서 전달되는 budget=null이 반영되지 않아, 광고그룹 예산이 DB에 잔존할 수 있습니다. NaverAdApiService.updateAdGroupBudgetNaverConverter.updateAdGroup 모두 해당 경로를 타고 들어옵니다.

🔧 제안 수정(의도 전달을 위해 플래그 포함)
-    public void updateBudget(Long budget, Long bidAmount) {
-        if (budget != null) this.budget = budget;
-        if (bidAmount != null) this.bidAmount = bidAmount;
-    }
+    public void updateBudget(Boolean useDailyBudget, Long budget, Long bidAmount) {
+        if (Boolean.FALSE.equals(useDailyBudget)) {
+            this.budget = null;
+        } else if (budget != null) {
+            this.budget = budget;
+        }
+        if (bidAmount != null) {
+            this.bidAmount = bidAmount;
+        }
+    }

(호출부에서 useDailyBudget 전달도 함께 맞춰주세요.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/entity/AdGroup.java`
around lines 65 - 68, The updateBudget method currently treats budget==null as
"no change", but callers sometimes mean "clear the budget"
(useDailyBudget=false); change the signature of AdGroup.updateBudget to include
a boolean flag (e.g., boolean useDailyBudget) and implement logic: if
useDailyBudget is false set this.budget = null (clearing saved budget) and set
this.bidAmount only if bidAmount != null; if useDailyBudget is true then apply
the existing behavior (set this.budget = budget when budget != null and
this.bidAmount = bidAmount when bidAmount != null). Update call sites such as
NaverAdApiService.updateAdGroupBudget and NaverConverter.updateAdGroup to pass
the new useDailyBudget flag accordingly.

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;
Expand Down Expand Up @@ -139,4 +140,28 @@ public ResponseEntity<DataResponse<AdvertisementResponse.NaverStatSyncResponse>>
) {
return ResponseEntity.ok(DataResponse.from(naverAdSyncService.syncConversionReports(connectionId, statDate)));
}

// 캠페인 예산 수정
@PutMapping("/campaigns/{campaignId}/budget")
public ResponseEntity<DataResponse<NaverDTO.CampaignResponse>> updateCampaignBudget(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable Long connectionId,
@PathVariable String campaignId,
@RequestBody NaverDTO.UpdateCampaignBudgetRequest request
) {
return ResponseEntity.ok(DataResponse.from(
naverAdApiService.updateCampaignBudget(userId, connectionId, campaignId, request)));
}

// 광고그룹 예산 수정
@PutMapping("/adgroups/{adgroupId}/budget")
public ResponseEntity<DataResponse<NaverDTO.AdGroupResponse>> updateAdGroupBudget(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable Long connectionId,
@PathVariable String adgroupId,
@RequestBody NaverDTO.UpdateAdGroupBudgetRequest request
) {
return ResponseEntity.ok(DataResponse.from(
naverAdApiService.updateAdGroupBudget(userId, connectionId, adgroupId, request)));
}
}
Loading
Loading