Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,41 +1,45 @@
package until.the.eternity.auctionhistory.application.scheduler;

import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import until.the.eternity.auctionhistory.application.service.AuctionHistoryService;
import until.the.eternity.auctionhistory.application.service.fetcher.AuctionHistoryFetcher;
import until.the.eternity.auctionhistory.application.service.persister.AuctionHistoryPersister;
import until.the.eternity.auctionhistory.domain.entity.AuctionHistory;
import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse;
import until.the.eternity.common.enums.ItemCategory;

@Slf4j
@Component
@RequiredArgsConstructor
public class AuctionHistoryScheduler {

private final AuctionHistoryService auctionHistoryService;

@Value("${openapi.auction-history.delay-ms}")
private long delayMs;
private final AuctionHistoryService service;
private final AuctionHistoryFetcher fetcher;
private final AuctionHistoryPersister persister;

@Scheduled(cron = "${openapi.auction-history.cron}", zone = "Asia/Seoul")
public void fetchAndSaveAuctionHistoryAll() {
List<AuctionHistory> newEntities = new ArrayList<>();
for (ItemCategory category : ItemCategory.values()) {
try {
auctionHistoryService.fetchAndSaveAuctionHistory(category);
List<OpenApiAuctionHistoryResponse> fetchedDtos = fetcher.fetch(category);
List<AuctionHistory> entities = persister.filterOutExisting(fetchedDtos, category);
newEntities.addAll(entities);
} catch (Exception e) {
log.error("Error during processing category [{}]", category.getSubCategory(), e);
log.error(
"> [SCHEDULE] Error during processing category [{}]",
category.getSubCategory(),
e);
}
delayBetweenRequests();
}
}

private void delayBetweenRequests() {
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("Interrupted during delay between requests", e);
}
service.saveAll(newEntities);
log.info(
"> [SCHEDULE] AuctionHistoryScheduler saved [{}] new auction history records complete",
newEntities.size());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,9 @@
import until.the.eternity.auctionhistory.domain.entity.AuctionHistory;
import until.the.eternity.auctionhistory.domain.mapper.AuctionHistoryMapper;
import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort;
import until.the.eternity.auctionhistory.domain.service.fetcher.AuctionHistoryFetcherPort;
import until.the.eternity.auctionhistory.domain.service.persister.AuctionHistoryPersisterPort;
import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse;
import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest;
import until.the.eternity.auctionhistory.interfaces.rest.dto.response.AuctionHistoryDetailResponse;
import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse;
import until.the.eternity.common.enums.ItemCategory;
import until.the.eternity.common.request.PageRequestDto;
import until.the.eternity.common.response.PageResponseDto;

Expand All @@ -25,8 +21,6 @@
public class AuctionHistoryService {

private final AuctionHistoryRepositoryPort repository;
private final AuctionHistoryFetcherPort fetcher;
private final AuctionHistoryPersisterPort persister;
private final AuctionHistoryMapper mapper;

@Transactional(readOnly = true)
Expand All @@ -51,8 +45,7 @@ public AuctionHistoryDetailResponse<ItemOptionResponse> findByIdOrElseThrow(Long
}

@Transactional
public void fetchAndSaveAuctionHistory(ItemCategory category) {
List<OpenApiAuctionHistoryResponse> dtoList = fetcher.fetch(category);
persister.saveIfNotExists(dtoList, category);
public void saveAll(List<AuctionHistory> entities) {
repository.saveAll(entities);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,47 @@ public class AuctionHistoryFetcher implements AuctionHistoryFetcherPort {
public List<OpenApiAuctionHistoryResponse> fetch(ItemCategory category) {

List<OpenApiAuctionHistoryResponse> result = new ArrayList<>();
String cursor = null;
String cursor = "";

while (true) {
var response = client.fetchAuctionHistory(category, cursor).block();

do {
var response = client.fetchAuctionHistory(category, cursor);
if (response == null || response.auctionHistory() == null) {
log.warn(
"> [SCHEDULE] [{}] response or its history is null, something is wrong with open api call",
category.getSubCategory());
break;
}

log.debug(
"> [SCHEDULE] [{}] fetched '{}' data",
category.getSubCategory(),
response.auctionHistory().size());

if (response.auctionHistory().isEmpty()) {
log.debug("> [SCHEDULE] [{}] fetched no data", category.getSubCategory());
break;
}

var batch = response.auctionHistory();
result.addAll(batch);

if (duplicateChecker.hasDuplicate(batch.getLast())) {
log.debug("[{}] fetched {} data", category.getSubCategory(), result.size());
log.debug(
"> [SCHEDULE] [{}] this fetched data has duplicate data, skip to next item subcategory",
category.getSubCategory());
break;
}

cursor = response.nextCursor();
} while (cursor != null);

if (cursor == null || cursor.isEmpty()) {
log.debug(
"> [SCHEDULE] [{}] response cursor is null, fetched end",
category.getSubCategory());
break;
}
}

return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import org.springframework.stereotype.Component;
import until.the.eternity.auctionhistory.domain.entity.AuctionHistory;
import until.the.eternity.auctionhistory.domain.mapper.OpenApiAuctionHistoryMapper;
import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort;
import until.the.eternity.auctionhistory.domain.service.AuctionHistoryDuplicateChecker;
import until.the.eternity.auctionhistory.domain.service.persister.AuctionHistoryPersisterPort;
import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse;
Expand All @@ -17,35 +16,24 @@
@Component
public class AuctionHistoryPersister implements AuctionHistoryPersisterPort {

private final AuctionHistoryRepositoryPort repository;
private final OpenApiAuctionHistoryMapper mapper;
private final AuctionHistoryDuplicateChecker duplicateChecker;

public void saveIfNotExists(
public List<AuctionHistory> filterOutExisting(
List<OpenApiAuctionHistoryResponse> dtoList, ItemCategory category) {

List<AuctionHistory> entities =
mapper.toEntityList(duplicateChecker.filterExisting(dtoList), category);

if (entities.isEmpty()) {
log.info("[{}] No new auction history to save", category.getSubCategory());
return;
log.info("> [SCHEDULE] [{}] No new auction history to save", category.getSubCategory());
} else {
log.info(
"> [SCHEDULE] [{}] After remove duplicate existing '{}' new auction history records left to save",
category.getSubCategory(),
entities.size());
}

repository.saveAll(entities);
logSummary(category, entities);
}

private void logSummary(ItemCategory category, List<AuctionHistory> entities) {
int optionCnt =
entities.stream()
.mapToInt(e -> e.getItemOptions() == null ? 0 : e.getItemOptions().size())
.sum();

log.info(
"[{}] Saved {} new auction history records (with {} options)",
category.getSubCategory(),
entities.size(),
optionCnt);
return entities;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ public boolean hasDuplicate(OpenApiAuctionHistoryResponse lastDto) {
return lastDto.dateAuctionBuy().isAfter(latestDate);
}

// TODO: 로직 변경 후 점검 중인데, 뭔가 문제가 있는 거 같음

public List<OpenApiAuctionHistoryResponse> filterExisting(
List<OpenApiAuctionHistoryResponse> dtos) {
if (dtos.isEmpty()) {
return dtos;
}
Instant latestDate = getLatestAuctionDateOrMin(dtos.getFirst());
return dtos.stream().filter(dto -> dto.dateAuctionBuy().isAfter(latestDate)).toList();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package until.the.eternity.auctionhistory.domain.service.persister;

import java.util.List;
import until.the.eternity.auctionhistory.domain.entity.AuctionHistory;
import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse;
import until.the.eternity.common.enums.ItemCategory;

public interface AuctionHistoryPersisterPort {

void saveIfNotExists(List<OpenApiAuctionHistoryResponse> dtoList, ItemCategory category);
List<AuctionHistory> filterOutExisting(
List<OpenApiAuctionHistoryResponse> dtoList, ItemCategory category);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,6 @@
import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryListResponse;
import until.the.eternity.common.enums.ItemCategory;

/**
* Nexon OPEN API 호출 전담 클라이언트.
*
* <p>– 전역 WebClient 설정(필터 · 헤더 · 타임아웃 · 재시도)은 {@link
* until.the.eternity.config.openapi.OpenApiWebClientConfig} 에서 담당한다. – 이 클래스는 “엔드포인트·쿼리 파라미터·로깅” 만
* 책임지는 SRP 구조다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
Expand All @@ -28,47 +21,40 @@ public class AuctionHistoryClient {
*
* @param category 조회할 카테고리
* @param cursor 다음 페이지 커서(null 가능)
* @return 응답 DTO, 호출 실패 시 null
* @return 응답 DTO를 담은 Mono, 호출 실패 시 Mono.empty()
*/
public OpenApiAuctionHistoryListResponse fetchAuctionHistory(
public Mono<OpenApiAuctionHistoryListResponse> fetchAuctionHistory(
ItemCategory category, String cursor) {

try {
// TODO: 하드코딩 값 변경
log.info(
"Calling 'https://open.api.nexon.com/mabinogi/v1/auction/history?auction_item_category={} with cursor='{}'",
category.getSubCategory(),
cursor == null ? "" : "&cursor=" + cursor);
log.info(
"[SCHEDULE] [{}] Calling Nexon Open API Auction History API with cursor='{}'",
category.getSubCategory(),
cursor == null ? "" : cursor);

return openApiWebClient
.get()
.uri(
uriBuilder ->
uriBuilder
.path("/auction/history")
.queryParam(
"auction_item_category",
category.getSubCategory())
.queryParamIfPresent(
"cursor",
Mono.justOrEmpty(cursor).blockOptional())
.build())
.retrieve()
.bodyToMono(OpenApiAuctionHistoryListResponse.class)
// 필터에서 재시도·타임아웃·에러로깅이 이미 적용됨
.onErrorResume(
throwable -> {
log.warn(
"Failed to fetch auction history [category={} cursor={}]: {}",
category,
cursor,
throwable.toString());
return Mono.empty(); // graceful fail
})
.block();
} catch (Exception ex) {
log.error("Unexpected exception during auction history fetch", ex);
return null;
}
return openApiWebClient
.get()
.uri(
uriBuilder -> {
uriBuilder
.path("/auction/history")
.queryParam("auction_item_category", category.getSubCategory());
if (cursor != null) {
uriBuilder.queryParam("cursor", cursor);
}
return uriBuilder.build();
})
.retrieve()
.bodyToMono(OpenApiAuctionHistoryListResponse.class)
// 필터에서 재시도·타임아웃·에러로깅이 이미 적용됨
.onErrorResume(
throwable -> {
log.warn(
"[SCHEDULE] [{}] Failed to fetch Nexon Open API Auction History API with cursor='{}': error='{}', message='{}'",
category.getSubCategory(),
cursor,
throwable.toString(),
throwable.getMessage());
return Mono.empty(); // graceful fail
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public enum ItemCategory {
MARIONETTE("마리오네트", "특수 장비"),
ECHOSTONE("에코스톤", "특수 장비"),
EIDOS("에이도스", "특수 장비"),
PALLIASH_RELIC("팔리아스 유물", "특수 장비"),
RELIC("유물", "특수 장비"),
ETC_EQUIPMENT("기타 장비", "특수 장비"),

// 설치물
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@
import until.the.eternity.auctionhistory.domain.mapper.AuctionHistoryMapper;
import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort;
import until.the.eternity.auctionhistory.domain.service.fetcher.AuctionHistoryFetcherPort;
import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse;
import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest;
import until.the.eternity.auctionhistory.interfaces.rest.dto.response.AuctionHistoryDetailResponse;
import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse;
import until.the.eternity.common.enums.ItemCategory;
import until.the.eternity.common.request.PageRequestDto;
import until.the.eternity.common.response.PageResponseDto;

Expand Down Expand Up @@ -100,21 +98,21 @@ void findByIdOrElseThrow_should_throw_exception_when_not_found() {
verifyNoInteractions(mapper);
}

@Test
@DisplayName("경매장 fetch 및 Save 로직은 fetcher와 persister에 위임한다")
void fetchAndSaveAuctionHistory_should_delegate_to_fetcher_and_persister() {
// given
ItemCategory category = ItemCategory.ETC;
List<OpenApiAuctionHistoryResponse> fetchedDtoList =
List.of(mock(OpenApiAuctionHistoryResponse.class));

when(fetcherPort.fetch(category)).thenReturn(fetchedDtoList);

// when
service.fetchAndSaveAuctionHistory(category);

// then
verify(fetcherPort).fetch(category);
verify(persister).saveIfNotExists(fetchedDtoList, category);
}
// @Test
// @DisplayName("경매장 fetch 및 Save 로직은 fetcher와 persister에 위임한다")
// void fetchAndSaveAuctionHistory_should_delegate_to_fetcher_and_persister() {
// // given
// ItemCategory category = ItemCategory.ETC;
// List<OpenApiAuctionHistoryResponse> fetchedDtoList =
// List.of(mock(OpenApiAuctionHistoryResponse.class));
//
// when(fetcherPort.fetch(category)).thenReturn(fetchedDtoList);
//
// // when
// service.fetchAndSaveAuctionHistory(category);
//
// // then
// verify(fetcherPort).fetch(category);
// verify(persister).saveIfNotExists(fetchedDtoList, category);
// }
}
Loading