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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/infra.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ make ps # 컨테이너 상태

---

## 4. AWS 새벽시간대 자동 정지 스케줄링
## 4. AWS 새벽시간대 자동 정지 스케줄링 (중단)

- 서버 비용 절감을 위해 AWS 운영서버를 새벽 12시 ~ 아침 9시(9시간) 동안 정지 예정입니다.
~~- 서버 비용 절감을 위해 AWS 운영서버를 새벽 12시 ~ 아침 9시(9시간) 동안 정지 예정입니다.~~
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- EventBridge Scheduler와 Global Target를 적용했습니다. (테스트 서버는 상시 동작합니다)

상세 코드는 `../infra/modules/aws/app/schedule.tf`를 참고해주세요.
Expand Down
6 changes: 4 additions & 2 deletions infra/modules/aws/app/schedule.tf
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ resource "aws_iam_role_policy" "scheduler" {
}

resource "aws_scheduler_schedule" "stop" {
name = "${var.name_prefix}-stop-night"
name = "${var.name_prefix}-stop-night"
state = "DISABLED"
flexible_time_window {
mode = "OFF"
}
Expand All @@ -42,7 +43,8 @@ resource "aws_scheduler_schedule" "stop" {
}

resource "aws_scheduler_schedule" "start" {
name = "${var.name_prefix}-start-morning"
name = "${var.name_prefix}-start-morning"
state = "DISABLED"
flexible_time_window {
mode = "OFF"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package chaeso.zip.server.channel.domain.vo;

public enum AgeBand {
AGE_10S, AGE_20S, AGE_30S, AGE_40S, AGE_50S_PLUS
AGE_10S, AGE_20S, AGE_30S, AGE_40S, AGE_50S_PLUS, UNDECIDED
Comment thread
SinnoLn marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import chaeso.zip.server.channel.domain.ChannelNotFoundException;
import chaeso.zip.server.channel.domain.entity.Channel;
import chaeso.zip.server.channel.domain.repository.ChannelRepository;
import chaeso.zip.server.channel.domain.vo.AgeBand;
import chaeso.zip.server.onboarding.application.dto.AdHistoryCommand;
import chaeso.zip.server.onboarding.application.dto.OnboardingSubmitResponse;
import chaeso.zip.server.onboarding.application.dto.PresignPerformanceFileCommand;
Expand Down Expand Up @@ -53,7 +54,8 @@ public class OnboardingServiceImpl implements OnboardingService {
@Override
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public OnboardingSubmitResponse submit(UUID userId, SubmitOnboardingCommand command) {
validateSubmission(command.adExperience(), command.adHistory(), command.rawFileKeys());
validateSubmission(command.targetAgeBands(), command.adExperience(), command.adHistory(),
command.rawFileKeys());

List<String> fileUrls = verifyPerformanceFiles(command.rawFileKeys());

Expand Down Expand Up @@ -89,9 +91,18 @@ private OnboardingSubmitResponse saveOnboarding(UUID userId, SubmitOnboardingCom
Onboarding saved = saveResponse(response);

adPerformanceRepository.saveAll(command.adHistory().stream()
.map(row -> AdPerformance.fromOnboarding(userId, PerfSource.MANUAL, row.channelId(),
row.channelNameRaw(), row.budgetWon(), row.impressions(), row.clicks(),
row.conversions(), row.startedAt(), row.endedAt(), null))
.map(row -> AdPerformance.builder()
.userId(userId)
.sourceType(PerfSource.MANUAL)
.channelId(row.channelId())
.externalChannelName(row.channelNameRaw())
.budgetWon(row.budgetWon())
.impressions(row.impressions())
.clicks(row.clicks())
.conversions(row.conversions())
.startedAt(row.startedAt())
.endedAt(row.endedAt())
.build())
.toList());

onboardingAdHistorySnapshotRepository.saveAll(command.adHistory().stream()
Expand All @@ -118,10 +129,13 @@ private Onboarding saveResponse(Onboarding response) {
}

/**
* adHistory/rawFileKeys의 관계 규칙을 검증한다.
* targetAgeBands/adHistory/rawFileKeys의 관계 규칙을 검증한다.
*/
private void validateSubmission(AdExperience adExperience, List<AdHistoryCommand> adHistory,
List<String> rawFileKeys) {
private void validateSubmission(List<AgeBand> targetAgeBands, AdExperience adExperience,
List<AdHistoryCommand> adHistory, List<String> rawFileKeys) {
if (targetAgeBands.contains(AgeBand.UNDECIDED) && targetAgeBands.size() > 1) {
throw new OnboardingBusinessException(OnboardingErrorCode.INVALID_AGE_BAND_SELECTION);
}
boolean experienced = adExperience == AdExperience.EXPERIENCED;
boolean hasAnyHistory = !adHistory.isEmpty() || !rawFileKeys.isEmpty();
if (experienced != hasAnyHistory) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public enum OnboardingErrorCode implements ErrorCode {
CONCURRENT_SUBMISSION(HttpStatus.CONFLICT, "ONB-006", "동시에 제출된 요청이 있어 처리할 수 없습니다. 다시 시도해주세요."),
ONBOARDING_NOT_FOUND(HttpStatus.NOT_FOUND, "ONB-007", "온보딩 정보가 없습니다."),
PERFORMANCE_FILE_INVALID(HttpStatus.BAD_REQUEST, "ONB-008", "첨부한 성과파일을 확인할 수 없습니다."),
TOO_FEW_MANUAL_FIELDS(HttpStatus.BAD_REQUEST, "ONB-010", "직접 입력한 집행 내역은 예산/집행기간/노출수/클릭수/전환수 중 2개 이상을 입력해야 합니다.");
TOO_FEW_MANUAL_FIELDS(HttpStatus.BAD_REQUEST, "ONB-010", "직접 입력한 집행 내역은 예산/집행기간/노출수/클릭수/전환수 중 2개 이상을 입력해야 합니다."),
INVALID_AGE_BAND_SELECTION(HttpStatus.BAD_REQUEST, "ONB-011", "잘 모르겠어요는 다른 연령대와 함께 선택할 수 없습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package chaeso.zip.server.onboarding.domain;

import chaeso.zip.server.common.exception.BusinessException;
import java.util.UUID;

public class OnboardingNotFoundException extends BusinessException {

public OnboardingNotFoundException(UUID id) {
super(OnboardingErrorCode.ONBOARDING_NOT_FOUND, "온보딩 정보가 없습니다. id=" + id);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ public interface OnboardingApiDocs {
}
""";

String INVALID_AGE_BAND_SELECTION_EXAMPLE = """
{
"success": false,
"error": {
"code": "ONB-011",
"message": "잘 모르겠어요는 다른 연령대와 함께 선택할 수 없습니다.",
"fieldErrors": []
}
}
""";

@Operation(operationId = "submitOnboarding", summary = "온보딩 제출",
description = """
로그인 여부와 관계없이 제출할 수 있다. \
Expand All @@ -152,15 +163,16 @@ public interface OnboardingApiDocs {
content = @Content(
examples = @ExampleObject(name = "SUBMIT_SUCCESS", value = SUBMIT_SUCCESS_EXAMPLE)))
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400",
description = "형식 오류(C-001) 또는 비즈니스 규칙 위반(ONB-001~003, ONB-008, ONB-010)",
description = "형식 오류(C-001) 또는 비즈니스 규칙 위반(ONB-001~003, ONB-008, ONB-010~011)",
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = {
@ExampleObject(name = "VALIDATION_ERROR", value = VALIDATION_ERROR_EXAMPLE),
@ExampleObject(name = "INVALID_BUDGET_RANGE", value = INVALID_BUDGET_RANGE_EXAMPLE),
@ExampleObject(name = "OBJECTIVE_NOT_ALLOWED", value = OBJECTIVE_NOT_ALLOWED_EXAMPLE),
@ExampleObject(name = "AD_EXPERIENCE_MISMATCH", value = AD_EXPERIENCE_MISMATCH_EXAMPLE),
@ExampleObject(name = "PERFORMANCE_FILE_INVALID", value = PERFORMANCE_FILE_INVALID_EXAMPLE),
@ExampleObject(name = "TOO_FEW_MANUAL_FIELDS", value = TOO_FEW_MANUAL_FIELDS_EXAMPLE)
@ExampleObject(name = "TOO_FEW_MANUAL_FIELDS", value = TOO_FEW_MANUAL_FIELDS_EXAMPLE),
@ExampleObject(name = "INVALID_AGE_BAND_SELECTION", value = INVALID_AGE_BAND_SELECTION_EXAMPLE)
}))
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404",
description = "존재하지 않는 채널(CH-001)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public record SubmitOnboardingRequest(
@Schema(description = "서비스 형태", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull ServiceType serviceType,

@Schema(description = "주요 연령대. 1개 이상", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "주요 연령대. 1개 이상. 잘 모르겠어요는 UNDECIDED 단독 선택",
requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty List<AgeBand> targetAgeBands,

@Schema(description = "광고 목표(단일 선택). 앱이면 APP_INSTALL/IN_APP_ACTION도 가능",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.UuidGenerator;
Expand Down Expand Up @@ -78,6 +79,7 @@ public class AdPerformance {
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Builder
private AdPerformance(UUID userId, PerfSource sourceType, UUID channelId,
String externalChannelName, Long budgetWon, Long impressions, Long clicks, Long conversions,
LocalDate startedAt, LocalDate endedAt, String rawFileUrl) {
Expand All @@ -101,16 +103,6 @@ private AdPerformance(UUID userId, PerfSource sourceType, UUID channelId,
this.cpaActual = ratio(budgetWon, conversions);
}

/**
* 온보딩에서 입력한 과거 집행 실적.
*/
public static AdPerformance fromOnboarding(UUID userId, PerfSource sourceType, UUID channelId,
String channelName, Long budgetWon, Long impressions, Long clicks, Long conversions,
LocalDate startedAt, LocalDate endedAt, String rawFileUrl) {
return new AdPerformance(userId, sourceType, channelId, channelName,
budgetWon, impressions, clicks, conversions, startedAt, endedAt, rawFileUrl);
}

private static BigDecimal ratio(Long numerator, Long denominator) {
if (numerator == null || denominator == null || denominator == 0L) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import chaeso.zip.server.estimation.domain.vo.PeriodDaysPolicy;
import chaeso.zip.server.onboarding.domain.OnboardingBusinessException;
import chaeso.zip.server.onboarding.domain.OnboardingErrorCode;
import chaeso.zip.server.onboarding.domain.OnboardingNotFoundException;
import chaeso.zip.server.onboarding.domain.entity.Onboarding;
import chaeso.zip.server.onboarding.domain.repository.OnboardingRepository;
import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse;
Expand Down Expand Up @@ -95,16 +96,13 @@ public SavedRecommendationResponse save(UUID userId, UUID onboardingId) {

private Onboarding findOnboarding(UUID onboardingId) {
return onboardingRepository.findById(onboardingId)
.orElseThrow(() -> new OnboardingBusinessException(
OnboardingErrorCode.ONBOARDING_NOT_FOUND,
"온보딩 정보가 없습니다. id=" + onboardingId));
.orElseThrow(() -> new OnboardingNotFoundException(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);
throw new OnboardingNotFoundException(onboardingId);
Comment thread
SinnoLn marked this conversation as resolved.
}
return onboarding;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@
import chaeso.zip.server.onboarding.domain.vo.CampaignPeriod;
import chaeso.zip.server.performance.domain.entity.AdPerformance;
import chaeso.zip.server.performance.domain.repository.AdPerformanceRepository;
import chaeso.zip.server.performance.domain.vo.PerfSource;
import chaeso.zip.server.simulation.domain.entity.BudgetSimulation;
import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem;
import chaeso.zip.server.simulation.domain.repository.BudgetSimulationItemRepository;
import chaeso.zip.server.simulation.domain.repository.BudgetSimulationRepository;
import chaeso.zip.server.support.AdPerformanceFixture;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.ZoneOffset;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -57,10 +58,7 @@ void fillsAuthIdentityCreatedAt() {
@Test
@DisplayName("광고 집행 실적 저장 시 생성 시각이 채워진다")
void fillsAdPerformanceCreatedAt() {
AdPerformance saved = adPerformanceRepository.save(AdPerformance.fromOnboarding(
UUID.randomUUID(), PerfSource.MANUAL, UUID.randomUUID(), "11번가 광고",
1_000_000L, 100_000L, 2_000L, 10L, LocalDate.of(2026, 7, 1),
LocalDate.of(2026, 7, 31), null));
AdPerformance saved = adPerformanceRepository.save(AdPerformanceFixture.adPerformance());

assertUtcNow(saved.getCreatedAt());
}
Expand All @@ -71,7 +69,7 @@ void fillsOnboardingAdHistorySnapshotCreatedAt() {
OnboardingAdHistorySnapshot saved =
onboardingAdHistorySnapshotRepository.save(OnboardingAdHistorySnapshot.snapshot(
UUID.randomUUID(), UUID.randomUUID(), "11번가 광고", 1_000_000L, 100_000L,
2_000L, 10L, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 31)));
2_000L, 10L, LocalDate.of(2026, Month.JULY, 1), LocalDate.of(2026, Month.JULY, 31)));

assertUtcNow(saved.getCreatedAt());
}
Expand Down Expand Up @@ -109,7 +107,8 @@ void fillsBudgetSimulationItemCreatedAt() {
*/
private static void assertUtcNow(LocalDateTime createdAt) {
assertThat(createdAt).isNotNull();
LocalDateTime utcNow = LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC);
assertThat(Duration.between(utcNow, createdAt).abs()).isLessThan(Duration.ofSeconds(30));
Instant createdAtInstant = createdAt.toInstant(ZoneOffset.UTC);
assertThat(Duration.between(Instant.now(), createdAtInstant).abs())
.isLessThan(Duration.ofSeconds(30));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import chaeso.zip.server.channel.domain.ChannelNotFoundException;
import chaeso.zip.server.channel.domain.entity.Channel;
import chaeso.zip.server.channel.domain.repository.ChannelRepository;
import chaeso.zip.server.channel.domain.vo.AgeBand;
import chaeso.zip.server.channel.domain.vo.CampaignObjective;
import chaeso.zip.server.onboarding.application.dto.AdHistoryCommand;
import chaeso.zip.server.onboarding.application.dto.SubmitOnboardingCommand;
Expand Down Expand Up @@ -147,6 +148,40 @@ void rejectsConcurrentSubmission() {
}
}

@Nested
@DisplayName("연령대 선택을 검증한다")
class TargetAgeBands {

@Test
@DisplayName("잘 모르겠어요를 다른 연령대와 함께 선택하면 ONB-011")
void rejectsUndecidedWithOtherAgeBands() {
SubmitOnboardingCommand command = OnboardingFixture.submitCommand(ServiceType.WEB,
List.of(AgeBand.UNDECIDED, AgeBand.AGE_20S), CampaignObjective.TRAFFIC, 1L, 2L,
AdExperience.NONE, List.of(), List.of());

assertThatThrownBy(() -> onboardingService.submit(USER_ID, command))
.isInstanceOf(OnboardingBusinessException.class)
.extracting("errorCode")
.isEqualTo(OnboardingErrorCode.INVALID_AGE_BAND_SELECTION);
then(onboardingRepository).should(never()).saveAndFlush(any());
}

@Test
@DisplayName("잘 모르겠어요만 단독으로 선택하면 통과한다")
void allowsUndecidedAlone() {
SubmitOnboardingCommand command = OnboardingFixture.submitCommand(ServiceType.WEB,
List.of(AgeBand.UNDECIDED), CampaignObjective.TRAFFIC, 1L, 2L, AdExperience.NONE,
List.of(), List.of());
given(onboardingRepository.findByUserIdAndIsActiveTrue(USER_ID)).willReturn(List.of());
given(onboardingRepository.saveAndFlush(any(Onboarding.class)))
.willAnswer(invocation -> invocation.getArgument(0));

onboardingService.submit(USER_ID, command);

then(onboardingRepository).should().saveAndFlush(any(Onboarding.class));
}
}

@Nested
@DisplayName("예산과 목표를 검증한다")
class BudgetAndObjective {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import chaeso.zip.server.onboarding.domain.repository.OnboardingRepository;
import chaeso.zip.server.performance.domain.entity.AdPerformance;
import chaeso.zip.server.performance.domain.repository.AdPerformanceRepository;
import chaeso.zip.server.performance.domain.vo.PerfSource;
import chaeso.zip.server.support.AdPerformanceFixture;
import chaeso.zip.server.support.OnboardingFixture;
import chaeso.zip.server.support.PostgresDataJpaTest;
import chaeso.zip.server.support.UserFixture;
Expand Down Expand Up @@ -58,9 +58,17 @@ void savesAgeBands() {
void savesAdPerformances() {
UUID userId = persistUser();

adPerformanceRepository.saveAndFlush(AdPerformance.fromOnboarding(
userId, PerfSource.MANUAL, null, "인스타그램", 3_000_000L, 250_000L, 3_000L, 120L,
LocalDate.of(2025, Month.MARCH, 1), LocalDate.of(2025, Month.MAY, 31), null));
adPerformanceRepository.saveAndFlush(AdPerformanceFixture.builder()
.userId(userId)
.channelId(null)
.externalChannelName("인스타그램")
.budgetWon(3_000_000L)
.impressions(250_000L)
.clicks(3_000L)
.conversions(120L)
.startedAt(LocalDate.of(2025, Month.MARCH, 1))
.endedAt(LocalDate.of(2025, Month.MAY, 31))
.build());

List<AdPerformance> found = adPerformanceRepository.findByUserId(userId);
assertThat(found).hasSize(1);
Expand Down
Loading
Loading