diff --git a/src/main/java/chaeso/zip/server/simulation/application/SimulationService.java b/src/main/java/chaeso/zip/server/simulation/application/SimulationService.java index 775a9e6..761dba1 100644 --- a/src/main/java/chaeso/zip/server/simulation/application/SimulationService.java +++ b/src/main/java/chaeso/zip/server/simulation/application/SimulationService.java @@ -2,8 +2,11 @@ import chaeso.zip.server.simulation.application.dto.SimulationCommand; import chaeso.zip.server.simulation.application.dto.SimulationResponse; +import chaeso.zip.server.simulation.application.dto.SimulationSummaryResponse; import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface SimulationService { @@ -23,4 +26,14 @@ public interface SimulationService { * @return 저장된 결과. 없으면 {@link Optional#empty()} */ Optional findLatest(UUID userId); + + /** + * 사용자가 저장한 시뮬레이션 목록을 최신순으로 반환한다. 매체별 항목은 담지 않는다. + */ + Page findMySimulations(UUID userId, Pageable pageable); + + /** + * 저장된 시뮬레이션 하나를 매체별 항목까지 재계산 없이 그대로 반환한다. + */ + SimulationResponse findSimulation(UUID userId, UUID simulationId); } diff --git a/src/main/java/chaeso/zip/server/simulation/application/SimulationServiceImpl.java b/src/main/java/chaeso/zip/server/simulation/application/SimulationServiceImpl.java index 5bb9fa5..61db879 100644 --- a/src/main/java/chaeso/zip/server/simulation/application/SimulationServiceImpl.java +++ b/src/main/java/chaeso/zip/server/simulation/application/SimulationServiceImpl.java @@ -17,6 +17,8 @@ import chaeso.zip.server.simulation.application.dto.SimulationCommand; import chaeso.zip.server.simulation.application.dto.SimulationItemResponse; import chaeso.zip.server.simulation.application.dto.SimulationResponse; +import chaeso.zip.server.simulation.application.dto.SimulationSummaryResponse; +import chaeso.zip.server.simulation.domain.SimulationNotFoundException; import chaeso.zip.server.simulation.domain.entity.BudgetSimulation; import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem; import chaeso.zip.server.simulation.domain.repository.BudgetSimulationItemRepository; @@ -32,6 +34,8 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -80,6 +84,41 @@ public Optional findLatest(UUID userId) { .map(this::restore); } + @Override + public Page findMySimulations(UUID userId, Pageable pageable) { + Page simulations = + budgetSimulationRepository.findByUserIdOrderByCreatedAtDescIdDesc(userId, pageable); + + Map> itemsBySimulation = itemsOf( + simulations.getContent().stream().map(BudgetSimulation::getId).toList()); + Map channelNames = channelNames(itemsBySimulation.values().stream() + .flatMap(List::stream) + .map(BudgetSimulationItem::getChannelId) + .distinct() + .toList()); + + return simulations.map(simulation -> SimulationSummaryResponse.from(simulation, + itemsBySimulation.getOrDefault(simulation.getId(), List.of()), channelNames)); + } + + @Override + public SimulationResponse findSimulation(UUID userId, UUID simulationId) { + // 남의 것을 조회했을 때도 없는 것과 같은 404로 응답해 그 id 가 존재한다는 사실을 알려주지 않는다 + return budgetSimulationRepository.findById(simulationId) + .filter(simulation -> simulation.getUserId().equals(userId)) + .map(this::restore) + .orElseThrow(() -> new SimulationNotFoundException(simulationId)); + } + + private Map> itemsOf(List simulationIds) { + if (simulationIds.isEmpty()) { + return Map.of(); + } + return budgetSimulationItemRepository + .findByBudgetSimulationIdInOrderBySortOrderAsc(simulationIds).stream() + .collect(Collectors.groupingBy(BudgetSimulationItem::getBudgetSimulationId)); + } + private SimulationResponse restore(BudgetSimulation simulation) { List items = budgetSimulationItemRepository .findByBudgetSimulationIdOrderBySortOrderAsc(simulation.getId()); @@ -171,6 +210,9 @@ private Map loadChannels(List channelIds) { } private Map channelNames(List channelIds) { + if (channelIds.isEmpty()) { + return Map.of(); + } return channelRepository.findAllById(channelIds).stream() .collect(Collectors.toMap(Channel::getId, Channel::getName)); } diff --git a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationItemResponse.java b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationItemResponse.java index 97311f5..67b0e35 100644 --- a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationItemResponse.java +++ b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationItemResponse.java @@ -7,22 +7,27 @@ import chaeso.zip.server.estimation.domain.vo.EstimationResult; import chaeso.zip.server.simulation.domain.BasisNote; import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem; +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 SimulationItemResponse( @Schema(description = "채널 id", requiredMode = Schema.RequiredMode.REQUIRED) UUID channelId, @Schema(description = "채널명", example = "11번가 광고", requiredMode = Schema.RequiredMode.REQUIRED) String channelName, - @Schema(description = "추정 근거가 된 대표 상품 id. 단가 정보가 없으면 null", nullable = true) + @Schema(description = "추정 근거가 된 대표 상품 id. 단가 정보가 없으면 생략", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) UUID channelProductId, @Schema(description = "배분 예산(원). 0 은 미집행", example = "1000000", requiredMode = Schema.RequiredMode.REQUIRED) long allocatedBudgetWon, - @Schema(description = "전체 예산 대비 배분 비율(%)", example = "40", nullable = true) + @Schema(description = "전체 예산 대비 배분 비율(%)", example = "40", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) BigDecimal allocationPct, @Schema(description = "추정 노출 수 범위. 추정 불가 시 생략", requiredMode = Schema.RequiredMode.NOT_REQUIRED) CountRangeResponse estImpressions, @@ -30,17 +35,18 @@ public record SimulationItemResponse( CountRangeResponse estClicks, @Schema(description = """ 클릭당 비용(원). 클릭당 과금 매체는 단가 그대로, 그 외 매체는 \ - 배분 예산 / 예상 클릭 수(중앙값)로 환산한다. 예상 클릭이 없으면 null""", - nullable = true) + 배분 예산 / 예상 클릭 수(중앙값)로 환산한다. 예상 클릭이 없으면 생략""", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) BigDecimal cpcWon, @Schema(description = """ 1000회 노출당 단가(원). 대표 단가가 CPM 일 때만 채워진다. \ - 화면에는 쓰지 않고 어떤 단가로 추정했는지 남기는 값""", nullable = true) + 화면에는 쓰지 않고 어떤 단가로 추정했는지 남기는 값""", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) BigDecimal cpmWon, @Schema(description = "배분 예산으로 집행 가능한지 여부", requiredMode = Schema.RequiredMode.REQUIRED) boolean isExecutable, - @Schema(description = "집행에 부족한 금액(원). 집행 가능하면 null", example = "500000", - nullable = true) + @Schema(description = "집행에 부족한 금액(원). 집행 가능하면 생략", example = "500000", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) Long shortfallWon, @Schema(description = "산출 근거 고지", requiredMode = Schema.RequiredMode.REQUIRED) String basisNote) { diff --git a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationResponse.java b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationResponse.java index 29c491a..adb8d66 100644 --- a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationResponse.java +++ b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationResponse.java @@ -11,7 +11,8 @@ @Schema(description = "예산 시뮬레이션 결과") @JsonInclude(Include.NON_NULL) public record SimulationResponse( - @Schema(description = "저장된 시뮬레이션 id", nullable = true) + @Schema(description = "저장된 시뮬레이션 id. 저장 전 계산 결과에는 생략된다", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) UUID simulationId, @Schema(description = "총 예산(원)", example = "3000000", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationSummaryResponse.java b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationSummaryResponse.java new file mode 100644 index 0000000..f88e6e7 --- /dev/null +++ b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationSummaryResponse.java @@ -0,0 +1,68 @@ +package chaeso.zip.server.simulation.application.dto; + +import chaeso.zip.server.onboarding.domain.vo.CampaignPeriod; +import chaeso.zip.server.simulation.domain.entity.BudgetSimulation; +import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +@Schema(description = "저장된 시뮬레이션 목록 요약. 매체별 상세는 상세 조회에서 받는다") +public record SimulationSummaryResponse( + @Schema(description = "저장된 시뮬레이션 id", requiredMode = Schema.RequiredMode.REQUIRED) + UUID simulationId, + @Schema(description = "저장 시각", requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime createdAt, + @Schema(description = "총 예산(원)", example = "3000000", + requiredMode = Schema.RequiredMode.REQUIRED) + long totalBudgetWon, + @Schema(description = "집행 기간(온보딩과 같은 구간)", example = "M1", + requiredMode = Schema.RequiredMode.REQUIRED) + CampaignPeriod period, + @Schema(description = "저장 당시 추정 노출 수 합(범위 중앙값 기준)", example = "1150000", + requiredMode = Schema.RequiredMode.REQUIRED) + long totalEstImpressions, + @Schema(description = "저장 당시 추정 클릭 수 합(범위 중앙값 기준)", example = "23000", + requiredMode = Schema.RequiredMode.REQUIRED) + long totalEstClicks, + @Schema(description = "예산을 배분한 매체 개수", example = "3", + requiredMode = Schema.RequiredMode.REQUIRED) + int channelCount, + @Schema(description = "집행 가능한 매체 개수", example = "2", + requiredMode = Schema.RequiredMode.REQUIRED) + int executableChannelCount, + @Schema(description = "어떤 조합이었는지 알아볼 수 있게 예산을 배분한 매체명만 최대 3개 보여 준다. null 이 아닌 배열", + requiredMode = Schema.RequiredMode.REQUIRED) + List channelNames) { + + /** 목록에서 조합을 알아볼 정도로만 보여 주는 대표 매체 수 */ + private static final int PREVIEW_CHANNEL_NAMES = 3; + + /** + * 스냅샷에는 사용자가 담아만 두고 예산을 주지 않은 매체(배분 0원)도 상세 화면을 그대로 재현하려고 + * 남아 있다. 목록의 매체 수와 대표 매체명은 실제로 예산을 배분한 매체만 센다. + */ + public static SimulationSummaryResponse from(BudgetSimulation simulation, + List items, Map channelNames) { + List allocated = items.stream() + .filter(item -> item.getAllocatedBudgetWon() > 0) + .toList(); + return new SimulationSummaryResponse( + simulation.getId(), + simulation.getCreatedAt(), + simulation.getTotalBudgetWon(), + simulation.getPeriod(), + simulation.getTotalEstImpressions(), + simulation.getTotalEstClicks(), + allocated.size(), + (int) allocated.stream().filter(BudgetSimulationItem::isExecutable).count(), + allocated.stream() + .map(item -> channelNames.get(item.getChannelId())) + .filter(Objects::nonNull) + .limit(PREVIEW_CHANNEL_NAMES) + .toList()); + } +} diff --git a/src/main/java/chaeso/zip/server/simulation/domain/SimulationErrorCode.java b/src/main/java/chaeso/zip/server/simulation/domain/SimulationErrorCode.java new file mode 100644 index 0000000..650d993 --- /dev/null +++ b/src/main/java/chaeso/zip/server/simulation/domain/SimulationErrorCode.java @@ -0,0 +1,17 @@ +package chaeso.zip.server.simulation.domain; + +import chaeso.zip.server.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SimulationErrorCode implements ErrorCode { + + SIMULATION_NOT_FOUND(HttpStatus.NOT_FOUND, "SIM-001", "존재하지 않는 시뮬레이션입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/chaeso/zip/server/simulation/domain/SimulationNotFoundException.java b/src/main/java/chaeso/zip/server/simulation/domain/SimulationNotFoundException.java new file mode 100644 index 0000000..e98a75f --- /dev/null +++ b/src/main/java/chaeso/zip/server/simulation/domain/SimulationNotFoundException.java @@ -0,0 +1,12 @@ +package chaeso.zip.server.simulation.domain; + +import chaeso.zip.server.common.exception.BusinessException; +import java.util.UUID; + +public class SimulationNotFoundException extends BusinessException { + + public SimulationNotFoundException(UUID simulationId) { + super(SimulationErrorCode.SIMULATION_NOT_FOUND, + "존재하지 않는 시뮬레이션입니다. id=" + simulationId); + } +} diff --git a/src/main/java/chaeso/zip/server/simulation/domain/entity/BudgetSimulationItem.java b/src/main/java/chaeso/zip/server/simulation/domain/entity/BudgetSimulationItem.java index c18cbb6..f91b567 100644 --- a/src/main/java/chaeso/zip/server/simulation/domain/entity/BudgetSimulationItem.java +++ b/src/main/java/chaeso/zip/server/simulation/domain/entity/BudgetSimulationItem.java @@ -36,7 +36,7 @@ public class BudgetSimulationItem { @Column(name = "channel_id", nullable = false) private UUID channelId; - /** 대표로 선택된 상품. 단가 정보가 있는 상품이 없으면 null */ + /** 대표로 선택된 상품 */ @Column(name = "channel_product_id") private UUID channelProductId; diff --git a/src/main/java/chaeso/zip/server/simulation/domain/repository/BudgetSimulationItemRepository.java b/src/main/java/chaeso/zip/server/simulation/domain/repository/BudgetSimulationItemRepository.java index 70101b0..d693948 100644 --- a/src/main/java/chaeso/zip/server/simulation/domain/repository/BudgetSimulationItemRepository.java +++ b/src/main/java/chaeso/zip/server/simulation/domain/repository/BudgetSimulationItemRepository.java @@ -1,6 +1,7 @@ package chaeso.zip.server.simulation.domain.repository; import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem; +import java.util.Collection; import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +10,7 @@ public interface BudgetSimulationItemRepository extends JpaRepository { List findByBudgetSimulationIdOrderBySortOrderAsc(UUID budgetSimulationId); + + List findByBudgetSimulationIdInOrderBySortOrderAsc( + Collection budgetSimulationIds); } diff --git a/src/main/java/chaeso/zip/server/simulation/domain/repository/BudgetSimulationRepository.java b/src/main/java/chaeso/zip/server/simulation/domain/repository/BudgetSimulationRepository.java index fcaf44e..133aa78 100644 --- a/src/main/java/chaeso/zip/server/simulation/domain/repository/BudgetSimulationRepository.java +++ b/src/main/java/chaeso/zip/server/simulation/domain/repository/BudgetSimulationRepository.java @@ -3,9 +3,13 @@ import chaeso.zip.server.simulation.domain.entity.BudgetSimulation; import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface BudgetSimulationRepository extends JpaRepository { Optional findFirstByUserIdOrderByCreatedAtDescIdDesc(UUID userId); + + Page findByUserIdOrderByCreatedAtDescIdDesc(UUID userId, Pageable pageable); } diff --git a/src/main/java/chaeso/zip/server/simulation/presentation/SimulationApiDocs.java b/src/main/java/chaeso/zip/server/simulation/presentation/SimulationApiDocs.java index 8cc2157..469d684 100644 --- a/src/main/java/chaeso/zip/server/simulation/presentation/SimulationApiDocs.java +++ b/src/main/java/chaeso/zip/server/simulation/presentation/SimulationApiDocs.java @@ -2,7 +2,10 @@ import chaeso.zip.server.auth.application.UserPrincipal; import chaeso.zip.server.common.response.ApiResponse; +import chaeso.zip.server.common.response.PageResponse; import chaeso.zip.server.simulation.application.dto.SimulationResponse; +import chaeso.zip.server.simulation.application.dto.SimulationSummaryResponse; +import chaeso.zip.server.simulation.presentation.dto.SimulationPageRequest; import chaeso.zip.server.simulation.presentation.dto.SimulationRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -12,8 +15,11 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.UUID; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "Simulation", description = "예산 시뮬레이터 API") @@ -40,21 +46,14 @@ public interface SimulationApiDocs { "cpcWon": 150, "cpmWon": 3000, "isExecutable": true, - "shortfallWon": null, "basisNote": "매체 소개서 기반 / VAT 별도 가정 / CTR 미제공 시 전체 평균 CTR 적용" }, { "channelId": "9c1e8c2a-3f4d-4a5b-9c6d-7e8f9a0b1c2e", "channelName": "당근마켓 광고", - "channelProductId": null, "allocatedBudgetWon": 1000000, "allocationPct": 33.3, - "estImpressions": null, - "estClicks": null, - "cpcWon": null, - "cpmWon": null, "isExecutable": false, - "shortfallWon": null, "basisNote": "견적 문의 필요 (등록된 단가 정보 없음) / 매체 소개서 기반 / VAT 별도 가정 / CTR 미제공 시 전체 평균 CTR 적용" } ] @@ -84,7 +83,6 @@ public interface SimulationApiDocs { "cpcWon": 150, "cpmWon": 3000, "isExecutable": true, - "shortfallWon": null, "basisNote": "매체 소개서 기반 / VAT 별도 가정 / CTR 미제공 시 전체 평균 CTR 적용" } ] @@ -92,6 +90,55 @@ public interface SimulationApiDocs { } """; + String SIMULATION_LIST_EXAMPLE = """ + { + "success": true, + "data": { + "content": [ + { + "simulationId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "createdAt": "2026-03-14T10:22:31", + "totalBudgetWon": 3000000, + "period": "M1", + "totalEstImpressions": 1150000, + "totalEstClicks": 23000, + "channelCount": 2, + "executableChannelCount": 1, + "channelNames": ["11번가 광고", "당근마켓 광고"] + } + ], + "number": 0, + "size": 5, + "totalElements": 1, + "totalPages": 1, + "first": true, + "last": true + } + } + """; + + String PAGE_SIZE_ERROR_EXAMPLE = """ + { + "success": false, + "error": { + "code": "C-001", + "message": "page 는 0 이상, size 는 1 이상 50 이하여야 합니다", + "fieldErrors": [] + } + } + """; + + String SIMULATION_NOT_FOUND_EXAMPLE = """ + { + "success": false, + "error": { + "code": "SIM-001", + "message": "존재하지 않는 시뮬레이션입니다. id=3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "fieldErrors": [] + } + } + """; + String VALIDATION_ERROR_EXAMPLE = """ { "success": false, @@ -177,4 +224,41 @@ ApiResponse saveSimulation( description = "저장된 결과 없음", content = @Content) ResponseEntity> getLatestSimulation( @Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal); + + @SecurityRequirement(name = "bearerAuth") + @Operation(operationId = "getMySimulations", summary = "내가 저장한 시뮬레이션 목록", + description = """ + 로그인한 사용자가 저장한 시뮬레이션을 최신순으로 반환한다. 목록은 요약만 담으며 \ + 매체별 항목은 상세 조회에서 받는다. 정렬은 최신순 고정이고 page/size 를 생략하면 \ + 0 페이지 5건을 반환한다. 저장된 결과가 없으면 빈 목록으로 200 을 반환한다.""") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", + useReturnTypeSchema = true, + content = @Content( + examples = @ExampleObject(name = "SIMULATION_LIST", value = SIMULATION_LIST_EXAMPLE))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", + description = "page/size 범위 위반(C-001). size 는 1 이상 50 이하여야 한다", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "PAGE_SIZE_ERROR", value = PAGE_SIZE_ERROR_EXAMPLE))) + ApiResponse> getMySimulations( + @Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal, + @ParameterObject SimulationPageRequest request); + + @SecurityRequirement(name = "bearerAuth") + @Operation(operationId = "getSimulation", summary = "저장된 시뮬레이션 상세", + description = """ + 저장된 시뮬레이션 하나를 매체별 항목까지 재계산 없이 그대로 반환한다. 본인이 저장한 것만 \ + 조회할 수 있고, 다른 사용자의 시뮬레이션은 그 id 가 존재한다는 사실을 숨기기 위해 없는 \ + 것과 같은 404(SIM-001) 로 응답한다.""") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", + useReturnTypeSchema = true, + content = @Content( + examples = @ExampleObject(name = "SIMULATION_SAVED", value = SIMULATION_SAVED_EXAMPLE))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", + description = "존재하지 않거나 다른 사용자의 시뮬레이션(SIM-001)", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "SIMULATION_NOT_FOUND", + value = SIMULATION_NOT_FOUND_EXAMPLE))) + ApiResponse getSimulation( + @Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal principal, + @Parameter(description = "저장된 시뮬레이션 id") @PathVariable UUID simulationId); } diff --git a/src/main/java/chaeso/zip/server/simulation/presentation/SimulationController.java b/src/main/java/chaeso/zip/server/simulation/presentation/SimulationController.java index e644a79..53b6836 100644 --- a/src/main/java/chaeso/zip/server/simulation/presentation/SimulationController.java +++ b/src/main/java/chaeso/zip/server/simulation/presentation/SimulationController.java @@ -2,15 +2,21 @@ import chaeso.zip.server.auth.application.UserPrincipal; import chaeso.zip.server.common.response.ApiResponse; +import chaeso.zip.server.common.response.PageResponse; import chaeso.zip.server.simulation.application.SimulationService; import chaeso.zip.server.simulation.application.dto.SimulationResponse; +import chaeso.zip.server.simulation.application.dto.SimulationSummaryResponse; +import chaeso.zip.server.simulation.presentation.dto.SimulationPageRequest; import chaeso.zip.server.simulation.presentation.dto.SimulationRequest; import jakarta.validation.Valid; +import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -49,4 +55,22 @@ public ResponseEntity> getLatestSimulation( .map(simulation -> ResponseEntity.ok(ApiResponse.success(simulation))) .orElseGet(() -> ResponseEntity.noContent().build()); } + + @Override + @GetMapping + public ApiResponse> getMySimulations( + @AuthenticationPrincipal UserPrincipal principal, + @ParameterObject SimulationPageRequest request) { + return ApiResponse.success(PageResponse.from( + simulationService.findMySimulations(principal.userId(), request.toPageable()))); + } + + @Override + @GetMapping("/{simulationId}") + public ApiResponse getSimulation( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable UUID simulationId) { + return ApiResponse.success( + simulationService.findSimulation(principal.userId(), simulationId)); + } } diff --git a/src/main/java/chaeso/zip/server/simulation/presentation/dto/SimulationPageRequest.java b/src/main/java/chaeso/zip/server/simulation/presentation/dto/SimulationPageRequest.java new file mode 100644 index 0000000..7684e04 --- /dev/null +++ b/src/main/java/chaeso/zip/server/simulation/presentation/dto/SimulationPageRequest.java @@ -0,0 +1,33 @@ +package chaeso.zip.server.simulation.presentation.dto; + +import chaeso.zip.server.common.exception.BusinessException; +import chaeso.zip.server.common.exception.CommonErrorCode; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@Schema(description = "저장된 시뮬레이션 목록 조회 요청") +public record SimulationPageRequest( + @Schema(description = "페이지 번호(0-base)", example = "0") + Integer page, + + @Schema(description = "페이지 크기", example = "5") + Integer size) { + + private static final int DEFAULT_PAGE_SIZE = 5; + + private static final int MAX_PAGE_SIZE = 50; + + /** + * 정렬은 최신순으로 고정이라 클라이언트가 바꿀 수 없고, 목록은 항상 페이지로 끊어 준다. + */ + public Pageable toPageable() { + int pageNumber = page == null ? 0 : page; + int pageSize = size == null ? DEFAULT_PAGE_SIZE : size; + if (pageNumber < 0 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT_VALUE, + "page 는 0 이상, size 는 1 이상 " + MAX_PAGE_SIZE + " 이하여야 합니다"); + } + return PageRequest.of(pageNumber, pageSize); + } +} diff --git a/src/test/java/chaeso/zip/server/simulation/application/SimulationServiceImplTest.java b/src/test/java/chaeso/zip/server/simulation/application/SimulationServiceImplTest.java index 924fcdd..4f2e498 100644 --- a/src/test/java/chaeso/zip/server/simulation/application/SimulationServiceImplTest.java +++ b/src/test/java/chaeso/zip/server/simulation/application/SimulationServiceImplTest.java @@ -9,6 +9,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -27,14 +28,18 @@ import chaeso.zip.server.simulation.application.dto.SimulationCommand; import chaeso.zip.server.simulation.application.dto.SimulationItemResponse; import chaeso.zip.server.simulation.application.dto.SimulationResponse; +import chaeso.zip.server.simulation.application.dto.SimulationSummaryResponse; +import chaeso.zip.server.simulation.domain.SimulationNotFoundException; 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 java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -45,6 +50,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -58,6 +66,8 @@ class SimulationServiceImplTest { /** 카탈로그 평균 CTR. 상품에 CTR 이 없을 때 이 값이 쓰이는지로 주입을 확인한다. */ private static final BigDecimal AVERAGE_CTR = new BigDecimal("2.5"); + private static final LocalDateTime CREATED_AT = LocalDateTime.of(2026, 3, 14, 10, 22, 31); + @Mock private ChannelRepository channelRepository; @Mock @@ -589,6 +599,162 @@ void returnsEmptyWhenNothingSaved() { } } + @Nested + @DisplayName("내 목록 (GET /simulations)") + class FindMySimulations { + + private final Pageable pageable = PageRequest.of(0, 10); + + @Test + @DisplayName("저장된 요약을 반환하고 집행 가능 매체 수는 항목에서 센다") + void summarizesSavedSnapshots() { + UUID simulationId = UUID.randomUUID(); + UUID otherChannelId = UUID.randomUUID(); + givenMyPage(savedSimulation(simulationId, USER_ID)); + given(budgetSimulationItemRepository + .findByBudgetSimulationIdInOrderBySortOrderAsc(List.of(simulationId))) + .willReturn(List.of( + savedItem(simulationId, CHANNEL_ID, 0, true), + savedItem(simulationId, otherChannelId, 1, false))); + given(channelRepository.findAllById(anyList())).willReturn(List.of( + channel(CHANNEL_ID, CHANNEL_NAME), channel(otherChannelId, "당근마켓 광고"))); + + SimulationSummaryResponse summary = + simulationService.findMySimulations(USER_ID, pageable).getContent().getFirst(); + + assertThat(summary.simulationId()).isEqualTo(simulationId); + assertThat(summary.createdAt()).isEqualTo(CREATED_AT); + assertThat(summary.totalBudgetWon()).isEqualTo(3_000_000); + assertThat(summary.period()).isEqualTo(CampaignPeriod.M1); + assertThat(summary.totalEstImpressions()).isEqualTo(1_000_000); + assertThat(summary.totalEstClicks()).isEqualTo(25_000); + assertThat(summary.channelCount()).isEqualTo(2); + assertThat(summary.executableChannelCount()).isEqualTo(1); // 2개 중 1개만 + assertThat(summary.channelNames()).containsExactly(CHANNEL_NAME, "당근마켓 광고"); + + // 재계산하지 않았음: 상품·단가 조회도, CTR 집계도 하지 않는다 + verifyNoInteractions(channelProductRepository, channelPricingRepository, defaultCtrProvider); + } + + @Test + @DisplayName("대표 매체명은 저장 순서대로 최대 3개만 담는다") + void previewsFirstThreeChannelNames() { + UUID simulationId = UUID.randomUUID(); + List channelIds = List.of(CHANNEL_ID, UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID()); + givenMyPage(savedSimulation(simulationId, USER_ID)); + given(budgetSimulationItemRepository + .findByBudgetSimulationIdInOrderBySortOrderAsc(List.of(simulationId))) + .willReturn(IntStream.range(0, channelIds.size()) + .mapToObj(order -> savedItem(simulationId, channelIds.get(order), order, true)) + .toList()); + given(channelRepository.findAllById(anyList())).willReturn( + IntStream.range(0, channelIds.size()) + .mapToObj(order -> channel(channelIds.get(order), "매체" + order)) + .toList()); + + SimulationSummaryResponse summary = + simulationService.findMySimulations(USER_ID, pageable).getContent().getFirst(); + + assertThat(summary.channelCount()).isEqualTo(4); // 자르는 건 대표 매체명뿐이다 + assertThat(summary.channelNames()).containsExactly("매체0", "매체1", "매체2"); + } + + @Test + @DisplayName("예산을 배분하지 않은 매체는 매체 수와 대표 매체명에서 뺀다") + void excludesUnallocatedChannels() { + // 담아만 두고 0원을 준 매체는 상세 재현을 위해 저장되지만, 목록에서는 배분한 매체가 아니다 + UUID simulationId = UUID.randomUUID(); + UUID unallocatedChannelId = UUID.randomUUID(); + givenMyPage(savedSimulation(simulationId, USER_ID)); + given(budgetSimulationItemRepository + .findByBudgetSimulationIdInOrderBySortOrderAsc(List.of(simulationId))) + .willReturn(List.of( + savedItem(simulationId, unallocatedChannelId, 0, false, 0L), + savedItem(simulationId, CHANNEL_ID, 1, true))); + given(channelRepository.findAllById(anyList())).willReturn(List.of( + channel(unallocatedChannelId, "당근마켓 광고"), channel(CHANNEL_ID, CHANNEL_NAME))); + + SimulationSummaryResponse summary = + simulationService.findMySimulations(USER_ID, pageable).getContent().getFirst(); + + assertThat(summary.channelCount()).isEqualTo(1); + assertThat(summary.executableChannelCount()).isEqualTo(1); + assertThat(summary.channelNames()).containsExactly(CHANNEL_NAME); + } + + @Test + @DisplayName("저장된 결과가 없으면 빈 페이지를 반환하고 항목·채널은 조회하지 않는다") + void returnsEmptyPageWithoutLoadingItems() { + givenMyPage(); + + assertThat(simulationService.findMySimulations(USER_ID, pageable)).isEmpty(); + + verifyNoInteractions(channelRepository); + verify(budgetSimulationItemRepository, never()) + .findByBudgetSimulationIdInOrderBySortOrderAsc(anyList()); + } + + private void givenMyPage(BudgetSimulation... simulations) { + given(budgetSimulationRepository.findByUserIdOrderByCreatedAtDescIdDesc(USER_ID, pageable)) + .willReturn(new PageImpl<>(List.of(simulations), pageable, simulations.length)); + } + } + + @Nested + @DisplayName("상세 (GET /simulations/{simulationId})") + class FindSimulation { + + @Test + @DisplayName("본인이 저장한 시뮬레이션은 매체별 항목까지 재계산 없이 그대로 반환한다") + void restoresOwnSnapshot() { + UUID simulationId = UUID.randomUUID(); + given(budgetSimulationRepository.findById(simulationId)) + .willReturn(Optional.of(savedSimulation(simulationId, USER_ID))); + given(budgetSimulationItemRepository + .findByBudgetSimulationIdOrderBySortOrderAsc(simulationId)) + .willReturn(List.of(savedItem(simulationId, CHANNEL_ID, 0, true))); + given(channelRepository.findAllById(anyList())) + .willReturn(List.of(channel(CHANNEL_ID, CHANNEL_NAME))); + + SimulationResponse response = simulationService.findSimulation(USER_ID, simulationId); + + assertThat(response.simulationId()).isEqualTo(simulationId); + assertThat(response.totalBudgetWon()).isEqualTo(3_000_000); + assertThat(response.executableChannelCount()).isEqualTo(1); + SimulationItemResponse item = response.items().getFirst(); + assertThat(item.channelName()).isEqualTo(CHANNEL_NAME); + assertThat(item.allocatedBudgetWon()).isEqualTo(1_000_000); + assertThat(item.basisNote()).isEqualTo("저장 당시 고지"); + + verifyNoInteractions(channelProductRepository, channelPricingRepository, defaultCtrProvider); + } + + @Test + @DisplayName("다른 사용자가 저장한 시뮬레이션은 없는 것과 같은 404 로 숨기고 항목도 읽지 않는다") + void hidesOtherUsersSnapshot() { + UUID simulationId = UUID.randomUUID(); + UUID otherUserId = UUID.randomUUID(); + given(budgetSimulationRepository.findById(simulationId)) + .willReturn(Optional.of(savedSimulation(simulationId, otherUserId))); + + assertThatThrownBy(() -> simulationService.findSimulation(USER_ID, simulationId)) + .isInstanceOf(SimulationNotFoundException.class); + + verifyNoInteractions(budgetSimulationItemRepository, channelRepository); + } + + @Test + @DisplayName("존재하지 않는 id 는 404 로 거부한다") + void rejectsUnknownId() { + UUID simulationId = UUID.randomUUID(); + given(budgetSimulationRepository.findById(simulationId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> simulationService.findSimulation(USER_ID, simulationId)) + .isInstanceOf(SimulationNotFoundException.class); + } + } + @Captor private ArgumentCaptor> itemsCaptor; @@ -619,4 +785,35 @@ private static T withId(T entity, UUID id) { ReflectionTestUtils.setField(entity, "id", id); return entity; } + + /** 이미 저장된 헤더. id 와 저장 시각은 JPA 가 채우는 값이라 목에서 직접 심는다. */ + private static BudgetSimulation savedSimulation(UUID simulationId, UUID userId) { + BudgetSimulation simulation = withId(BudgetSimulation.builder() + .userId(userId) + .totalBudgetWon(3_000_000L) + .period(CampaignPeriod.M1) + .totalEstImpressions(1_000_000L) + .totalEstClicks(25_000L) + .build(), simulationId); + ReflectionTestUtils.setField(simulation, "createdAt", CREATED_AT); + return simulation; + } + + private static BudgetSimulationItem savedItem(UUID simulationId, UUID channelId, int sortOrder, + boolean executable) { + return savedItem(simulationId, channelId, sortOrder, executable, 1_000_000L); + } + + private static BudgetSimulationItem savedItem(UUID simulationId, UUID channelId, int sortOrder, + boolean executable, long allocatedBudgetWon) { + return BudgetSimulationItem.builder() + .budgetSimulationId(simulationId) + .channelId(channelId) + .sortOrder(sortOrder) + .allocatedBudgetWon(allocatedBudgetWon) + .allocationPct(new BigDecimal("50")) + .executable(executable) + .basisNote("저장 당시 고지") + .build(); + } } diff --git a/src/test/java/chaeso/zip/server/simulation/presentation/SimulationControllerTest.java b/src/test/java/chaeso/zip/server/simulation/presentation/SimulationControllerTest.java index f72d3f8..7120c1e 100644 --- a/src/test/java/chaeso/zip/server/simulation/presentation/SimulationControllerTest.java +++ b/src/test/java/chaeso/zip/server/simulation/presentation/SimulationControllerTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; 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.jsonPath; @@ -18,11 +19,14 @@ import chaeso.zip.server.simulation.application.dto.SimulationCommand; import chaeso.zip.server.simulation.application.dto.SimulationItemResponse; import chaeso.zip.server.simulation.application.dto.SimulationResponse; +import chaeso.zip.server.simulation.application.dto.SimulationSummaryResponse; import chaeso.zip.server.simulation.domain.BasisNote; +import chaeso.zip.server.simulation.domain.SimulationNotFoundException; import chaeso.zip.server.simulation.presentation.dto.AllocationRequest; import chaeso.zip.server.simulation.presentation.dto.SimulationRequest; import com.fasterxml.jackson.databind.ObjectMapper; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -35,6 +39,9 @@ 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.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -134,6 +141,102 @@ void latestReturnsNoContentWhenNothingSaved() throws Exception { .andExpect(status().isNoContent()); } + @Test + @DisplayName("내 목록은 200 과 페이지 요약을 반환한다") + void listReturnsSummaryPage() throws Exception { + UUID simulationId = UUID.randomUUID(); + given(simulationService.findMySimulations(eq(USER_ID), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(summary(simulationId)), PageRequest.of(0, 10), 1)); + + mockMvc.perform(get("/api/v1/simulations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].simulationId").value(simulationId.toString())) + .andExpect(jsonPath("$.data.content[0].createdAt").value("2026-03-14T10:22:31")) + .andExpect(jsonPath("$.data.content[0].period").value("M1")) + .andExpect(jsonPath("$.data.content[0].channelCount").value(2)) + .andExpect(jsonPath("$.data.content[0].executableChannelCount").value(1)) + .andExpect(jsonPath("$.data.content[0].channelNames[0]").value("11번가 광고")) + // 목록은 요약만 담고 매체별 항목은 상세에서 받는다 + .andExpect(jsonPath("$.data.content[0].items").doesNotExist()) + .andExpect(jsonPath("$.data.totalElements").value(1)) + .andExpect(jsonPath("$.data.size").value(10)); + } + + @Test + @DisplayName("page/size 를 생략하면 0 페이지 5건으로 조회한다") + void listDefaultsToFirstPageOfFive() throws Exception { + given(simulationService.findMySimulations(eq(USER_ID), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(), PageRequest.of(0, 5), 0)); + + mockMvc.perform(get("/api/v1/simulations")) + .andExpect(status().isOk()); + + verify(simulationService).findMySimulations(USER_ID, PageRequest.of(0, 5)); + } + + @Test + @DisplayName("요청한 page/size 를 그대로 조회에 넘긴다") + void listPassesRequestedPageAndSize() throws Exception { + given(simulationService.findMySimulations(eq(USER_ID), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(), PageRequest.of(2, 5), 0)); + + mockMvc.perform(get("/api/v1/simulations").param("page", "2").param("size", "5")) + .andExpect(status().isOk()); + + verify(simulationService).findMySimulations(USER_ID, PageRequest.of(2, 5)); + } + + @Test + @DisplayName("page/size 가 허용 범위를 벗어나면 400 C-001 을 반환한다") + void listRejectsPageSizeOutOfRange() throws Exception { + mockMvc.perform(get("/api/v1/simulations").param("size", "51")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("C-001")); + } + + @Test + @DisplayName("상세 조회는 200 과 매체별 항목까지 담은 스냅샷을 반환한다") + void detailReturnsSnapshotWithItems() throws Exception { + UUID simulationId = UUID.randomUUID(); + given(simulationService.findSimulation(USER_ID, simulationId)) + .willReturn(response(simulationId)); + + mockMvc.perform(get("/api/v1/simulations/{simulationId}", simulationId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.simulationId").value(simulationId.toString())) + .andExpect(jsonPath("$.data.executableChannelCount").value(1)) + .andExpect(jsonPath("$.data.items[0].channelName").value("11번가 광고")) + .andExpect(jsonPath("$.data.items[0].estClicks.min").value(21_250)) + // 값이 없는 선택 필드는 null 로 담지 않고 생략한다. 스키마의 NOT_REQUIRED 와 같은 계약 + .andExpect(jsonPath("$.data.items[0].shortfallWon").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].basisNote").value(BasisNote.COMMON)); + } + + @Test + @DisplayName("없는 id 나 남의 시뮬레이션을 상세 조회하면 404 SIM-001 을 반환한다") + void detailReturnsNotFound() throws Exception { + UUID simulationId = UUID.randomUUID(); + willThrow(new SimulationNotFoundException(simulationId)) + .given(simulationService).findSimulation(USER_ID, simulationId); + + mockMvc.perform(get("/api/v1/simulations/{simulationId}", simulationId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("SIM-001")); + } + + @Test + @DisplayName("상세 경로가 /latest 를 가로채지 않는다") + void detailDoesNotShadowLatest() throws Exception { + given(simulationService.findLatest(USER_ID)) + .willReturn(Optional.of(response(UUID.randomUUID()))); + + mockMvc.perform(get("/api/v1/simulations/latest")) + .andExpect(status().isOk()); + } + @ParameterizedTest @ValueSource(ints = {99_999, 5_000_001}) @DisplayName("총 예산이 10만~500만 범위를 벗어나면 400 C-001 과 필드 에러를 반환한다") @@ -271,6 +374,12 @@ private static SimulationRequest request(int totalBudgetWon, CampaignPeriod peri List.of(new AllocationRequest(CHANNEL_ID, 3_000_000, new BigDecimal("100")))); } + private static SimulationSummaryResponse summary(UUID simulationId) { + return new SimulationSummaryResponse(simulationId, + LocalDateTime.of(2026, 3, 14, 10, 22, 31), 3_000_000L, CampaignPeriod.M1, + 1_000_000L, 25_000L, 2, 1, List.of("11번가 광고", "당근마켓 광고")); + } + private static SimulationResponse response(UUID simulationId) { SimulationItemResponse item = new SimulationItemResponse( CHANNEL_ID, "11번가 광고", PRODUCT_ID, 3_000_000L, new BigDecimal("100"),