diff --git a/README.md b/README.md index 60ff1c8..a6c7958 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ๐ŸŽฎ ๋งˆ๋น„๋…ธ๊ธฐ ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์กฐํšŒ ๋ฐ ํ†ต๊ณ„ ์„œ๋น„์Šค +# ๋งˆ๋น„๋…ธ๊ธฐ ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์กฐํšŒ ๋ฐ ํ†ต๊ณ„ ์„œ๋น„์Šค [![codecov](https://codecov.io/gh/devnogi/open-api-batch-server/branch/dev/graph/badge.svg)](https://codecov.io/gh/devnogi/open-api-batch-server) [![License](https://img.shields.io/github/license/devnogi/open-api-batch-server)](LICENSE) @@ -8,65 +8,165 @@
-### ๐Ÿ“Œ ์ฃผ์š” ๊ธฐ๋Šฅ +## ์ฃผ์š” ๊ธฐ๋Šฅ -- **๋ฐ์ดํ„ฐ ์ˆ˜์ง‘**: ๋งค 1์‹œ๊ฐ„ 0๋ถ„ 0์ดˆ์— ๋งˆ๋น„๋…ธ๊ธฐ ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ Open API๋ฅผ ํ†ตํ•ด ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ -- **๋ฐ์ดํ„ฐ ๋ถ„์„**: ์ˆ˜์ง‘๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์•„์ดํ…œ๋ณ„ ์ตœ์ €๊ฐ€, ์ตœ๊ณ ๊ฐ€, ํ‰๊ท ๊ฐ€, ๊ฑฐ๋ž˜๋Ÿ‰ ๋“ฑ์˜ ํ†ต๊ณ„ ์‚ฐ์ถœ -- **๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์กฐํšŒ**: ์‚ฌ์šฉ์ž๊ฐ€ ์•„์ดํ…œ์˜ ์‹œ์„ธ๋ฅผ ์กฐํšŒํ•˜๊ณ  ๊ฒฌ์ ์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ +### ๊ฒฝ๋งค์žฅ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ +- ๋งค 1์‹œ๊ฐ„๋งˆ๋‹ค ๋งˆ๋น„๋…ธ๊ธฐ ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ์„ Nexon Open API๋ฅผ ํ†ตํ•ด ์ˆ˜์ง‘ +- ์•ฝ 70๊ฐœ์˜ ์•„์ดํ…œ ์นดํ…Œ๊ณ ๋ฆฌ์— ๋Œ€ํ•œ ์ปค์„œ ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง€๋„ค์ด์…˜์œผ๋กœ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ +- ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์š”์ฒญ ๊ฐ„ ๋”œ๋ ˆ์ด ์„ค์ •์œผ๋กœ API Rate Limit ์ค€์ˆ˜ + +### ๋ฟ”ํ”ผ๋ฆฌ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ +- 5๋ถ„๋งˆ๋‹ค ๊ฑฐ๋Œ€ํ•œ ์™ธ์นจ์˜ ๋ฟ”ํ”ผ๋ฆฌ ๋‚ด์—ญ ์ˆ˜์ง‘ +- 4๊ฐœ ์„œ๋ฒ„ ์ง€์› (๋ฅ˜ํŠธ, ๋งŒ๋Œ๋ฆฐ, ํ•˜ํ”„, ์šธํ”„) +- ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„ ๊ธฐ๋ฐ˜ ์žฌ์‹œ๋„ ๋กœ์ง ๊ตฌํ˜„ + +### ํ†ต๊ณ„ ๋ถ„์„ +- **์ผ๊ฐ„ ํ†ต๊ณ„**: ๊ฒฝ๋งค ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์™„๋ฃŒ ์‹œ ์ž๋™ ํŠธ๋ฆฌ๊ฑฐ (์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜) +- **์ฃผ๊ฐ„ ํ†ต๊ณ„**: ๋งค์ฃผ ์›”์š”์ผ 04:00์— ์ง‘๊ณ„ +- **์ „์ผ ํ†ต๊ณ„**: ๋งค์ผ 00:10์— ์ „์ผ ํ†ต๊ณ„ ํ™•์ • + +### ๋ฐ์ดํ„ฐ ์กฐํšŒ +- ์•„์ดํ…œ๋ณ„ ์ตœ์ €๊ฐ€, ์ตœ๊ณ ๊ฐ€, ํ‰๊ท ๊ฐ€, ๊ฑฐ๋ž˜๋Ÿ‰ ๋“ฑ ์‹œ์„ธ ์กฐํšŒ +- ์„œ๋ฒ„๋ณ„/์ „์ฒด ๋ฟ”ํ”ผ๋ฆฌ ๋‚ด์—ญ ์กฐํšŒ +- ์•„์ดํ…œ ์˜ต์…˜ ํ•„ํ„ฐ๋ง (๋ฌด๊ธฐ ๊ณต๊ฒฉ๋ ฅ, ๋ฐฉ์–ด๊ตฌ ๋ฐฉ์–ด๋ ฅ ๋“ฑ)
-### ๐Ÿ›  ๊ธฐ์ˆ  ์Šคํƒ +## ๊ธฐ์ˆ  ์Šคํƒ -- **Backend**: Java 21, Spring Boot, Data JPA (Hibernate) -- **Test**: JUnit5, Mockito, K6 -- **Database**: MySQL 8, Redis -- **DevOps**: Docker Compose, Flyway, GitHub Actions -- **Deployment**: Oracle Cloud -- **Document**: Swagger, Notion +| ๋ถ„๋ฅ˜ | ๊ธฐ์ˆ  | +|------|------| +| **Backend** | Java 21, Spring Boot 3.5.0, Spring Data JPA, QueryDSL | +| **HTTP Client** | Spring WebFlux (WebClient) | +| **Database** | MySQL 8, Flyway | +| **Test** | JUnit5, Mockito, AssertJ, Testcontainers | +| **Code Quality** | Spotless (Google Java Format AOSP), Jacoco | +| **Documentation** | Swagger, Spring REST Docs | +| **DevOps** | Docker Compose, GitHub Actions | +| **Deployment** | Oracle Cloud |
-### ๐Ÿ“ˆ ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ +## ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ -- `auction-history/`: ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ๋ฐ ํ†ต๊ณ„ ๊ฒ€์ƒ‰ -- `nexon-open-api/`: Open API ํ˜ธ์ถœ ๋ฐ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ -- `statics/`: ๋ฐฐ์น˜ ์ž‘์—…์„ ํ†ตํ•œ ํ†ต๊ณ„ ์‚ฐ์ถœ +``` +src/main/java/until/the/eternity/ +โ”œโ”€โ”€ auctionhistory/ # ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์ˆ˜์ง‘ ๋ฐ ๊ฒ€์ƒ‰ +โ”œโ”€โ”€ hornBugle/ # ๋ฟ”ํ”ผ๋ฆฌ ๋‚ด์—ญ ์ˆ˜์ง‘ ๋ฐ ๊ฒ€์ƒ‰ +โ”œโ”€โ”€ statistics/ # ์ผ๊ฐ„/์ฃผ๊ฐ„ ํ†ต๊ณ„ ์ง‘๊ณ„ +โ”œโ”€โ”€ iteminfo/ # ์•„์ดํ…œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ +โ”œโ”€โ”€ itemoptioninfo/ # ์•„์ดํ…œ ์˜ต์…˜ ์ •๋ณด +โ”œโ”€โ”€ metalwareinfo/ # ๊ธˆ์†๋ฅ˜ ์•„์ดํ…œ ์ •๋ณด +โ”œโ”€โ”€ auctionsearchoption/ # ๊ฒ€์ƒ‰ ์˜ต์…˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ +โ”œโ”€โ”€ common/ # ๊ณตํ†ต ์œ ํ‹ธ๋ฆฌํ‹ฐ, ์˜ˆ์™ธ, ํ•„ํ„ฐ +โ””โ”€โ”€ config/ # ์„ค์ • (Security, QueryDSL, Web, OpenAPI) +``` + +### ์•„ํ‚คํ…์ฒ˜ (Clean Architecture) +``` +interfaces/ # Controller, DTO, External API Client + โ””โ”€โ”€ rest/ # REST API ์—”๋“œํฌ์ธํŠธ + โ””โ”€โ”€ external/ # ์™ธ๋ถ€ API ์—ฐ๋™ (Nexon Open API) +application/ # Service, Scheduler, Event +domain/ # Entity, Repository Port, Mapper +infrastructure/ # Repository ๊ตฌํ˜„์ฒด, JPA +```
-### ๐Ÿ’ป for developers +## API ์—”๋“œํฌ์ธํŠธ + +| Endpoint | Method | ์„ค๋ช… | +|----------|--------|------| +| `/auction-history/search` | GET | ๊ฒฝ๋งค ๋‚ด์—ญ ๊ฒ€์ƒ‰ (ํ•„ํ„ฐ ๋ฐ ํŽ˜์ด์ง•) | +| `/auction-history/{id}` | GET | ๋‹จ์ผ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์กฐํšŒ | +| `/auction-history/batch` | POST | ๋ฐฐ์น˜ ์ˆ˜๋™ ์‹คํ–‰ | +| `/horn-bugle` | GET | ๋ฟ”ํ”ผ๋ฆฌ ๋‚ด์—ญ ๊ฒ€์ƒ‰ (์„œ๋ฒ„๋ณ„/์ „์ฒด) | +| `/horn-bugle/batch` | POST | ๋ฟ”ํ”ผ๋ฆฌ ๋ฐฐ์น˜ ์ˆ˜๋™ ์‹คํ–‰ | +| `/statistics/daily/items` | GET | ์ผ๊ฐ„ ์•„์ดํ…œ ํ†ต๊ณ„ | +| `/statistics/daily/subcategories` | GET | ์ผ๊ฐ„ ์„œ๋ธŒ์นดํ…Œ๊ณ ๋ฆฌ ํ†ต๊ณ„ | +| `/statistics/daily/top-categories` | GET | ์ผ๊ฐ„ ์ƒ์œ„์นดํ…Œ๊ณ ๋ฆฌ ํ†ต๊ณ„ | +| `/statistics/weekly/items` | GET | ์ฃผ๊ฐ„ ์•„์ดํ…œ ํ†ต๊ณ„ | +| `/api/item-infos` | GET | ์•„์ดํ…œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ | +| `/api/v1/item-option-infos` | GET | ์•„์ดํ…œ ์˜ต์…˜ ์ •๋ณด | +| `/actuator/health` | GET | ํ—ฌ์Šค์ฒดํฌ | +| `/swagger-ui/index.html` | - | API ๋ฌธ์„œ | -- **How To Run**: Notion | [ํ”„๋กœ์ ํŠธ ์‹คํ–‰ ๋ฐฉ๋ฒ•](https://periwinkle-bridge-1c6.notion.site/How-to-run-2385c107dcf380f993d8e733d664caf9?source=copy_link) -- **API ๋ช…์„ธ์„œ**: Notion | [API ๋ช…์„ธ์„œ](https://periwinkle-bridge-1c6.notion.site/API-2195c107dcf380f2a465f9840b5d5dbf?source=copy_link) -- **Git branch ์ „๋žต**: Git-flow [๊ด€๋ จ ๋ธ”๋กœ๊ทธ](https://velog.io/@kw2577/Git-branch-%EC%A0%84%EB%9E%B5) +
+ +## ์Šค์ผ€์ค„๋Ÿฌ + +| ์Šค์ผ€์ค„๋Ÿฌ | Cron ํ‘œํ˜„์‹ | ์„ค๋ช… | +|----------|-------------|------| +| ๊ฒฝ๋งค ๋‚ด์—ญ ์ˆ˜์ง‘ | `0 0 * * * *` | ๋งค ์‹œ ์ •๊ฐ | +| ๋ฟ”ํ”ผ๋ฆฌ ์ˆ˜์ง‘ | `0 */5 * * * *` | 5๋ถ„๋งˆ๋‹ค | +| ์ „์ผ ํ†ต๊ณ„ ํ™•์ • | `0 10 0 * * *` | ๋งค์ผ 00:10 | +| ์ฃผ๊ฐ„ ํ†ต๊ณ„ ์ง‘๊ณ„ | `5 0 4 * * MON` | ๋งค์ฃผ ์›”์š”์ผ 04:00 |
-### ๐Ÿณ ๋กœ์ปฌ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ (Docker) +## ํ™˜๊ฒฝ ๋ณ€์ˆ˜ + +### ํ•„์ˆ˜ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +```bash +# ์„œ๋ฒ„ +SERVER_PORT=8080 + +# ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค +DB_IP=localhost +DB_PORT=3306 +DB_SCHEMA=devnogi +DB_USER=username +DB_PASSWORD=password + +# JWT +JWT_SECRET_KEY=your-secret-key +JWT_ACCESS_TOKEN_VALIDITY=3600000 +JWT_REFRESH_TOKEN_VALIDITY=86400000 + +# Nexon Open API +NEXON_OPEN_API_KEY=your-api-key +``` + +### ์„ ํƒ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ +```bash +# ๊ฒฝ๋งค ๋‚ด์—ญ ๋ฐฐ์น˜ +AUCTION_HISTORY_CRON=0 0 * * * * +AUCTION_HISTORY_DELAY_MS=1000 + +# ๋ฟ”ํ”ผ๋ฆฌ ๋ฐฐ์น˜ +HORN_BUGLE_CRON=0 */5 * * * * +HORN_BUGLE_MAX_RETRIES=3 +HORN_BUGLE_RETRY_DELAY_MS=2000 + +# ํ†ต๊ณ„ +STATISTICS_PREVIOUS_DAY_CRON=0 10 0 * * * +STATISTICS_WEEKLY_CRON=5 0 4 * * MON +``` + +
-๋กœ์ปฌ์—์„œ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜๋ฉด์„œ ๊ฐœ๋ฐœํ•  ๋•Œ๋Š” Docker Hub์— ํ‘ธ์‹œํ•˜์ง€ ์•Š๊ณ  ๋กœ์ปฌ ๋นŒ๋“œ๋กœ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +## ๋กœ์ปฌ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ (Docker) -#### 1. ํ™˜๊ฒฝ ์„ค์ • +### 1. ํ™˜๊ฒฝ ์„ค์ • ```bash # .env.local.sample์„ ๋ณต์‚ฌํ•˜์—ฌ .env.local ์ƒ์„ฑ cp .env.local.sample .env.local -# .env.local ํŒŒ์ผ์„ ์—ด์–ด์„œ ํ•„์š”ํ•œ ๊ฐ’๋“ค์„ ์ˆ˜์ • -# - NEXON_OPEN_API_KEY: Nexon Open API ํ‚ค ์ž…๋ ฅ -# - DB_PASSWORD: ๋กœ์ปฌ MySQL ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ -# - ๊ธฐํƒ€ ํ•„์š”ํ•œ ์„ค์ • ์ˆ˜์ • +# .env.local ํŒŒ์ผ ์ˆ˜์ • +# - NEXON_OPEN_API_KEY: Nexon Open API ํ‚ค +# - DB_PASSWORD: ๋กœ์ปฌ MySQL ๋น„๋ฐ€๋ฒˆํ˜ธ ``` -#### 2. ๋กœ์ปฌ์—์„œ Docker๋กœ ์‹คํ–‰ +### 2. Docker๋กœ ์‹คํ–‰ ```bash -# ๋กœ์ปฌ ์ฝ”๋“œ๋ฅผ ๋นŒ๋“œํ•˜๊ณ  Docker ์ปจํ…Œ์ด๋„ˆ๋กœ ์‹คํ–‰ -docker-compose -f docker-compose-local.yml up --build +# ๋นŒ๋“œ ๋ฐ ์‹คํ–‰ +docker-compose -f docker-compose-local.yml --env-file .env.local up --build # ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์‹คํ–‰ -docker-compose -f docker-compose-local.yml up -d --build +docker-compose -f docker-compose-local.yml --env-file .env.local up -d --build # ๋กœ๊ทธ ํ™•์ธ docker-compose -f docker-compose-local.yml logs -f spring-app @@ -75,29 +175,72 @@ docker-compose -f docker-compose-local.yml logs -f spring-app docker-compose -f docker-compose-local.yml down ``` -#### 3. ์ฝ”๋“œ ์ˆ˜์ • ํ›„ ์žฌ์‹คํ–‰ +### 3. ํ™˜๊ฒฝ๋ณ„ Docker Compose ํŒŒ์ผ + +| ํ™˜๊ฒฝ | ํŒŒ์ผ | ์„ค๋ช… | +|------|------|------| +| ๋กœ์ปฌ ๊ฐœ๋ฐœ | `docker-compose-local.yml` | ๋กœ์ปฌ ๋นŒ๋“œ, ๋‚ฎ์€ ๋ฆฌ์†Œ์Šค | +| ๊ฐœ๋ฐœ ์„œ๋ฒ„ | `docker-compose-dev.yml` | ๊ฐœ๋ฐœ ํ™˜๊ฒฝ ๋ฐฐํฌ | +| ์šด์˜ ์„œ๋ฒ„ | `docker-compose-prod.yml` | ์šด์˜ ํ™˜๊ฒฝ ๋ฐฐํฌ | + +
+ +## ๋นŒ๋“œ ๋ฐ ํ…Œ์ŠคํŠธ ```bash -# ์ฝ”๋“œ ์ˆ˜์ • ํ›„ ๋‹ค์‹œ ๋นŒ๋“œํ•˜์—ฌ ์‹คํ–‰ -docker-compose -f docker-compose-local.yml up --build +# ๋นŒ๋“œ +./gradlew clean build -# ๋˜๋Š” ๊ธฐ์กด ์ปจํ…Œ์ด๋„ˆ ์ •๋ฆฌ ํ›„ ์žฌ์‹คํ–‰ -docker-compose -f docker-compose-local.yml down -docker-compose -f docker-compose-local.yml up --build +# ํ…Œ์ŠคํŠธ +./gradlew test + +# ์ฝ”๋“œ ํฌ๋งทํŒ… +./gradlew spotlessApply + +# ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ +./gradlew jacocoTestReport + +# REST Docs ์ƒ์„ฑ +./gradlew asciidoctor + +# ๋กœ์ปฌ ์‹คํ–‰ +./gradlew bootRun ``` -#### 4. ํ™˜๊ฒฝ๋ณ„ ์‹คํ–‰ ๋ฐฉ๋ฒ• +
+ +## API ์‘๋‹ต ํ˜•์‹ -| ํ™˜๊ฒฝ | Docker Compose ํŒŒ์ผ | ์„ค๋ช… | -|------|---------------------|------| -| **๋กœ์ปฌ ๊ฐœ๋ฐœ** | `docker-compose.local.yml` | ๋กœ์ปฌ ์ฝ”๋“œ ๋นŒ๋“œ, ๋‚ฎ์€ ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ | -| **๊ฐœ๋ฐœ/์šด์˜ ์„œ๋ฒ„** | `docker-compose.yaml` | Docker Hub ์ด๋ฏธ์ง€ ์‚ฌ์šฉ | +```json +{ + "success": true, + "code": "string", + "message": "string", + "data": {}, + "timestamp": "2025-01-01T12:00:00Z" +} +``` + +
-#### 5. ์ฐธ๊ณ ์‚ฌํ•ญ +## ๊ฐœ๋ฐœ์ž ๋ฌธ์„œ -- **๋กœ์ปฌ ๊ฐœ๋ฐœ**: ์ฝ”๋“œ ์ˆ˜์ • ์‹œ๋งˆ๋‹ค `--build` ์˜ต์…˜์œผ๋กœ ์žฌ๋นŒ๋“œ ํ•„์š” -- **๋ฉ”๋ชจ๋ฆฌ ์„ค์ •**: ๋กœ์ปฌ ํ™˜๊ฒฝ์€ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์ด ๋‚ฎ๊ฒŒ ์„ค์ •๋˜์–ด ์žˆ์Œ (512MB) -- **๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค**: `DB_IP=host.docker.internal`๋กœ ํ˜ธ์ŠคํŠธ ๋จธ์‹ ์˜ MySQL์— ์ ‘๊ทผ -- **ํฌํŠธ**: ๊ธฐ๋ณธ 8080 ํฌํŠธ ์‚ฌ์šฉ (`.env.local`์—์„œ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ) +- **์‹คํ–‰ ๋ฐฉ๋ฒ•**: [Notion - How to run](https://periwinkle-bridge-1c6.notion.site/How-to-run-2385c107dcf380f993d8e733d664caf9) +- **API ๋ช…์„ธ์„œ**: [Notion - API ๋ช…์„ธ์„œ](https://periwinkle-bridge-1c6.notion.site/API-2195c107dcf380f2a465f9840b5d5dbf) +- **Git branch ์ „๋žต**: Git-flow ([๊ด€๋ จ ๋ธ”๋กœ๊ทธ](https://velog.io/@kw2577/Git-branch-%EC%A0%84%EB%9E%B5))
+ +## ์ง€์› ์•„์ดํ…œ ์นดํ…Œ๊ณ ๋ฆฌ + +์•ฝ 70๊ฐœ์˜ ๋งˆ๋น„๋…ธ๊ธฐ ์•„์ดํ…œ ์นดํ…Œ๊ณ ๋ฆฌ ์ง€์›: +- **์ „ํˆฌ ์žฅ๋น„**: ํ•œ์†/์–‘์† ๋ฌด๊ธฐ, ๊ฒ€, ๋„๋ผ, ๋‘”๊ธฐ, ๋žœ์Šค ๋“ฑ +- **์›๊ฑฐ๋ฆฌ ์žฅ๋น„**: ํ™œ, ์„๊ถ, ์ด, ์ˆ˜๋ฆฌ๊ฒ€, ์•„ํ‹€๋ผํ‹€ +- **๋งˆ๋ฒ• ์žฅ๋น„**: ์‹ค๋ฆฐ๋”, ์Šคํƒœํ”„, ์™„๋“œ, ๋งˆ๋ฒ•์„œ, ์˜ค๋ธŒ +- **๋ฐฉ์–ด๊ตฌ**: ์ค‘๊ฐ‘/๊ฒฝ๊ฐ‘/์ฒœ์˜ท, ์žฅ๊ฐ‘, ์‹ ๋ฐœ, ๋ชจ์ž, ๋ฐฉํŒจ, ๋กœ๋ธŒ +- **์•…์„ธ์„œ๋ฆฌ**: ์–ผ๊ตด ์žฅ์‹, ๋‚ ๊ฐœ, ๊ผฌ๋ฆฌ, ์ผ๋ฐ˜ ์•…์„ธ์„œ๋ฆฌ +- **ํŠน์ˆ˜ ์žฅ๋น„**: ์•…๊ธฐ, ๋ผ์ดํ”„ ๋„๊ตฌ, ๋งˆ๋ฆฌ์˜ค๋„คํŠธ, ์—์ฝ”์Šคํ†ค, ์œ ๋ฌผ +- **์†Œ๋ชจํ’ˆ**: ๋ฌผ์•ฝ, ์Œ์‹, ํ—ˆ๋ธŒ, ๋˜์ „ ํ†ตํ–‰์ฆ, ๋ณด์„, ์—ผ๋ฃŒ +- **๊ฐ•ํ™” ์žฌ๋ฃŒ**: ์ธ์ฑˆํŠธ ์Šคํฌ๋กค, ๋งˆ๋ฒ• ๊ฐ€๋ฃจ, ์„ค๊ณ„๋„, ์•…๋งˆ ์Šคํฌ๋กค +- **์„œ์ **: ์ฑ…, ํŽ˜์ด์ง€, ๋งˆ๋น„๋…ธ๋ฒจ +- **๊ตฌ์กฐ๋ฌผ**: ์˜์ž, ํŒœ ์•„์ผ๋žœ๋“œ ์•„์ดํ…œ diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 4c99268..a79cadb 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -50,15 +50,15 @@ services: # === JVM Configuration (Local - ๊ฒฝ๋Ÿ‰ ๊ฐœ๋ฐœ์šฉ) === # Heap: 256m~512m, Non-Heap: 256m, Total: ~768m JAVA_OPTS: >- - -Xms${JAVA_OPTS_XMS:-256m} - -Xmx${JAVA_OPTS_XMX:-512m} - -XX:MaxMetaspaceSize=${JAVA_OPTS_MAX_METASPACE_SIZE:-150m} - -XX:ReservedCodeCacheSize=${JAVA_OPTS_RESERVED_CODE_CACHE_SIZE:-48m} - -XX:MaxDirectMemorySize=${JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE:-64m} - -Xss${JAVA_OPTS_XSS:-512k} + -Xms${JAVA_OPTS_XMS:-512m} + -Xmx${JAVA_OPTS_XMX:-1024m} + -XX:MaxMetaspaceSize=${JAVA_OPTS_MAX_METASPACE_SIZE:-300m} + -XX:ReservedCodeCacheSize=${JAVA_OPTS_RESERVED_CODE_CACHE_SIZE:-96m} + -XX:MaxDirectMemorySize=${JAVA_OPTS_MAX_DIRECT_MEMORY_SIZE:-128m} + -Xss${JAVA_OPTS_XSS:-1024k} -XX:+UseG1GC - -XX:MaxGCPauseMillis=${JAVA_OPTS_MAX_GC_PAUSE_MILLIS:-200} - -XX:G1HeapRegionSize=${JAVA_OPTS_G1_HEAP_REGION_SIZE:-2m} + -XX:MaxGCPauseMillis=${JAVA_OPTS_MAX_GC_PAUSE_MILLIS:-400} + -XX:G1HeapRegionSize=${JAVA_OPTS_G1_HEAP_REGION_SIZE:-4m} -XX:InitiatingHeapOccupancyPercent=${JAVA_OPTS_INITIATING_HEAP_OCCUPANCY_PERCENT:-45} -XX:+TieredCompilation -XX:TieredStopAtLevel=${JAVA_OPTS_TIERED_STOP_AT_LEVEL:-2} diff --git a/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java b/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java index 50eb72b..2ef0398 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/scheduler/AuctionHistoryScheduler.java @@ -113,7 +113,9 @@ public void fetchAndSaveAuctionHistoryAll() { totalSavedCount); // ํ†ต๊ณ„ ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ ์ด๋ฒคํŠธ ๋ฐœํ–‰ - log.debug("> [SCHEDULE] Publishing AuctionHistorySavedEvent with {} records", totalSavedCount); + log.debug( + "> [SCHEDULE] Publishing AuctionHistorySavedEvent with {} records", + totalSavedCount); eventPublisher.publishEvent(new AuctionHistorySavedEvent(totalSavedCount)); } } diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java b/src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java index c0774da..3aa4084 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/fetcher/AuctionHistoryFetcher.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.OptionalInt; @Slf4j @Component @@ -47,15 +48,24 @@ public List fetch(ItemCategory category) { } var batch = response.auctionHistory(); - result.addAll(batch); - if (duplicateChecker.hasDuplicate(batch.getLast())) { + OptionalInt duplicateIndex = duplicateChecker.checkDuplicateInBatch(batch, category); + + if (duplicateIndex.isPresent()) { + int index = duplicateIndex.getAsInt(); + if (index > 0) { + result.addAll(batch.subList(0, index)); + } log.debug( - "> [SCHEDULE] [{}] this fetched data has duplicate data, skip to next item subcategory", - category.getSubCategory()); + "> [SCHEDULE] [{}] duplicate found at index {}, added {} items before duplicate", + category.getSubCategory(), + index, + index); break; } + result.addAll(batch); + cursor = response.nextCursor(); if (cursor == null || cursor.isEmpty()) { diff --git a/src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java b/src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java index 9eefb68..ee426dd 100644 --- a/src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java +++ b/src/main/java/until/the/eternity/auctionhistory/application/service/persister/AuctionHistoryPersister.java @@ -24,7 +24,7 @@ public List filterOutExisting( List dtoList, ItemCategory category) { List entities = - mapper.toEntityList(duplicateChecker.filterExisting(dtoList), category); + mapper.toEntityList(duplicateChecker.filterExisting(dtoList, category), category); entities.forEach(AuctionHistory::linkItemOptions); diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/event/AuctionHistorySavedEvent.java b/src/main/java/until/the/eternity/auctionhistory/domain/event/AuctionHistorySavedEvent.java index bb3c181..fe8f771 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/event/AuctionHistorySavedEvent.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/event/AuctionHistorySavedEvent.java @@ -4,10 +4,7 @@ import java.time.LocalDateTime; -/** - * ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์ €์žฅ ์™„๋ฃŒ ์ด๋ฒคํŠธ - * AuctionHistoryScheduler๊ฐ€ ๊ฑฐ๋ž˜ ๋‚ด์—ญ์„ ์„ฑ๊ณต์ ์œผ๋กœ ์ €์žฅํ•œ ํ›„ ๋ฐœํ–‰๋ฉ๋‹ˆ๋‹ค. - */ +/** ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ ์ €์žฅ ์™„๋ฃŒ ์ด๋ฒคํŠธ AuctionHistoryScheduler๊ฐ€ ๊ฑฐ๋ž˜ ๋‚ด์—ญ์„ ์„ฑ๊ณต์ ์œผ๋กœ ์ €์žฅํ•œ ํ›„ ๋ฐœํ–‰๋ฉ๋‹ˆ๋‹ค. */ @Getter public class AuctionHistorySavedEvent { 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 e667d5f..c006585 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 @@ -13,10 +13,7 @@ public interface OpenApiAuctionHistoryMapper { @Named("toEntity(OpenApiAuctionHistoryResponse, ItemCategory)") - @Mapping( - source = "dateAuctionBuy", - target = "dateAuctionBuy", - qualifiedByName = "utcToKst") + @Mapping(source = "dateAuctionBuy", target = "dateAuctionBuy", qualifiedByName = "utcToKst") @Mapping(source = "openApiAuctionItemOptionResponse", target = "auctionItemOptions") @Mapping( target = "itemTopCategory", diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java b/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java index b94ec7a..f8a1907 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java @@ -9,10 +9,13 @@ import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.Set; /** ๊ฒฝ๋งค์žฅ ๊ฑฐ๋ž˜ ๋‚ด์—ญ POJO Repository - Mock ๋˜๋Š” Stub ์œผ๋กœ ๋Œ€์ฒดํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ ํ™•๋ณด */ public interface AuctionHistoryRepositoryPort { + record LatestDateWithIds(Instant latestDate, Set existingIds) {} + Page search(AuctionHistorySearchRequest condition, Pageable pageable); Optional findById(String id); @@ -21,5 +24,7 @@ public interface AuctionHistoryRepositoryPort { Optional findLatestDateAuctionBuyBySubCategory(ItemCategory itemCategory); + Optional findLatestDateWithIdsBySubCategory(ItemCategory itemCategory); + List findDistinctItemInfo(); } diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java b/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java index 5eac2b0..bdb29aa 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java @@ -4,11 +4,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; +import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort.LatestDateWithIds; import until.the.eternity.auctionhistory.interfaces.external.dto.OpenApiAuctionHistoryResponse; import until.the.eternity.common.enums.ItemCategory; import java.time.Instant; import java.util.List; +import java.util.OptionalInt; +import java.util.Set; @Slf4j @Component @@ -18,27 +21,84 @@ public class AuctionHistoryDuplicateChecker { private final AuctionHistoryRepositoryPort repository; /** - * ์ฃผ์–ด์ง„ DTO ์ปฌ๋ ‰์…˜ ์•ˆ์— ์ด๋ฏธ ์ €์žฅ๋œ auctionBuyId ๊ฐ€ ์žˆ๋Š”์ง€ ์ถ”ํ›„ ๊ฑฐ๋Œ€ํ•œ ๋ฟ”ํ”ผ๋ฆฌ ๋ฐ ์‹ค์‹œ๊ฐ„ ๊ฑฐ๋ž˜ ์ •๋ณด API๋ฅผ ํ™œ์šฉํ•˜๋ฉด ๊ณตํ†ต component๋กœ ๋ณ€๊ฒฝ ๊ณ ๋ ค + * ๋ฐฐ์น˜์—์„œ ์ฒซ ๋ฒˆ์งธ ์ค‘๋ณต ๋ฐ์ดํ„ฐ์˜ ์ธ๋ฑ์Šค๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + * + *

์ค‘๋ณต ํŒ์ • ๋กœ์ง: + * + *

    + *
  • date_auction_buy < latestDate โ†’ ์ค‘๋ณต (๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ) + *
  • date_auction_buy == latestDate โ†’ auctionBuyId๊ฐ€ DB์— ์กด์žฌํ•˜๋ฉด ์ค‘๋ณต + *
  • date_auction_buy > latestDate โ†’ ์‹ ๊ทœ ๋ฐ์ดํ„ฐ + *
+ * + * @param batch ๊ฒ€์‚ฌํ•  ๋ฐฐ์น˜ ๋ฐ์ดํ„ฐ + * @param category ์•„์ดํ…œ ์นดํ…Œ๊ณ ๋ฆฌ + * @return ์ฒซ ๋ฒˆ์งธ ์ค‘๋ณต ์ธ๋ฑ์Šค, ์ค‘๋ณต์ด ์—†์œผ๋ฉด empty */ - public boolean hasDuplicate(OpenApiAuctionHistoryResponse lastDto) { - Instant latestDate = getLatestAuctionDateOrMin(lastDto); + public OptionalInt checkDuplicateInBatch( + List batch, ItemCategory category) { + if (batch.isEmpty()) { + return OptionalInt.empty(); + } + + var latestInfo = repository.findLatestDateWithIdsBySubCategory(category); + + if (latestInfo.isEmpty()) { + return OptionalInt.empty(); + } + + LatestDateWithIds info = latestInfo.get(); + Instant latestDate = info.latestDate(); + Set existingIds = info.existingIds(); - return lastDto.dateAuctionBuy().isAfter(latestDate); + for (int i = 0; i < batch.size(); i++) { + OpenApiAuctionHistoryResponse dto = batch.get(i); + if (isDuplicate(dto, latestDate, existingIds)) { + return OptionalInt.of(i); + } + } + + return OptionalInt.empty(); } + /** + * DTO ๋ฆฌ์ŠคํŠธ์—์„œ ์ด๋ฏธ DB์— ์กด์žฌํ•˜๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ํ•„ํ„ฐ๋งํ•ฉ๋‹ˆ๋‹ค. + * + * @param dtos ํ•„ํ„ฐ๋งํ•  DTO ๋ฆฌ์ŠคํŠธ + * @param category ์•„์ดํ…œ ์นดํ…Œ๊ณ ๋ฆฌ + * @return ์ƒˆ๋กœ์šด ๋ฐ์ดํ„ฐ๋งŒ ํฌํ•จ๋œ ๋ฆฌ์ŠคํŠธ + */ public List filterExisting( - List dtos) { + List dtos, ItemCategory category) { if (dtos.isEmpty()) { return dtos; } - Instant latestDate = getLatestAuctionDateOrMin(dtos.getFirst()); - return dtos.stream().filter(dto -> dto.dateAuctionBuy().isAfter(latestDate)).toList(); + + var latestInfo = repository.findLatestDateWithIdsBySubCategory(category); + + if (latestInfo.isEmpty()) { + return dtos; + } + + LatestDateWithIds info = latestInfo.get(); + Instant latestDate = info.latestDate(); + Set existingIds = info.existingIds(); + + return dtos.stream().filter(dto -> !isDuplicate(dto, latestDate, existingIds)).toList(); } - private Instant getLatestAuctionDateOrMin(OpenApiAuctionHistoryResponse dto) { - return repository - .findLatestDateAuctionBuyBySubCategory( - ItemCategory.findBySubCategory(dto.itemSubCategory())) - .orElse(Instant.MIN); // ๊ธฐ์กด์— ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†๋Š” ์•„์ดํ…œ์ด๋ฉด ๋ฌด์กฐ๊ฑด ์ €์žฅํ•˜๊ธฐ ์œ„ํ•ด์„œ Instant.MIN ๋ฐ˜ํ™˜ + private boolean isDuplicate( + OpenApiAuctionHistoryResponse dto, Instant latestDate, Set existingIds) { + Instant dtoDate = dto.dateAuctionBuy(); + + if (dtoDate.isBefore(latestDate)) { + return true; + } + + if (dtoDate.equals(latestDate)) { + return existingIds.contains(dto.auctionBuyId()); + } + + return false; } } diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryJpaRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryJpaRepository.java index 50fddea..bdb982a 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryJpaRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryJpaRepository.java @@ -44,4 +44,20 @@ select MAX(a.dateAuctionBuy) from AuctionHistory a """) List findDistinctItemInfo(); + + @Query( + """ + select a.auctionBuyId + from AuctionHistory a + where a.itemTopCategory = :topCategory + and a.itemSubCategory = :subCategory + and a.dateAuctionBuy = ( + select max(a2.dateAuctionBuy) + from AuctionHistory a2 + where a2.itemTopCategory = :topCategory + and a2.itemSubCategory = :subCategory + ) + """) + List findAuctionBuyIdsByLatestDateAndSubCategory( + String topCategory, String subCategory); } diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java index 8e8882a..65428ed 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java @@ -13,6 +13,7 @@ import until.the.eternity.common.enums.ItemCategory; import java.time.Instant; +import java.util.HashSet; import java.util.List; import java.util.Optional; @@ -60,6 +61,19 @@ public Optional findLatestDateAuctionBuyBySubCategory(ItemCategory item itemCategory.getTopCategory(), itemCategory.getSubCategory()); } + @Override + public Optional findLatestDateWithIdsBySubCategory( + ItemCategory itemCategory) { + Optional latestDate = findLatestDateAuctionBuyBySubCategory(itemCategory); + if (latestDate.isEmpty()) { + return Optional.empty(); + } + List ids = + jpaRepository.findAuctionBuyIdsByLatestDateAndSubCategory( + itemCategory.getTopCategory(), itemCategory.getSubCategory()); + return Optional.of(new LatestDateWithIds(latestDate.get(), new HashSet<>(ids))); + } + @Override public List findDistinctItemInfo() { return jpaRepository.findDistinctItemInfo(); diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java index 62c0af1..52f6b9b 100644 --- a/src/main/java/until/the/eternity/config/SecurityConfig.java +++ b/src/main/java/until/the/eternity/config/SecurityConfig.java @@ -42,7 +42,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // TODO: API endpoint ์ •๋ฆฌ ํ›„ matcher ์ˆ˜์ • // TODO: ๊ถŒํ•œ ๊ด€๋ จ ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ ์™„๋ฃŒ ํ›„ hasRole ์ถ”๊ฐ€ .requestMatchers( - "/api/**", "/auction-history/**", "/statistics/**") + "/api/**", + "/auction-history/**", + "/statistics/**", + "/horn-bugle/**") .permitAll() // ๋‚˜๋จธ์ง€ ์š”์ฒญ์€ ์ธ์ฆ ํ•„์š” .anyRequest() diff --git a/src/main/java/until/the/eternity/hornBugle/application/scheduler/HornBugleScheduler.java b/src/main/java/until/the/eternity/hornBugle/application/scheduler/HornBugleScheduler.java new file mode 100644 index 0000000..9418055 --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/application/scheduler/HornBugleScheduler.java @@ -0,0 +1,176 @@ +package until.the.eternity.hornBugle.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.hornBugle.application.service.HornBugleService; +import until.the.eternity.hornBugle.domain.enums.HornBugleServer; +import until.the.eternity.hornBugle.infrastructure.client.HornBugleClient; +import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryListResponse; +import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryResponse; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class HornBugleScheduler { + + private final HornBugleClient client; + private final HornBugleService service; + + private static final long RATE_LIMIT_DELAY_MS = 1000L; + + @Value("${openapi.horn-bugle.max-retries:3}") + private int maxRetries; + + @Value("${openapi.horn-bugle.retry-delay-ms:2000}") + private long retryDelayMs; + + @Scheduled(cron = "${openapi.horn-bugle.cron:0 */5 * * * *}", zone = "Asia/Seoul") + public void fetchAndSaveHornBugleHistoryAll() { + log.info("[HornBugle] Starting Horn Bugle World History scheduler"); + + HornBugleServer[] servers = HornBugleServer.values(); + int totalSavedCount = 0; + List failedServers = new ArrayList<>(); + + for (int i = 0; i < servers.length; i++) { + HornBugleServer server = servers[i]; + int savedCount = fetchAndSaveForServer(server); + + if (savedCount < 0) { + failedServers.add(server); + } else { + totalSavedCount += savedCount; + } + + // Rate Limit: ๋งˆ์ง€๋ง‰ ์„œ๋ฒ„๊ฐ€ ์•„๋‹ˆ๋ฉด 1์ดˆ ๋Œ€๊ธฐ + if (i < servers.length - 1) { + waitForRateLimit(); + } + } + + // ์‹คํŒจํ•œ ์„œ๋ฒ„๋“ค ์žฌ์‹œ๋„ + if (!failedServers.isEmpty()) { + log.info( + "[HornBugle] Retrying {} failed servers: {}", + failedServers.size(), + failedServers); + totalSavedCount += retryFailedServers(failedServers); + } + + log.info( + "[HornBugle] Horn Bugle World History scheduler completed. Total saved: {}", + totalSavedCount); + } + + /** + * ์„œ๋ฒ„๋ณ„ API ํ˜ธ์ถœ ๋ฐ ์ €์žฅ + * + * @param server ์„œ๋ฒ„ + * @return ์ €์žฅ๋œ ๊ฑด์ˆ˜ (-1: ์‹คํŒจ) + */ + private int fetchAndSaveForServer(HornBugleServer server) { + try { + OpenApiHornBugleHistoryListResponse response = + client.fetchHornBugleHistory(server).block(); + + if (response == null || response.hornBugleWorldHistory() == null) { + log.warn("[HornBugle] [{}] Empty response from API", server.getServerName()); + return 0; + } + + List histories = response.hornBugleWorldHistory(); + int savedCount = service.saveAll(server, histories); + + log.info( + "[HornBugle] [{}] Fetched {} records, saved {} new records", + server.getServerName(), + histories.size(), + savedCount); + + return savedCount; + } catch (Exception e) { + log.error( + "[HornBugle] [{}] Failed to fetch and save: {}", + server.getServerName(), + e.getMessage(), + e); + return -1; + } + } + + /** + * ์‹คํŒจํ•œ ์„œ๋ฒ„๋“ค ์žฌ์‹œ๋„ + * + * @param failedServers ์‹คํŒจํ•œ ์„œ๋ฒ„ ๋ชฉ๋ก + * @return ์žฌ์‹œ๋„๋กœ ์ €์žฅ๋œ ์ด ๊ฑด์ˆ˜ + */ + private int retryFailedServers(List failedServers) { + int totalSavedCount = 0; + + for (HornBugleServer server : failedServers) { + int savedCount = retryForServer(server); + if (savedCount >= 0) { + totalSavedCount += savedCount; + } + } + + return totalSavedCount; + } + + /** + * ๋‹จ์ผ ์„œ๋ฒ„ ์žฌ์‹œ๋„ (์ง€์ˆ˜ ๋ฐฑ์˜คํ”„) + * + * @param server ์„œ๋ฒ„ + * @return ์ €์žฅ๋œ ๊ฑด์ˆ˜ (-1: ์ตœ์ข… ์‹คํŒจ) + */ + private int retryForServer(HornBugleServer server) { + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + long delay = retryDelayMs * (long) Math.pow(2, attempt - 1); + log.info( + "[HornBugle] [{}] Retry attempt {}/{}, waiting {}ms", + server.getServerName(), + attempt, + maxRetries, + delay); + + Thread.sleep(delay); + + int savedCount = fetchAndSaveForServer(server); + if (savedCount >= 0) { + log.info( + "[HornBugle] [{}] Retry successful on attempt {}", + server.getServerName(), + attempt); + return savedCount; + } + } catch (InterruptedException e) { + log.error("[HornBugle] [{}] Retry interrupted", server.getServerName(), e); + Thread.currentThread().interrupt(); + return -1; + } + } + + log.error( + "[HornBugle] [{}] All {} retry attempts failed", + server.getServerName(), + maxRetries); + return -1; + } + + private void waitForRateLimit() { + try { + log.debug("[HornBugle] Waiting {}ms for rate limit", RATE_LIMIT_DELAY_MS); + Thread.sleep(RATE_LIMIT_DELAY_MS); + } catch (InterruptedException e) { + log.error("[HornBugle] Rate limit wait interrupted", e); + Thread.currentThread().interrupt(); + } + } +} 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 new file mode 100644 index 0000000..70db2e6 --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/application/service/HornBugleService.java @@ -0,0 +1,90 @@ +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; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; +import until.the.eternity.hornBugle.domain.enums.HornBugleServer; +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.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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HornBugleService { + + private final HornBugleRepositoryPort repository; + private final HornBugleDuplicateChecker duplicateChecker; + private final HornBugleMapper mapper; + + /** + * API ์‘๋‹ต ๋ฐ์ดํ„ฐ๋ฅผ ์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ์ €์žฅํ•œ๋‹ค. + * + * @param server ์„œ๋ฒ„ ์ •๋ณด + * @param responses API ์‘๋‹ต ๋ฐ์ดํ„ฐ + * @return ์ €์žฅ๋œ ๊ฑด์ˆ˜ + */ + @Transactional + public int saveAll(HornBugleServer server, List responses) { + if (responses == null || responses.isEmpty()) { + log.debug("[HornBugle] [{}] No data to save.", server.getServerName()); + return 0; + } + + // ์ค‘๋ณต ์ œ๊ฑฐ + List filtered = + duplicateChecker.filterDuplicates(server, responses); + + if (filtered.isEmpty()) { + log.debug( + "[HornBugle] [{}] All data is duplicated. Nothing to save.", + server.getServerName()); + return 0; + } + + // Entity ๋ณ€ํ™˜ ๋ฐ ์ €์žฅ + Instant registerTime = Instant.now(); + List entities = + filtered.stream().map(dto -> mapper.toEntity(dto, server, registerTime)).toList(); + + repository.saveAll(entities); + + log.info("[HornBugle] [{}] Saved {} new records.", server.getServerName(), entities.size()); + + return entities.size(); + } + + /** + * ์„œ๋ฒ„๋ณ„ ์ตœ์‹  N๊ฑด ์กฐํšŒ (ํŽ˜์ด์ง•) + * + * @param serverName ์„œ๋ฒ„ ์ด๋ฆ„ (์„ ํƒ ์‚ฌํ•ญ, null์ด๋ฉด ์ „์ฒด ์กฐํšŒ) + * @param pageRequest ํŽ˜์ด์ง€ ์š”์ฒญ ์ •๋ณด + * @return ํŽ˜์ด์ง• ์‘๋‹ต + */ + @Transactional(readOnly = true) + public PageResponseDto search( + String serverName, HornBuglePageRequestDto pageRequest) { + + Page page; + + if (serverName != null && !serverName.isBlank()) { + page = repository.findByServerName(serverName, pageRequest.toPageable()); + } else { + page = repository.findAll(pageRequest.toPageable()); + } + + Page responsePage = page.map(mapper::toResponse); + + return PageResponseDto.of(responsePage); + } +} diff --git a/src/main/java/until/the/eternity/hornBugle/domain/entity/HornBugleWorldHistory.java b/src/main/java/until/the/eternity/hornBugle/domain/entity/HornBugleWorldHistory.java index ca38a0e..05ca8df 100644 --- a/src/main/java/until/the/eternity/hornBugle/domain/entity/HornBugleWorldHistory.java +++ b/src/main/java/until/the/eternity/hornBugle/domain/entity/HornBugleWorldHistory.java @@ -3,13 +3,19 @@ import jakarta.persistence.*; import lombok.*; -import java.time.LocalDateTime; +import java.time.Instant; @Entity -@Table(name = "horn_bugle_world_history") +@Table( + name = "horn_bugle_world_history", + indexes = { + @Index( + name = "idx_horn_bugle_server_date_send", + columnList = "server_name, date_send DESC"), + @Index(name = "idx_horn_bugle_date_send", columnList = "date_send DESC") + }) @Getter -@Setter -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder public class HornBugleWorldHistory { @@ -17,14 +23,20 @@ public class HornBugleWorldHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") - private Long id; // ๊ณ ์œ  ์‹๋ณ„์ž + private Long id; + + @Column(name = "server_name", nullable = false, length = 20) + private String serverName; @Column(name = "character_name", nullable = false, length = 100) - private String characterName; // ๋ฐœํ™”ํ•œ ์œ ์ €์˜ ์บ๋ฆญํ„ฐ๋ช… + private String characterName; - @Column(name = "message", nullable = false) - private String message; // ๋ฐœํ™”ํ•œ ๊ฑฐ๋Œ€ํ•œ ์™ธ์นจ์˜ ๋ฟ”ํ”ผ๋ฆฌ ๋‚ด์šฉ + @Column(name = "message", nullable = false, columnDefinition = "TEXT") + private String message; @Column(name = "date_send", nullable = false) - private LocalDateTime dateSend; // ๋ฐœํ™”ํ•œ ์‹œ๊ฐ (UTC0) + private Instant dateSend; + + @Column(name = "date_register", nullable = false) + private Instant dateRegister; } diff --git a/src/main/java/until/the/eternity/hornBugle/domain/enums/HornBugleServer.java b/src/main/java/until/the/eternity/hornBugle/domain/enums/HornBugleServer.java new file mode 100644 index 0000000..51bd034 --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/domain/enums/HornBugleServer.java @@ -0,0 +1,31 @@ +package until.the.eternity.hornBugle.domain.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum HornBugleServer { + LUTE("๋ฅ˜ํŠธ"), + MANDOLIN("๋งŒ๋Œ๋ฆฐ"), + HARP("ํ•˜ํ”„"), + WOLF("์šธํ”„"); + + private final String serverName; + + public String getEncodedServerName() { + return URLEncoder.encode(serverName, StandardCharsets.UTF_8); + } + + public static HornBugleServer fromServerName(String serverName) { + return Arrays.stream(values()) + .filter(server -> server.serverName.equals(serverName)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("Unknown server name: " + serverName)); + } +} 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 new file mode 100644 index 0000000..c9340bf --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/domain/mapper/HornBugleMapper.java @@ -0,0 +1,23 @@ +package until.the.eternity.hornBugle.domain.mapper; + +import org.mapstruct.Mapper; +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.interfaces.external.dto.OpenApiHornBugleHistoryResponse; +import until.the.eternity.hornBugle.interfaces.rest.dto.response.HornBugleHistoryResponse; + +import java.time.Instant; + +@Mapper(componentModel = "spring") +public interface HornBugleMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "serverName", expression = "java(server.getServerName())") + @Mapping(target = "dateRegister", source = "registerTime") + @Mapping(target = "dateSend", expression = "java(dto.dateSend().plusSeconds(32400))") + HornBugleWorldHistory toEntity( + OpenApiHornBugleHistoryResponse dto, HornBugleServer server, Instant registerTime); + + HornBugleHistoryResponse toResponse(HornBugleWorldHistory entity); +} 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 new file mode 100644 index 0000000..0162f66 --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/domain/repository/HornBugleRepositoryPort.java @@ -0,0 +1,22 @@ +package until.the.eternity.hornBugle.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface HornBugleRepositoryPort { + + void saveAll(List entities); + + Optional findLatestByServerName(String serverName); + + Page findByServerName(String serverName, Pageable pageable); + + Page findAll(Pageable pageable); + + List findByServerNameAndDateSend(String serverName, Instant dateSend); +} diff --git a/src/main/java/until/the/eternity/hornBugle/domain/service/HornBugleDuplicateChecker.java b/src/main/java/until/the/eternity/hornBugle/domain/service/HornBugleDuplicateChecker.java new file mode 100644 index 0000000..267b975 --- /dev/null +++ b/src/main/java/until/the/eternity/hornBugle/domain/service/HornBugleDuplicateChecker.java @@ -0,0 +1,115 @@ +package until.the.eternity.hornBugle.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import until.the.eternity.hornBugle.domain.entity.HornBugleWorldHistory; +import until.the.eternity.hornBugle.domain.enums.HornBugleServer; +import until.the.eternity.hornBugle.domain.repository.HornBugleRepositoryPort; +import until.the.eternity.hornBugle.interfaces.external.dto.OpenApiHornBugleHistoryResponse; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class HornBugleDuplicateChecker { + + private final HornBugleRepositoryPort repository; + + /** + * API ์‘๋‹ต ๋ฐ์ดํ„ฐ์—์„œ ์ค‘๋ณต์„ ์ œ๊ฑฐํ•˜๊ณ  ์‹ ๊ทœ ๋ฐ์ดํ„ฐ๋งŒ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + * + *

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