findByServerNameAndDateSend(String serverName, Instant dateSend);
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/domain/service/HornBugleDuplicateChecker.java b/src/main/java/until/the/eternity/hornBugle/domain/service/HornBugleDuplicateChecker.java
new file mode 100644
index 0000000..267b975
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/domain/service/HornBugleDuplicateChecker.java
@@ -0,0 +1,115 @@
+package until.the.eternity.hornBugle.domain.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory;
+import until.the.eternity.hornBugle.domain.enums.HornBugleServer;
+import until.the.eternity.hornBugle.domain.repository.HornBugleRepositoryPort;
+import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryResponse;
+
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HornBugleDuplicateChecker {
+
+ private final HornBugleRepositoryPort repository;
+
+ /**
+ * API ์๋ต ๋ฐ์ดํฐ์์ ์ค๋ณต์ ์ ๊ฑฐํ๊ณ ์ ๊ท ๋ฐ์ดํฐ๋ง ๋ฐํํ๋ค.
+ *
+ * ์ค๋ณต ์ ๊ฑฐ ๋ก์ง: 1. DB์์ ํด๋น ์๋ฒ์ ๊ฐ์ฅ ์ต๊ทผ date_send๋ฅผ ์กฐํ 2. API ์๋ต ๋ฐ์ดํฐ ์ค DB์ ์ต์ date_send ์ด์ ์ธ ๋ฐ์ดํฐ๋ ๋ชจ๋
+ * ์ ๊ฑฐ 3. date_send๊ฐ DB์ ์ต์ date_send์ ๋์ผํ ๊ฒฝ์ฐ: server_name + character_name + message ๊ธฐ์ค์ผ๋ก ์ค๋ณต ๊ฒ์ฆ
+ * 4. DB ์ต์ date_send ์ดํ์ ๋ฐ์ดํฐ๋ ๋ชจ๋ ์ ๊ท ๋ฐ์ดํฐ๋ก ๊ฐ์ฃผ
+ *
+ * @param server ์๋ฒ ์ ๋ณด
+ * @param responses API ์๋ต ๋ฐ์ดํฐ (date_send desc ์ ๋ ฌ)
+ * @return ์ค๋ณต์ด ์ ๊ฑฐ๋ ์ ๊ท ๋ฐ์ดํฐ ๋ชฉ๋ก
+ */
+ public List filterDuplicates(
+ HornBugleServer server, List responses) {
+
+ if (responses == null || responses.isEmpty()) {
+ return List.of();
+ }
+
+ String serverName = server.getServerName();
+ Optional latestRecordOpt =
+ repository.findLatestByServerName(serverName);
+
+ if (latestRecordOpt.isEmpty()) {
+ log.debug(
+ "[HornBugle] [{}] No existing data found. All {} responses are new.",
+ serverName,
+ responses.size());
+ return responses;
+ }
+
+ Instant latestDateSend = latestRecordOpt.get().getDateSend();
+ log.debug("[HornBugle] [{}] Latest date_send in DB: {}", serverName, latestDateSend);
+
+ // ๋์ผํ date_send๋ฅผ ๊ฐ์ง ๊ธฐ์กด ๋ฐ์ดํฐ๋ค์ (character_name + message) ์กฐํฉ์ ์กฐํ
+ Set existingKeys = buildExistingKeysForDateSend(serverName, latestDateSend);
+
+ List filtered =
+ responses.stream()
+ .filter(
+ response -> {
+ Instant responseDateSend = response.dateSend();
+
+ // ์ต์ date_send๋ณด๋ค ์ด์ ์ธ ๋ฐ์ดํฐ๋ ์ ๊ฑฐ
+ if (responseDateSend.isBefore(latestDateSend)) {
+ return false;
+ }
+
+ // date_send๊ฐ ๋์ผํ ๊ฒฝ์ฐ: ์ค๋ณต ํค ์ฒดํฌ
+ if (responseDateSend.equals(latestDateSend)) {
+ String key =
+ buildDuplicateKey(
+ response.characterName(),
+ response.message());
+ return !existingKeys.contains(key);
+ }
+
+ // ์ต์ date_send๋ณด๋ค ์ดํ์ธ ๋ฐ์ดํฐ๋ ์ ๊ท
+ return true;
+ })
+ .toList();
+
+ log.info(
+ "[HornBugle] [{}] Filtered {} duplicates. {} new records to save.",
+ serverName,
+ responses.size() - filtered.size(),
+ filtered.size());
+
+ return filtered;
+ }
+
+ private Set buildExistingKeysForDateSend(String serverName, Instant dateSend) {
+ List existingRecords =
+ repository.findByServerNameAndDateSend(serverName, dateSend);
+
+ Set keys = new HashSet<>();
+ for (HornBugleWorldHistory record : existingRecords) {
+ keys.add(buildDuplicateKey(record.getCharacterName(), record.getMessage()));
+ }
+
+ log.debug(
+ "[HornBugle] [{}] Found {} existing records with date_send={}",
+ serverName,
+ keys.size(),
+ dateSend);
+
+ return keys;
+ }
+
+ private String buildDuplicateKey(String characterName, String message) {
+ return characterName + "|" + message;
+ }
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/client/HornBugleClient.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/client/HornBugleClient.java
new file mode 100644
index 0000000..579793b
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/client/HornBugleClient.java
@@ -0,0 +1,58 @@
+package until.the.eternity.hornBugle.infrastructure.client;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+import until.the.eternity.hornBugle.domain.enums.HornBugleServer;
+import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryListResponse;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HornBugleClient {
+
+ private final WebClient openApiWebClient;
+
+ /**
+ * ์๋ฒ๋ณ ๋ฟํผ๋ฆฌ ํ์คํ ๋ฆฌ ์กฐํ.
+ *
+ * @param server ์กฐํํ ์๋ฒ
+ * @return ์๋ต DTO๋ฅผ ๋ด์ Mono, ํธ์ถ ์คํจ ์ Mono.empty()
+ */
+ public Mono fetchHornBugleHistory(HornBugleServer server) {
+ log.info(
+ "[HornBugle] Calling Nexon Open API Horn Bugle History API for server='{}'",
+ server.getServerName());
+
+ return openApiWebClient
+ .get()
+ .uri(
+ uriBuilder ->
+ uriBuilder
+ .path("/horn-bugle-world/history")
+ .queryParam("server_name", server.getServerName())
+ .build())
+ .retrieve()
+ .bodyToMono(OpenApiHornBugleHistoryListResponse.class)
+ .doOnNext(
+ response ->
+ log.debug(
+ "[HornBugle] [{}] API response received: {} records",
+ server.getServerName(),
+ response.hornBugleWorldHistory() != null
+ ? response.hornBugleWorldHistory().size()
+ : 0))
+ .onErrorResume(
+ throwable -> {
+ log.error(
+ "[HornBugle] Failed to fetch Nexon Open API Horn Bugle History API for server='{}': error='{}', message='{}'",
+ server.getServerName(),
+ throwable.getClass().getSimpleName(),
+ throwable.getMessage(),
+ throwable);
+ return Mono.empty();
+ });
+ }
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java
new file mode 100644
index 0000000..2fd3bb5
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java
@@ -0,0 +1,29 @@
+package until.the.eternity.hornBugle.infrastructure.persistence;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface HornBugleJpaRepository extends JpaRepository {
+
+ @Query(
+ """
+ SELECT h FROM HornBugleWorldHistory h
+ WHERE h.serverName = :serverName
+ ORDER BY h.dateSend DESC
+ LIMIT 1
+ """)
+ Optional findLatestByServerName(String serverName);
+
+ Page findByServerName(String serverName, Pageable pageable);
+
+ List findByServerNameAndDateSend(String serverName, Instant dateSend);
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java
new file mode 100644
index 0000000..469b376
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java
@@ -0,0 +1,63 @@
+package until.the.eternity.hornBugle.infrastructure.persistence;
+
+import jakarta.persistence.EntityManager;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory;
+import until.the.eternity.hornBugle.domain.repository.HornBugleRepositoryPort;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class HornBugleRepositoryPortImpl implements HornBugleRepositoryPort {
+
+ private final HornBugleJpaRepository jpaRepository;
+ private final EntityManager em;
+
+ @Value("${spring.jpa.properties.hibernate.jdbc.batch_size:500}")
+ private int batchSize;
+
+ @Override
+ @Transactional
+ public void saveAll(List entities) {
+ if (entities.isEmpty()) {
+ return;
+ }
+
+ for (int i = 0; i < entities.size(); i += batchSize) {
+ int toIndex = Math.min(i + batchSize, entities.size());
+ List subList = entities.subList(i, toIndex);
+ jpaRepository.saveAll(subList);
+ em.flush();
+ em.clear();
+ }
+ }
+
+ @Override
+ public Optional findLatestByServerName(String serverName) {
+ return jpaRepository.findLatestByServerName(serverName);
+ }
+
+ @Override
+ public Page findByServerName(String serverName, Pageable pageable) {
+ return jpaRepository.findByServerName(serverName, pageable);
+ }
+
+ @Override
+ public Page findAll(Pageable pageable) {
+ return jpaRepository.findAll(pageable);
+ }
+
+ @Override
+ public List findByServerNameAndDateSend(
+ String serverName, Instant dateSend) {
+ return jpaRepository.findByServerNameAndDateSend(serverName, dateSend);
+ }
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/interfaces/external/dto/OpenApiHornBugleHistoryListResponse.java b/src/main/java/until/the/eternity/hornBugle/interfaces/external/dto/OpenApiHornBugleHistoryListResponse.java
new file mode 100644
index 0000000..adea275
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/interfaces/external/dto/OpenApiHornBugleHistoryListResponse.java
@@ -0,0 +1,9 @@
+package until.the.eternity.hornBugle.interfaces.external.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+public record OpenApiHornBugleHistoryListResponse(
+ @JsonProperty("horn_bugle_world_history")
+ List hornBugleWorldHistory) {}
diff --git a/src/main/java/until/the/eternity/hornBugle/interfaces/external/dto/OpenApiHornBugleHistoryResponse.java b/src/main/java/until/the/eternity/hornBugle/interfaces/external/dto/OpenApiHornBugleHistoryResponse.java
new file mode 100644
index 0000000..78f6777
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/interfaces/external/dto/OpenApiHornBugleHistoryResponse.java
@@ -0,0 +1,16 @@
+package until.the.eternity.hornBugle.interfaces.external.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.time.Instant;
+
+public record OpenApiHornBugleHistoryResponse(
+ @JsonProperty("character_name") String characterName,
+ @JsonProperty("message") String message,
+ @JsonProperty("date_send")
+ @JsonFormat(
+ shape = JsonFormat.Shape.STRING,
+ pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
+ timezone = "UTC")
+ Instant dateSend) {}
diff --git a/src/main/java/until/the/eternity/hornBugle/interfaces/rest/controller/HornBugleController.java b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/controller/HornBugleController.java
new file mode 100644
index 0000000..60d7efd
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/controller/HornBugleController.java
@@ -0,0 +1,52 @@
+package until.the.eternity.hornBugle.interfaces.rest.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import until.the.eternity.common.response.PageResponseDto;
+import until.the.eternity.hornBugle.application.scheduler.HornBugleScheduler;
+import until.the.eternity.hornBugle.application.service.HornBugleService;
+import until.the.eternity.hornBugle.interfaces.rest.dto.request.HornBuglePageRequestDto;
+import until.the.eternity.hornBugle.interfaces.rest.dto.response.HornBugleHistoryResponse;
+
+@Slf4j
+@RequestMapping("/horn-bugle")
+@RestController
+@RequiredArgsConstructor
+@Tag(name = "๋ฟํผ๋ฆฌ ํ์คํ ๋ฆฌ API", description = "๊ฑฐ๋ํ ์ธ์นจ์ ๋ฟํผ๋ฆฌ ๋ด์ญ API")
+public class HornBugleController {
+
+ private final HornBugleService service;
+ private final HornBugleScheduler scheduler;
+
+ @GetMapping
+ @Operation(summary = "๋ฟํผ๋ฆฌ ํ์คํ ๋ฆฌ ์กฐํ", description = "๊ฑฐ๋ํ ์ธ์นจ์ ๋ฟํผ๋ฆฌ ๋ด์ญ์ ์กฐํํฉ๋๋ค. ์๋ฒ๋ณ ๋๋ ์ ์ฒด ์กฐํ๊ฐ ๊ฐ๋ฅํฉ๋๋ค.")
+ public ResponseEntity> search(
+ @Parameter(description = "์๋ฒ ์ด๋ฆ (๋ฅํธ, ๋ง๋๋ฆฐ, ํํ, ์ธํ). ๋ฏธ์
๋ ฅ์ ์ ์ฒด ์กฐํ")
+ @RequestParam(required = false)
+ String serverName,
+ @ParameterObject @ModelAttribute @Valid HornBuglePageRequestDto pageRequest) {
+ PageResponseDto result = service.search(serverName, pageRequest);
+ return ResponseEntity.ok(result);
+ }
+
+ @PostMapping("/batch")
+ @Operation(summary = "๋ฟํผ๋ฆฌ ํ์คํ ๋ฆฌ ๋ฐฐ์น ์คํ", description = "๋ชจ๋ ์๋ฒ์ ๊ฑฐ๋ํ ์ธ์นจ์ ๋ฟํผ๋ฆฌ ๋ด์ญ์ ์์งํ์ฌ ์ ์ฅํฉ๋๋ค.")
+ public ResponseEntity triggerBatch() {
+ log.info("[HornBugle] Batch API triggered");
+ try {
+ scheduler.fetchAndSaveHornBugleHistoryAll();
+ log.info("[HornBugle] Batch API completed successfully");
+ } catch (Exception e) {
+ log.error("[HornBugle] Batch API failed: {}", e.getMessage(), e);
+ throw e;
+ }
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/request/HornBuglePageRequestDto.java b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/request/HornBuglePageRequestDto.java
new file mode 100644
index 0000000..06adcf3
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/request/HornBuglePageRequestDto.java
@@ -0,0 +1,35 @@
+package until.the.eternity.hornBugle.interfaces.rest.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+
+@Schema(description = "๋ฟํผ๋ฆฌ ํ์คํ ๋ฆฌ ํ์ด์ง ์์ฒญ ํ๋ผ๋ฏธํฐ")
+public record HornBuglePageRequestDto(
+ @Schema(description = "์์ฒญํ ํ์ด์ง ๋ฒํธ (1๋ถํฐ ์์)", example = "1") @Min(1) Integer page,
+ @Schema(description = "ํ์ด์ง๋น ํญ๋ชฉ ์ (์ต์ 1, ์ต๋ 50)", example = "20") @Min(1) @Max(50)
+ Integer size) {
+
+ private static final int DEFAULT_PAGE = 1;
+ private static final int DEFAULT_SIZE = 20;
+ private static final String SORT_BY_DATE_SEND = "dateSend";
+
+ public Pageable toPageable() {
+ int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE - 1;
+ int resolvedSize = this.size != null ? this.size : DEFAULT_SIZE;
+
+ return PageRequest.of(
+ resolvedPage, resolvedSize, Sort.by(Sort.Direction.DESC, SORT_BY_DATE_SEND));
+ }
+
+ public int getResolvedPage() {
+ return this.page != null ? this.page : DEFAULT_PAGE;
+ }
+
+ public int getResolvedSize() {
+ return this.size != null ? this.size : DEFAULT_SIZE;
+ }
+}
diff --git a/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/response/HornBugleHistoryResponse.java b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/response/HornBugleHistoryResponse.java
new file mode 100644
index 0000000..7660ff1
--- /dev/null
+++ b/src/main/java/until/the/eternity/hornBugle/interfaces/rest/dto/response/HornBugleHistoryResponse.java
@@ -0,0 +1,19 @@
+package until.the.eternity.hornBugle.interfaces.rest.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.Instant;
+
+@Schema(description = "๋ฟํผ๋ฆฌ ํ์คํ ๋ฆฌ ์๋ต")
+public record HornBugleHistoryResponse(
+ @Schema(description = "๊ณ ์ ์๋ณ์", example = "1") Long id,
+ @Schema(description = "์๋ฒ ์ด๋ฆ", example = "๋ฅํธ") String serverName,
+ @Schema(description = "์บ๋ฆญํฐ ์ด๋ฆ", example = "ํ๊ธธ๋") String characterName,
+ @Schema(description = "๋ฉ์์ง ๋ด์ฉ", example = "์๋
ํ์ธ์") String message,
+ @Schema(description = "๋ฐํ ์๊ฐ (UTC)", example = "2026-01-21T11:25:43.000Z")
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
+ Instant dateSend,
+ @Schema(description = "์์ง ์๊ฐ (UTC)", example = "2026-01-21T11:30:00.000Z")
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
+ Instant dateRegister) {}
diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemDailyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemDailyStatisticsController.java
index d6f5f8d..a8fcbdb 100644
--- a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemDailyStatisticsController.java
+++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemDailyStatisticsController.java
@@ -27,10 +27,7 @@ public class ItemDailyStatisticsController {
summary = "์์ดํ
๋ณ ์ผ๊ฐ ํต๊ณ ์กฐํ",
description = "์์ดํ
์ด๋ฆ, ์๋ธ ์นดํ
๊ณ ๋ฆฌ, ํ ์นดํ
๊ณ ๋ฆฌ๋ก ์ผ๊ฐ ํต๊ณ๋ฅผ ์กฐํํฉ๋๋ค. ์ต๋ 30์ผ๊น์ง ์กฐํ ๊ฐ๋ฅํฉ๋๋ค.")
public ResponseEntity> searchItemDailyStatistics(
- @ParameterObject @ModelAttribute
- @Valid
- ItemDailyStatisticsSearchRequest
- request) {
+ @ParameterObject @ModelAttribute @Valid ItemDailyStatisticsSearchRequest request) {
java.util.List results =
service.search(
request.itemName(),
diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemWeeklyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemWeeklyStatisticsController.java
index a85cfa5..9e71788 100644
--- a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemWeeklyStatisticsController.java
+++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/ItemWeeklyStatisticsController.java
@@ -25,9 +25,9 @@ public class ItemWeeklyStatisticsController {
summary = "์์ดํ
๋ณ ์ฃผ๊ฐ ํต๊ณ ์กฐํ",
description = "์์ดํ
์ด๋ฆ, ์๋ธ ์นดํ
๊ณ ๋ฆฌ, ํ ์นดํ
๊ณ ๋ฆฌ๋ก ์ฃผ๊ฐ ํต๊ณ๋ฅผ ์กฐํํฉ๋๋ค. ์ต๋ 4๊ฐ์๊น์ง ์กฐํ ๊ฐ๋ฅํฉ๋๋ค.")
public ResponseEntity> searchItemWeeklyStatistics(
- @ParameterObject @ModelAttribute
- @jakarta.validation.Valid
- until.the.eternity.statistics.interfaces.rest.dto.request.ItemWeeklyStatisticsSearchRequest
+ @ParameterObject @ModelAttribute @jakarta.validation.Valid
+ until.the.eternity.statistics.interfaces.rest.dto.request
+ .ItemWeeklyStatisticsSearchRequest
request) {
java.util.List results =
service.search(
diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryDailyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryDailyStatisticsController.java
index 668ff6e..0db8030 100644
--- a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryDailyStatisticsController.java
+++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryDailyStatisticsController.java
@@ -26,9 +26,9 @@ public class SubcategoryDailyStatisticsController {
description = "ํ ์นดํ
๊ณ ๋ฆฌ์ ์๋ธ ์นดํ
๊ณ ๋ฆฌ๋ก ์ผ๊ฐ ํต๊ณ๋ฅผ ์กฐํํฉ๋๋ค. ์ต๋ 30์ผ๊น์ง ์กฐํ ๊ฐ๋ฅํฉ๋๋ค.")
public ResponseEntity>
searchSubcategoryDailyStatistics(
- @ParameterObject @ModelAttribute
- @jakarta.validation.Valid
- until.the.eternity.statistics.interfaces.rest.dto.request.SubcategoryDailyStatisticsSearchRequest
+ @ParameterObject @ModelAttribute @jakarta.validation.Valid
+ until.the.eternity.statistics.interfaces.rest.dto.request
+ .SubcategoryDailyStatisticsSearchRequest
request) {
java.util.List results =
service.search(
diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryWeeklyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryWeeklyStatisticsController.java
index 63db0bf..28ba5a6 100644
--- a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryWeeklyStatisticsController.java
+++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/SubcategoryWeeklyStatisticsController.java
@@ -26,9 +26,9 @@ public class SubcategoryWeeklyStatisticsController {
description = "ํ ์นดํ
๊ณ ๋ฆฌ์ ์๋ธ ์นดํ
๊ณ ๋ฆฌ๋ก ์ฃผ๊ฐ ํต๊ณ๋ฅผ ์กฐํํฉ๋๋ค. ์ต๋ 4๊ฐ์๊น์ง ์กฐํ ๊ฐ๋ฅํฉ๋๋ค.")
public ResponseEntity>
searchSubcategoryWeeklyStatistics(
- @ParameterObject @ModelAttribute
- @jakarta.validation.Valid
- until.the.eternity.statistics.interfaces.rest.dto.request.SubcategoryWeeklyStatisticsSearchRequest
+ @ParameterObject @ModelAttribute @jakarta.validation.Valid
+ until.the.eternity.statistics.interfaces.rest.dto.request
+ .SubcategoryWeeklyStatisticsSearchRequest
request) {
java.util.List results =
service.search(
diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryDailyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryDailyStatisticsController.java
index eeb3faf..b224656 100644
--- a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryDailyStatisticsController.java
+++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryDailyStatisticsController.java
@@ -26,12 +26,15 @@ public class TopCategoryDailyStatisticsController {
description = "ํ ์นดํ
๊ณ ๋ฆฌ๋ก ์ผ๊ฐ ํต๊ณ๋ฅผ ์กฐํํฉ๋๋ค. ์ต๋ 30์ผ๊น์ง ์กฐํ ๊ฐ๋ฅํฉ๋๋ค.")
public ResponseEntity>
searchTopCategoryDailyStatistics(
- @ParameterObject @ModelAttribute
- @jakarta.validation.Valid
- until.the.eternity.statistics.interfaces.rest.dto.request.TopCategoryDailyStatisticsSearchRequest
+ @ParameterObject @ModelAttribute @jakarta.validation.Valid
+ until.the.eternity.statistics.interfaces.rest.dto.request
+ .TopCategoryDailyStatisticsSearchRequest
request) {
java.util.List results =
- service.search(request.topCategory(), request.getStartDateWithDefault(), request.getEndDateWithDefault());
+ service.search(
+ request.topCategory(),
+ request.getStartDateWithDefault(),
+ request.getEndDateWithDefault());
return ResponseEntity.ok(results);
}
}
diff --git a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryWeeklyStatisticsController.java b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryWeeklyStatisticsController.java
index 732f150..b35b797 100644
--- a/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryWeeklyStatisticsController.java
+++ b/src/main/java/until/the/eternity/statistics/interfaces/rest/controller/TopCategoryWeeklyStatisticsController.java
@@ -26,12 +26,15 @@ public class TopCategoryWeeklyStatisticsController {
description = "ํ ์นดํ
๊ณ ๋ฆฌ๋ก ์ฃผ๊ฐ ํต๊ณ๋ฅผ ์กฐํํฉ๋๋ค. ์ต๋ 4๊ฐ์๊น์ง ์กฐํ ๊ฐ๋ฅํฉ๋๋ค.")
public ResponseEntity>
searchTopCategoryWeeklyStatistics(
- @ParameterObject @ModelAttribute
- @jakarta.validation.Valid
- until.the.eternity.statistics.interfaces.rest.dto.request.TopCategoryWeeklyStatisticsSearchRequest
+ @ParameterObject @ModelAttribute @jakarta.validation.Valid
+ until.the.eternity.statistics.interfaces.rest.dto.request
+ .TopCategoryWeeklyStatisticsSearchRequest
request) {
java.util.List results =
- service.search(request.topCategory(), request.getStartDateWithDefault(), request.getEndDateWithDefault());
+ service.search(
+ request.topCategory(),
+ request.getStartDateWithDefault(),
+ request.getEndDateWithDefault());
return ResponseEntity.ok(results);
}
}
diff --git a/src/main/java/until/the/eternity/statistics/repository/daily/ItemDailyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/daily/ItemDailyStatisticsRepository.java
index ee22aac..253bcac 100644
--- a/src/main/java/until/the/eternity/statistics/repository/daily/ItemDailyStatisticsRepository.java
+++ b/src/main/java/until/the/eternity/statistics/repository/daily/ItemDailyStatisticsRepository.java
@@ -35,8 +35,8 @@ List findByItemAndDateRange(
@Param("endDate") LocalDate endDate);
/**
- * ๋น์ผ ๊ฑฐ๋๋ ๊ฐ ์์ดํ
์ ํต๊ณ๋ฅผ item_daily_statistics ํ
์ด๋ธ์ upsert
- * AuctionHistoryScheduler๊ฐ ์คํ๋ ๋๋ง๋ค ๋น์ผ ํต๊ณ๋ง ์
๋ฐ์ดํธ
+ * ๋น์ผ ๊ฑฐ๋๋ ๊ฐ ์์ดํ
์ ํต๊ณ๋ฅผ item_daily_statistics ํ
์ด๋ธ์ upsert AuctionHistoryScheduler๊ฐ ์คํ๋ ๋๋ง๋ค ๋น์ผ ํต๊ณ๋ง
+ * ์
๋ฐ์ดํธ
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@@ -83,8 +83,7 @@ GROUP BY ah.item_name, ah.item_top_category, ah.item_sub_category, DATE(ah.date_
void upsertCurrentDayStatistics();
/**
- * ์ ๋ ๊ฑฐ๋๋ ๊ฐ ์์ดํ
์ ํต๊ณ๋ฅผ item_daily_statistics ํ
์ด๋ธ์ ์ต์ข
ํ์
- * ๋งค์ผ ์๋ฒฝ์ ํ ๋ฒ ์คํ๋์ด ์ ๋ 23์๋ ๊ฑฐ๋ ๋ด์ญ๊น์ง ํฌํจํ ํต๊ณ๋ฅผ ์์ฑ
+ * ์ ๋ ๊ฑฐ๋๋ ๊ฐ ์์ดํ
์ ํต๊ณ๋ฅผ item_daily_statistics ํ
์ด๋ธ์ ์ต์ข
ํ์ ๋งค์ผ ์๋ฒฝ์ ํ ๋ฒ ์คํ๋์ด ์ ๋ 23์๋ ๊ฑฐ๋ ๋ด์ญ๊น์ง ํฌํจํ ํต๊ณ๋ฅผ ์์ฑ
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
diff --git a/src/main/java/until/the/eternity/statistics/repository/daily/SubcategoryDailyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/daily/SubcategoryDailyStatisticsRepository.java
index ed0ff44..e8f72fc 100644
--- a/src/main/java/until/the/eternity/statistics/repository/daily/SubcategoryDailyStatisticsRepository.java
+++ b/src/main/java/until/the/eternity/statistics/repository/daily/SubcategoryDailyStatisticsRepository.java
@@ -31,8 +31,8 @@ List findBySubcategoryAndDateRange(
@Param("endDate") LocalDate endDate);
/**
- * ๋น์ผ์ ItemDailyStatistics ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ธ์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ๋ฅผ ์ง๊ณํ์ฌ upsert
- * item_daily_statistics ํ
์ด๋ธ๋ง ์ฌ์ฉํ์ฌ ํจ์จ์ ์ผ๋ก ์ง๊ณ
+ * ๋น์ผ์ ItemDailyStatistics ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ธ์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ๋ฅผ ์ง๊ณํ์ฌ upsert item_daily_statistics ํ
์ด๋ธ๋ง ์ฌ์ฉํ์ฌ
+ * ํจ์จ์ ์ผ๋ก ์ง๊ณ
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@@ -75,8 +75,7 @@ INSERT INTO subcategory_daily_statistics (
void upsertCurrentDayStatistics();
/**
- * ์ ๋ ์ ItemDailyStatistics ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ธ์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ๋ฅผ ์ต์ข
ํ์
- * item_daily_statistics ํ
์ด๋ธ๋ง ์ฌ์ฉํ์ฌ ํจ์จ์ ์ผ๋ก ์ง๊ณ
+ * ์ ๋ ์ ItemDailyStatistics ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ธ์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ๋ฅผ ์ต์ข
ํ์ item_daily_statistics ํ
์ด๋ธ๋ง ์ฌ์ฉํ์ฌ ํจ์จ์ ์ผ๋ก ์ง๊ณ
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
diff --git a/src/main/java/until/the/eternity/statistics/repository/daily/TopCategoryDailyStatisticsRepository.java b/src/main/java/until/the/eternity/statistics/repository/daily/TopCategoryDailyStatisticsRepository.java
index a4bf3a0..0fff60a 100644
--- a/src/main/java/until/the/eternity/statistics/repository/daily/TopCategoryDailyStatisticsRepository.java
+++ b/src/main/java/until/the/eternity/statistics/repository/daily/TopCategoryDailyStatisticsRepository.java
@@ -31,8 +31,8 @@ List findByTopCategoryAndDateRange(
@Param("endDate") LocalDate endDate);
/**
- * ๋น์ผ์ SubcategoryDailyStatistics ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ๋ฅผ ์ง๊ณํ์ฌ upsert
- * item_daily_statistics ํ
์ด๋ธ์ ์ฌ์ฉํ์ฌ top_category ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ด
+ * ๋น์ผ์ SubcategoryDailyStatistics ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ๋ฅผ ์ง๊ณํ์ฌ upsert item_daily_statistics ํ
์ด๋ธ์
+ * ์ฌ์ฉํ์ฌ top_category ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ด
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@@ -77,8 +77,8 @@ INSERT INTO top_category_daily_statistics (
void upsertCurrentDayStatistics();
/**
- * ์ ๋ ์ SubcategoryDailyStatistics ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ๋ฅผ ์ต์ข
ํ์
- * item_daily_statistics ํ
์ด๋ธ์ ์ฌ์ฉํ์ฌ top_category ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ด
+ * ์ ๋ ์ SubcategoryDailyStatistics ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ํ์นดํ
๊ณ ๋ฆฌ๋ณ ํต๊ณ๋ฅผ ์ต์ข
ํ์ item_daily_statistics ํ
์ด๋ธ์ ์ฌ์ฉํ์ฌ
+ * top_category ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ด
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
diff --git a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsScheduler.java b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsScheduler.java
index 4b2f5a0..ceeed96 100644
--- a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsScheduler.java
+++ b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsScheduler.java
@@ -13,10 +13,7 @@ public class DailyStatisticsScheduler {
private final DailyStatisticsService dailyStatisticsService;
- /**
- * AuctionHistory ์ ์ฅ ์๋ฃ ์ด๋ฒคํธ ์์ ์ ๋น์ผ ํต๊ณ ์
๋ฐ์ดํธ
- * AuctionHistoryScheduler๊ฐ ์คํ๋ ๋๋ง๋ค ์๋์ผ๋ก ํธ์ถ๋จ
- */
+ /** AuctionHistory ์ ์ฅ ์๋ฃ ์ด๋ฒคํธ ์์ ์ ๋น์ผ ํต๊ณ ์
๋ฐ์ดํธ AuctionHistoryScheduler๊ฐ ์คํ๋ ๋๋ง๋ค ์๋์ผ๋ก ํธ์ถ๋จ */
@EventListener
public void onAuctionHistorySaved(AuctionHistorySavedEvent event) {
log.info(
diff --git a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java
index 5ed3562..6f09dff 100644
--- a/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java
+++ b/src/main/java/until/the/eternity/statistics/service/DailyStatisticsService.java
@@ -18,9 +18,8 @@ public class DailyStatisticsService {
private final TopCategoryDailyStatisticsRepository topCategoryDailyStatisticsRepository;
/**
- * ๋น์ผ์ ๊ฒฝ๋งค ๊ฑฐ๋ ๋ด์ญ์ ๊ธฐ๋ฐ์ผ๋ก ์ผ๊ฐ ํต๊ณ๋ฅผ ์
๋ฐ์ดํธ
- * AuctionHistoryScheduler๊ฐ ์คํ๋ ๋๋ง๋ค ํธ์ถ๋์ด ๋น์ผ ํต๊ณ๋ง ๊ฐฑ์
- * ์์: auction_history โ ItemDaily โ SubcategoryDaily โ TopCategoryDaily
+ * ๋น์ผ์ ๊ฒฝ๋งค ๊ฑฐ๋ ๋ด์ญ์ ๊ธฐ๋ฐ์ผ๋ก ์ผ๊ฐ ํต๊ณ๋ฅผ ์
๋ฐ์ดํธ AuctionHistoryScheduler๊ฐ ์คํ๋ ๋๋ง๋ค ํธ์ถ๋์ด ๋น์ผ ํต๊ณ๋ง ๊ฐฑ์ ์์:
+ * auction_history โ ItemDaily โ SubcategoryDaily โ TopCategoryDaily
*/
@Transactional
public void calculateAndSaveCurrentDayStatistics() {
@@ -36,8 +35,7 @@ public void calculateAndSaveCurrentDayStatistics() {
System.currentTimeMillis() - start);
// 2. ItemDailyStatistics โ SubcategoryDailyStatistics (๋น์ผ)
- log.info(
- "[Current Day Statistics] Step 2/3: Calculating subcategory daily statistics...");
+ log.info("[Current Day Statistics] Step 2/3: Calculating subcategory daily statistics...");
long step2Start = System.currentTimeMillis();
subcategoryDailyStatisticsRepository.upsertCurrentDayStatistics();
log.info(
@@ -45,8 +43,7 @@ public void calculateAndSaveCurrentDayStatistics() {
System.currentTimeMillis() - step2Start);
// 3. SubcategoryDailyStatistics โ TopCategoryDailyStatistics (๋น์ผ)
- log.info(
- "[Current Day Statistics] Step 3/3: Calculating top category daily statistics...");
+ log.info("[Current Day Statistics] Step 3/3: Calculating top category daily statistics...");
long step3Start = System.currentTimeMillis();
topCategoryDailyStatisticsRepository.upsertCurrentDayStatistics();
log.info(
@@ -59,9 +56,8 @@ public void calculateAndSaveCurrentDayStatistics() {
}
/**
- * ์ ๋ ์ ๊ฒฝ๋งค ๊ฑฐ๋ ๋ด์ญ์ ๊ธฐ๋ฐ์ผ๋ก ์ผ๊ฐ ํต๊ณ๋ฅผ ์ต์ข
ํ์
- * ๋งค์ผ ์๋ฒฝ ํ ๋ฒ ์คํ๋์ด ์ ๋ 23์๋ ๊ฑฐ๋๊น์ง ํฌํจํ ํต๊ณ๋ฅผ ์์ฑ
- * ์์: auction_history โ ItemDaily โ SubcategoryDaily โ TopCategoryDaily
+ * ์ ๋ ์ ๊ฒฝ๋งค ๊ฑฐ๋ ๋ด์ญ์ ๊ธฐ๋ฐ์ผ๋ก ์ผ๊ฐ ํต๊ณ๋ฅผ ์ต์ข
ํ์ ๋งค์ผ ์๋ฒฝ ํ ๋ฒ ์คํ๋์ด ์ ๋ 23์๋ ๊ฑฐ๋๊น์ง ํฌํจํ ํต๊ณ๋ฅผ ์์ฑ ์์: auction_history โ
+ * ItemDaily โ SubcategoryDaily โ TopCategoryDaily
*/
@Transactional
public void calculateAndSavePreviousDayStatistics() {
@@ -77,8 +73,7 @@ public void calculateAndSavePreviousDayStatistics() {
System.currentTimeMillis() - start);
// 2. ItemDailyStatistics โ SubcategoryDailyStatistics (์ ๋ )
- log.info(
- "[Previous Day Statistics] Step 2/3: Finalizing subcategory daily statistics...");
+ log.info("[Previous Day Statistics] Step 2/3: Finalizing subcategory daily statistics...");
long step2Start = System.currentTimeMillis();
subcategoryDailyStatisticsRepository.upsertPreviousDayStatistics();
log.info(
@@ -86,8 +81,7 @@ public void calculateAndSavePreviousDayStatistics() {
System.currentTimeMillis() - step2Start);
// 3. SubcategoryDailyStatistics โ TopCategoryDailyStatistics (์ ๋ )
- log.info(
- "[Previous Day Statistics] Step 3/3: Finalizing top category daily statistics...");
+ log.info("[Previous Day Statistics] Step 3/3: Finalizing top category daily statistics...");
long step3Start = System.currentTimeMillis();
topCategoryDailyStatisticsRepository.upsertPreviousDayStatistics();
log.info(
diff --git a/src/main/java/until/the/eternity/statistics/service/PreviousDayStatisticsScheduler.java b/src/main/java/until/the/eternity/statistics/service/PreviousDayStatisticsScheduler.java
index 81b5f03..21ca850 100644
--- a/src/main/java/until/the/eternity/statistics/service/PreviousDayStatisticsScheduler.java
+++ b/src/main/java/until/the/eternity/statistics/service/PreviousDayStatisticsScheduler.java
@@ -13,9 +13,8 @@ public class PreviousDayStatisticsScheduler {
private final DailyStatisticsService dailyStatisticsService;
/**
- * ๋งค์ผ ์๋ฒฝ ์ ๋ ํต๊ณ ์ต์ข
ํ์
- * ๊ธฐ๋ณธ cron: ๋งค์ผ ์๋ฒฝ 0์ 10๋ถ (AuctionHistoryScheduler 0์ 5๋ถ ์คํ ์ดํ)
- * ์ ๋ 23์๋ ๊ฑฐ๋ ๋ด์ญ๊น์ง ๋ชจ๋ ํฌํจ๋ ์ต์ข
ํต๊ณ๋ฅผ ์ ์ฅ
+ * ๋งค์ผ ์๋ฒฝ ์ ๋ ํต๊ณ ์ต์ข
ํ์ ๊ธฐ๋ณธ cron: ๋งค์ผ ์๋ฒฝ 0์ 10๋ถ (AuctionHistoryScheduler 0์ 5๋ถ ์คํ ์ดํ) ์ ๋ 23์๋ ๊ฑฐ๋ ๋ด์ญ๊น์ง
+ * ๋ชจ๋ ํฌํจ๋ ์ต์ข
ํต๊ณ๋ฅผ ์ ์ฅ
*/
@Scheduled(cron = "${statistics.previous-day.cron:0 10 0 * * *}", zone = "Asia/Seoul")
public void schedulePreviousDayStatistics() {
diff --git a/src/main/java/until/the/eternity/statistics/util/DateRangeValidator.java b/src/main/java/until/the/eternity/statistics/util/DateRangeValidator.java
index 4aa75f7..540480c 100644
--- a/src/main/java/until/the/eternity/statistics/util/DateRangeValidator.java
+++ b/src/main/java/until/the/eternity/statistics/util/DateRangeValidator.java
@@ -22,8 +22,7 @@ public static void validateDailyDateRange(LocalDate startDate, LocalDate endDate
if (daysBetween > DAILY_MAX_DAYS) {
throw new IllegalArgumentException(
String.format(
- "์ผ๊ฐ ํต๊ณ ์กฐํ๋ ์ต๋ %d์ผ๊น์ง๋ง ๊ฐ๋ฅํฉ๋๋ค. ์์ฒญ ๊ธฐ๊ฐ: %d์ผ",
- DAILY_MAX_DAYS, daysBetween));
+ "์ผ๊ฐ ํต๊ณ ์กฐํ๋ ์ต๋ %d์ผ๊น์ง๋ง ๊ฐ๋ฅํฉ๋๋ค. ์์ฒญ ๊ธฐ๊ฐ: %d์ผ", DAILY_MAX_DAYS, daysBetween));
}
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 976d59a..5892cbf 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -89,6 +89,10 @@ openapi:
cron: ${AUCTION_HISTORY_CRON}
min-price:
cron: ${AUCTION_HISTORY_MIN_PRICE_CRON}
+ horn-bugle:
+ cron: ${HORN_BUGLE_CRON:0 */5 * * * *}
+ max-retries: ${HORN_BUGLE_MAX_RETRIES:3}
+ retry-delay-ms: ${HORN_BUGLE_RETRY_DELAY_MS:2000}
statistics:
previous-day:
diff --git a/src/main/resources/db/migration/V14__add_server_name_and_date_register_to_horn_bugle_world_history.sql b/src/main/resources/db/migration/V14__add_server_name_and_date_register_to_horn_bugle_world_history.sql
new file mode 100644
index 0000000..8b69e23
--- /dev/null
+++ b/src/main/resources/db/migration/V14__add_server_name_and_date_register_to_horn_bugle_world_history.sql
@@ -0,0 +1,15 @@
+-- horn_bugle_world_history ํ
์ด๋ธ์ server_name, date_register ์ปฌ๋ผ ์ถ๊ฐ ๋ฐ ์ธ๋ฑ์ค ์์ฑ
+
+-- 1. server_name ์ปฌ๋ผ ์ถ๊ฐ (์๋ฒ ๊ตฌ๋ถ์ฉ)
+ALTER TABLE horn_bugle_world_history
+ ADD COLUMN server_name VARCHAR(20) NOT NULL DEFAULT '' COMMENT '์๋ฒ ์ด๋ฆ (๋ฅํธ, ๋ง๋๋ฆฐ, ํํ, ์ธํ)';
+
+-- 2. date_register ์ปฌ๋ผ ์ถ๊ฐ (์์ง ์๊ฐ)
+ALTER TABLE horn_bugle_world_history
+ ADD COLUMN date_register DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'ํด๋น ๋ฟํผ๋ฆฌ ๋ด์ญ์ ์์งํ ์๊ฐ';
+
+-- 3. ๋ณตํฉ ์ธ๋ฑ์ค ์์ฑ (server_name, date_send desc) - ์๋ฒ๋ณ ์ต์ ์กฐํ ์ต์ ํ
+CREATE INDEX idx_horn_bugle_server_date_send ON horn_bugle_world_history (server_name, date_send DESC);
+
+-- 4. ๋จ์ผ ์ธ๋ฑ์ค ์์ฑ (date_send desc) - ์ ์ฒด ์ต์ ์กฐํ ์ต์ ํ
+CREATE INDEX idx_horn_bugle_date_send ON horn_bugle_world_history (date_send DESC);
diff --git a/src/main/resources/logback/logback-display.xml b/src/main/resources/logback/logback-display.xml
index a91f49a..bb9dbae 100644
--- a/src/main/resources/logback/logback-display.xml
+++ b/src/main/resources/logback/logback-display.xml
@@ -6,6 +6,9 @@
+
+
+
diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java
index 87dbdbc..d118725 100644
--- a/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java
+++ b/src/test/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcherTest.java
@@ -16,6 +16,7 @@
import java.time.Instant;
import java.util.List;
+import java.util.OptionalInt;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -29,20 +30,18 @@ class AuctionHistoryFetcherTest {
@Mock AuctionHistoryDuplicateChecker duplicateChecker;
- @InjectMocks AuctionHistoryFetcher fetcher; // ์ฃผ์
ํ ๋์
+ @InjectMocks AuctionHistoryFetcher fetcher;
- // ๋๋ฏธ ๋ฐ์ดํฐ ์์ฑ ๋ฉ์๋
private OpenApiAuctionHistoryResponse dummy(String id) {
return new OpenApiAuctionHistoryResponse(
- "ํ๋ฌ์์ฐ์ค ํ์ดํ ๋ธ๋ ์ด๋", // itemName
- "์ ์ฑํ ํ๋ฌ์์ฐ์ค ํ์ดํ ๋ธ๋ ์ด๋", // itemDisplayName
- ItemCategory.SWORD.getSubCategory(), // itemSubCategory
- 1L, // itemCount
- 100L, // auctionPricePerUnit
- Instant.now(), // dateAuctionBuy
- id, // auctionBuyId
- null // itemOption์ ํ
์คํธ ๊ฒฐ๊ณผ์ ์๊ด์ด ์์ผ๋ null ์ฒ๋ฆฌ
- );
+ "ํ๋ฌ์์ฐ์ค ํ์ดํ ๋ธ๋ ์ด๋",
+ "์ ์ฑํ ํ๋ฌ์์ฐ์ค ํ์ดํ ๋ธ๋ ์ด๋",
+ ItemCategory.SWORD.getSubCategory(),
+ 1L,
+ 100L,
+ Instant.now(),
+ id,
+ null);
}
@Nested
@@ -52,30 +51,29 @@ class NormalFlow {
@Test
@DisplayName("๋ชจ๋ ํ์ด์ง๋ฅผ ์์งํ๊ณ cursor๊ฐ null์ด๋ฉด ์ข
๋ฃํ๋ค")
void fetchAllPages() {
- // given โ ์ฒซ ๋ฒ์งธยท๋ ๋ฒ์งธ ํ์ด์ง
+ // given
var page1 =
new OpenApiAuctionHistoryListResponse(
List.of(dummy("1"), dummy("2")), "cursor-1");
- // 2๋ฒ์งธ ํ์ด์ง๊ฐ ๋์ด๋ผ Nexon Open API๊ฐ null์ ๋ฐํํ ๋
var page2 = new OpenApiAuctionHistoryListResponse(List.of(dummy("3")), null);
when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1));
when(client.fetchAuctionHistory(ItemCategory.SWORD, "cursor-1"))
.thenReturn(Mono.just(page2));
- // ๊ธฐ์กด ๋ฐ์ดํฐ์ ๋ง์ง๋ง ํ์ด์ง (2ํ์ด์ง) ๋ฐ์ดํฐ์ ์ค๋ณต์ด ์๋ค๊ณ ๊ฐ์
- when(duplicateChecker.hasDuplicate(any())).thenReturn(false);
+ when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD)))
+ .thenReturn(OptionalInt.empty());
// when
var result = fetcher.fetch(ItemCategory.SWORD);
- // then - ๋ชจ๋ ๋ฐ์ดํฐ๋ฅผ result์ ํฌํจ
+ // then
assertThat(result)
.hasSize(3)
.extracting(OpenApiAuctionHistoryResponse::auctionBuyId)
.containsExactly("1", "2", "3");
verify(client, times(2)).fetchAuctionHistory(eq(ItemCategory.SWORD), any());
- verify(duplicateChecker, times(2)).hasDuplicate(any());
+ verify(duplicateChecker, times(2)).checkDuplicateInBatch(any(), eq(ItemCategory.SWORD));
}
}
@@ -84,27 +82,77 @@ void fetchAllPages() {
class EarlyBreakFlow {
@Test
- @DisplayName("duplicateChecker๊ฐ true๋ฅผ ๋ฐํํ๋ฉด ์์ง์ ์ค๋จํ๋ค")
- void stopOnDuplicate() {
- // given - API ํธ์ถ์ 1๋ฒ๋ง ํ๊ณ ์ค๋ณต์ผ๋ก ์ธํด ์ค๋จ
+ @DisplayName("์ฒซ ๋ฐฐ์น ์ฒซ ํญ๋ชฉ์์ ์ค๋ณต์ด๋ฉด ๋น ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค")
+ void stopOnDuplicateAtFirstItem() {
+ // given
var page1 =
new OpenApiAuctionHistoryListResponse(
List.of(dummy("1"), dummy("2")), "cursor-1");
when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1));
- // when - ๊ธฐ์กด ๋ฐ์ดํฐ์ ์ฒซ ํ์ด์ง ๋ฐ์ดํฐ์ ์ค๋ณต์ด ์๋ค๊ณ ๊ฐ์
- when(duplicateChecker.hasDuplicate(page1.auctionHistory().getLast())).thenReturn(true);
+ when(duplicateChecker.checkDuplicateInBatch(page1.auctionHistory(), ItemCategory.SWORD))
+ .thenReturn(OptionalInt.of(0));
+ // when
var result = fetcher.fetch(ItemCategory.SWORD);
- // then - ์ฒซ ํ์ด์ง ๋ฐ์ดํฐ๋ง ์์งํ๊ณ ์ข
๋ฃ
- assertThat(result).hasSize(2);
- assertThat(result.getFirst().auctionBuyId()).isEqualTo("1");
+ // then
+ assertThat(result).isEmpty();
+ verify(client, times(1)).fetchAuctionHistory(ItemCategory.SWORD, "");
+ verifyNoMoreInteractions(client);
+ }
+ @Test
+ @DisplayName("์ฒซ ๋ฐฐ์น ์ค๊ฐ์์ ์ค๋ณต์ด๋ฉด ์ค๋ณต ์ ๊น์ง๋ง ๋ฐํํ๋ค")
+ void stopOnDuplicateAtMiddle() {
+ // given
+ var batch = List.of(dummy("1"), dummy("2"), dummy("3"));
+ var page1 = new OpenApiAuctionHistoryListResponse(batch, "cursor-1");
+
+ when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1));
+ when(duplicateChecker.checkDuplicateInBatch(batch, ItemCategory.SWORD))
+ .thenReturn(OptionalInt.of(2));
+
+ // when
+ var result = fetcher.fetch(ItemCategory.SWORD);
+
+ // then
+ assertThat(result)
+ .hasSize(2)
+ .extracting(OpenApiAuctionHistoryResponse::auctionBuyId)
+ .containsExactly("1", "2");
verify(client, times(1)).fetchAuctionHistory(ItemCategory.SWORD, "");
verifyNoMoreInteractions(client);
}
+ @Test
+ @DisplayName("๋ ๋ฒ์งธ ๋ฐฐ์น์์ ์ค๋ณต์ด๋ฉด ์ฒซ ๋ฐฐ์น ์ ์ฒด + ์ค๋ณต ์ ๊น์ง๋ง ๋ฐํํ๋ค")
+ void stopOnDuplicateAtSecondBatch() {
+ // given
+ var batch1 = List.of(dummy("1"), dummy("2"));
+ var batch2 = List.of(dummy("3"), dummy("4"), dummy("5"));
+ var page1 = new OpenApiAuctionHistoryListResponse(batch1, "cursor-1");
+ var page2 = new OpenApiAuctionHistoryListResponse(batch2, "cursor-2");
+
+ when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1));
+ when(client.fetchAuctionHistory(ItemCategory.SWORD, "cursor-1"))
+ .thenReturn(Mono.just(page2));
+ when(duplicateChecker.checkDuplicateInBatch(batch1, ItemCategory.SWORD))
+ .thenReturn(OptionalInt.empty());
+ when(duplicateChecker.checkDuplicateInBatch(batch2, ItemCategory.SWORD))
+ .thenReturn(OptionalInt.of(1));
+
+ // when
+ var result = fetcher.fetch(ItemCategory.SWORD);
+
+ // then
+ assertThat(result)
+ .hasSize(3)
+ .extracting(OpenApiAuctionHistoryResponse::auctionBuyId)
+ .containsExactly("1", "2", "3");
+ verify(client, times(2)).fetchAuctionHistory(eq(ItemCategory.SWORD), any());
+ }
+
@Test
@DisplayName("์ฒซ ์๋ต์ด null(Mono.empty)์ด๋ฉด ๋น ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค")
void responseNull() {
@@ -113,7 +161,7 @@ void responseNull() {
var result = fetcher.fetch(ItemCategory.SWORD);
assertThat(result).isEmpty();
- verify(duplicateChecker, never()).hasDuplicate(any());
+ verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any());
}
@Test
@@ -126,16 +174,17 @@ void auctionHistoryEmpty() {
var result = fetcher.fetch(ItemCategory.SWORD);
assertThat(result).isEmpty();
- verify(duplicateChecker, never()).hasDuplicate(any());
+ verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any());
}
@Test
@DisplayName("nextCursor๊ฐ ๋น ๋ฌธ์์ด์ด๋ฉด ์์ง์ ์ค๋จํ๋ค")
void stopWhenNextCursorIsEmptyString() {
// given
- var page1 = new OpenApiAuctionHistoryListResponse(List.of(dummy("1")), ""); // ์ปค์๊ฐ ๋น์ด์์
+ var page1 = new OpenApiAuctionHistoryListResponse(List.of(dummy("1")), "");
when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1));
- when(duplicateChecker.hasDuplicate(any())).thenReturn(false);
+ when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD)))
+ .thenReturn(OptionalInt.empty());
// when
var result = fetcher.fetch(ItemCategory.SWORD);
@@ -151,13 +200,13 @@ void stopWhenNextCursorIsEmptyString() {
void stopWhenMiddlePageIsEmpty() {
// given
var page1 = new OpenApiAuctionHistoryListResponse(List.of(dummy("1")), "cursor-1");
- var emptyPage =
- new OpenApiAuctionHistoryListResponse(List.of(), "cursor-2"); // ๋น์ด์๋ ํ์ด์ง
+ var emptyPage = new OpenApiAuctionHistoryListResponse(List.of(), "cursor-2");
when(client.fetchAuctionHistory(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1));
when(client.fetchAuctionHistory(ItemCategory.SWORD, "cursor-1"))
.thenReturn(Mono.just(emptyPage));
- when(duplicateChecker.hasDuplicate(any())).thenReturn(false);
+ when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD)))
+ .thenReturn(OptionalInt.empty());
// when
var result = fetcher.fetch(ItemCategory.SWORD);
@@ -183,7 +232,7 @@ void stopWhenAuctionHistoryListIsNull() {
assertThat(result).isEmpty();
verify(client, times(1)).fetchAuctionHistory(eq(ItemCategory.SWORD), any());
verifyNoMoreInteractions(client);
- verify(duplicateChecker, never()).hasDuplicate(any());
+ verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any());
}
}
}
diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java
index 63edf84..7764a91 100644
--- a/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java
+++ b/src/test/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersisterTest.java
@@ -53,7 +53,7 @@ void setUp() {
@DisplayName("์๋ก์ด ๊ฒฝ๋งค ๊ธฐ๋ก์ด ์์ ๋ ํํฐ๋ง๋ ์ํฐํฐ ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค")
void filterOutExisting_WhenNewRecordsExist_ShouldReturnFilteredEntities() {
// given
- when(duplicateChecker.filterExisting(dtoList)).thenReturn(filteredDtoList);
+ when(duplicateChecker.filterExisting(dtoList, category)).thenReturn(filteredDtoList);
when(mapper.toEntityList(filteredDtoList, category)).thenReturn(entities);
// when
@@ -62,7 +62,7 @@ void filterOutExisting_WhenNewRecordsExist_ShouldReturnFilteredEntities() {
// then
assertThat(actualEntities).isEqualTo(entities);
- verify(duplicateChecker).filterExisting(dtoList);
+ verify(duplicateChecker).filterExisting(dtoList, category);
verify(mapper).toEntityList(filteredDtoList, category);
}
@@ -70,7 +70,8 @@ void filterOutExisting_WhenNewRecordsExist_ShouldReturnFilteredEntities() {
@DisplayName("์๋ก์ด ๊ฒฝ๋งค ๊ธฐ๋ก์ด ์์ ๋ ๋น ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค")
void filterOutExisting_WhenNoNewRecords_ShouldReturnEmptyList() {
// given
- when(duplicateChecker.filterExisting(dtoList)).thenReturn(Collections.emptyList());
+ when(duplicateChecker.filterExisting(dtoList, category))
+ .thenReturn(Collections.emptyList());
when(mapper.toEntityList(Collections.emptyList(), category))
.thenReturn(Collections.emptyList());
@@ -80,7 +81,7 @@ void filterOutExisting_WhenNoNewRecords_ShouldReturnEmptyList() {
// then
assertThat(actualEntities).isEmpty();
- verify(duplicateChecker).filterExisting(dtoList);
+ verify(duplicateChecker).filterExisting(dtoList, category);
verify(mapper).toEntityList(Collections.emptyList(), category);
}
@@ -89,7 +90,8 @@ void filterOutExisting_WhenNoNewRecords_ShouldReturnEmptyList() {
void filterOutExisting_WhenEmptyDtoList_ShouldReturnEmptyList() {
// given
List emptyList = Collections.emptyList();
- when(duplicateChecker.filterExisting(emptyList)).thenReturn(Collections.emptyList());
+ when(duplicateChecker.filterExisting(emptyList, category))
+ .thenReturn(Collections.emptyList());
when(mapper.toEntityList(Collections.emptyList(), category))
.thenReturn(Collections.emptyList());
@@ -99,7 +101,7 @@ void filterOutExisting_WhenEmptyDtoList_ShouldReturnEmptyList() {
// then
assertThat(actualEntities).isEmpty();
- verify(duplicateChecker).filterExisting(emptyList);
+ verify(duplicateChecker).filterExisting(emptyList, category);
verify(mapper).toEntityList(Collections.emptyList(), category);
}
@@ -111,7 +113,7 @@ void filterOutExisting_WhenDtoListContainsNull_ShouldProcessCorrectly() {
List listWithNulls = Arrays.asList(dto1, null);
List filteredList = List.of(dto1);
- when(duplicateChecker.filterExisting(listWithNulls)).thenReturn(filteredList);
+ when(duplicateChecker.filterExisting(listWithNulls, category)).thenReturn(filteredList);
when(mapper.toEntityList(filteredList, category)).thenReturn(entities);
// when
@@ -120,7 +122,7 @@ void filterOutExisting_WhenDtoListContainsNull_ShouldProcessCorrectly() {
// then
assertThat(actualEntities).isEqualTo(entities);
- verify(duplicateChecker).filterExisting(listWithNulls);
+ verify(duplicateChecker).filterExisting(listWithNulls, category);
verify(mapper).toEntityList(filteredList, category);
}
@@ -133,11 +135,10 @@ void filterOutExisting_WhenSomeDtosAreDuplicate_ShouldConvertNonDuplicates() {
OpenApiAuctionHistoryResponse dto3 = mock(OpenApiAuctionHistoryResponse.class);
List originalList = Arrays.asList(dto1, dto2, dto3);
- // dto2๋ ์ค๋ณต์ด๋ผ ๊ฐ์ ํ๊ณ , dto1, dto3๋ง ๋จ๊น
List nonDuplicateList = Arrays.asList(dto1, dto3);
List expectedEntities = List.of(mock(AuctionHistory.class));
- when(duplicateChecker.filterExisting(originalList)).thenReturn(nonDuplicateList);
+ when(duplicateChecker.filterExisting(originalList, category)).thenReturn(nonDuplicateList);
when(mapper.toEntityList(nonDuplicateList, category)).thenReturn(expectedEntities);
// when
@@ -146,7 +147,7 @@ void filterOutExisting_WhenSomeDtosAreDuplicate_ShouldConvertNonDuplicates() {
// then
assertThat(actualEntities).isEqualTo(expectedEntities);
- verify(duplicateChecker).filterExisting(originalList);
+ verify(duplicateChecker).filterExisting(originalList, category);
verify(mapper).toEntityList(nonDuplicateList, category);
}
}
diff --git a/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java b/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java
new file mode 100644
index 0000000..6cfdc81
--- /dev/null
+++ b/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java
@@ -0,0 +1,308 @@
+package until.the.eternity.auctionhistory.domain.service;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort;
+import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort.LatestDateWithIds;
+import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse;
+import until.the.eternity.common.enums.ItemCategory;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalInt;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AuctionHistoryDuplicateCheckerTest {
+
+ @Mock AuctionHistoryRepositoryPort repository;
+
+ @InjectMocks AuctionHistoryDuplicateChecker checker;
+
+ private static final ItemCategory CATEGORY = ItemCategory.SWORD;
+
+ private OpenApiAuctionHistoryResponse dto(String id, Instant dateAuctionBuy) {
+ return new OpenApiAuctionHistoryResponse(
+ "ํ๋ฌ์์ฐ์ค ํ์ดํ ๋ธ๋ ์ด๋",
+ "์ ์ฑํ ํ๋ฌ์์ฐ์ค ํ์ดํ ๋ธ๋ ์ด๋",
+ CATEGORY.getSubCategory(),
+ 1L,
+ 100L,
+ dateAuctionBuy,
+ id,
+ null);
+ }
+
+ @Nested
+ @DisplayName("checkDuplicateInBatch ํ
์คํธ")
+ class CheckDuplicateInBatchTest {
+
+ @Test
+ @DisplayName("DB์ ๋ฐ์ดํฐ๊ฐ ์์ผ๋ฉด ์ค๋ณต ์์์ผ๋ก ํ์ ")
+ void noDuplicateWhenNoDataInDb() {
+ // given
+ Instant now = Instant.now();
+ var batch = List.of(dto("1", now), dto("2", now.minusSeconds(10)));
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(Optional.empty());
+
+ // when
+ OptionalInt result = checker.checkDuplicateInBatch(batch, CATEGORY);
+
+ // then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("๋น ๋ฐฐ์น์ด๋ฉด ์ค๋ณต ์์์ผ๋ก ํ์ ")
+ void noDuplicateWhenEmptyBatch() {
+ // given
+ List batch = List.of();
+
+ // when
+ OptionalInt result = checker.checkDuplicateInBatch(batch, CATEGORY);
+
+ // then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("๋ชจ๋ ๋ฐ์ดํฐ๊ฐ latestDate ์ดํ๋ฉด ์ค๋ณต ์์")
+ void noDuplicateWhenAllDataAfterLatestDate() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ Instant afterLatest = latestDate.plusSeconds(100);
+ var batch = List.of(dto("1", afterLatest), dto("2", afterLatest.plusSeconds(10)));
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(
+ Optional.of(new LatestDateWithIds(latestDate, Set.of("existing-1"))));
+
+ // when
+ OptionalInt result = checker.checkDuplicateInBatch(batch, CATEGORY);
+
+ // then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("์ค๊ฐ์ latestDate ์ด์ ๋ฐ์ดํฐ๊ฐ ์์ผ๋ฉด ํด๋น ์ธ๋ฑ์ค ๋ฐํ")
+ void duplicateFoundWhenDataBeforeLatestDate() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ Instant afterLatest = latestDate.plusSeconds(100);
+ Instant beforeLatest = latestDate.minusSeconds(100);
+ var batch =
+ List.of(dto("1", afterLatest), dto("2", afterLatest), dto("3", beforeLatest));
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(Optional.of(new LatestDateWithIds(latestDate, Set.of())));
+
+ // when
+ OptionalInt result = checker.checkDuplicateInBatch(batch, CATEGORY);
+
+ // then
+ assertThat(result).hasValue(2);
+ }
+
+ @Test
+ @DisplayName("๋์ผ ๋ ์ง, ๋ค๋ฅธ auctionBuyId๋ ์ ๊ท๋ก ํ์ ")
+ void sameDateDifferentIdIsNew() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ var batch = List.of(dto("new-id-1", latestDate), dto("new-id-2", latestDate));
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(
+ Optional.of(
+ new LatestDateWithIds(
+ latestDate, Set.of("existing-1", "existing-2"))));
+
+ // when
+ OptionalInt result = checker.checkDuplicateInBatch(batch, CATEGORY);
+
+ // then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("๋์ผ ๋ ์ง, ๊ฐ์ auctionBuyId๋ ์ค๋ณต์ผ๋ก ํ์ ")
+ void sameDateSameIdIsDuplicate() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ var batch = List.of(dto("new-id", latestDate), dto("existing-1", latestDate));
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(
+ Optional.of(
+ new LatestDateWithIds(
+ latestDate, Set.of("existing-1", "existing-2"))));
+
+ // when
+ OptionalInt result = checker.checkDuplicateInBatch(batch, CATEGORY);
+
+ // then
+ assertThat(result).hasValue(1);
+ }
+
+ @Test
+ @DisplayName("์ฒซ ๋ฒ์งธ ํญ๋ชฉ์ด ์ค๋ณต์ด๋ฉด ์ธ๋ฑ์ค 0 ๋ฐํ")
+ void duplicateAtFirstIndex() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ Instant beforeLatest = latestDate.minusSeconds(100);
+ var batch = List.of(dto("1", beforeLatest), dto("2", latestDate));
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(Optional.of(new LatestDateWithIds(latestDate, Set.of())));
+
+ // when
+ OptionalInt result = checker.checkDuplicateInBatch(batch, CATEGORY);
+
+ // then
+ assertThat(result).hasValue(0);
+ }
+ }
+
+ @Nested
+ @DisplayName("filterExisting ํ
์คํธ")
+ class FilterExistingTest {
+
+ @Test
+ @DisplayName("DB์ ๋ฐ์ดํฐ๊ฐ ์์ผ๋ฉด ๋ชจ๋ ๋ฐ์ดํฐ ๋ฐํ")
+ void returnAllWhenNoDataInDb() {
+ // given
+ Instant now = Instant.now();
+ var dtos = List.of(dto("1", now), dto("2", now.minusSeconds(10)));
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(Optional.empty());
+
+ // when
+ var result = checker.filterExisting(dtos, CATEGORY);
+
+ // then
+ assertThat(result).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("๋น ๋ฆฌ์คํธ์ด๋ฉด ๋น ๋ฆฌ์คํธ ๋ฐํ")
+ void returnEmptyWhenEmptyList() {
+ // given
+ List dtos = List.of();
+
+ // when
+ var result = checker.filterExisting(dtos, CATEGORY);
+
+ // then
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ @DisplayName("latestDate ์ด์ ๋ฐ์ดํฐ๋ ํํฐ๋ง๋จ")
+ void filterOutDataBeforeLatestDate() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ Instant afterLatest = latestDate.plusSeconds(100);
+ Instant beforeLatest = latestDate.minusSeconds(100);
+ var dtos =
+ List.of(dto("1", afterLatest), dto("2", beforeLatest), dto("3", afterLatest));
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(Optional.of(new LatestDateWithIds(latestDate, Set.of())));
+
+ // when
+ var result = checker.filterExisting(dtos, CATEGORY);
+
+ // then
+ assertThat(result).hasSize(2);
+ assertThat(result)
+ .extracting(OpenApiAuctionHistoryResponse::auctionBuyId)
+ .containsExactly("1", "3");
+ }
+
+ @Test
+ @DisplayName("๋์ผ ๋ ์ง์ง๋ง ๊ธฐ์กด ID๊ฐ ์๋๋ฉด ํฌํจ๋จ")
+ void includeSameDateNewId() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ var dtos = List.of(dto("new-1", latestDate), dto("new-2", latestDate));
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(
+ Optional.of(new LatestDateWithIds(latestDate, Set.of("existing-1"))));
+
+ // when
+ var result = checker.filterExisting(dtos, CATEGORY);
+
+ // then
+ assertThat(result).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("๋์ผ ๋ ์ง์ด๊ณ ๊ธฐ์กด ID๋ฉด ํํฐ๋ง๋จ")
+ void filterOutSameDateExistingId() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ var dtos =
+ List.of(
+ dto("new-1", latestDate),
+ dto("existing-1", latestDate),
+ dto("new-2", latestDate));
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(
+ Optional.of(new LatestDateWithIds(latestDate, Set.of("existing-1"))));
+
+ // when
+ var result = checker.filterExisting(dtos, CATEGORY);
+
+ // then
+ assertThat(result).hasSize(2);
+ assertThat(result)
+ .extracting(OpenApiAuctionHistoryResponse::auctionBuyId)
+ .containsExactly("new-1", "new-2");
+ }
+
+ @Test
+ @DisplayName("๋ณตํฉ ์๋๋ฆฌ์ค: ์ด์ /๋์ผ(๊ธฐ์กดID)/๋์ผ(์ ๊ทID)/์ดํ ๋ฐ์ดํฐ")
+ void complexScenario() {
+ // given
+ Instant latestDate = Instant.parse("2024-01-01T00:00:00Z");
+ Instant afterLatest = latestDate.plusSeconds(100);
+ Instant beforeLatest = latestDate.minusSeconds(100);
+
+ var dtos =
+ List.of(
+ dto("after-1", afterLatest), // ์ ๊ท: ํฌํจ
+ dto("before-1", beforeLatest), // ๊ณผ๊ฑฐ: ์ ์ธ
+ dto("same-new", latestDate), // ๋์ผ ๋ ์ง, ์ ๊ท ID: ํฌํจ
+ dto("existing-1", latestDate), // ๋์ผ ๋ ์ง, ๊ธฐ์กด ID: ์ ์ธ
+ dto("after-2", afterLatest) // ์ ๊ท: ํฌํจ
+ );
+
+ when(repository.findLatestDateWithIdsBySubCategory(CATEGORY))
+ .thenReturn(
+ Optional.of(
+ new LatestDateWithIds(
+ latestDate, Set.of("existing-1", "existing-2"))));
+
+ // when
+ var result = checker.filterExisting(dtos, CATEGORY);
+
+ // then
+ assertThat(result).hasSize(3);
+ assertThat(result)
+ .extracting(OpenApiAuctionHistoryResponse::auctionBuyId)
+ .containsExactly("after-1", "same-new", "after-2");
+ }
+ }
+}