diff --git a/.env.prod.sample b/.env.prod.sample new file mode 100644 index 00000000..2355df86 --- /dev/null +++ b/.env.prod.sample @@ -0,0 +1,81 @@ +# ============================================================================= +# Production Environment Configuration +# ============================================================================= +# 운영 환경용 설정 파일 +# +# 사용법: +# 1. 이 파일을 .env.prod로 복사하세요 +# 2. 필요한 값들을 채워넣으세요 +# 3. docker-compose -f docker-compose-prod.yml --env-file .env.prod up -d 로 실행하세요 +# +# 주의: .env.prod 파일은 민감한 정보를 포함하므로 절대 Git에 커밋하지 마세요! +# ============================================================================= + +# ============================================================================= +# Application Configuration +# ============================================================================= +SERVER_PORT=8080 + +# ============================================================================= +# Docker Configuration +# ============================================================================= +DOCKER_USERNAME=your_docker_username +DOCKER_REPO=your_docker_repo +DOCKER_IMAGE_TAG=prod + +# ============================================================================= +# Database Configuration +# ============================================================================= +DB_IP=your_db_host +DB_PORT=3306 +DB_SCHEMA=your_db_schema +DB_USER=your_db_user +DB_PASSWORD=your_db_password + +# ============================================================================= +# Security Configuration +# ============================================================================= +# JWT 시크릿 키 (최소 256비트 권장, 운영 환경에서는 반드시 강력한 키 사용) +JWT_SECRET_KEY=your_production_secret_key_minimum_32_characters_long +JWT_ACCESS_TOKEN_VALIDITY=3600000 # 1시간 (밀리초) +JWT_REFRESH_TOKEN_VALIDITY=86400000 # 24시간 (밀리초) + +# ============================================================================= +# External API Configuration +# ============================================================================= +# Nexon Open API 키 (https://openapi.nexon.com/에서 발급) +NEXON_OPEN_API_KEY=your_nexon_api_key_here + +# 경매 데이터 수집 설정 +AUCTION_HISTORY_DELAY_MS=1000 # API 호출 간 딜레이 (1초) +AUCTION_HISTORY_CRON=0 0 * * * * # 매시간 정각에 실행 + +# 통계 스케줄러 설정 +STATISTICS_PREVIOUS_DAY_CRON=0 10 0 * * * # 매일 00:10에 전일 통계 확정 + +# ============================================================================= +# Elasticsearch Configuration +# ============================================================================= +# Elasticsearch 기능 활성화 여부 +ELASTICSEARCH_ENABLED=true +ELASTICSEARCH_INDEX_ENABLED=true + +# Elasticsearch 버전 (docker-compose-prod.yml에서 사용) +ELASTICSEARCH_VERSION=8.11.0 + +# Elasticsearch 포트 (기본값: 9200, 9300) +ELASTICSEARCH_PORT=9200 +ELASTICSEARCH_TRANSPORT_PORT=9300 + +# Elasticsearch JVM Heap 크기 (최대 3GB 권장) +# 주의: 시스템 메모리의 50%를 넘지 않도록 설정 +ELASTICSEARCH_HEAP_SIZE=1536m + +# ============================================================================= +# JVM Configuration (선택사항 - docker-compose에 기본값 있음) +# ============================================================================= +# JAVA_OPTS_XMS=768m +# JAVA_OPTS_XMX=1536m +# JAVA_OPTS_MAX_METASPACE_SIZE=450m +# JAVA_OPTS_RESERVED_CODE_CACHE_SIZE=144m +# JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE=192m diff --git a/README.md b/README.md index a6c79585..ba022487 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,21 @@ ## 주요 기능 -### 경매장 데이터 수집 +### 경매장 거래 내역 수집 - 매 1시간마다 마비노기 경매장 거래 내역을 Nexon Open API를 통해 수집 - 약 70개의 아이템 카테고리에 대한 커서 기반 페이지네이션으로 데이터 수집 - 카테고리별 요청 간 딜레이 설정으로 API Rate Limit 준수 +### 실시간 경매장 데이터 수집 +- 10분 간격으로 현재 판매 중인 아이템 정보 수집 +- 만료된 아이템 자동 삭제 +- 거래 내역과 분리된 별도 테이블로 관리 + ### 뿔피리 데이터 수집 - 5분마다 거대한 외침의 뿔피리 내역 수집 - 4개 서버 지원 (류트, 만돌린, 하프, 울프) - 지수 백오프 기반 재시도 로직 구현 +- **Elasticsearch 연동**: 한글 전문 검색 지원 (Ngram Parser) ### 통계 분석 - **일간 통계**: 경매 데이터 수집 완료 시 자동 트리거 (이벤트 기반) @@ -28,7 +34,7 @@ ### 데이터 조회 - 아이템별 최저가, 최고가, 평균가, 거래량 등 시세 조회 - 서버별/전체 뿔피리 내역 조회 -- 아이템 옵션 필터링 (무기 공격력, 방어구 방어력 등) +- 아이템 옵션 필터링 (무기 공격력, 방어구 방어력, 세공 옵션 등)
@@ -39,9 +45,11 @@ | **Backend** | Java 21, Spring Boot 3.5.0, Spring Data JPA, QueryDSL | | **HTTP Client** | Spring WebFlux (WebClient) | | **Database** | MySQL 8, Flyway | +| **Search Engine** | Elasticsearch (한글 전문 검색) | +| **Security** | Spring Security, JWT | | **Test** | JUnit5, Mockito, AssertJ, Testcontainers | | **Code Quality** | Spotless (Google Java Format AOSP), Jacoco | -| **Documentation** | Swagger, Spring REST Docs | +| **Documentation** | Swagger (Springdoc OpenAPI), Spring REST Docs | | **DevOps** | Docker Compose, GitHub Actions | | **Deployment** | Oracle Cloud | @@ -52,7 +60,10 @@ ``` src/main/java/until/the/eternity/ ├── auctionhistory/ # 경매장 거래 내역 수집 및 검색 -├── hornBugle/ # 뿔피리 내역 수집 및 검색 +├── auctionrealtime/ # 실시간 경매장 데이터 수집 (10분 간격) +├── auctionitem/ # 경매장 아이템 엔티티 +├── auctionitemoption/ # 아이템 옵션 정보 (세공 옵션 포함) +├── hornBugle/ # 뿔피리 내역 수집 및 검색 (Elasticsearch 연동) ├── statistics/ # 일간/주간 통계 집계 ├── iteminfo/ # 아이템 메타데이터 ├── itemoptioninfo/ # 아이템 옵션 정보 @@ -76,19 +87,45 @@ infrastructure/ # Repository 구현체, JPA ## API 엔드포인트 +### 경매장 거래 내역 | Endpoint | Method | 설명 | |----------|--------|------| | `/auction-history/search` | GET | 경매 내역 검색 (필터 및 페이징) | | `/auction-history/{id}` | GET | 단일 거래 내역 조회 | | `/auction-history/batch` | POST | 배치 수동 실행 | + +### 실시간 경매장 +| Endpoint | Method | 설명 | +|----------|--------|------| +| `/auction-realtime/search` | GET | 현재 판매 중인 아이템 검색 | +| `/auction-realtime/{id}` | GET | 단일 아이템 조회 | + +### 뿔피리 +| Endpoint | Method | 설명 | +|----------|--------|------| | `/horn-bugle` | GET | 뿔피리 내역 검색 (서버별/전체) | | `/horn-bugle/batch` | POST | 뿔피리 배치 수동 실행 | + +### 통계 +| Endpoint | Method | 설명 | +|----------|--------|------| | `/statistics/daily/items` | GET | 일간 아이템 통계 | | `/statistics/daily/subcategories` | GET | 일간 서브카테고리 통계 | | `/statistics/daily/top-categories` | GET | 일간 상위카테고리 통계 | | `/statistics/weekly/items` | GET | 주간 아이템 통계 | +| `/statistics/weekly/subcategories` | GET | 주간 서브카테고리 통계 | +| `/statistics/weekly/top-categories` | GET | 주간 상위카테고리 통계 | + +### 메타데이터 +| Endpoint | Method | 설명 | +|----------|--------|------| | `/api/item-infos` | GET | 아이템 메타데이터 | | `/api/v1/item-option-infos` | GET | 아이템 옵션 정보 | +| `/api/auction-search-options` | GET | 검색 옵션 메타데이터 | + +### 시스템 +| Endpoint | Method | 설명 | +|----------|--------|------| | `/actuator/health` | GET | 헬스체크 | | `/swagger-ui/index.html` | - | API 문서 | @@ -99,6 +136,7 @@ infrastructure/ # Repository 구현체, JPA | 스케줄러 | Cron 표현식 | 설명 | |----------|-------------|------| | 경매 내역 수집 | `0 0 * * * *` | 매 시 정각 | +| 실시간 경매장 수집 | `0 0/10 * * * *` | 10분마다 | | 뿔피리 수집 | `0 */5 * * * *` | 5분마다 | | 전일 통계 확정 | `0 10 0 * * *` | 매일 00:10 | | 주간 통계 집계 | `5 0 4 * * MON` | 매주 월요일 04:00 | @@ -126,6 +164,11 @@ JWT_REFRESH_TOKEN_VALIDITY=86400000 # Nexon Open API NEXON_OPEN_API_KEY=your-api-key + +# Elasticsearch +ELASTICSEARCH_URIS=http://localhost:9200 +ELASTICSEARCH_USERNAME= +ELASTICSEARCH_PASSWORD= ``` ### 선택 환경 변수 @@ -134,11 +177,19 @@ NEXON_OPEN_API_KEY=your-api-key AUCTION_HISTORY_CRON=0 0 * * * * AUCTION_HISTORY_DELAY_MS=1000 +# 실시간 경매장 배치 +AUCTION_REALTIME_CRON=0 0/10 * * * * +AUCTION_REALTIME_DELAY_MS=500 + # 뿔피리 배치 HORN_BUGLE_CRON=0 */5 * * * * HORN_BUGLE_MAX_RETRIES=3 HORN_BUGLE_RETRY_DELAY_MS=2000 +# Elasticsearch 기능 +ELASTICSEARCH_ENABLED=true +ELASTICSEARCH_INDEX_ENABLED=true + # 통계 STATISTICS_PREVIOUS_DAY_CRON=0 10 0 * * * STATISTICS_WEEKLY_CRON=5 0 4 * * MON @@ -179,9 +230,9 @@ docker-compose -f docker-compose-local.yml down | 환경 | 파일 | 설명 | |------|------|------| -| 로컬 개발 | `docker-compose-local.yml` | 로컬 빌드, 낮은 리소스 | -| 개발 서버 | `docker-compose-dev.yml` | 개발 환경 배포 | -| 운영 서버 | `docker-compose-prod.yml` | 운영 환경 배포 | +| 로컬 개발 | `docker-compose-local.yml` | 로컬 빌드, MySQL, Elasticsearch 포함 | +| 개발 서버 | `docker-compose-dev.yml` | 개발 환경 배포, Autoheal 컨테이너 포함 | +| 운영 서버 | `docker-compose-prod.yml` | 운영 환경 배포, 높은 리소스 할당 |
@@ -209,6 +260,30 @@ docker-compose -f docker-compose-local.yml down
+## 데이터베이스 스키마 + +Flyway를 통한 마이그레이션 관리 (17개 버전) + +### 주요 테이블 +| 테이블 | 설명 | +|--------|------| +| `auction_history` | 경매장 거래 내역 | +| `auction_realtime_item` | 현재 판매 중인 아이템 | +| `auction_history_item_option` | 거래 아이템 옵션 (세공 포함) | +| `auction_realtime_item_option` | 실시간 아이템 옵션 | +| `horn_bugle_world_history` | 뿔피리 내역 (FULLTEXT 인덱스) | +| `item_daily_statistics` | 일간 아이템 통계 | +| `item_weekly_statistics` | 주간 아이템 통계 | +| `subcategory_daily_statistics` | 일간 서브카테고리 통계 | +| `subcategory_weekly_statistics` | 주간 서브카테고리 통계 | +| `top_category_daily_statistics` | 일간 상위카테고리 통계 | +| `top_category_weekly_statistics` | 주간 상위카테고리 통계 | +| `item_info` | 아이템 메타데이터 | +| `item_option_value_info` | 아이템 옵션 정보 | +| `metalware_info` | 금속류 정보 | + +
+ ## API 응답 형식 ```json diff --git a/build.gradle.kts b/build.gradle.kts index 14249adc..573cde83 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -64,6 +64,9 @@ dependencies { // P6Spy implementation("com.github.gavlyukovskiy:p6spy-spring-boot-starter:${property("p6spyVersion")}") + // Elasticsearch + implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch") + // QueryDSL (with Jakarta API) implementation("com.querydsl:querydsl-core:5.1.0") implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta") diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 88871b78..07952045 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -57,6 +57,7 @@ services: -Xmx${JAVA_OPTS_XMX:-512m} -XX:MaxMetaspaceSize=${JAVA_OPTS_MAX_METASPACE_SIZE:-150m} -XX:ReservedCodeCacheSize=${JAVA_OPTS_RESERVED_CODE_CACHE_SIZE:-48m} + -XX:ReservedCodeCacheSize=${JAVA_OPTS_RESERVED_CODE_CACHE_SIZE:-48m} -XX:MaxDirectMemorySize=${JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE:-64m} -Xss${JAVA_OPTS_XSS:-512k} -XX:+UseG1GC diff --git a/docker-compose-local.yml b/docker-compose-local.yml index a79cadb9..e2289713 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -47,6 +47,11 @@ services: AUCTION_HISTORY_CRON: "${AUCTION_HISTORY_CRON:-0 0 * * * *}" STATISTICS_PREVIOUS_DAY_CRON: "${STATISTICS_PREVIOUS_DAY_CRON:-0 0 * * * *}" + # === Elasticsearch Configuration === + ELASTICSEARCH_ENABLED: ${ELASTICSEARCH_ENABLED:-true} + ELASTICSEARCH_INDEX_ENABLED: ${ELASTICSEARCH_INDEX_ENABLED:-true} + SPRING_ELASTICSEARCH_URIS: http://elasticsearch:9200 + # === JVM Configuration (Local - 경량 개발용) === # Heap: 256m~512m, Non-Heap: 256m, Total: ~768m JAVA_OPTS: >- @@ -85,10 +90,12 @@ services: - app-network - my-network # MySQL 컨테이너와 통신을 위해 추가 - # MySQL이 준비될 때까지 대기 + # MySQL, Elasticsearch가 준비될 때까지 대기 depends_on: mysql: condition: service_healthy + elasticsearch: + condition: service_healthy # === Health Check (Local - 표준) === healthcheck: @@ -144,6 +151,41 @@ services: - --default-time-zone=+09:00 # MySQL 레벨 타임존 설정 - --explicit_defaults_for_timestamp=1 # TIMESTAMP 기본값 명시 허용 + # Elasticsearch + # Nori 플러그인 사용 시: build 섹션 주석 해제, image 주석 처리 + # build: + # context: ./docker/elasticsearch + # dockerfile: Dockerfile + # image: open-api-batch-elasticsearch:local + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 + container_name: open-api-batch-elasticsearch + restart: unless-stopped + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + - cluster.name=devnogi-es-cluster + ports: + - "${ELASTICSEARCH_PORT:-9200}:9200" + - "${ELASTICSEARCH_TRANSPORT_PORT:-9300}:9300" + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 60s + deploy: + resources: + limits: + memory: 1g + reservations: + memory: 512m + # === Autoheal (Local - 표준) === # unhealthy 컨테이너 자동 재시작 서비스 autoheal: @@ -168,6 +210,7 @@ services: volumes: mysql_data: + elasticsearch_data: app-logs: driver: local diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 4b5eac7e..cc6c47e7 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -17,6 +17,11 @@ services: labels: autoheal: "true" + + depends_on: + devnogi-elastic-search: + condition: service_healthy + environment: # === Application Configuration === SPRING_PROFILES_ACTIVE: prod @@ -42,6 +47,11 @@ services: AUCTION_HISTORY_CRON: "${AUCTION_HISTORY_CRON}" STATISTICS_PREVIOUS_DAY_CRON: "${STATISTICS_PREVIOUS_DAY_CRON:-0 0 * * * *}" + # === Elasticsearch Configuration === + ELASTICSEARCH_ENABLED: ${ELASTICSEARCH_ENABLED:-true} + ELASTICSEARCH_INDEX_ENABLED: ${ELASTICSEARCH_INDEX_ENABLED:-true} + SPRING_ELASTICSEARCH_URIS: http://devnogi-elastic-search:9200 + # === Docker Configuration === DOCKER_USERNAME: ${DOCKER_USERNAME} DOCKER_REPO: ${DOCKER_REPO} @@ -97,6 +107,49 @@ services: max-size: "50m" max-file: "5" + # === Elasticsearch (Prod - 고성능, 최대 3GB) === + devnogi-elastic-search: + image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-8.11.0} + container_name: devnogi-elastic-search + restart: unless-stopped + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms${ELASTICSEARCH_HEAP_SIZE:-1536m} -Xmx${ELASTICSEARCH_HEAP_SIZE:-1536m}" + - cluster.name=devnogi-es-cluster-prod + - bootstrap.memory_lock=true + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + ports: + - "${ELASTICSEARCH_PORT:-9200}:9200" + - "${ELASTICSEARCH_TRANSPORT_PORT:-9300}:9300" + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 90s + deploy: + resources: + limits: + memory: 3g + reservations: + memory: 1536m + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + # === Autoheal (Prod - 빈번한 체크, 긴 graceful shutdown) === autoheal: image: willfarrell/autoheal:latest @@ -121,6 +174,8 @@ services: volumes: app-logs: driver: local + elasticsearch_data: + driver: local networks: app-network: diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/entity/AuctionHistory.java b/src/main/java/until/the/eternity/auctionhistory/domain/entity/AuctionHistory.java index 201e09a3..6f4cd7d8 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/entity/AuctionHistory.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/entity/AuctionHistory.java @@ -2,7 +2,7 @@ import jakarta.persistence.*; import lombok.*; -import until.the.eternity.auctionitemoption.domain.entity.AuctionItemOption; +import until.the.eternity.auctionitemoption.domain.entity.AuctionHistoryItemOption; import java.time.Instant; import java.util.List; @@ -36,7 +36,7 @@ public class AuctionHistory { private Instant dateAuctionBuy; @OneToMany(mappedBy = "auctionHistory", cascade = CascadeType.ALL, orphanRemoval = true) - private List auctionItemOptions; + private List auctionHistoryItemOptions; @Column(name = "item_sub_category", nullable = false) private String itemSubCategory; @@ -45,8 +45,8 @@ public class AuctionHistory { private String itemTopCategory; public AuctionHistory linkItemOptions() { - if (this.auctionItemOptions != null) { - for (AuctionItemOption o : this.auctionItemOptions) { + if (this.auctionHistoryItemOptions != null) { + for (AuctionHistoryItemOption o : this.auctionHistoryItemOptions) { o.setAuctionHistory(this); } } diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/AuctionHistoryMapper.java b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/AuctionHistoryMapper.java index 2fd9fb25..083ea00f 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/AuctionHistoryMapper.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/AuctionHistoryMapper.java @@ -5,7 +5,7 @@ import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.interfaces.rest.dto.response.AuctionHistoryDetailResponse; import until.the.eternity.auctionhistory.interfaces.rest.dto.response.ItemOptionResponse; -import until.the.eternity.auctionitemoption.domain.entity.AuctionItemOption; +import until.the.eternity.auctionitemoption.domain.entity.AuctionHistoryItemOption; import java.util.List; @@ -17,17 +17,16 @@ public interface AuctionHistoryMapper { // Entity → DTO - @Mapping(target = "itemOptions", source = "auctionItemOptions") + @Mapping(target = "itemOptions", source = "auctionHistoryItemOptions") AuctionHistoryDetailResponse toDto(AuctionHistory entity); // 하위 매핑 @Mapping(target = "auctionHistory", ignore = true) - @Mapping(target = "auctionItem", ignore = true) - AuctionItemOption toEntity(ItemOptionResponse dto); + AuctionHistoryItemOption toEntity(ItemOptionResponse dto); - ItemOptionResponse toDto(AuctionItemOption entity); + ItemOptionResponse toDto(AuctionHistoryItemOption entity); - List toEntityList(List dtoList); + List toEntityList(List dtoList); - List toDtoList(List entityList); + List toDtoList(List entityList); } diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java index c0065857..7df5d25b 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java @@ -12,14 +12,25 @@ @Mapper(componentModel = "spring", uses = OpenApiItemOptionMapper.class) public interface OpenApiAuctionHistoryMapper { + String UNKNOWN_ITEM_NAME = "(Unknown)"; + @Named("toEntity(OpenApiAuctionHistoryResponse, ItemCategory)") @Mapping(source = "dateAuctionBuy", target = "dateAuctionBuy", qualifiedByName = "utcToKst") - @Mapping(source = "openApiAuctionItemOptionResponse", target = "auctionItemOptions") + @Mapping(source = "openApiAuctionItemOptionResponse", target = "auctionHistoryItemOptions") @Mapping( target = "itemTopCategory", expression = "java(ItemCategory.findTopCategory(dto.itemSubCategory()))") AuctionHistory toEntity(OpenApiAuctionHistoryResponse dto, @Context ItemCategory itemCategory); + @AfterMapping + default void afterMapping( + OpenApiAuctionHistoryResponse dto, @MappingTarget AuctionHistory entity) { + // item_name이 "(Unknown)"인 경우 item_display_name으로 대체 + if (UNKNOWN_ITEM_NAME.equals(entity.getItemName())) { + entity.setItemName(dto.itemDisplayName()); + } + } + @IterableMapping(qualifiedByName = "toEntity(OpenApiAuctionHistoryResponse, ItemCategory)") List toEntityList( List dtoList, @Context ItemCategory itemCategory); diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiItemOptionMapper.java b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiItemOptionMapper.java index c5ddce3d..43776379 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiItemOptionMapper.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiItemOptionMapper.java @@ -1,15 +1,58 @@ package until.the.eternity.auctionhistory.domain.mapper; +import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; import until.the.eternity.auctionitemoption.domain.dto.external.OpenApiAuctionItemOptionResponse; -import until.the.eternity.auctionitemoption.domain.entity.AuctionItemOption; +import until.the.eternity.auctionitemoption.domain.entity.AuctionHistoryItemOption; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Mapper(componentModel = "spring") public interface OpenApiItemOptionMapper { + String SEGONG_OPTION_TYPE = "세공 옵션"; + + // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨") + // 그룹1: 스킬명, 그룹2: "숫자 레벨" 또는 "숫자레벨" (option_desc), 그룹3: 숫자 (option_value2) + Pattern PATTERN_LEVEL_SUFFIX = Pattern.compile("^(.+?) ((\\d+) ?레벨)$"); + + // 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)") + // 그룹1: 스킬명, 그룹2: 괄호 전체 (option_desc), 그룹3: 숫자 (option_value2) + Pattern PATTERN_LEVEL_PARENTHESIS = Pattern.compile("^(.+?)(\\((\\d+)레벨:.+\\))$"); + @Mapping(target = "id", ignore = true) // PK 자동 생성 @Mapping(target = "auctionHistory", ignore = true) - @Mapping(target = "auctionItem", ignore = true) - AuctionItemOption toEntity(OpenApiAuctionItemOptionResponse itemOption); + AuctionHistoryItemOption toEntity(OpenApiAuctionItemOptionResponse itemOption); + + @AfterMapping + default void afterMapping( + OpenApiAuctionItemOptionResponse dto, @MappingTarget AuctionHistoryItemOption entity) { + // option_type이 "세공 옵션"인 경우에만 파싱 수행 + if (!SEGONG_OPTION_TYPE.equals(entity.getOptionType()) || entity.getOptionValue() == null) { + return; + } + + String originalValue = entity.getOptionValue(); + + // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 + Matcher matcher1 = PATTERN_LEVEL_SUFFIX.matcher(originalValue); + if (matcher1.matches()) { + entity.setOptionValue(matcher1.group(1)); + entity.setOptionValue2(matcher1.group(3)); + entity.setOptionDesc(matcher1.group(2)); + return; + } + + // 패턴 2: "스킬명(숫자레벨:효과)" 형식 + Matcher matcher2 = PATTERN_LEVEL_PARENTHESIS.matcher(originalValue); + if (matcher2.matches()) { + entity.setOptionValue(matcher2.group(1)); + entity.setOptionValue2(matcher2.group(3)); + entity.setOptionDesc(matcher2.group(2)); + } + // 두 패턴 모두 매칭되지 않으면 원본 값 유지 + } } diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index adc69bbe..d5a86889 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -21,7 +21,7 @@ import until.the.eternity.auctionhistory.interfaces.rest.dto.request.DateAuctionBuyRequest; import until.the.eternity.auctionhistory.interfaces.rest.dto.request.ItemOptionSearchRequest; import until.the.eternity.auctionhistory.interfaces.rest.dto.request.PriceSearchRequest; -import until.the.eternity.auctionitemoption.domain.entity.QAuctionItemOption; +import until.the.eternity.auctionitemoption.domain.entity.QAuctionHistoryItemOption; import java.time.Instant; import java.time.LocalDate; @@ -41,15 +41,15 @@ record OptionConditionResult(BooleanBuilder builder, int count) {} /** 경매 거래내역 검색 (옵션 조건 포함) */ public Page search(AuctionHistorySearchRequest condition, Pageable pageable) { QAuctionHistory ah = QAuctionHistory.auctionHistory; - QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; + QAuctionHistoryItemOption aio = QAuctionHistoryItemOption.auctionHistoryItemOption; // 1단계: 거래내역 조건 빌드 BooleanBuilder historyBuilder = buildHistoryPredicate(condition, ah); // 2단계: 옵션 조건이 있으면 서브쿼리 추가 if (condition.itemOptionSearchRequest() != null) { - // 서브쿼리용 별도 QAuctionItemOption 인스턴스 - QAuctionItemOption subOption = new QAuctionItemOption("subOption"); + // 서브쿼리용 별도 QAuctionHistoryItemOption 인스턴스 + QAuctionHistoryItemOption subOption = new QAuctionHistoryItemOption("subOption"); OptionConditionResult optionResult = buildItemOptionConditions(condition.itemOptionSearchRequest(), subOption); @@ -93,7 +93,7 @@ public Page search(AuctionHistorySearchRequest condition, Pageab List content = queryFactory .selectFrom(ah) - .leftJoin(ah.auctionItemOptions, aio) + .leftJoin(ah.auctionHistoryItemOptions, aio) .fetchJoin() .where(ah.auctionBuyId.in(ids)) .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) @@ -159,7 +159,7 @@ private BooleanBuilder buildHistoryPredicate( /** 옵션 검색 조건 빌드 (서브쿼리용) */ private OptionConditionResult buildItemOptionConditions( - ItemOptionSearchRequest opt, QAuctionItemOption aio) { + ItemOptionSearchRequest opt, QAuctionHistoryItemOption aio) { BooleanBuilder builder = new BooleanBuilder(); int conditionCount = 0; boolean ergConditionAdded = false; // 에르그 조건 추가 여부 (레벨/랭크 통합) @@ -400,7 +400,10 @@ private OptionConditionResult buildItemOptionConditions( /** 옵션 조건 빌드 헬퍼 (option_type + 숫자 비교 + SearchStandard) */ private BooleanExpression buildOptionCondition( - QAuctionItemOption aio, String optionType, Integer value, SearchStandard standard) { + QAuctionHistoryItemOption aio, + String optionType, + Integer value, + SearchStandard standard) { BooleanExpression optionTypeCondition = aio.optionType.eq(optionType); NumberTemplate numValue = castOptionValueToInt(aio); @@ -422,7 +425,7 @@ private BooleanExpression buildOptionCondition( } /** option_value2 또는 option_value를 Integer로 변환하는 NumberTemplate */ - private NumberTemplate castOptionValueToInt(QAuctionItemOption aio) { + private NumberTemplate castOptionValueToInt(QAuctionHistoryItemOption aio) { return Expressions.numberTemplate( Integer.class, "COALESCE({0}, {1}, 0)", aio.optionValue2, aio.optionValue); } diff --git a/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionItem.java b/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionItem.java deleted file mode 100644 index 2e9264da..00000000 --- a/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionItem.java +++ /dev/null @@ -1,40 +0,0 @@ -package until.the.eternity.auctionitem.domain.entity; - -import jakarta.persistence.*; -import lombok.*; -import until.the.eternity.auctionitemoption.domain.entity.AuctionItemOption; - -import java.time.LocalDateTime; -import java.util.List; - -@Entity -@Table(name = "auction_item") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class AuctionItem { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "item_name", nullable = false) - private String itemName; - - @Column(name = "item_display_name", nullable = false) - private String itemDisplayName; - - @Column(name = "item_count", nullable = false) - private Long itemCount; - - @Column(name = "auction_price_per_unit", nullable = false) - private Long auctionPricePerUnit; - - @Column(name = "date_auction_expire", nullable = false) - private LocalDateTime dateAuctionExpire; - - @OneToMany(mappedBy = "auctionItem", cascade = CascadeType.ALL, orphanRemoval = true) - private List auctionItemOptions; -} diff --git a/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItem.java b/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItem.java new file mode 100644 index 00000000..d93c2eab --- /dev/null +++ b/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItem.java @@ -0,0 +1,58 @@ +package until.the.eternity.auctionitem.domain.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.List; + +/** 실시간 경매장에서 판매 중인 아이템 정보. V15 마이그레이션에서 auction_item → auction_realtime_item으로 변경됨. */ +@Entity +@Table(name = "auction_realtime_item") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AuctionRealtimeItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "item_name", nullable = false) + private String itemName; + + @Column(name = "item_display_name", nullable = false) + private String itemDisplayName; + + @Column(name = "item_count", nullable = false) + private Long itemCount; + + @Column(name = "auction_price_per_unit", nullable = false) + private Long auctionPricePerUnit; + + @Column(name = "date_auction_expire", nullable = false) + private Instant dateAuctionExpire; + + @Column(name = "date_register", nullable = false) + private Instant dateRegister; + + @Column(name = "item_sub_category", nullable = false) + private String itemSubCategory; + + @Column(name = "item_top_category", nullable = false) + private String itemTopCategory; + + @OneToMany(mappedBy = "auctionRealtimeItem", cascade = CascadeType.ALL, orphanRemoval = true) + private List auctionRealtimeItemOptions; + + public AuctionRealtimeItem linkItemOptions() { + if (this.auctionRealtimeItemOptions != null) { + for (AuctionRealtimeItemOption o : this.auctionRealtimeItemOptions) { + o.setAuctionRealtimeItem(this); + } + } + return this; + } +} diff --git a/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItemOption.java b/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItemOption.java new file mode 100644 index 00000000..28ba2905 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionitem/domain/entity/AuctionRealtimeItemOption.java @@ -0,0 +1,76 @@ +package until.the.eternity.auctionitem.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** 실시간 경매장 아이템(auction_realtime_item)에 연결된 아이템 옵션 정보. */ +@Entity +@Table(name = "auction_realtime_item_option") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AuctionRealtimeItemOption { + + @Id + @Column(name = "id") + private String id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "auction_realtime_item_id", nullable = false) + private AuctionRealtimeItem auctionRealtimeItem; + + @Column(name = "option_type") + private String optionType; + + @Column(name = "option_sub_type") + private String optionSubType; + + @Column(name = "option_value") + private String optionValue; + + @Column(name = "option_value2") + private String optionValue2; + + @Column(name = "option_desc", columnDefinition = "TEXT") + private String optionDesc; + + @PrePersist + public void createId() { + this.id = UUID.randomUUID().toString(); + } + + public void setAuctionRealtimeItem(AuctionRealtimeItem auctionRealtimeItem) { + + // 이전 연관관계 정리 + if (this.auctionRealtimeItem != null) { + this.auctionRealtimeItem.getAuctionRealtimeItemOptions().remove(this); + } + + // 새 연관관계 설정 + this.auctionRealtimeItem = auctionRealtimeItem; + + // 반대 쪽 컬렉션 동기화 + if (auctionRealtimeItem != null + && !auctionRealtimeItem.getAuctionRealtimeItemOptions().contains(this)) { + auctionRealtimeItem.getAuctionRealtimeItemOptions().add(this); + } + } + + public void setOptionValue(String optionValue) { + this.optionValue = optionValue; + } + + public void setOptionValue2(String optionValue2) { + this.optionValue2 = optionValue2; + } + + public void setOptionDesc(String optionDesc) { + this.optionDesc = optionDesc; + } +} diff --git a/src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionItemOption.java b/src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionHistoryItemOption.java similarity index 62% rename from src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionItemOption.java rename to src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionHistoryItemOption.java index 34a42dea..332605de 100644 --- a/src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionItemOption.java +++ b/src/main/java/until/the/eternity/auctionitemoption/domain/entity/AuctionHistoryItemOption.java @@ -6,17 +6,20 @@ import lombok.Getter; import lombok.NoArgsConstructor; import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; -import until.the.eternity.auctionitem.domain.entity.AuctionItem; import java.util.UUID; +/** + * 경매장 거래 내역(auction_history)에 연결된 아이템 옵션 정보. V15 마이그레이션에서 auction_item_option → + * auction_history_item_option으로 변경됨. + */ @Entity -@Table(name = "auction_item_option") +@Table(name = "auction_history_item_option") @Getter @NoArgsConstructor @AllArgsConstructor @Builder -public class AuctionItemOption { +public class AuctionHistoryItemOption { @Id @Column(name = "id") @@ -29,10 +32,6 @@ public class AuctionItemOption { nullable = false) private AuctionHistory auctionHistory; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "auction_item_id", nullable = true) - private AuctionItem auctionItem; - @Column(name = "option_type") private String optionType; @@ -57,15 +56,28 @@ public void setAuctionHistory(AuctionHistory auctionHistory) { // 이전 연관관계 정리 if (this.auctionHistory != null) { - this.auctionHistory.getAuctionItemOptions().remove(this); + this.auctionHistory.getAuctionHistoryItemOptions().remove(this); } // 새 연관관계 설정 this.auctionHistory = auctionHistory; // 반대 쪽 컬렉션 동기화 - if (auctionHistory != null && !auctionHistory.getAuctionItemOptions().contains(this)) { - auctionHistory.getAuctionItemOptions().add(this); + if (auctionHistory != null + && !auctionHistory.getAuctionHistoryItemOptions().contains(this)) { + auctionHistory.getAuctionHistoryItemOptions().add(this); } } + + public void setOptionValue(String optionValue) { + this.optionValue = optionValue; + } + + public void setOptionValue2(String optionValue2) { + this.optionValue2 = optionValue2; + } + + public void setOptionDesc(String optionDesc) { + this.optionDesc = optionDesc; + } } diff --git a/src/main/java/until/the/eternity/auctionrealtime/application/scheduler/AuctionRealtimeScheduler.java b/src/main/java/until/the/eternity/auctionrealtime/application/scheduler/AuctionRealtimeScheduler.java new file mode 100644 index 00000000..a854ddd8 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/application/scheduler/AuctionRealtimeScheduler.java @@ -0,0 +1,148 @@ +package until.the.eternity.auctionrealtime.application.scheduler; + +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.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionrealtime.application.service.AuctionRealtimeService; +import until.the.eternity.auctionrealtime.application.service.fetcher.AuctionRealtimeFetcher; +import until.the.eternity.auctionrealtime.application.service.persister.AuctionRealtimePersister; +import until.the.eternity.auctionrealtime.domain.service.fetcher.AuctionRealtimeFetcherPort.FetchResult; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 실시간 경매장 데이터 수집 스케줄러. + * + *

10분 간격으로 Nexon Open API /auction/list를 호출하여 현재 판매 중인 아이템 정보를 수집한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuctionRealtimeScheduler { + + private final AuctionRealtimeService service; + private final AuctionRealtimeFetcher fetcher; + private final AuctionRealtimePersister persister; + + @Value("${openapi.auction-realtime.delay-ms:500}") + private long delayMs; + + /** 10분 간격으로 실행: 0, 10, 20, 30, 40, 50분. */ + @Scheduled(cron = "${openapi.auction-realtime.cron:0 0/10 * * * *}", zone = "Asia/Seoul") + public void fetchAndSaveAuctionRealtimeAll() { + log.info("[REALTIME] Starting Auction Realtime scheduler"); + + // ItemCategory를 topCategory별로 그룹화 + Map> categoriesByTopCategory = + Arrays.stream(ItemCategory.values()) + .collect( + Collectors.groupingBy( + ItemCategory::getTopCategory, + LinkedHashMap::new, + Collectors.toList())); + + int totalSavedCount = 0; + int totalFailedCount = 0; + List topCategories = new ArrayList<>(categoriesByTopCategory.keySet()); + + for (int topIndex = 0; topIndex < topCategories.size(); topIndex++) { + String topCategory = topCategories.get(topIndex); + List subCategories = categoriesByTopCategory.get(topCategory); + List newEntities = new ArrayList<>(); + + log.debug("[REALTIME] Processing top category [{}]", topCategory); + + for (int subIndex = 0; subIndex < subCategories.size(); subIndex++) { + ItemCategory category = subCategories.get(subIndex); + try { + log.debug("[REALTIME] Processing category [{}]", category.getSubCategory()); + + // API 호출 및 데이터 수집 + FetchResult fetchResult = fetcher.fetch(category); + + if (fetchResult.items().isEmpty()) { + log.debug("[REALTIME] [{}] No data fetched", category.getSubCategory()); + continue; + } + + // 엔티티 변환 및 필터링 + List entities = + persister.prepareEntities( + fetchResult.items(), category, fetchResult.latestDate()); + + if (entities.isEmpty()) { + continue; + } + + // 동일 날짜 데이터가 있으면 삭제 후 저장 + if (fetchResult.hasEqualDate() && fetchResult.latestDate() != null) { + service.deleteAndSave(category, fetchResult.latestDate(), entities); + } else { + newEntities.addAll(entities); + } + + // 마지막 서브 카테고리가 아닌 경우에만 delay 적용 + if (subIndex < subCategories.size() - 1) { + log.debug( + "[REALTIME] Waiting {}ms before processing next category", delayMs); + Thread.sleep(delayMs); + } + } catch (InterruptedException e) { + log.error( + "[REALTIME] Thread interrupted during delay for category [{}]", + category.getSubCategory(), + e); + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + log.error( + "[REALTIME] Error during processing category [{}]", + category.getSubCategory(), + e); + totalFailedCount++; + } + } + + // Top Category별로 저장 (동일 날짜가 아닌 신규 데이터) + if (!newEntities.isEmpty()) { + service.saveAll(newEntities); + totalSavedCount += newEntities.size(); + log.info( + "[REALTIME] Saved [{}] new auction realtime items for top category [{}]", + newEntities.size(), + topCategory); + } + + // 마지막 탑 카테고리가 아닌 경우에만 delay 적용 + if (topIndex < topCategories.size() - 1) { + try { + log.debug( + "[REALTIME] Waiting {}ms before processing next top category", delayMs); + Thread.sleep(delayMs); + } catch (InterruptedException e) { + log.error( + "[REALTIME] Thread interrupted during delay for top category [{}]", + topCategory, + e); + Thread.currentThread().interrupt(); + break; + } + } + } + + // 만료된 아이템 삭제 + int deletedExpired = service.deleteExpiredItems(Instant.now()); + + log.info( + "[REALTIME] Auction Realtime scheduler completed. Total saved: {}, Failed categories: {}, Expired deleted: {}", + totalSavedCount, + totalFailedCount, + deletedExpired); + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeService.java b/src/main/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeService.java new file mode 100644 index 00000000..1042d372 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/AuctionRealtimeService.java @@ -0,0 +1,119 @@ +package until.the.eternity.auctionrealtime.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionrealtime.domain.mapper.AuctionRealtimeMapper; +import until.the.eternity.auctionrealtime.domain.repository.AuctionRealtimeItemRepositoryPort; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.AuctionRealtimeDetailResponse; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.RealtimeItemOptionResponse; +import until.the.eternity.common.enums.ItemCategory; +import until.the.eternity.common.response.PageResponseDto; + +import java.time.Instant; +import java.util.List; + +/** 실시간 경매장 데이터 Service. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuctionRealtimeService { + + private final AuctionRealtimeItemRepositoryPort repository; + private final AuctionRealtimeMapper mapper; + + /** + * 실시간 경매장 아이템을 검색한다. + * + * @param requestDto 검색 조건 + * @param pageable 페이지 정보 + * @return 검색 결과 + */ + @Transactional(readOnly = true) + public PageResponseDto> search( + AuctionRealtimeSearchRequest requestDto, Pageable pageable) { + + Page page = repository.search(requestDto, pageable); + Page> dtoPage = + page.map(mapper::toDto); + return PageResponseDto.of(dtoPage); + } + + /** + * ID로 실시간 경매장 아이템을 조회한다. + * + * @param id 아이템 ID + * @return 아이템 상세 정보 + */ + @Transactional(readOnly = true) + public AuctionRealtimeDetailResponse findByIdOrElseThrow(Long id) { + AuctionRealtimeItem item = + repository + .findById(id) + .orElseThrow( + () -> + new IllegalArgumentException( + "AuctionRealtimeItem not found: " + id)); + return mapper.toDto(item); + } + + /** + * 해당 카테고리 & 동일 date_auction_expire 레코드 삭제 후 새 엔티티들을 저장한다. + * + * @param category 아이템 카테고리 + * @param dateAuctionExpire 삭제할 date_auction_expire + * @param entities 저장할 엔티티 리스트 + */ + @Transactional + public void deleteAndSave( + ItemCategory category, Instant dateAuctionExpire, List entities) { + + // 동일 date_auction_expire 레코드 삭제 + int deleted = + repository.deleteBySubCategoryAndDateAuctionExpire(category, dateAuctionExpire); + log.info( + "[REALTIME] [{}] Deleted {} records with date_auction_expire={}", + category.getSubCategory(), + deleted, + dateAuctionExpire); + + // 새 엔티티 저장 + repository.saveAll(entities); + log.info( + "[REALTIME] [{}] Saved {} new auction realtime items", + category.getSubCategory(), + entities.size()); + } + + /** + * 엔티티들을 저장한다. (삭제 없이) + * + * @param entities 저장할 엔티티 리스트 + */ + @Transactional + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + repository.saveAll(entities); + log.debug("[REALTIME] Saved {} auction realtime items", entities.size()); + } + + /** + * 만료된 아이템을 삭제한다. + * + * @param now 현재 시각 + * @return 삭제된 레코드 수 + */ + @Transactional + public int deleteExpiredItems(Instant now) { + int deleted = repository.deleteExpiredItems(now); + log.info("[REALTIME] Deleted {} expired auction realtime items", deleted); + return deleted; + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcher.java b/src/main/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcher.java new file mode 100644 index 00000000..b14b73b3 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcher.java @@ -0,0 +1,101 @@ +package until.the.eternity.auctionrealtime.application.service.fetcher; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker; +import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker.DuplicateCheckResult; +import until.the.eternity.auctionrealtime.domain.service.fetcher.AuctionRealtimeFetcherPort; +import until.the.eternity.auctionrealtime.infrastructure.client.AuctionRealtimeClient; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeListResponse; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** 실시간 경매장 데이터 Fetcher 구현체. Cursor 기반 페이징으로 API를 호출하고, 중복 감지 시 호출을 중단한다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuctionRealtimeFetcher implements AuctionRealtimeFetcherPort { + + private final AuctionRealtimeClient client; + private final AuctionRealtimeDuplicateChecker duplicateChecker; + + @Override + public FetchResult fetch(ItemCategory category) { + List result = new ArrayList<>(); + String cursor = ""; + boolean hasEqualDate = false; + Instant latestDate = null; + + while (true) { + OpenApiAuctionRealtimeListResponse response = + client.fetchAuctionList(category, cursor).block(); + + if (response == null || response.auctionItems() == null) { + log.warn( + "[REALTIME] [{}] response or its items is null, something is wrong with open api call", + category.getSubCategory()); + break; + } + + log.debug( + "[REALTIME] [{}] fetched '{}' data", + category.getSubCategory(), + response.auctionItems().size()); + + if (response.auctionItems().isEmpty()) { + log.debug("[REALTIME] [{}] fetched no data", category.getSubCategory()); + break; + } + + List batch = response.auctionItems(); + + // 중복 체크 + DuplicateCheckResult checkResult = + duplicateChecker.checkDuplicateInBatch(batch, category); + + if (checkResult.isDuplicate()) { + int index = checkResult.duplicateIndex(); + latestDate = checkResult.latestDate(); + hasEqualDate = checkResult.hasEqualDate(); + + if (index > 0) { + result.addAll(batch.subList(0, index)); + } + + if (hasEqualDate) { + log.debug( + "[REALTIME] [{}] equal date found at index {}, need to delete and re-save, added {} items", + category.getSubCategory(), + index, + index); + } else { + log.debug( + "[REALTIME] [{}] duplicate found at index {}, added {} items before duplicate", + category.getSubCategory(), + index, + index); + } + break; + } + + latestDate = checkResult.latestDate(); + result.addAll(batch); + + cursor = response.nextCursor(); + + if (cursor == null || cursor.isEmpty()) { + log.debug( + "[REALTIME] [{}] response cursor is null, fetched end", + category.getSubCategory()); + break; + } + } + + return new FetchResult(result, hasEqualDate, latestDate); + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/application/service/persister/AuctionRealtimePersister.java b/src/main/java/until/the/eternity/auctionrealtime/application/service/persister/AuctionRealtimePersister.java new file mode 100644 index 00000000..41731e35 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/application/service/persister/AuctionRealtimePersister.java @@ -0,0 +1,54 @@ +package until.the.eternity.auctionrealtime.application.service.persister; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionrealtime.domain.mapper.OpenApiAuctionRealtimeMapper; +import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker; +import until.the.eternity.auctionrealtime.domain.service.persister.AuctionRealtimePersisterPort; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.List; + +/** 실시간 경매장 데이터 Persister 구현체. */ +@Slf4j +@RequiredArgsConstructor +@Component +public class AuctionRealtimePersister implements AuctionRealtimePersisterPort { + + private final OpenApiAuctionRealtimeMapper mapper; + private final AuctionRealtimeDuplicateChecker duplicateChecker; + + @Override + public List prepareEntities( + List dtoList, + ItemCategory category, + Instant latestDate) { + + // 저장 가능한 데이터만 필터링 + List filtered = + duplicateChecker.filterForSave(dtoList, latestDate); + + // Entity 변환 + List entities = mapper.toEntityList(filtered, category); + + // 아이템 옵션 링크 설정 + entities.forEach(AuctionRealtimeItem::linkItemOptions); + + if (entities.isEmpty()) { + log.info( + "[REALTIME] [{}] No new auction realtime items to save", + category.getSubCategory()); + } else { + log.info( + "[REALTIME] [{}] Prepared {} auction realtime items for save", + category.getSubCategory(), + entities.size()); + } + + return entities; + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/AuctionRealtimeMapper.java b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/AuctionRealtimeMapper.java new file mode 100644 index 00000000..f292d803 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/AuctionRealtimeMapper.java @@ -0,0 +1,22 @@ +package until.the.eternity.auctionrealtime.domain.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItemOption; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.AuctionRealtimeDetailResponse; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.RealtimeItemOptionResponse; + +import java.util.List; + +/** AuctionRealtimeItem Entity to DTO mapper class. */ +@Mapper(componentModel = "spring") +public interface AuctionRealtimeMapper { + + @Mapping(target = "itemOptions", source = "auctionRealtimeItemOptions") + AuctionRealtimeDetailResponse toDto(AuctionRealtimeItem entity); + + RealtimeItemOptionResponse toDto(AuctionRealtimeItemOption entity); + + List toDtoList(List entityList); +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiAuctionRealtimeMapper.java b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiAuctionRealtimeMapper.java new file mode 100644 index 00000000..8f65de6b --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiAuctionRealtimeMapper.java @@ -0,0 +1,49 @@ +package until.the.eternity.auctionrealtime.domain.mapper; + +import org.mapstruct.*; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +/** OpenApiAuctionRealtimeResponse → AuctionRealtimeItem Entity 변환 Mapper. */ +@Mapper(componentModel = "spring", uses = OpenApiRealtimeItemOptionMapper.class) +public interface OpenApiAuctionRealtimeMapper { + + String UNKNOWN_ITEM_NAME = "(Unknown)"; + + @Named("toEntity(OpenApiAuctionRealtimeResponse, ItemCategory)") + @Mapping( + source = "dateAuctionExpire", + target = "dateAuctionExpire", + qualifiedByName = "utcToKst") + @Mapping(source = "itemOptions", target = "auctionRealtimeItemOptions") + @Mapping(target = "itemSubCategory", expression = "java(itemCategory.getSubCategory())") + @Mapping(target = "itemTopCategory", expression = "java(itemCategory.getTopCategory())") + @Mapping(target = "id", ignore = true) + @Mapping(target = "dateRegister", expression = "java(java.time.Instant.now())") + AuctionRealtimeItem toEntity( + OpenApiAuctionRealtimeResponse dto, @Context ItemCategory itemCategory); + + @AfterMapping + default void afterMapping( + OpenApiAuctionRealtimeResponse dto, @MappingTarget AuctionRealtimeItem entity) { + // item_name이 "(Unknown)"인 경우 item_display_name으로 대체 + if (UNKNOWN_ITEM_NAME.equals(entity.getItemName())) { + entity.setItemName(dto.itemDisplayName()); + } + } + + @IterableMapping(qualifiedByName = "toEntity(OpenApiAuctionRealtimeResponse, ItemCategory)") + List toEntityList( + List dtoList, @Context ItemCategory itemCategory); + + @Named("utcToKst") + default Instant utcToKst(Instant utcTime) { + // API에서 받은 UTC 시간에 9시간을 더하여 KST로 변환 + return utcTime != null ? utcTime.plus(9, ChronoUnit.HOURS) : null; + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java new file mode 100644 index 00000000..ae30e87f --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java @@ -0,0 +1,59 @@ +package until.the.eternity.auctionrealtime.domain.mapper; + +import org.mapstruct.AfterMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItemOption; +import until.the.eternity.auctionitemoption.domain.dto.external.OpenApiAuctionItemOptionResponse; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** OpenApiAuctionItemOptionResponse → AuctionRealtimeItemOption Entity 변환 Mapper. */ +@Mapper(componentModel = "spring") +public interface OpenApiRealtimeItemOptionMapper { + + String SEGONG_OPTION_TYPE = "세공 옵션"; + + // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨") + // 그룹1: 스킬명, 그룹2: "숫자 레벨" 또는 "숫자레벨" (option_desc), 그룹3: 숫자 (option_value2) + Pattern PATTERN_LEVEL_SUFFIX = Pattern.compile("^(.+?) ((\\d+) ?레벨)$"); + + // 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)") + // 그룹1: 스킬명, 그룹2: 괄호 전체 (option_desc), 그룹3: 숫자 (option_value2) + Pattern PATTERN_LEVEL_PARENTHESIS = Pattern.compile("^(.+?)(\\((\\d+)레벨:.+\\))$"); + + @Mapping(target = "id", ignore = true) // PK 자동 생성 + @Mapping(target = "auctionRealtimeItem", ignore = true) + AuctionRealtimeItemOption toEntity(OpenApiAuctionItemOptionResponse itemOption); + + @AfterMapping + default void afterMapping( + OpenApiAuctionItemOptionResponse dto, @MappingTarget AuctionRealtimeItemOption entity) { + // option_type이 "세공 옵션"인 경우에만 파싱 수행 + if (!SEGONG_OPTION_TYPE.equals(entity.getOptionType()) || entity.getOptionValue() == null) { + return; + } + + String originalValue = entity.getOptionValue(); + + // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 + Matcher matcher1 = PATTERN_LEVEL_SUFFIX.matcher(originalValue); + if (matcher1.matches()) { + entity.setOptionValue(matcher1.group(1)); + entity.setOptionValue2(matcher1.group(3)); + entity.setOptionDesc(matcher1.group(2)); + return; + } + + // 패턴 2: "스킬명(숫자레벨:효과)" 형식 + Matcher matcher2 = PATTERN_LEVEL_PARENTHESIS.matcher(originalValue); + if (matcher2.matches()) { + entity.setOptionValue(matcher2.group(1)); + entity.setOptionValue2(matcher2.group(3)); + entity.setOptionDesc(matcher2.group(2)); + } + // 두 패턴 모두 매칭되지 않으면 원본 값 유지 + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/repository/AuctionRealtimeItemRepositoryPort.java b/src/main/java/until/the/eternity/auctionrealtime/domain/repository/AuctionRealtimeItemRepositoryPort.java new file mode 100644 index 00000000..d11c7408 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/repository/AuctionRealtimeItemRepositoryPort.java @@ -0,0 +1,64 @@ +package until.the.eternity.auctionrealtime.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** AuctionRealtimeItem Repository Port (Hexagonal Architecture). */ +public interface AuctionRealtimeItemRepositoryPort { + + /** + * 실시간 경매장 아이템을 검색한다. + * + * @param condition 검색 조건 + * @param pageable 페이지 정보 + * @return 검색 결과 + */ + Page search(AuctionRealtimeSearchRequest condition, Pageable pageable); + + /** + * ID로 실시간 경매장 아이템을 조회한다. + * + * @param id 아이템 ID + * @return 아이템 (없으면 Optional.empty()) + */ + Optional findById(Long id); + + /** + * 해당 subcategory의 최신 date_auction_expire를 조회한다. + * + * @param category 아이템 카테고리 + * @return 최신 date_auction_expire (없으면 Optional.empty()) + */ + Optional findLatestDateAuctionExpireBySubCategory(ItemCategory category); + + /** + * 해당 subcategory & date_auction_expire에 해당하는 모든 레코드를 삭제한다. + * + * @param category 아이템 카테고리 + * @param dateAuctionExpire 만료 시각 + * @return 삭제된 레코드 수 + */ + int deleteBySubCategoryAndDateAuctionExpire(ItemCategory category, Instant dateAuctionExpire); + + /** + * date_auction_expire가 현재 시각보다 이전인 모든 레코드를 삭제한다. + * + * @param now 현재 시각 + * @return 삭제된 레코드 수 + */ + int deleteExpiredItems(Instant now); + + /** + * 엔티티들을 일괄 저장한다. + * + * @param entities 저장할 엔티티 리스트 + */ + void saveAll(List entities); +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateChecker.java b/src/main/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateChecker.java new file mode 100644 index 00000000..09637f55 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateChecker.java @@ -0,0 +1,132 @@ +package until.the.eternity.auctionrealtime.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import until.the.eternity.auctionrealtime.domain.repository.AuctionRealtimeItemRepositoryPort; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * 실시간 경매장 데이터의 중복 체크 로직. + * + *

중복 판단 기준: + * + *

    + *
  • date_auction_expire < latestDate → 중복 (과거 데이터) + *
  • date_auction_expire == latestDate → 해당 시간대 데이터 삭제 후 재저장 필요 + *
  • date_auction_expire > latestDate → 신규 데이터 + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuctionRealtimeDuplicateChecker { + + private final AuctionRealtimeItemRepositoryPort repository; + + /** + * 중복 체크 결과. + * + * @param isDuplicate 중복 여부 + * @param hasEqualDate 동일 날짜 데이터 존재 여부 (삭제 후 재저장 필요) + * @param duplicateIndex 중복이 발생한 첫 번째 인덱스 (-1이면 중복 없음) + * @param latestDate DB의 최신 date_auction_expire + */ + public record DuplicateCheckResult( + boolean isDuplicate, boolean hasEqualDate, int duplicateIndex, Instant latestDate) { + + public static DuplicateCheckResult noDuplicate() { + return new DuplicateCheckResult(false, false, -1, null); + } + + public static DuplicateCheckResult noDuplicateWithLatestDate(Instant latestDate) { + return new DuplicateCheckResult(false, false, -1, latestDate); + } + + public static DuplicateCheckResult duplicateFound(int index, Instant latestDate) { + return new DuplicateCheckResult(true, false, index, latestDate); + } + + public static DuplicateCheckResult equalDateFound(int index, Instant latestDate) { + return new DuplicateCheckResult(true, true, index, latestDate); + } + } + + /** + * 배치에서 중복 또는 동일 날짜 데이터를 체크한다. + * + * @param batch 검사할 배치 데이터 + * @param category 아이템 카테고리 + * @return 중복 체크 결과 + */ + public DuplicateCheckResult checkDuplicateInBatch( + List batch, ItemCategory category) { + if (batch == null || batch.isEmpty()) { + return DuplicateCheckResult.noDuplicate(); + } + + Optional latestDateOpt = + repository.findLatestDateAuctionExpireBySubCategory(category); + + if (latestDateOpt.isEmpty()) { + // DB에 데이터가 없으면 모두 신규 + return DuplicateCheckResult.noDuplicate(); + } + + Instant latestDate = latestDateOpt.get(); + + for (int i = 0; i < batch.size(); i++) { + OpenApiAuctionRealtimeResponse dto = batch.get(i); + Instant dtoDate = dto.dateAuctionExpire(); + + if (dtoDate == null) { + continue; + } + + if (dtoDate.isBefore(latestDate)) { + // 과거 데이터 → 중복 + return DuplicateCheckResult.duplicateFound(i, latestDate); + } + + if (dtoDate.equals(latestDate)) { + // 동일 날짜 → 삭제 후 재저장 필요 + return DuplicateCheckResult.equalDateFound(i, latestDate); + } + } + + // 모든 데이터가 latestDate보다 이후 + return DuplicateCheckResult.noDuplicateWithLatestDate(latestDate); + } + + /** + * API 응답 데이터를 필터링하여 저장 가능한 데이터만 반환한다. + * + * @param dtos 필터링할 DTO 리스트 + * @param latestDate DB의 최신 date_auction_expire (null이면 모두 저장) + * @return 저장할 데이터 리스트 + */ + public List filterForSave( + List dtos, Instant latestDate) { + if (dtos == null || dtos.isEmpty()) { + return List.of(); + } + + if (latestDate == null) { + return dtos; + } + + return dtos.stream() + .filter( + dto -> { + Instant dtoDate = dto.dateAuctionExpire(); + // latestDate 이후 또는 동일한 날짜만 저장 + return dtoDate != null && !dtoDate.isBefore(latestDate); + }) + .toList(); + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/service/fetcher/AuctionRealtimeFetcherPort.java b/src/main/java/until/the/eternity/auctionrealtime/domain/service/fetcher/AuctionRealtimeFetcherPort.java new file mode 100644 index 00000000..da3a9c02 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/service/fetcher/AuctionRealtimeFetcherPort.java @@ -0,0 +1,34 @@ +package until.the.eternity.auctionrealtime.domain.service.fetcher; + +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.List; + +/** 실시간 경매장 데이터 Fetcher Port. */ +public interface AuctionRealtimeFetcherPort { + + /** + * 페칭 결과. + * + * @param items 수집된 아이템 리스트 + * @param hasEqualDate 동일 날짜 데이터 존재 여부 (삭제 후 재저장 필요) + * @param latestDate DB의 최신 date_auction_expire + */ + record FetchResult( + List items, boolean hasEqualDate, Instant latestDate) { + + public static FetchResult empty() { + return new FetchResult(List.of(), false, null); + } + } + + /** + * 해당 카테고리의 실시간 경매장 데이터를 수집한다. + * + * @param category 아이템 카테고리 + * @return 페칭 결과 + */ + FetchResult fetch(ItemCategory category); +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/service/persister/AuctionRealtimePersisterPort.java b/src/main/java/until/the/eternity/auctionrealtime/domain/service/persister/AuctionRealtimePersisterPort.java new file mode 100644 index 00000000..cadc56f1 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/service/persister/AuctionRealtimePersisterPort.java @@ -0,0 +1,25 @@ +package until.the.eternity.auctionrealtime.domain.service.persister; + +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.List; + +/** 실시간 경매장 데이터 Persister Port. */ +public interface AuctionRealtimePersisterPort { + + /** + * API 응답 데이터를 Entity로 변환하고 필터링한다. + * + * @param dtoList API 응답 DTO 리스트 + * @param category 아이템 카테고리 + * @param latestDate DB의 최신 date_auction_expire (null이면 모두 저장) + * @return 저장할 Entity 리스트 + */ + List prepareEntities( + List dtoList, + ItemCategory category, + Instant latestDate); +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/client/AuctionRealtimeClient.java b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/client/AuctionRealtimeClient.java new file mode 100644 index 00000000..5e92495a --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/client/AuctionRealtimeClient.java @@ -0,0 +1,59 @@ +package until.the.eternity.auctionrealtime.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.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeListResponse; +import until.the.eternity.common.enums.ItemCategory; + +/** Nexon Open API /auction/list 엔드포인트 호출 클라이언트. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuctionRealtimeClient { + + private final WebClient openApiWebClient; + + /** + * 카테고리·커서 기반 경매장 현재 판매 목록 조회. + * + * @param category 조회할 카테고리 + * @param cursor 다음 페이지 커서(null 가능) + * @return 응답 DTO를 담은 Mono, 호출 실패 시 Mono.empty() + */ + public Mono fetchAuctionList( + ItemCategory category, String cursor) { + + log.info( + "[REALTIME] [{}] Calling Nexon Open API Auction List API with cursor='{}'", + category.getSubCategory(), + cursor == null ? "" : cursor); + + return openApiWebClient + .get() + .uri( + uriBuilder -> { + uriBuilder + .path("/auction/list") + .queryParam("auction_item_category", category.getSubCategory()); + if (cursor != null && !cursor.isEmpty()) { + uriBuilder.queryParam("cursor", cursor); + } + return uriBuilder.build(); + }) + .retrieve() + .bodyToMono(OpenApiAuctionRealtimeListResponse.class) + .onErrorResume( + throwable -> { + log.warn( + "[REALTIME] [{}] Failed to fetch Nexon Open API Auction List API with cursor='{}': error='{}', message='{}'", + category.getSubCategory(), + cursor, + throwable.toString(), + throwable.getMessage()); + return Mono.empty(); + }); + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepository.java b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepository.java new file mode 100644 index 00000000..d247cd44 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepository.java @@ -0,0 +1,35 @@ +package until.the.eternity.auctionrealtime.infrastructure.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; + +import java.time.Instant; +import java.util.Optional; + +/** AuctionRealtimeItem JPA Repository. */ +public interface AuctionRealtimeItemRepository extends JpaRepository { + + @Query( + value = + "SELECT date_auction_expire FROM auction_realtime_item " + + "WHERE item_sub_category = :subCategory " + + "ORDER BY date_auction_expire DESC LIMIT 1", + nativeQuery = true) + Optional findLatestDateAuctionExpireBySubCategory( + @Param("subCategory") String subCategory); + + @Modifying + @Query( + "DELETE FROM AuctionRealtimeItem a " + + "WHERE a.itemSubCategory = :subCategory AND a.dateAuctionExpire = :dateAuctionExpire") + int deleteBySubCategoryAndDateAuctionExpire( + @Param("subCategory") String subCategory, + @Param("dateAuctionExpire") Instant dateAuctionExpire); + + @Modifying + @Query("DELETE FROM AuctionRealtimeItem a WHERE a.dateAuctionExpire < :now") + int deleteExpiredItems(@Param("now") Instant now); +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepositoryPortImpl.java b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepositoryPortImpl.java new file mode 100644 index 00000000..3d88f07b --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeItemRepositoryPortImpl.java @@ -0,0 +1,79 @@ +package until.the.eternity.auctionrealtime.infrastructure.persistence; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionrealtime.domain.repository.AuctionRealtimeItemRepositoryPort; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** AuctionRealtimeItemRepositoryPort 구현체. */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class AuctionRealtimeItemRepositoryPortImpl implements AuctionRealtimeItemRepositoryPort { + + private static final int BATCH_SIZE = 500; + + private final AuctionRealtimeItemRepository jpaRepository; + private final AuctionRealtimeQueryDslRepository queryDslRepository; + private final EntityManager entityManager; + + @Override + public Page search( + AuctionRealtimeSearchRequest condition, Pageable pageable) { + return queryDslRepository.search(condition, pageable); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public Optional findLatestDateAuctionExpireBySubCategory(ItemCategory category) { + return jpaRepository.findLatestDateAuctionExpireBySubCategory(category.getSubCategory()); + } + + @Override + public int deleteBySubCategoryAndDateAuctionExpire( + ItemCategory category, Instant dateAuctionExpire) { + return jpaRepository.deleteBySubCategoryAndDateAuctionExpire( + category.getSubCategory(), dateAuctionExpire); + } + + @Override + public int deleteExpiredItems(Instant now) { + return jpaRepository.deleteExpiredItems(now); + } + + @Override + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + for (int i = 0; i < entities.size(); i++) { + entityManager.persist(entities.get(i)); + + if ((i + 1) % BATCH_SIZE == 0) { + entityManager.flush(); + entityManager.clear(); + } + } + + // 마지막 배치 처리 + entityManager.flush(); + entityManager.clear(); + + log.debug("[REALTIME] Saved {} auction realtime items", entities.size()); + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeQueryDslRepository.java b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeQueryDslRepository.java new file mode 100644 index 00000000..d3e25e59 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/infrastructure/persistence/AuctionRealtimeQueryDslRepository.java @@ -0,0 +1,423 @@ +package until.the.eternity.auctionrealtime.infrastructure.persistence; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberTemplate; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.ItemOptionSearchRequest; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.PriceSearchRequest; +import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItem; +import until.the.eternity.auctionitem.domain.entity.QAuctionRealtimeItem; +import until.the.eternity.auctionitem.domain.entity.QAuctionRealtimeItemOption; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +class AuctionRealtimeQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + /** 옵션 조건 빌드 결과 (조건 BooleanBuilder + 추가된 조건 개수) */ + record OptionConditionResult(BooleanBuilder builder, int count) {} + + /** 실시간 경매장 검색 (옵션 조건 포함) */ + public Page search( + AuctionRealtimeSearchRequest condition, Pageable pageable) { + QAuctionRealtimeItem ar = QAuctionRealtimeItem.auctionRealtimeItem; + QAuctionRealtimeItemOption aro = QAuctionRealtimeItemOption.auctionRealtimeItemOption; + + // 1단계: 기본 조건 빌드 + BooleanBuilder itemBuilder = buildItemPredicate(condition, ar); + + // 2단계: 옵션 조건이 있으면 서브쿼리 추가 + if (condition.itemOptionSearchRequest() != null) { + QAuctionRealtimeItemOption subOption = new QAuctionRealtimeItemOption("subOption"); + OptionConditionResult optionResult = + buildItemOptionConditions(condition.itemOptionSearchRequest(), subOption); + + if (optionResult.builder().hasValue() && optionResult.count() > 0) { + var subQuery = + JPAExpressions.select(subOption.auctionRealtimeItem.id) + .from(subOption) + .where(optionResult.builder()) + .groupBy(subOption.auctionRealtimeItem.id) + .having(subOption.count().eq((long) optionResult.count())); + + itemBuilder.and(ar.id.in(subQuery)); + } + } + + // 3단계: 정렬 조건 빌드 + List> orderSpecifiers = buildOrderSpecifiers(pageable, ar); + + // 4단계: Deferred Join 패턴 적용 + // 4-1단계: ID만 먼저 조회 + List ids = + queryFactory + .select(ar.id) + .from(ar) + .where(itemBuilder) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + if (ids.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0L); + } + + // 4-2단계: ID로 상세 조회 + List content = + queryFactory + .selectFrom(ar) + .leftJoin(ar.auctionRealtimeItemOptions, aro) + .fetchJoin() + .where(ar.id.in(ids)) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .distinct() + .fetch(); + + // Count 쿼리 + Long total = queryFactory.select(ar.countDistinct()).from(ar).where(itemBuilder).fetchOne(); + + return new PageImpl<>(content, pageable, total == null ? 0L : total); + } + + /** 기본 조건 빌드 (카테고리, 아이템명, 가격) */ + private BooleanBuilder buildItemPredicate( + AuctionRealtimeSearchRequest c, QAuctionRealtimeItem ar) { + BooleanBuilder builder = new BooleanBuilder(); + + if (c.itemTopCategory() != null && !c.itemTopCategory().isBlank()) { + builder.and(ar.itemTopCategory.eq(c.itemTopCategory())); + } + if (c.itemSubCategory() != null && !c.itemSubCategory().isBlank()) { + builder.and(ar.itemSubCategory.eq(c.itemSubCategory())); + } + if (c.itemName() != null && !c.itemName().isBlank()) { + builder.and(ar.itemName.containsIgnoreCase(c.itemName())); + } + + if (c.priceSearchRequest() != null) { + PriceSearchRequest price = c.priceSearchRequest(); + if (price.priceFrom() != null) { + builder.and(ar.auctionPricePerUnit.goe(price.priceFrom())); + } + if (price.priceTo() != null) { + builder.and(ar.auctionPricePerUnit.loe(price.priceTo())); + } + } + + return builder; + } + + /** 옵션 검색 조건 빌드 (서브쿼리용) */ + private OptionConditionResult buildItemOptionConditions( + ItemOptionSearchRequest opt, QAuctionRealtimeItemOption aro) { + BooleanBuilder builder = new BooleanBuilder(); + int conditionCount = 0; + boolean ergConditionAdded = false; + + // 1. Balance (밸런스) + if (opt.balanceSearch() != null && opt.balanceSearch().balance() != null) { + builder.or( + buildOptionCondition( + aro, + "밸런스", + opt.balanceSearch().balance(), + opt.balanceSearch().balanceStandard())); + conditionCount++; + } + + // 2. Critical (크리티컬) + if (opt.criticalSearch() != null && opt.criticalSearch().critical() != null) { + builder.or( + buildOptionCondition( + aro, + "크리티컬", + opt.criticalSearch().critical(), + opt.criticalSearch().criticalStandard())); + conditionCount++; + } + + // 3. Defense (방어력) + if (opt.defenseSearch() != null && opt.defenseSearch().defense() != null) { + builder.or( + buildOptionCondition( + aro, + "방어력", + opt.defenseSearch().defense(), + opt.defenseSearch().defenseStandard())); + conditionCount++; + } + + // 4. Erg (에르그) - 범위 검색 + if (opt.ergSearch() != null) { + BooleanExpression ergTypeCondition = aro.optionType.eq("에르그"); + BooleanExpression ergValueCondition = null; + + if (opt.ergSearch().ergFrom() != null && opt.ergSearch().ergTo() != null) { + ergValueCondition = + castOptionValueToInt(aro) + .between(opt.ergSearch().ergFrom(), opt.ergSearch().ergTo()); + } else if (opt.ergSearch().ergFrom() != null) { + ergValueCondition = castOptionValueToInt(aro).goe(opt.ergSearch().ergFrom()); + } else if (opt.ergSearch().ergTo() != null) { + ergValueCondition = castOptionValueToInt(aro).loe(opt.ergSearch().ergTo()); + } + + if (ergValueCondition != null) { + BooleanExpression combined = ergTypeCondition.and(ergValueCondition); + builder.or(Expressions.booleanTemplate("({0})", combined)); + if (!ergConditionAdded) { + conditionCount++; + ergConditionAdded = true; + } + } + } + + // 5. ErgRank (에르그 등급) + if (opt.ergRankSearch() != null && opt.ergRankSearch().ergRank() != null) { + BooleanExpression combined = + aro.optionType.eq("에르그").and(aro.optionValue.eq(opt.ergRankSearch().ergRank())); + builder.or(Expressions.booleanTemplate("({0})", combined)); + if (!ergConditionAdded) { + conditionCount++; + ergConditionAdded = true; + } + } + + // 6. MagicDefense (마법 방어력) + if (opt.magicDefenseSearch() != null && opt.magicDefenseSearch().magicDefense() != null) { + builder.or( + buildOptionCondition( + aro, + "마법 방어력", + opt.magicDefenseSearch().magicDefense(), + opt.magicDefenseSearch().magicDefenseStandard())); + conditionCount++; + } + + // 7. MagicProtect (마법 보호) + if (opt.magicProtectSearch() != null && opt.magicProtectSearch().magicProtect() != null) { + builder.or( + buildOptionCondition( + aro, + "마법 보호", + opt.magicProtectSearch().magicProtect(), + opt.magicProtectSearch().magicProtectStandard())); + conditionCount++; + } + + // 8. MaxAttack (공격) - 범위 검색 + if (opt.maxAttackSearch() != null) { + BooleanExpression attackTypeCondition = aro.optionType.eq("공격"); + BooleanExpression attackValueCondition = null; + + if (opt.maxAttackSearch().maxAttackFrom() != null + && opt.maxAttackSearch().maxAttackTo() != null) { + attackValueCondition = + castOptionValueToInt(aro) + .between( + opt.maxAttackSearch().maxAttackFrom(), + opt.maxAttackSearch().maxAttackTo()); + } else if (opt.maxAttackSearch().maxAttackFrom() != null) { + attackValueCondition = + castOptionValueToInt(aro).goe(opt.maxAttackSearch().maxAttackFrom()); + } else if (opt.maxAttackSearch().maxAttackTo() != null) { + attackValueCondition = + castOptionValueToInt(aro).loe(opt.maxAttackSearch().maxAttackTo()); + } + + if (attackValueCondition != null) { + BooleanExpression combined = attackTypeCondition.and(attackValueCondition); + builder.or(Expressions.booleanTemplate("({0})", combined)); + conditionCount++; + } + } + + // 9. MaximumDurability (내구력) + if (opt.maximumDurabilitySearch() != null + && opt.maximumDurabilitySearch().maximumDurability() != null) { + builder.or( + buildOptionCondition( + aro, + "내구력", + opt.maximumDurabilitySearch().maximumDurability(), + opt.maximumDurabilitySearch().maximumDurabilityStandard())); + conditionCount++; + } + + // 10. MaxInjuryRate (부상률) - 범위 검색 + if (opt.maxInjuryRateSearch() != null) { + BooleanExpression injuryTypeCondition = aro.optionType.eq("부상률"); + BooleanExpression injuryValueCondition = null; + + if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null + && opt.maxInjuryRateSearch().maxInjuryRateTo() != null) { + injuryValueCondition = + castOptionValueToInt(aro) + .between( + opt.maxInjuryRateSearch().maxInjuryRateFrom(), + opt.maxInjuryRateSearch().maxInjuryRateTo()); + } else if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null) { + injuryValueCondition = + castOptionValueToInt(aro) + .goe(opt.maxInjuryRateSearch().maxInjuryRateFrom()); + } else if (opt.maxInjuryRateSearch().maxInjuryRateTo() != null) { + injuryValueCondition = + castOptionValueToInt(aro).loe(opt.maxInjuryRateSearch().maxInjuryRateTo()); + } + + if (injuryValueCondition != null) { + BooleanExpression combined = injuryTypeCondition.and(injuryValueCondition); + builder.or(Expressions.booleanTemplate("({0})", combined)); + conditionCount++; + } + } + + // 11. Proficiency (숙련) + if (opt.proficiencySearch() != null && opt.proficiencySearch().proficiency() != null) { + builder.or( + buildOptionCondition( + aro, + "숙련", + opt.proficiencySearch().proficiency(), + opt.proficiencySearch().proficiencyStandard())); + conditionCount++; + } + + // 12. Protect (보호) + if (opt.protectSearch() != null && opt.protectSearch().protect() != null) { + builder.or( + buildOptionCondition( + aro, + "보호", + opt.protectSearch().protect(), + opt.protectSearch().protectStandard())); + conditionCount++; + } + + // 13. RemainingTransactionCount (남은 거래 횟수) + if (opt.remainingTransactionCountSearch() != null + && opt.remainingTransactionCountSearch().remainingTransactionCount() != null) { + builder.or( + buildOptionCondition( + aro, + "남은 거래 횟수", + opt.remainingTransactionCountSearch().remainingTransactionCount(), + opt.remainingTransactionCountSearch() + .remainingTransactionCountStandard())); + conditionCount++; + } + + // 14. RemainingUnsealCount (남은 전용 해제 가능 횟수) + if (opt.remainingUnsealCountSearch() != null + && opt.remainingUnsealCountSearch().remainingUnsealCount() != null) { + builder.or( + buildOptionCondition( + aro, + "남은 전용 해제 가능 횟수", + opt.remainingUnsealCountSearch().remainingUnsealCount(), + opt.remainingUnsealCountSearch().remainingUnsealCountStandard())); + conditionCount++; + } + + // 15. RemainingUseCount (남은 사용 횟수) + if (opt.remainingUseCountSearch() != null + && opt.remainingUseCountSearch().remainingUseCount() != null) { + builder.or( + buildOptionCondition( + aro, + "남은 사용 횟수", + opt.remainingUseCountSearch().remainingUseCount(), + opt.remainingUseCountSearch().remainingUseCountStandard())); + conditionCount++; + } + + // 16. WearingRestrictions (착용 제한) + if (opt.wearingRestrictionsSearch() != null + && opt.wearingRestrictionsSearch().wearingRestrictions() != null) { + BooleanExpression condition = + aro.optionValue.contains(opt.wearingRestrictionsSearch().wearingRestrictions()); + builder.or(Expressions.booleanTemplate("({0})", condition)); + conditionCount++; + } + + return new OptionConditionResult(builder, conditionCount); + } + + /** 옵션 조건 빌드 헬퍼 */ + private BooleanExpression buildOptionCondition( + QAuctionRealtimeItemOption aro, + String optionType, + Integer value, + SearchStandard standard) { + BooleanExpression optionTypeCondition = aro.optionType.eq(optionType); + NumberTemplate numValue = castOptionValueToInt(aro); + + BooleanExpression valueCondition; + if (standard == null || standard.isEqual()) { + valueCondition = numValue.eq(value); + } else if (standard.isUp()) { + valueCondition = numValue.goe(value); + } else if (standard.isDown()) { + valueCondition = numValue.loe(value); + } else { + valueCondition = numValue.eq(value); + } + + BooleanExpression combined = optionTypeCondition.and(valueCondition); + return Expressions.booleanTemplate("({0})", combined); + } + + /** option_value2 또는 option_value를 Integer로 변환 */ + private NumberTemplate castOptionValueToInt(QAuctionRealtimeItemOption aro) { + return Expressions.numberTemplate( + Integer.class, "COALESCE({0}, {1}, 0)", aro.optionValue2, aro.optionValue); + } + + /** Pageable의 Sort를 QueryDSL OrderSpecifier로 변환 */ + private List> buildOrderSpecifiers( + Pageable pageable, QAuctionRealtimeItem ar) { + List> orders = new ArrayList<>(); + + if (pageable.getSort().isSorted()) { + for (Sort.Order order : pageable.getSort()) { + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + String property = order.getProperty(); + + OrderSpecifier orderSpecifier = + switch (property) { + case "dateAuctionExpire" -> + new OrderSpecifier<>(direction, ar.dateAuctionExpire); + case "dateRegister" -> new OrderSpecifier<>(direction, ar.dateRegister); + case "auctionPricePerUnit" -> + new OrderSpecifier<>(direction, ar.auctionPricePerUnit); + case "itemName" -> new OrderSpecifier<>(direction, ar.itemName); + default -> new OrderSpecifier<>(Order.ASC, ar.dateAuctionExpire); + }; + + orders.add(orderSpecifier); + } + } else { + orders.add(new OrderSpecifier<>(Order.ASC, ar.dateAuctionExpire)); + } + + return orders; + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeListResponse.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeListResponse.java new file mode 100644 index 00000000..114de80e --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeListResponse.java @@ -0,0 +1,10 @@ +package until.the.eternity.auctionrealtime.interfaces.external.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** Nexon Open API /auction/list 응답 리스트 DTO. */ +public record OpenApiAuctionRealtimeListResponse( + @JsonProperty("auction_item") List auctionItems, + @JsonProperty("next_cursor") String nextCursor) {} diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeResponse.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeResponse.java new file mode 100644 index 00000000..2b337bfe --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/external/dto/OpenApiAuctionRealtimeResponse.java @@ -0,0 +1,19 @@ +package until.the.eternity.auctionrealtime.interfaces.external.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import until.the.eternity.auctionitemoption.domain.dto.external.OpenApiAuctionItemOptionResponse; + +import java.time.Instant; +import java.util.List; + +/** Nexon Open API /auction/list 응답 DTO. 현재 경매장에서 판매 중인 아이템 정보. */ +public record OpenApiAuctionRealtimeResponse( + @JsonProperty("item_name") String itemName, + @JsonProperty("item_display_name") String itemDisplayName, + @JsonProperty("item_count") long itemCount, + @JsonProperty("auction_price_per_unit") long auctionPricePerUnit, + @JsonProperty("date_auction_expire") + @JsonFormat(shape = JsonFormat.Shape.STRING, timezone = "Asia/Seoul") + Instant dateAuctionExpire, + @JsonProperty("item_option") List itemOptions) {} diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/controller/AuctionRealtimeController.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/controller/AuctionRealtimeController.java new file mode 100644 index 00000000..3742f819 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/controller/AuctionRealtimeController.java @@ -0,0 +1,46 @@ +package until.the.eternity.auctionrealtime.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import until.the.eternity.auctionrealtime.application.service.AuctionRealtimeService; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.AuctionRealtimeSearchRequest; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.RealtimePageRequestDto; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.AuctionRealtimeDetailResponse; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.response.RealtimeItemOptionResponse; +import until.the.eternity.common.response.PageResponseDto; + +@RequestMapping("/auction-realtime") +@RestController +@RequiredArgsConstructor +@Tag(name = "실시간 경매장 API", description = "실시간 경매장 아이템 조회 API") +public class AuctionRealtimeController { + + private final AuctionRealtimeService service; + + @GetMapping("/search") + @Operation(summary = "실시간 경매장 아이템 검색", description = "실시간 경매장에 등록된 아이템 검색") + public ResponseEntity< + PageResponseDto>> + search( + @ParameterObject @ModelAttribute @Valid RealtimePageRequestDto pageDto, + @ParameterObject @ModelAttribute @Valid + AuctionRealtimeSearchRequest requestDto) { + PageResponseDto> result = + service.search(requestDto, pageDto.toPageable()); + return ResponseEntity.ok(result); + } + + @GetMapping("/{id}") + @Operation(summary = "실시간 경매장 아이템 단건 조회", description = "실시간 경매장 아이템 ID로 단건 조회") + public ResponseEntity> findById( + @PathVariable Long id) { + AuctionRealtimeDetailResponse result = + service.findByIdOrElseThrow(id); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/AuctionRealtimeSearchRequest.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/AuctionRealtimeSearchRequest.java new file mode 100644 index 00000000..19d06834 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/AuctionRealtimeSearchRequest.java @@ -0,0 +1,14 @@ +package until.the.eternity.auctionrealtime.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.ItemOptionSearchRequest; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.PriceSearchRequest; + +/** 실시간 경매장 검색 조건 DTO */ +@Schema(description = "실시간 경매장 검색 조건") +public record AuctionRealtimeSearchRequest( + @Schema(description = "아이템 이름 (like 검색)", example = "페러시우스 타이탄 블레이드") String itemName, + @Schema(description = "대분류 카테고리", example = "근거리 장비") String itemTopCategory, + @Schema(description = "소분류 카테고리", example = "검") String itemSubCategory, + @Schema(description = "가격 검색 조건") PriceSearchRequest priceSearchRequest, + @Schema(description = "아이템 옵션 검색 조건") ItemOptionSearchRequest itemOptionSearchRequest) {} diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimePageRequestDto.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimePageRequestDto.java new file mode 100644 index 00000000..9719ba72 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimePageRequestDto.java @@ -0,0 +1,39 @@ +package until.the.eternity.auctionrealtime.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; +import until.the.eternity.common.enums.SortDirection; + +@Schema(description = "실시간 경매장 페이지 요청 파라미터") +public record RealtimePageRequestDto( + @Schema(description = "요청할 페이지 번호 (1부터 시작)", example = "1") @Min(1) Integer page, + @Schema(description = "페이지당 항목 수", example = "20") @Min(1) @Max(100) Integer size, + @Schema( + description = + "정렬 필드 (dateAuctionExpire, dateRegister, auctionPricePerUnit, itemName)", + example = "dateAuctionExpire") + RealtimeSortField sortBy, + @Schema(description = "정렬 방향 (ASC, DESC)", example = "ASC") SortDirection direction) { + + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_SIZE = 20; + private static final RealtimeSortField DEFAULT_SORT_BY = RealtimeSortField.DATE_AUCTION_EXPIRE; + private static final SortDirection DEFAULT_DIRECTION = SortDirection.ASC; + + public Pageable toPageable() { + int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE - 1; + int resolvedSize = this.size != null ? this.size : DEFAULT_SIZE; + RealtimeSortField resolvedSortBy = this.sortBy != null ? this.sortBy : DEFAULT_SORT_BY; + SortDirection resolvedDirection = + this.direction != null ? this.direction : DEFAULT_DIRECTION; + + return PageRequest.of( + resolvedPage, + resolvedSize, + Sort.by(resolvedDirection.toSpringDirection(), resolvedSortBy.getFieldName())); + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimeSortField.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimeSortField.java new file mode 100644 index 00000000..7d56e88d --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/request/RealtimeSortField.java @@ -0,0 +1,44 @@ +package until.the.eternity.auctionrealtime.interfaces.rest.dto.request; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Arrays; + +/** 실시간 경매장 정렬 필드 */ +@Schema(description = "실시간 경매장 정렬 필드", enumAsRef = true) +public enum RealtimeSortField { + DATE_AUCTION_EXPIRE("dateAuctionExpire", "경매 만료 일시"), + DATE_REGISTER("dateRegister", "등록 일시"), + AUCTION_PRICE_PER_UNIT("auctionPricePerUnit", "개당 가격"), + ITEM_NAME("itemName", "아이템 이름"); + + private final String fieldName; + private final String description; + + RealtimeSortField(String fieldName, String description) { + this.fieldName = fieldName; + this.description = description; + } + + @JsonValue + public String getFieldName() { + return fieldName; + } + + public String getDescription() { + return description; + } + + @JsonCreator + public static RealtimeSortField from(String fieldName) { + if (fieldName == null) { + return DATE_AUCTION_EXPIRE; + } + return Arrays.stream(RealtimeSortField.values()) + .filter(field -> field.fieldName.equalsIgnoreCase(fieldName)) + .findFirst() + .orElse(DATE_AUCTION_EXPIRE); + } +} diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/AuctionRealtimeDetailResponse.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/AuctionRealtimeDetailResponse.java new file mode 100644 index 00000000..a3d5a9e0 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/AuctionRealtimeDetailResponse.java @@ -0,0 +1,18 @@ +package until.the.eternity.auctionrealtime.interfaces.rest.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.Instant; +import java.util.List; + +public record AuctionRealtimeDetailResponse( + Long id, + String itemName, + String itemDisplayName, + Long itemCount, + Long auctionPricePerUnit, + @JsonFormat(shape = JsonFormat.Shape.STRING) Instant dateAuctionExpire, + @JsonFormat(shape = JsonFormat.Shape.STRING) Instant dateRegister, + String itemSubCategory, + String itemTopCategory, + List itemOptions) {} diff --git a/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/RealtimeItemOptionResponse.java b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/RealtimeItemOptionResponse.java new file mode 100644 index 00000000..c72b8504 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionrealtime/interfaces/rest/dto/response/RealtimeItemOptionResponse.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionrealtime.interfaces.rest.dto.response; + +public record RealtimeItemOptionResponse( + String id, + String optionType, + String optionSubType, + String optionValue, + String optionValue2, + String optionDesc) {} diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java index 52f6b9b4..13363f0a 100644 --- a/src/main/java/until/the/eternity/config/SecurityConfig.java +++ b/src/main/java/until/the/eternity/config/SecurityConfig.java @@ -45,7 +45,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/**", "/auction-history/**", "/statistics/**", - "/horn-bugle/**") + "/horn-bugle/**", + "/auction-realtime/**") .permitAll() // 나머지 요청은 인증 필요 .anyRequest() diff --git a/src/main/java/until/the/eternity/config/WebConfig.java b/src/main/java/until/the/eternity/config/WebConfig.java index b3ebbc84..3f577f0d 100644 --- a/src/main/java/until/the/eternity/config/WebConfig.java +++ b/src/main/java/until/the/eternity/config/WebConfig.java @@ -3,6 +3,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import until.the.eternity.auctionrealtime.interfaces.rest.dto.request.RealtimeSortField; import until.the.eternity.common.enums.SortDirection; import until.the.eternity.common.enums.SortField; @@ -13,5 +14,6 @@ public class WebConfig implements WebMvcConfigurer { public void addFormatters(FormatterRegistry registry) { registry.addConverter(String.class, SortField.class, SortField::from); registry.addConverter(String.class, SortDirection.class, SortDirection::from); + registry.addConverter(String.class, RealtimeSortField.class, RealtimeSortField::from); } } diff --git a/src/main/java/until/the/eternity/hornBugle/application/runner/HornBugleIndexRunner.java b/src/main/java/until/the/eternity/hornBugle/application/runner/HornBugleIndexRunner.java new file mode 100644 index 00000000..663122b1 --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/application/runner/HornBugleIndexRunner.java @@ -0,0 +1,89 @@ +package until.the.eternity.hornBugle.application.runner; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; +import until.the.eternity.hornBugle.domain.repository.HornBugleRepositoryPort; +import until.the.eternity.hornBugle.infrastructure.elasticsearch.HornBugleIndexService; + +import java.util.List; + +/** + * 서버 재기동 시 DB 데이터를 Elasticsearch에 일괄 색인하는 Runner. application.yml에서 + * elasticsearch.index.enabled=true로 설정 시 활성화됩니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + name = "elasticsearch.index.enabled", + havingValue = "true", + matchIfMissing = false) +public class HornBugleIndexRunner implements ApplicationRunner { + + private static final int BATCH_SIZE = 500; + + private final HornBugleRepositoryPort repository; + private final HornBugleIndexService indexService; + + @Override + public void run(ApplicationArguments args) { + log.info("[ES] Starting batch indexing on application startup..."); + + try { + // 인덱스 삭제 후 재생성 + indexService.recreateIndex(); + + // DB 전체 데이터를 배치로 조회하여 색인 + long totalIndexed = indexAllFromDatabase(); + + log.info("[ES] Batch indexing completed. Total indexed: {} documents", totalIndexed); + } catch (Exception e) { + log.error("[ES] Batch indexing failed: {}", e.getMessage(), e); + } + } + + /** + * DB의 모든 데이터를 배치로 조회하여 Elasticsearch에 색인한다. + * + * @return 색인된 총 문서 수 + */ + private long indexAllFromDatabase() { + long totalIndexed = 0; + int pageNumber = 0; + + while (true) { + Pageable pageable = PageRequest.of(pageNumber, BATCH_SIZE); + Page page = repository.findAll(pageable); + + if (!page.hasContent()) { + break; + } + + List entities = page.getContent(); + indexService.indexAll(entities); + + totalIndexed += entities.size(); + log.debug( + "[ES] Indexed page {}: {} documents (total: {})", + pageNumber, + entities.size(), + totalIndexed); + + if (!page.hasNext()) { + break; + } + + pageNumber++; + } + + return totalIndexed; + } +} diff --git a/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java b/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java index 70db2e65..c6e3fdc1 100644 --- a/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java +++ b/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java @@ -1,6 +1,5 @@ package until.the.eternity.hornBugle.application.service; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; @@ -11,21 +10,35 @@ import until.the.eternity.hornBugle.domain.mapper.HornBugleMapper; import until.the.eternity.hornBugle.domain.repository.HornBugleRepositoryPort; import until.the.eternity.hornBugle.domain.service.HornBugleDuplicateChecker; +import until.the.eternity.hornBugle.infrastructure.elasticsearch.HornBugleDocument; +import until.the.eternity.hornBugle.infrastructure.elasticsearch.HornBugleIndexService; import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryResponse; import until.the.eternity.hornBugle.interfaces.rest.dto.request.HornBuglePageRequestDto; import until.the.eternity.hornBugle.interfaces.rest.dto.response.HornBugleHistoryResponse; import java.time.Instant; import java.util.List; +import java.util.Optional; @Slf4j @Service -@RequiredArgsConstructor public class HornBugleService { private final HornBugleRepositoryPort repository; private final HornBugleDuplicateChecker duplicateChecker; private final HornBugleMapper mapper; + private final Optional indexService; + + public HornBugleService( + HornBugleRepositoryPort repository, + HornBugleDuplicateChecker duplicateChecker, + HornBugleMapper mapper, + Optional indexService) { + this.repository = repository; + this.duplicateChecker = duplicateChecker; + this.mapper = mapper; + this.indexService = indexService; + } /** * API 응답 데이터를 중복 제거 후 저장한다. @@ -59,20 +72,73 @@ public int saveAll(HornBugleServer server, List repository.saveAll(entities); + // Elasticsearch 실시간 색인 (DB 저장 성공 후, ES가 활성화된 경우에만) + indexService.ifPresent(service -> service.indexAll(entities)); + log.info("[HornBugle] [{}] Saved {} new records.", server.getServerName(), entities.size()); return entities.size(); } /** - * 서버별 최신 N건 조회 (페이징) + * 서버별 최신 N건 조회 (페이징). keyword가 있고 ES가 활성화되어 있으면 Elasticsearch 검색을 수행한다. ES가 비활성화되어 있거나 사용 불가능한 + * 경우 MySQL FULLTEXT 검색으로 fallback한다. * * @param serverName 서버 이름 (선택 사항, null이면 전체 조회) + * @param keyword 검색 키워드 (선택 사항, null이면 DB 검색) * @param pageRequest 페이지 요청 정보 * @return 페이징 응답 */ @Transactional(readOnly = true) public PageResponseDto search( + String serverName, String keyword, HornBuglePageRequestDto pageRequest) { + + // keyword가 없으면 단순 DB 조회 + if (keyword == null || keyword.isBlank()) { + return searchByDatabase(serverName, pageRequest); + } + + // keyword가 있고 ES가 활성화되어 있고 사용 가능하면 Elasticsearch 검색 + if (indexService.isPresent() && indexService.get().isAvailable()) { + try { + return searchByElasticsearch(serverName, keyword, pageRequest); + } catch (Exception e) { + log.warn( + "[HornBugle] Elasticsearch search failed, falling back to MySQL FULLTEXT search. keyword={}, error={}", + keyword, + e.getMessage()); + return searchByDatabaseWithKeyword(serverName, keyword, pageRequest); + } + } + + // ES가 비활성화되어 있거나 사용 불가능한 경우 MySQL FULLTEXT 검색 + if (indexService.isEmpty()) { + log.info( + "[HornBugle] Elasticsearch is not enabled. Using MySQL FULLTEXT search. keyword={}", + keyword); + } else { + log.warn( + "[HornBugle] Elasticsearch is not available. Falling back to MySQL FULLTEXT search. keyword={}", + keyword); + } + + return searchByDatabaseWithKeyword(serverName, keyword, pageRequest); + } + + /** Elasticsearch로 검색한다. */ + private PageResponseDto searchByElasticsearch( + String serverName, String keyword, HornBuglePageRequestDto pageRequest) { + + Page page = + indexService.get().search(keyword, serverName, pageRequest.toPageable()); + + Page responsePage = page.map(mapper::toResponse); + + return PageResponseDto.of(responsePage); + } + + /** DB로 검색한다 (keyword 없이). */ + private PageResponseDto searchByDatabase( String serverName, HornBuglePageRequestDto pageRequest) { Page page; @@ -87,4 +153,23 @@ public PageResponseDto search( return PageResponseDto.of(responsePage); } + + /** MySQL FULLTEXT 검색으로 keyword 검색을 수행한다. Native Query에서 ORDER BY를 지정하므로 Sort 없이 Pageable을 전달한다. */ + private PageResponseDto searchByDatabaseWithKeyword( + String serverName, String keyword, HornBuglePageRequestDto pageRequest) { + + Page page; + + if (serverName != null && !serverName.isBlank()) { + page = + repository.searchByKeywordAndServerName( + keyword, serverName, pageRequest.toPageableWithoutSort()); + } else { + page = repository.searchByKeyword(keyword, pageRequest.toPageableWithoutSort()); + } + + Page responsePage = page.map(mapper::toResponse); + + return PageResponseDto.of(responsePage); + } } diff --git a/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java b/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java index c9340bf3..c2bb9e73 100644 --- a/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java +++ b/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java @@ -4,6 +4,7 @@ import org.mapstruct.Mapping; import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; import until.the.eternity.hornBugle.domain.enums.HornBugleServer; +import until.the.eternity.hornBugle.infrastructure.elasticsearch.HornBugleDocument; import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryResponse; import until.the.eternity.hornBugle.interfaces.rest.dto.response.HornBugleHistoryResponse; @@ -20,4 +21,7 @@ HornBugleWorldHistory toEntity( OpenApiHornBugleHistoryResponse dto, HornBugleServer server, Instant registerTime); HornBugleHistoryResponse toResponse(HornBugleWorldHistory entity); + + @Mapping(target = "id", expression = "java(Long.parseLong(document.getId()))") + HornBugleHistoryResponse toResponse(HornBugleDocument document); } diff --git a/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java b/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java index 0162f664..75dfd277 100644 --- a/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java +++ b/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java @@ -19,4 +19,11 @@ public interface HornBugleRepositoryPort { Page findAll(Pageable pageable); List findByServerNameAndDateSend(String serverName, Instant dateSend); + + /** FULLTEXT 인덱스를 사용한 키워드 검색 (전체 서버) */ + Page searchByKeyword(String keyword, Pageable pageable); + + /** FULLTEXT 인덱스를 사용한 키워드 검색 (서버 필터 포함) */ + Page searchByKeywordAndServerName( + String keyword, String serverName, Pageable pageable); } diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleDocument.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleDocument.java new file mode 100644 index 00000000..98bb7864 --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleDocument.java @@ -0,0 +1,51 @@ +package until.the.eternity.hornBugle.infrastructure.elasticsearch; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; +import org.springframework.data.elasticsearch.annotations.Setting; +import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; + +import java.time.Instant; + +@Document(indexName = "horn_bugle_world_history") +@Setting(settingPath = "elasticsearch/horn-bugle-settings.json") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class HornBugleDocument { + + @Id private String id; + + @Field(type = FieldType.Keyword, name = "server_name") + private String serverName; + + @Field(type = FieldType.Text, name = "character_name", analyzer = "nori_analyzer") + private String characterName; + + @Field(type = FieldType.Text, name = "message", analyzer = "nori_analyzer") + private String message; + + @Field(type = FieldType.Date, name = "date_send") + private Instant dateSend; + + @Field(type = FieldType.Date, name = "date_register") + private Instant dateRegister; + + public static HornBugleDocument from(HornBugleWorldHistory entity) { + return HornBugleDocument.builder() + .id(String.valueOf(entity.getId())) + .serverName(entity.getServerName()) + .characterName(entity.getCharacterName()) + .message(entity.getMessage()) + .dateSend(entity.getDateSend()) + .dateRegister(entity.getDateRegister()) + .build(); + } +} diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchConfig.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchConfig.java new file mode 100644 index 00000000..9df5c5cd --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchConfig.java @@ -0,0 +1,12 @@ +package until.the.eternity.hornBugle.infrastructure.elasticsearch; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; + +/** Elasticsearch 설정 클래스. elasticsearch.enabled=true일 때만 Elasticsearch Repository를 활성화한다. */ +@Configuration +@ConditionalOnProperty(name = "elasticsearch.enabled", havingValue = "true", matchIfMissing = false) +@EnableElasticsearchRepositories( + basePackages = "until.the.eternity.hornBugle.infrastructure.elasticsearch") +public class HornBugleElasticsearchConfig {} diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchRepository.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchRepository.java new file mode 100644 index 00000000..6539c23e --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleElasticsearchRepository.java @@ -0,0 +1,6 @@ +package until.the.eternity.hornBugle.infrastructure.elasticsearch; + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +public interface HornBugleElasticsearchRepository + extends ElasticsearchRepository {} diff --git a/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleIndexService.java b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleIndexService.java new file mode 100644 index 00000000..0b6b8bd9 --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/elasticsearch/HornBugleIndexService.java @@ -0,0 +1,181 @@ +package until.the.eternity.hornBugle.infrastructure.elasticsearch; + +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.IndexOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.stereotype.Service; +import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "elasticsearch.enabled", havingValue = "true", matchIfMissing = false) +public class HornBugleIndexService { + + private static final String INDEX_NAME = "horn_bugle_world_history"; + private static final int BATCH_SIZE = 500; + + private final ElasticsearchOperations elasticsearchOperations; + private final HornBugleElasticsearchRepository repository; + + /** + * 단일 엔티티를 Elasticsearch에 색인한다. + * + * @param entity 색인할 엔티티 + */ + public void index(HornBugleWorldHistory entity) { + try { + HornBugleDocument document = HornBugleDocument.from(entity); + repository.save(document); + log.debug("[ES] Indexed document: id={}", entity.getId()); + } catch (Exception e) { + log.error( + "[ES] Failed to index document: id={}, error={}", + entity.getId(), + e.getMessage(), + e); + } + } + + /** + * 여러 엔티티를 Elasticsearch에 일괄 색인한다. + * + * @param entities 색인할 엔티티 목록 + */ + public void indexAll(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + try { + List documents = + entities.stream().map(HornBugleDocument::from).toList(); + + for (int i = 0; i < documents.size(); i += BATCH_SIZE) { + int toIndex = Math.min(i + BATCH_SIZE, documents.size()); + List batch = documents.subList(i, toIndex); + repository.saveAll(batch); + log.debug("[ES] Indexed batch: {} documents", batch.size()); + } + + log.info("[ES] Successfully indexed {} documents", entities.size()); + } catch (Exception e) { + log.error("[ES] Failed to index {} documents: {}", entities.size(), e.getMessage(), e); + } + } + + /** 인덱스를 삭제하고 재생성한다. */ + public void recreateIndex() { + try { + IndexOperations indexOps = elasticsearchOperations.indexOps(HornBugleDocument.class); + + if (indexOps.exists()) { + indexOps.delete(); + log.info("[ES] Deleted existing index: {}", INDEX_NAME); + } + + indexOps.createWithMapping(); + log.info("[ES] Created index with mapping: {}", INDEX_NAME); + } catch (Exception e) { + log.error("[ES] Failed to recreate index: {}", e.getMessage(), e); + throw new RuntimeException("Failed to recreate Elasticsearch index", e); + } + } + + /** + * keyword로 검색한다. serverName이 있으면 필터로 추가한다. + * + * @param keyword 검색 키워드 + * @param serverName 서버명 필터 (nullable) + * @param pageable 페이징 정보 + * @return 검색 결과 + */ + public Page search(String keyword, String serverName, Pageable pageable) { + try { + // Multi-match query for keyword search + // Note: date_send is Date type, excluded from text search + // server_name is Keyword type, included for exact match + Query multiMatchQuery = + Query.of( + q -> + q.multiMatch( + mm -> + mm.query(keyword) + .fields("character_name", "message"))); + + // Build bool query + BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder().must(multiMatchQuery); + + // Add serverName filter if present + if (serverName != null && !serverName.isBlank()) { + Query serverFilter = + Query.of(q -> q.term(t -> t.field("server_name").value(serverName))); + boolQueryBuilder.filter(serverFilter); + } + + Query finalQuery = Query.of(q -> q.bool(boolQueryBuilder.build())); + + NativeQuery searchQuery = + NativeQuery.builder().withQuery(finalQuery).withPageable(pageable).build(); + + SearchHits searchHits = + elasticsearchOperations.search( + searchQuery, HornBugleDocument.class, IndexCoordinates.of(INDEX_NAME)); + + List content = + searchHits.getSearchHits().stream().map(SearchHit::getContent).toList(); + + return new PageImpl<>(content, pageable, searchHits.getTotalHits()); + } catch (Exception e) { + log.error( + "[ES] Search failed: keyword={}, serverName={}, error={}", + keyword, + serverName, + e.getMessage(), + e); + throw new RuntimeException("Elasticsearch search failed", e); + } + } + + /** + * 인덱스 존재 여부를 확인한다. + * + * @return 인덱스 존재 여부 + */ + public boolean indexExists() { + try { + return elasticsearchOperations.indexOps(HornBugleDocument.class).exists(); + } catch (Exception e) { + log.error("[ES] Failed to check index existence: {}", e.getMessage(), e); + return false; + } + } + + /** + * Elasticsearch 서버가 사용 가능한 상태인지 확인한다. + * + * @return ES 서버 사용 가능 여부 + */ + public boolean isAvailable() { + try { + return elasticsearchOperations.indexOps(HornBugleDocument.class).exists() + || elasticsearchOperations.indexOps(HornBugleDocument.class).create(); + } catch (Exception e) { + log.warn("[ES] Elasticsearch is not available: {}", e.getMessage()); + return false; + } + } +} 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 index 2fd3bb51..e68b01eb 100644 --- a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java +++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleJpaRepository.java @@ -26,4 +26,39 @@ public interface HornBugleJpaRepository extends JpaRepository findByServerName(String serverName, Pageable pageable); List findByServerNameAndDateSend(String serverName, Instant dateSend); + + /** FULLTEXT 인덱스를 사용한 키워드 검색 (전체 서버) */ + @Query( + value = + """ + SELECT * FROM horn_bugle_world_history + WHERE MATCH(character_name, message, server_name, date_send_text) AGAINST(:keyword IN NATURAL LANGUAGE MODE) + ORDER BY date_send DESC + """, + countQuery = + """ + SELECT COUNT(*) FROM horn_bugle_world_history + WHERE MATCH(character_name, message, server_name, date_send_text) AGAINST(:keyword IN NATURAL LANGUAGE MODE) + """, + nativeQuery = true) + Page searchByKeyword(String keyword, Pageable pageable); + + /** FULLTEXT 인덱스를 사용한 키워드 검색 (서버 필터 포함) */ + @Query( + value = + """ + SELECT * FROM horn_bugle_world_history + WHERE MATCH(character_name, message, server_name, date_send_text) AGAINST(:keyword IN NATURAL LANGUAGE MODE) + AND server_name = :serverName + ORDER BY date_send DESC + """, + countQuery = + """ + SELECT COUNT(*) FROM horn_bugle_world_history + WHERE MATCH(character_name, message, server_name, date_send_text) AGAINST(:keyword IN NATURAL LANGUAGE MODE) + AND server_name = :serverName + """, + nativeQuery = true) + Page searchByKeywordAndServerName( + String keyword, String serverName, Pageable pageable); } 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 index 469b3760..706bc05b 100644 --- a/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/hornBugle/infrastructure/persistence/HornBugleRepositoryPortImpl.java @@ -60,4 +60,15 @@ public List findByServerNameAndDateSend( String serverName, Instant dateSend) { return jpaRepository.findByServerNameAndDateSend(serverName, dateSend); } + + @Override + public Page searchByKeyword(String keyword, Pageable pageable) { + return jpaRepository.searchByKeyword(keyword, pageable); + } + + @Override + public Page searchByKeywordAndServerName( + String keyword, String serverName, Pageable pageable) { + return jpaRepository.searchByKeywordAndServerName(keyword, serverName, pageable); + } } 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 index 60d7efdb..e8c1fca4 100644 --- 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 @@ -4,10 +4,12 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import until.the.eternity.common.response.PageResponseDto; import until.the.eternity.hornBugle.application.scheduler.HornBugleScheduler; @@ -19,6 +21,7 @@ @RequestMapping("/horn-bugle") @RestController @RequiredArgsConstructor +@Validated @Tag(name = "뿔피리 히스토리 API", description = "거대한 외침의 뿔피리 내역 API") public class HornBugleController { @@ -26,13 +29,22 @@ public class HornBugleController { private final HornBugleScheduler scheduler; @GetMapping - @Operation(summary = "뿔피리 히스토리 조회", description = "거대한 외침의 뿔피리 내역을 조회합니다. 서버별 또는 전체 조회가 가능합니다.") + @Operation( + summary = "뿔피리 히스토리 조회", + description = + "거대한 외침의 뿔피리 내역을 조회합니다. 서버별 또는 전체 조회가 가능합니다. " + + "keyword 입력 시 Elasticsearch 기반 전문 검색을 수행합니다.") public ResponseEntity> search( @Parameter(description = "서버 이름 (류트, 만돌린, 하프, 울프). 미입력시 전체 조회") @RequestParam(required = false) String serverName, + @Parameter(description = "검색 키워드 (캐릭터명, 메시지, 서버명, 발화시각 검색). 최대 50자") + @RequestParam(required = false) + @Size(max = 50, message = "검색 키워드는 최대 50자까지 입력 가능합니다.") + String keyword, @ParameterObject @ModelAttribute @Valid HornBuglePageRequestDto pageRequest) { - PageResponseDto result = service.search(serverName, pageRequest); + PageResponseDto result = + service.search(serverName, keyword, pageRequest); return ResponseEntity.ok(result); } 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 index 06adcf3d..c3648f68 100644 --- 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 @@ -25,6 +25,16 @@ public Pageable toPageable() { resolvedPage, resolvedSize, Sort.by(Sort.Direction.DESC, SORT_BY_DATE_SEND)); } + /** + * Native Query용 Pageable (정렬 없음). Native Query에서 ORDER BY를 직접 지정하므로 Sort를 제외한다. + */ + public Pageable toPageableWithoutSort() { + 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); + } + public int getResolvedPage() { return this.page != null ? this.page : DEFAULT_PAGE; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5892cbf8..4d7ac1c9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,6 +18,10 @@ management: spring: application: name: open-api-batch-server + elasticsearch: + uris: ${ELASTICSEARCH_URIS:http://localhost:9200} + username: ${ELASTICSEARCH_USERNAME:} + password: ${ELASTICSEARCH_PASSWORD:} web: resources: static-locations: classpath:/static/ @@ -93,10 +97,22 @@ openapi: cron: ${HORN_BUGLE_CRON:0 */5 * * * *} max-retries: ${HORN_BUGLE_MAX_RETRIES:3} retry-delay-ms: ${HORN_BUGLE_RETRY_DELAY_MS:2000} + auction-realtime: + # 실시간 경매장 데이터 수집 스케줄러 + # 10분 간격: 0, 10, 20, 30, 40, 50분 + cron: ${AUCTION_REALTIME_CRON:0 0/10 * * * *} + delay-ms: ${AUCTION_REALTIME_DELAY_MS:500} statistics: previous-day: # 전날 통계 최종 확정 스케줄러 # AuctionHistoryScheduler 실행 이후 전날 23시대 거래까지 포함한 통계를 확정 # 기본값: 매일 00:10 (AuctionHistory가 00:05에 실행되므로 그 이후) - cron: ${STATISTICS_PREVIOUS_DAY_CRON:0 10 0 * * *} \ No newline at end of file + cron: ${STATISTICS_PREVIOUS_DAY_CRON:0 10 0 * * *} + +elasticsearch: + # Elasticsearch 기능 활성화 여부 (false면 ES 관련 기능 비활성화, DB 검색만 사용) + enabled: ${ELASTICSEARCH_ENABLED:true} + index: + # 서버 재기동 시 DB 데이터 일괄 색인 활성화 여부 + enabled: ${ELASTICSEARCH_INDEX_ENABLED:true} \ No newline at end of file diff --git a/src/main/resources/db/migration/V15__refactor_auction_tables_for_realtime_history_separation.sql b/src/main/resources/db/migration/V15__refactor_auction_tables_for_realtime_history_separation.sql new file mode 100644 index 00000000..adc9b021 --- /dev/null +++ b/src/main/resources/db/migration/V15__refactor_auction_tables_for_realtime_history_separation.sql @@ -0,0 +1,52 @@ +-- V15: Refactor auction tables for realtime/history separation +-- 1. auction_item_option -> auction_history_item_option (rename, remove auction_item_id) +-- 2. auction_item -> auction_realtime_item (rename, add category columns) +-- 3. Create auction_realtime_item_option table + +-- ============================================================ +-- Step 1: Modify auction_item_option table +-- ============================================================ + +-- 1-1. Drop foreign key constraint for auction_item_id +ALTER TABLE auction_item_option DROP FOREIGN KEY fk_option_item; + +-- 1-2. Drop auction_item_id column +ALTER TABLE auction_item_option DROP COLUMN auction_item_id; + +-- 1-3. Rename table to auction_history_item_option +RENAME TABLE auction_item_option TO auction_history_item_option; + +-- ============================================================ +-- Step 2: Modify auction_item table +-- ============================================================ + +-- 2-1. Add category columns +ALTER TABLE auction_item ADD COLUMN item_sub_category VARCHAR(25) NOT NULL DEFAULT '' COMMENT '아이템 하위 카테고리'; +ALTER TABLE auction_item ADD COLUMN item_top_category VARCHAR(25) NOT NULL DEFAULT '' COMMENT '아이템 상위 카테고리'; + +-- 2-2. Rename table to auction_realtime_item +RENAME TABLE auction_item TO auction_realtime_item; + +-- 2-3. Create indexes for auction_realtime_item +CREATE INDEX idx_realtime_top_sub_item + ON auction_realtime_item (item_top_category, item_sub_category, item_name); + +CREATE INDEX idx_realtime_sub_category_expire + ON auction_realtime_item (item_sub_category, date_auction_expire DESC); + +-- ============================================================ +-- Step 3: Create auction_realtime_item_option table +-- ============================================================ + +CREATE TABLE auction_realtime_item_option ( + id VARCHAR(36) NOT NULL COMMENT 'ItemOption의 고유 식별자 (UUID)', + auction_realtime_item_id BIGINT NOT NULL COMMENT 'auction_realtime_item 테이블의 외래 키', + option_type VARCHAR(100) COMMENT '아이템 옵션 유형', + option_sub_type VARCHAR(100) COMMENT '아이템 옵션 하위 유형', + option_value VARCHAR(255) COMMENT '아이템 옵션 값', + option_value2 VARCHAR(255) COMMENT '아이템 옵션 값 2', + option_desc TEXT COMMENT '아이템 옵션 부가 정보', + PRIMARY KEY (id), + CONSTRAINT fk_realtime_option_item FOREIGN KEY (auction_realtime_item_id) + REFERENCES auction_realtime_item(id) ON DELETE CASCADE +) COMMENT='실시간 경매장 아이템 옵션 정보 테이블'; diff --git a/src/main/resources/db/migration/V16__update_unknown_item_name_and_segong_option_level.sql b/src/main/resources/db/migration/V16__update_unknown_item_name_and_segong_option_level.sql new file mode 100644 index 00000000..c26f1ebc --- /dev/null +++ b/src/main/resources/db/migration/V16__update_unknown_item_name_and_segong_option_level.sql @@ -0,0 +1,46 @@ +-- V16: item_name이 '(Unknown)'인 경우 item_display_name으로 업데이트, +-- 세공 옵션 파싱 (option_value, option_value2, option_desc) + +-- 1. auction_history 테이블: item_name이 '(Unknown)'인 경우 item_display_name으로 업데이트 +UPDATE auction_history +SET item_name = item_display_name +WHERE item_name = '(Unknown)'; + +-- 2. auction_realtime_item 테이블: item_name이 '(Unknown)'인 경우 item_display_name으로 업데이트 +UPDATE auction_realtime_item +SET item_name = item_display_name +WHERE item_name = '(Unknown)'; + +-- 3. auction_history_item_option 테이블: 세공 옵션 파싱 +-- 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨") +UPDATE auction_history_item_option +SET option_desc = REGEXP_SUBSTR(option_value, '[0-9]+ ?레벨$'), + option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?= ?레벨$)'), + option_value = REGEXP_REPLACE(option_value, ' [0-9]+ ?레벨$', '') +WHERE option_type = '세공 옵션' + AND option_value REGEXP '^.+ [0-9]+ ?레벨$'; + +-- 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)") +UPDATE auction_history_item_option +SET option_desc = REGEXP_SUBSTR(option_value, '\\([0-9]+레벨:.+\\)$'), + option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?=레벨:)'), + option_value = REGEXP_REPLACE(option_value, '\\([0-9]+레벨:.+\\)$', '') +WHERE option_type = '세공 옵션' + AND option_value REGEXP '^.+\\([0-9]+레벨:.+\\)$'; + +-- 4. auction_realtime_item_option 테이블: 세공 옵션 파싱 +-- 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨") +UPDATE auction_realtime_item_option +SET option_desc = REGEXP_SUBSTR(option_value, '[0-9]+ ?레벨$'), + option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?= ?레벨$)'), + option_value = REGEXP_REPLACE(option_value, ' [0-9]+ ?레벨$', '') +WHERE option_type = '세공 옵션' + AND option_value REGEXP '^.+ [0-9]+ ?레벨$'; + +-- 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)") +UPDATE auction_realtime_item_option +SET option_desc = REGEXP_SUBSTR(option_value, '\\([0-9]+레벨:.+\\)$'), + option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?=레벨:)'), + option_value = REGEXP_REPLACE(option_value, '\\([0-9]+레벨:.+\\)$', '') +WHERE option_type = '세공 옵션' + AND option_value REGEXP '^.+\\([0-9]+레벨:.+\\)$'; diff --git a/src/main/resources/db/migration/V17__add_fulltext_index_to_horn_bugle_world_history.sql b/src/main/resources/db/migration/V17__add_fulltext_index_to_horn_bugle_world_history.sql new file mode 100644 index 00000000..039a1896 --- /dev/null +++ b/src/main/resources/db/migration/V17__add_fulltext_index_to_horn_bugle_world_history.sql @@ -0,0 +1,8 @@ +-- FULLTEXT 인덱스를 위한 date_send 문자열 컬럼 추가 (Generated Column) +ALTER TABLE horn_bugle_world_history + ADD COLUMN date_send_text VARCHAR(30) GENERATED ALWAYS AS (DATE_FORMAT(date_send, '%Y-%m-%d %H:%i:%s')) STORED; + +-- character_name, message, server_name, date_send_text에 FULLTEXT 인덱스 추가 +-- MySQL 8.0+ 에서 ngram parser를 사용하여 한글 검색 지원 +ALTER TABLE horn_bugle_world_history + ADD FULLTEXT INDEX ft_horn_bugle_search (character_name, message, server_name, date_send_text) WITH PARSER ngram; diff --git a/src/main/resources/elasticsearch/horn-bugle-settings.json b/src/main/resources/elasticsearch/horn-bugle-settings.json new file mode 100644 index 00000000..f04948ef --- /dev/null +++ b/src/main/resources/elasticsearch/horn-bugle-settings.json @@ -0,0 +1,9 @@ +{ + "analysis": { + "analyzer": { + "nori_analyzer": { + "type": "standard" + } + } + } +} diff --git a/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java b/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java new file mode 100644 index 00000000..7f543ac5 --- /dev/null +++ b/src/test/java/until/the/eternity/auctionrealtime/application/service/fetcher/AuctionRealtimeFetcherTest.java @@ -0,0 +1,247 @@ +package until.the.eternity.auctionrealtime.application.service.fetcher; + +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 reactor.core.publisher.Mono; +import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker; +import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker.DuplicateCheckResult; +import until.the.eternity.auctionrealtime.domain.service.fetcher.AuctionRealtimeFetcherPort.FetchResult; +import until.the.eternity.auctionrealtime.infrastructure.client.AuctionRealtimeClient; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeListResponse; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuctionRealtimeFetcherTest { + + @Mock AuctionRealtimeClient client; + + @Mock AuctionRealtimeDuplicateChecker duplicateChecker; + + @InjectMocks AuctionRealtimeFetcher fetcher; + + private OpenApiAuctionRealtimeResponse dummy() { + return new OpenApiAuctionRealtimeResponse( + "페러시우스 타이탄 블레이드", "신성한 페러시우스 타이탄 블레이드", 1L, 100L, Instant.now(), null); + } + + @Nested + @DisplayName("OPEN API 끝까지 호출 시나리오") + class NormalFlow { + + @Test + @DisplayName("모든 페이지를 수집하고 cursor가 null이면 종료한다") + void fetchAllPages() { + // given + var page1 = + new OpenApiAuctionRealtimeListResponse(List.of(dummy(), dummy()), "cursor-1"); + var page2 = new OpenApiAuctionRealtimeListResponse(List.of(dummy()), null); + + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(client.fetchAuctionList(ItemCategory.SWORD, "cursor-1")) + .thenReturn(Mono.just(page2)); + when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD))) + .thenReturn(DuplicateCheckResult.noDuplicate()); + + // when + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result.items()).hasSize(3); + assertThat(result.hasEqualDate()).isFalse(); + + verify(client, times(2)).fetchAuctionList(eq(ItemCategory.SWORD), any()); + verify(duplicateChecker, times(2)).checkDuplicateInBatch(any(), eq(ItemCategory.SWORD)); + } + } + + @Nested + @DisplayName("OPEN API 호출 중단 시나리오") + class EarlyBreakFlow { + + @Test + @DisplayName("첫 배치 첫 항목에서 중복이면 빈 리스트를 반환한다") + void stopOnDuplicateAtFirstItem() { + // given + var page1 = + new OpenApiAuctionRealtimeListResponse(List.of(dummy(), dummy()), "cursor-1"); + Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); + + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(duplicateChecker.checkDuplicateInBatch(page1.auctionItems(), ItemCategory.SWORD)) + .thenReturn(DuplicateCheckResult.duplicateFound(0, latestDate)); + + // when + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result.items()).isEmpty(); + assertThat(result.latestDate()).isEqualTo(latestDate); + verify(client, times(1)).fetchAuctionList(ItemCategory.SWORD, ""); + verifyNoMoreInteractions(client); + } + + @Test + @DisplayName("첫 배치 중간에서 중복이면 중복 전까지만 반환한다") + void stopOnDuplicateAtMiddle() { + // given + var batch = List.of(dummy(), dummy(), dummy()); + var page1 = new OpenApiAuctionRealtimeListResponse(batch, "cursor-1"); + Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); + + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(duplicateChecker.checkDuplicateInBatch(batch, ItemCategory.SWORD)) + .thenReturn(DuplicateCheckResult.duplicateFound(2, latestDate)); + + // when + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result.items()).hasSize(2); + verify(client, times(1)).fetchAuctionList(ItemCategory.SWORD, ""); + verifyNoMoreInteractions(client); + } + + @Test + @DisplayName("동일 날짜 데이터가 감지되면 hasEqualDate가 true로 반환된다") + void stopOnEqualDate() { + // given + var batch = List.of(dummy(), dummy()); + var page1 = new OpenApiAuctionRealtimeListResponse(batch, "cursor-1"); + Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); + + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(duplicateChecker.checkDuplicateInBatch(batch, ItemCategory.SWORD)) + .thenReturn(DuplicateCheckResult.equalDateFound(1, latestDate)); + + // when + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result.items()).hasSize(1); + assertThat(result.hasEqualDate()).isTrue(); + assertThat(result.latestDate()).isEqualTo(latestDate); + } + + @Test + @DisplayName("두 번째 배치에서 중복이면 첫 배치 전체 + 중복 전까지만 반환한다") + void stopOnDuplicateAtSecondBatch() { + // given + var batch1 = List.of(dummy(), dummy()); + var batch2 = List.of(dummy(), dummy(), dummy()); + var page1 = new OpenApiAuctionRealtimeListResponse(batch1, "cursor-1"); + var page2 = new OpenApiAuctionRealtimeListResponse(batch2, "cursor-2"); + Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); + + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(client.fetchAuctionList(ItemCategory.SWORD, "cursor-1")) + .thenReturn(Mono.just(page2)); + when(duplicateChecker.checkDuplicateInBatch(batch1, ItemCategory.SWORD)) + .thenReturn(DuplicateCheckResult.noDuplicate()); + when(duplicateChecker.checkDuplicateInBatch(batch2, ItemCategory.SWORD)) + .thenReturn(DuplicateCheckResult.duplicateFound(1, latestDate)); + + // when + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result.items()).hasSize(3); // 2 from batch1 + 1 from batch2 + verify(client, times(2)).fetchAuctionList(eq(ItemCategory.SWORD), any()); + } + + @Test + @DisplayName("첫 응답이 null(Mono.empty)이면 빈 리스트를 반환한다") + void responseNull() { + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.empty()); + + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + assertThat(result.items()).isEmpty(); + verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any()); + } + + @Test + @DisplayName("auctionItems()가 비어있으면 빈 리스트를 반환한다") + void auctionItemsEmpty() { + var page = new OpenApiAuctionRealtimeListResponse(List.of(), "ignored"); + + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page)); + + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + assertThat(result.items()).isEmpty(); + verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any()); + } + + @Test + @DisplayName("nextCursor가 빈 문자열이면 수집을 중단한다") + void stopWhenNextCursorIsEmptyString() { + // given + var page1 = new OpenApiAuctionRealtimeListResponse(List.of(dummy()), ""); + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD))) + .thenReturn(DuplicateCheckResult.noDuplicate()); + + // when + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result.items()).hasSize(1); + verify(client, times(1)).fetchAuctionList(eq(ItemCategory.SWORD), any()); + verifyNoMoreInteractions(client); + } + + @Test + @DisplayName("중간 페이지의 auctionItems가 비어있으면 수집을 중단한다") + void stopWhenMiddlePageIsEmpty() { + // given + var page1 = new OpenApiAuctionRealtimeListResponse(List.of(dummy()), "cursor-1"); + var emptyPage = new OpenApiAuctionRealtimeListResponse(List.of(), "cursor-2"); + + when(client.fetchAuctionList(ItemCategory.SWORD, "")).thenReturn(Mono.just(page1)); + when(client.fetchAuctionList(ItemCategory.SWORD, "cursor-1")) + .thenReturn(Mono.just(emptyPage)); + when(duplicateChecker.checkDuplicateInBatch(any(), eq(ItemCategory.SWORD))) + .thenReturn(DuplicateCheckResult.noDuplicate()); + + // when + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result.items()).hasSize(1); + verify(client, times(2)).fetchAuctionList(eq(ItemCategory.SWORD), any()); + verifyNoMoreInteractions(client); + } + + @Test + @DisplayName("응답 내 auctionItems 리스트가 null이면 수집을 중단한다") + void stopWhenAuctionItemsListIsNull() { + // given + var pageWithNullList = new OpenApiAuctionRealtimeListResponse(null, "cursor-1"); + when(client.fetchAuctionList(ItemCategory.SWORD, "")) + .thenReturn(Mono.just(pageWithNullList)); + + // when + FetchResult result = fetcher.fetch(ItemCategory.SWORD); + + // then + assertThat(result.items()).isEmpty(); + verify(client, times(1)).fetchAuctionList(eq(ItemCategory.SWORD), any()); + verifyNoMoreInteractions(client); + verify(duplicateChecker, never()).checkDuplicateInBatch(any(), any()); + } + } +} diff --git a/src/test/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateCheckerTest.java b/src/test/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateCheckerTest.java new file mode 100644 index 00000000..a095f3b3 --- /dev/null +++ b/src/test/java/until/the/eternity/auctionrealtime/domain/service/AuctionRealtimeDuplicateCheckerTest.java @@ -0,0 +1,238 @@ +package until.the.eternity.auctionrealtime.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.auctionrealtime.domain.repository.AuctionRealtimeItemRepositoryPort; +import until.the.eternity.auctionrealtime.domain.service.AuctionRealtimeDuplicateChecker.DuplicateCheckResult; +import until.the.eternity.auctionrealtime.interfaces.external.dto.OpenApiAuctionRealtimeResponse; +import until.the.eternity.common.enums.ItemCategory; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuctionRealtimeDuplicateCheckerTest { + + @Mock AuctionRealtimeItemRepositoryPort repository; + + @InjectMocks AuctionRealtimeDuplicateChecker checker; + + private static final ItemCategory CATEGORY = ItemCategory.SWORD; + + private OpenApiAuctionRealtimeResponse dto(Instant dateAuctionExpire) { + return new OpenApiAuctionRealtimeResponse( + "페러시우스 타이탄 블레이드", "신성한 페러시우스 타이탄 블레이드", 1L, 100L, dateAuctionExpire, null); + } + + @Nested + @DisplayName("checkDuplicateInBatch 테스트") + class CheckDuplicateInBatchTest { + + @Test + @DisplayName("DB에 데이터가 없으면 중복 없음으로 판정") + void noDuplicateWhenNoDataInDb() { + // given + Instant now = Instant.now(); + var batch = List.of(dto(now), dto(now.minusSeconds(10))); + when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) + .thenReturn(Optional.empty()); + + // when + DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); + + // then + assertThat(result.isDuplicate()).isFalse(); + assertThat(result.hasEqualDate()).isFalse(); + assertThat(result.latestDate()).isNull(); + } + + @Test + @DisplayName("빈 배치이면 중복 없음으로 판정") + void noDuplicateWhenEmptyBatch() { + // given + List batch = List.of(); + + // when + DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); + + // then + assertThat(result.isDuplicate()).isFalse(); + assertThat(result.hasEqualDate()).isFalse(); + } + + @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(afterLatest), dto(afterLatest.plusSeconds(10))); + + when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) + .thenReturn(Optional.of(latestDate)); + + // when + DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); + + // then + assertThat(result.isDuplicate()).isFalse(); + assertThat(result.latestDate()).isEqualTo(latestDate); + } + + @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(afterLatest), dto(afterLatest), dto(beforeLatest)); + + when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) + .thenReturn(Optional.of(latestDate)); + + // when + DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); + + // then + assertThat(result.isDuplicate()).isTrue(); + assertThat(result.hasEqualDate()).isFalse(); + assertThat(result.duplicateIndex()).isEqualTo(2); + assertThat(result.latestDate()).isEqualTo(latestDate); + } + + @Test + @DisplayName("동일 날짜 데이터가 있으면 equalDateFound 반환") + void equalDateFoundWhenSameDate() { + // given + Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); + Instant afterLatest = latestDate.plusSeconds(100); + var batch = List.of(dto(afterLatest), dto(latestDate)); + + when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) + .thenReturn(Optional.of(latestDate)); + + // when + DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); + + // then + assertThat(result.isDuplicate()).isTrue(); + assertThat(result.hasEqualDate()).isTrue(); + assertThat(result.duplicateIndex()).isEqualTo(1); + assertThat(result.latestDate()).isEqualTo(latestDate); + } + + @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(beforeLatest), dto(latestDate)); + + when(repository.findLatestDateAuctionExpireBySubCategory(CATEGORY)) + .thenReturn(Optional.of(latestDate)); + + // when + DuplicateCheckResult result = checker.checkDuplicateInBatch(batch, CATEGORY); + + // then + assertThat(result.isDuplicate()).isTrue(); + assertThat(result.duplicateIndex()).isEqualTo(0); + } + } + + @Nested + @DisplayName("filterForSave 테스트") + class FilterForSaveTest { + + @Test + @DisplayName("latestDate가 null이면 모든 데이터 반환") + void returnAllWhenLatestDateNull() { + // given + Instant now = Instant.now(); + var dtos = List.of(dto(now), dto(now.minusSeconds(10))); + + // when + var result = checker.filterForSave(dtos, null); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("빈 리스트이면 빈 리스트 반환") + void returnEmptyWhenEmptyList() { + // given + List dtos = List.of(); + + // when + var result = checker.filterForSave(dtos, Instant.now()); + + // 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(afterLatest), dto(beforeLatest), dto(afterLatest)); + + // when + var result = checker.filterForSave(dtos, latestDate); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("동일 날짜 데이터는 포함됨") + void includeSameDateData() { + // given + Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); + var dtos = List.of(dto(latestDate), dto(latestDate)); + + // when + var result = checker.filterForSave(dtos, latestDate); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("복합 시나리오: 이전/동일/이후 데이터") + 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(afterLatest), // 신규: 포함 + dto(beforeLatest), // 과거: 제외 + dto(latestDate), // 동일 날짜: 포함 + dto(afterLatest) // 신규: 포함 + ); + + // when + var result = checker.filterForSave(dtos, latestDate); + + // then + assertThat(result).hasSize(3); + } + } +}