From d0507ce47a18a808563a915c279da6f8e006dae8 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Fri, 24 Oct 2025 00:48:08 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20auction=20history=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20item=20option=EB=8F=84=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 58 + AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md | 1787 +++++++++++++++++ AUCTION_SEARCH_OPTION_REQUIREMENTS.md | 639 ++++++ OPTION_DATA_STRUCTURE_ANALYSIS.md | 74 + auction_history_search_param.txt | 37 + error.log | 83 + .../AuctionHistoryQueryDslRepository.java | 312 ++- .../controller/AuctionHistoryController.java | 5 +- .../request/AuctionHistorySearchRequest.java | 6 +- .../dto/request/BalanceSearchRequest.java | 8 + .../dto/request/CriticalSearchRequest.java | 9 + .../dto/request/DefenseSearchRequest.java | 9 + .../dto/request/ErgRankSearchRequest.java | 11 + .../rest/dto/request/ErgSearchRequest.java | 8 + .../dto/request/ItemOptionSearchRequest.java | 27 + .../request/MagicDefenseSearchRequest.java | 9 + .../request/MagicProtectSearchRequest.java | 9 + .../dto/request/MaxAttackSearchRequest.java | 8 + .../request/MaxInjuryRateSearchRequest.java | 8 + .../MaximumDurabilitySearchRequest.java | 9 + .../rest/dto/request/PriceSearchRequest.java | 6 +- .../dto/request/ProficiencySearchRequest.java | 9 + .../dto/request/ProtectSearchRequest.java | 9 + ...emainingTransactionCountSearchRequest.java | 9 + .../RemainingUnsealCountSearchRequest.java | 9 + .../RemainingUseCountSearchRequest.java | 9 + .../WearingRestrictionsSearchRequest.java | 7 + .../service/AuctionHistoryServiceTest.java | 2 +- 28 files changed, 3162 insertions(+), 14 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md create mode 100644 AUCTION_SEARCH_OPTION_REQUIREMENTS.md create mode 100644 OPTION_DATA_STRUCTURE_ANALYSIS.md create mode 100644 auction_history_search_param.txt create mode 100644 error.log create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/BalanceSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/CriticalSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DefenseSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ErgRankSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ErgSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ItemOptionSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicDefenseSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicProtectSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaxAttackSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaxInjuryRateSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaximumDurabilitySearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProficiencySearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProtectSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingTransactionCountSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUnsealCountSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUseCountSearchRequest.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/WearingRestrictionsSearchRequest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b648bc0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,58 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew test:*)", + "Bash(./gradlew clean build:*)", + "Bash(./gradlew spotlessApply)", + "Read(//c/Program Files/Eclipse Adoptium/**)", + "Bash(export JAVA_HOME=\"C:/Program Files/Eclipse Adoptium/jdk-25.0.0.36-hotspot\")", + "Bash(where java)", + "Bash(curl -L \"https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse\" -o /tmp/jdk21.zip)", + "Read(//c/**)", + "Bash(export JAVA_HOME=\"/tmp/jdk-21.0.8+9\")", + "Bash(export JWT_SECRET_KEY=\"test-secret-key-for-migration-only\")", + "Bash(export JWT_ACCESS_TOKEN_VALIDITY=\"3600\")", + "Bash(export JWT_REFRESH_TOKEN_VALIDITY=\"86400\")", + "Bash(timeout 30 ./gradlew bootRun)", + "Bash(timeout 30 ./gradlew bootRun --info)", + "Bash(export JAVA_HOME=\"C:/Users/Desktop/.jdks/corretto-21.0.6\")", + "Bash(tee /tmp/bootrun.log)", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"SELECT id, search_option_name, display_order, is_active FROM auction_search_option_metadata ORDER BY display_order LIMIT 5;\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"SELECT search_option_name, JSON_PRETTY(search_condition_json) as json_data FROM auction_search_option_metadata WHERE id = 1;\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"SELECT COUNT(*) as total_count FROM auction_search_option_metadata; SELECT id, search_option_name, display_order FROM auction_search_option_metadata ORDER BY display_order;\")", + "Bash(curl -X GET http://localhost:8092/api/search-option -H \"Content-Type: application/json\")", + "Bash(python -m json.tool)", + "Bash(netstat -ano)", + "Bash(awk '{print $5}')", + "Bash(xargs -I {} taskkill //PID {} //F)", + "Bash(export JWT_SECRET_KEY=\"test-secret-key\")", + "Bash(timeout 40 ./gradlew bootRun)", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\nEXPLAIN\nSELECT *\nFROM auction_history ah\nINNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id\nWHERE ah.item_top_category = ''근거리 장비''\n AND ah.item_sub_category = ''검''\n AND ah.item_name = ''고스트 소드''\n AND ah.auction_price_per_unit BETWEEN 10 AND 1000000000\n AND ah.date_auction_buy BETWEEN ''2023-01-01'' AND ''2025-12-31''\n AND ((io.option_type = ''세공 옵션'' AND io.option_value2 >= 2)\n OR (io.option_type = ''공격'' AND io.option_value2 >= 2)\n OR (io.option_type = ''인챈트'' AND io.option_sub_type = ''접미'' AND io.option_value = ''다이어 울프''))\nLIMIT 10;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\nEXPLAIN\nSELECT *\nFROM auction_history ah\nINNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id\nWHERE ah.item_top_category = ''근거리 장비''\n AND ah.item_sub_category = ''검''\n AND ah.item_name = ''고스트 소드''\n AND ah.auction_price_per_unit BETWEEN 10 AND 1000000000\n AND ah.date_auction_buy BETWEEN ''2023-01-01'' AND ''2025-12-31''\n AND ah.auction_buy_id IN (\n SELECT io2.auction_history_id \n FROM auction_item_option io2 \n WHERE ((io2.option_type = ''세공 옵션'' AND io2.option_value2 >= 2)\n OR (io2.option_type = ''공격'' AND io2.option_value2 >= 2)\n OR (io2.option_type = ''인챈트'' AND io2.option_sub_type = ''접미'' AND io2.option_value = ''다이어 울프''))\n )\nLIMIT 10;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\nSELECT item_top_category, item_sub_category, item_name, COUNT(*) as cnt\nFROM auction_history\nGROUP BY item_top_category, item_sub_category, item_name\nORDER BY cnt DESC\nLIMIT 5;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\nDESCRIBE auction_item_option;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\nSELECT option_type, option_sub_type, COUNT(*) as cnt\nFROM auction_item_option\nGROUP BY option_type, option_sub_type\nORDER BY cnt DESC\nLIMIT 10;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\nEXPLAIN FORMAT=TREE\nSELECT ah.*, io.*\nFROM auction_history ah\nINNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id\nWHERE ah.item_top_category = ''소모품''\n AND ((io.option_type = ''공격'' AND io.option_value2 >= ''2'')\n OR (io.option_type = ''밸런스'' AND io.option_value2 >= ''5''))\nLIMIT 10;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\nSELECT ah.item_top_category, COUNT(DISTINCT ah.auction_buy_id) as history_cnt, COUNT(io.id) as option_cnt\nFROM auction_history ah\nLEFT JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id\nGROUP BY ah.item_top_category\nORDER BY option_cnt DESC\nLIMIT 5;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\n-- Query 1: Direct JOIN with WHERE\nEXPLAIN FORMAT=TREE\nSELECT ah.auction_buy_id, ah.item_name, io.option_type, io.option_value2\nFROM auction_history ah\nINNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id\nWHERE ah.item_top_category = ''근거리 장비''\n AND ((io.option_type = ''공격'' AND CAST(io.option_value2 AS UNSIGNED) >= 2)\n OR (io.option_type = ''밸런스'' AND CAST(io.option_value2 AS UNSIGNED) >= 5))\nLIMIT 10;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\n-- Check if JOIN works\nSELECT \n ah.auction_buy_id,\n ah.item_name,\n ah.item_top_category,\n io.option_type,\n io.option_value2\nFROM auction_history ah\nINNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id\nWHERE ah.item_top_category = ''근거리 장비''\nLIMIT 5;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\n-- Check auction_buy_id samples from both tables\nSELECT ''auction_history'' as source, auction_buy_id FROM auction_history LIMIT 3\nUNION ALL\nSELECT ''auction_item_option'' as source, auction_history_id FROM auction_item_option WHERE auction_history_id IS NOT NULL LIMIT 3;\n\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"SELECT auction_buy_id FROM auction_history LIMIT 3;\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"SELECT auction_history_id FROM auction_item_option WHERE auction_history_id IS NOT NULL LIMIT 3;\")", + "Bash(mysql -h 127.0.0.1 -P 3318 -u devnogi -pdevnogi0529! devnogi -e \"\nEXPLAIN FORMAT=JSON\nSELECT ah.auction_buy_id, ah.item_name, ah.item_top_category, io.option_type, io.option_value2\nFROM auction_history ah\nINNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id\nWHERE ah.item_top_category = ''''근거리 장비''''\n AND ((io.option_type = ''''공격'''')\n OR (io.option_type = ''''밸런스''''))\nLIMIT 10;\n\")", + "Bash(cat)", + "Bash(./gradlew clean test:*)", + "Bash(cat:*)", + "Bash(unset JAVA_HOME)", + "Bash(./gradlew:*)", + "Bash(export PATH=\"/c/Program Files/Eclipse Adoptium/jdk-25.0.0.36-hotspot/bin:$PATH\":*)", + "Bash(java:*)", + "Bash(curl:*)", + "Bash(unzip:*)", + "Bash(export PATH=\"$JAVA_HOME/bin:$PATH\")", + "Bash(mysql:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md b/AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md new file mode 100644 index 0000000..9505021 --- /dev/null +++ b/AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md @@ -0,0 +1,1787 @@ +# Auction Item Option 검색 기능 구현 계획서 + +> 작성일: 2025-10-20 +> 참조 문서: AUCTION_SEARCH_OPTION_REQUIREMENTS.md +> 프로젝트: open-api-batch-server + +--- + +## 📑 목차 + +1. [Phase 1: DB 스키마 및 Migration](#phase-1-db-스키마-및-migration) +2. [Phase 2: 검색 조건 메타데이터 API 구현](#phase-2-검색-조건-메타데이터-api-구현) +3. [Phase 3: Item Option 검색 Request DTO 구현](#phase-3-item-option-검색-request-dto-구현) +4. [Phase 4: QueryDSL 검색 로직 확장](#phase-4-querydsl-검색-로직-확장) +5. [Phase 5: 테스트 코드 작성](#phase-5-테스트-코드-작성) +6. [Phase 6: 문서화 및 마무리](#phase-6-문서화-및-마무리) + +--- + +## Phase 1: DB 스키마 및 Migration + +### 목표 +- `auction_search_option_metadata` 테이블 생성 +- 초기 데이터 INSERT (17개 검색 옵션) + +### 작업 순서 + +#### 1.1. Flyway 버전 확인 + +**명령어:** +```bash +ls src/main/resources/db/migration/ +``` + +**목적:** 다음 마이그레이션 버전 번호 확인 + +#### 1.2. V 스크립트 작성 (테이블 생성) + +**파일명:** `V{next_version}__create_auction_search_option_metadata.sql` +**예시:** `V7__create_auction_search_option_metadata.sql` (기존 V6까지 있다고 가정) + +**파일 경로:** +``` +src/main/resources/db/migration/V7__create_auction_search_option_metadata.sql +``` + +**파일 내용:** +```sql +-- 경매 검색 옵션 메타데이터 테이블 생성 +CREATE TABLE `auction_search_option_metadata` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 ID', + `search_option_name` VARCHAR(100) NOT NULL COMMENT '검색 옵션명 (한글)', + `search_condition_json` JSON NOT NULL COMMENT '검색 조건 (파라미터명:타입)', + `display_order` INT NOT NULL UNIQUE COMMENT '정렬 순서 (고유값)', + `is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성화 여부', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시', + PRIMARY KEY (`id`), + INDEX `idx_display_order` (`display_order`), + INDEX `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='경매 검색 옵션 메타데이터'; +``` + +#### 1.3. R 스크립트 작성 (초기 데이터) + +**파일명:** `R__insert_auction_search_option_metadata.sql` + +**파일 경로:** +``` +src/main/resources/db/migration/R__insert_auction_search_option_metadata.sql +``` + +**파일 내용:** +```sql +-- 경매 검색 옵션 메타데이터 초기 데이터 +-- Repeatable: 데이터 변경 시 자동 재실행 + +-- 기존 데이터 삭제 (Repeatable 스크립트이므로) +DELETE FROM `auction_search_option_metadata`; + +-- 1. 밸런스 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '밸런스', + JSON_OBJECT( + 'Balance', JSON_OBJECT('type', 'tinyint', 'required', false), + 'BalanceStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 1, + true +); + +-- 2. 크리티컬 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '크리티컬', + JSON_OBJECT( + 'Critical', JSON_OBJECT('type', 'tinyint', 'required', false), + 'CriticalStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 2, + true +); + +-- 3. 방어력 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '방어력', + JSON_OBJECT( + 'Defense', JSON_OBJECT('type', 'tinyint', 'required', false), + 'DefenseStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 3, + true +); + +-- 4. 에르그 (범위) +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '에르그', + JSON_OBJECT( + 'ErgFrom', JSON_OBJECT('type', 'tinyint', 'required', false), + 'ErgTo', JSON_OBJECT('type', 'tinyint', 'required', false) + ), + 4, + true +); + +-- 5. 에르그 등급 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '에르그 등급', + JSON_OBJECT( + 'ErgRank', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('S등급', 'A등급', 'B등급'), 'required', false) + ), + 5, + true +); + +-- 6. 마법 방어력 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '마법 방어력', + JSON_OBJECT( + 'MagicDefense', JSON_OBJECT('type', 'tinyint', 'required', false), + 'MagicDefenseStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 6, + true +); + +-- 7. 마법 보호 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '마법 보호', + JSON_OBJECT( + 'MagicProtect', JSON_OBJECT('type', 'tinyint', 'required', false), + 'MagicProtectStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 7, + true +); + +-- 8. 최대 공격력 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '최대 공격력', + JSON_OBJECT( + 'MaxAttackFrom', JSON_OBJECT('type', 'int', 'required', false), + 'MaxAttackTo', JSON_OBJECT('type', 'int', 'required', false) + ), + 8, + true +); + +-- 9. 최대 내구력 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '최대 내구력', + JSON_OBJECT( + 'MaximumDurability', JSON_OBJECT('type', 'tinyint', 'required', false), + 'MaximumDurabilityStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 9, + true +); + +-- 10. 최대 부상률 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '최대 부상률', + JSON_OBJECT( + 'MaxInjuryRateFrom', JSON_OBJECT('type', 'tinyint', 'required', false), + 'MaxInjuryRateTo', JSON_OBJECT('type', 'tinyint', 'required', false) + ), + 10, + true +); + +-- 11. 숙련도 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '숙련도', + JSON_OBJECT( + 'Proficiency', JSON_OBJECT('type', 'tinyint', 'required', false), + 'ProficiencyStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 11, + true +); + +-- 12. 보호 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '보호', + JSON_OBJECT( + 'Protect', JSON_OBJECT('type', 'tinyint', 'required', false), + 'ProtectStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 12, + true +); + +-- 13. 남은 거래 횟수 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '남은 거래 횟수', + JSON_OBJECT( + 'RemainingTransactionCount', JSON_OBJECT('type', 'tinyint', 'required', false), + 'RemainingTransactionCountStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 13, + true +); + +-- 14. 남은 전용 해제 가능 횟수 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '남은 전용 해제 가능 횟수', + JSON_OBJECT( + 'RemainingUnsealCount', JSON_OBJECT('type', 'tinyint', 'required', false), + 'RemainingUnsealCountStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 14, + true +); + +-- 15. 남은 사용 횟수 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '남은 사용 횟수', + JSON_OBJECT( + 'RemainingUseCount', JSON_OBJECT('type', 'tinyint', 'required', false), + 'RemainingUseCountStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) + ), + 15, + true +); + +-- 16. 착용 제한 +INSERT INTO `auction_search_option_metadata` +(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) +VALUES ( + '착용 제한', + JSON_OBJECT( + 'WearingRestrictions', JSON_OBJECT('type', 'string', 'required', false) + ), + 16, + true +); +``` + +#### 1.4. Migration 실행 및 검증 + +**명령어:** +```bash +./gradlew flywayMigrate +``` + +**검증 쿼리:** +```sql +-- 테이블 생성 확인 +SHOW CREATE TABLE auction_search_option_metadata; + +-- 데이터 확인 +SELECT id, search_option_name, display_order, is_active +FROM auction_search_option_metadata +ORDER BY display_order; + +-- JSON 데이터 확인 +SELECT search_option_name, JSON_PRETTY(search_condition_json) +FROM auction_search_option_metadata +WHERE id = 1; +``` + +--- + +## Phase 2: 검색 조건 메타데이터 API 구현 + +### 목표 +- Clean Architecture 패턴으로 검색 조건 메타데이터 조회 API 구현 +- `GET /api/search-option` 엔드포인트 제공 + +### 작업 순서 + +#### 2.1. 디렉토리 구조 생성 + +**생성할 디렉토리:** +``` +src/main/java/until/the/eternity/auctionsearchoption/ +├── application/ +│ └── service/ +├── domain/ +│ ├── entity/ +│ └── repository/ +├── infrastructure/ +│ └── persistence/ +└── interfaces/ + └── rest/ + └── dto/ + └── response/ +``` + +#### 2.2. Entity 작성 + +**파일:** `src/main/java/until/the/eternity/auctionsearchoption/domain/entity/AuctionSearchOptionMetadata.java` + +```java +package until.the.eternity.auctionsearchoption.domain.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table(name = "auction_search_option_metadata") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AuctionSearchOptionMetadata { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "search_option_name", nullable = false, length = 100) + private String searchOptionName; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "search_condition_json", nullable = false, columnDefinition = "JSON") + private String searchConditionJson; + + @Column(name = "display_order", nullable = false, unique = true) + private Integer displayOrder; + + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} +``` + +#### 2.3. Repository Port 인터페이스 작성 + +**파일:** `src/main/java/until/the/eternity/auctionsearchoption/domain/repository/AuctionSearchOptionRepositoryPort.java` + +```java +package until.the.eternity.auctionsearchoption.domain.repository; + +import java.util.List; +import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; + +public interface AuctionSearchOptionRepositoryPort { + + /** + * 모든 활성화된 검색 옵션 조회 (정렬 순서대로) + * + * @return 검색 옵션 메타데이터 리스트 + */ + List findAllActive(); + + /** + * 모든 검색 옵션 조회 (정렬 순서대로) + * + * @return 검색 옵션 메타데이터 리스트 + */ + List findAll(); +} +``` + +#### 2.4. JPA Repository 작성 + +**파일:** `src/main/java/until/the/eternity/auctionsearchoption/infrastructure/persistence/AuctionSearchOptionJpaRepository.java` + +```java +package until.the.eternity.auctionsearchoption.infrastructure.persistence; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; + +@Repository +interface AuctionSearchOptionJpaRepository + extends JpaRepository { + + List findByIsActiveTrueOrderByDisplayOrderAsc(); + + List findAllByOrderByDisplayOrderAsc(); +} +``` + +#### 2.5. Repository Port 구현체 작성 + +**파일:** `src/main/java/until/the/eternity/auctionsearchoption/infrastructure/persistence/AuctionSearchOptionRepositoryPortImpl.java` + +```java +package until.the.eternity.auctionsearchoption.infrastructure.persistence; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; +import until.the.eternity.auctionsearchoption.domain.repository.AuctionSearchOptionRepositoryPort; + +@Component +@RequiredArgsConstructor +class AuctionSearchOptionRepositoryPortImpl implements AuctionSearchOptionRepositoryPort { + + private final AuctionSearchOptionJpaRepository jpaRepository; + + @Override + public List findAllActive() { + return jpaRepository.findByIsActiveTrueOrderByDisplayOrderAsc(); + } + + @Override + public List findAll() { + return jpaRepository.findAllByOrderByDisplayOrderAsc(); + } +} +``` + +#### 2.6. Response DTO 작성 + +**파일 1:** `src/main/java/until/the/eternity/auctionsearchoption/interfaces/rest/dto/response/FieldMetadata.java` + +```java +package until.the.eternity.auctionsearchoption.interfaces.rest.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "검색 조건 필드 메타데이터") +@JsonInclude(JsonInclude.Include.NON_NULL) +public record FieldMetadata( + @Schema(description = "필드 타입", example = "tinyint") String type, + @Schema(description = "필수 여부", example = "false") Boolean required, + @Schema(description = "허용된 값 목록 (Enum인 경우)", example = "[\"UP\", \"DOWN\"]") + List allowedValues) {} +``` + +**파일 2:** `src/main/java/until/the/eternity/auctionsearchoption/interfaces/rest/dto/response/SearchOptionMetadataResponse.java` + +```java +package until.the.eternity.auctionsearchoption.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; + +@Schema(description = "검색 옵션 메타데이터 응답") +public record SearchOptionMetadataResponse( + @Schema(description = "검색 옵션 ID", example = "1") Long id, + @Schema(description = "검색 옵션명", example = "밸런스") String searchOptionName, + @Schema(description = "검색 조건 상세") + Map searchCondition, + @Schema(description = "정렬 순서", example = "1") Integer displayOrder) {} +``` + +#### 2.7. Service 작성 + +**파일:** `src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java` + +```java +package until.the.eternity.auctionsearchoption.application.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; +import until.the.eternity.auctionsearchoption.domain.repository.AuctionSearchOptionRepositoryPort; +import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.FieldMetadata; +import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuctionSearchOptionService { + + private final AuctionSearchOptionRepositoryPort repositoryPort; + private final ObjectMapper objectMapper; + + /** + * 모든 활성화된 검색 옵션 조회 + * + * @return 검색 옵션 메타데이터 리스트 + */ + @Transactional(readOnly = true) + public List getAllActiveSearchOptions() { + List entities = repositoryPort.findAllActive(); + + return entities.stream().map(this::toResponse).toList(); + } + + private SearchOptionMetadataResponse toResponse(AuctionSearchOptionMetadata entity) { + Map searchCondition = parseJsonToFieldMetadata(entity.getSearchConditionJson()); + + return new SearchOptionMetadataResponse( + entity.getId(), + entity.getSearchOptionName(), + searchCondition, + entity.getDisplayOrder()); + } + + private Map parseJsonToFieldMetadata(String json) { + try { + TypeReference> typeRef = new TypeReference<>() {}; + return objectMapper.readValue(json, typeRef); + } catch (Exception e) { + log.error("Failed to parse JSON to FieldMetadata: {}", json, e); + throw new IllegalStateException("JSON 파싱 실패", e); + } + } +} +``` + +#### 2.8. Controller 작성 + +**파일:** `src/main/java/until/the/eternity/auctionsearchoption/interfaces/rest/AuctionSearchOptionController.java` + +```java +package until.the.eternity.auctionsearchoption.interfaces.rest; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import until.the.eternity.auctionsearchoption.application.service.AuctionSearchOptionService; +import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; +import until.the.eternity.common.response.ApiResponse; + +@Tag(name = "Auction Search Option", description = "경매 검색 옵션 API") +@RestController +@RequestMapping("/api/search-option") +@RequiredArgsConstructor +public class AuctionSearchOptionController { + + private final AuctionSearchOptionService service; + + @Operation(summary = "검색 옵션 메타데이터 조회", description = "경매 검색에 사용 가능한 모든 옵션 메타데이터를 조회합니다.") + @GetMapping + public ResponseEntity>> getSearchOptions() { + List searchOptions = service.getAllActiveSearchOptions(); + + return ResponseEntity.ok( + ApiResponse.success(searchOptions, "검색 옵션 조회 성공")); + } +} +``` + +#### 2.9. API 테스트 + +**수동 테스트:** +```bash +curl -X GET http://localhost:8080/api/search-option +``` + +**예상 응답:** +```json +{ + "success": true, + "code": "SUCCESS", + "message": "검색 옵션 조회 성공", + "data": [ + { + "id": 1, + "searchOptionName": "밸런스", + "searchCondition": { + "Balance": { + "type": "tinyint", + "required": false + }, + "BalanceStandard": { + "type": "string", + "required": false, + "allowedValues": ["UP", "DOWN"] + } + }, + "displayOrder": 1 + } + ], + "timestamp": "2025-10-20T12:00:00Z" +} +``` + +--- + +## Phase 3: Item Option 검색 Request DTO 구현 + +### 목표 +- 16개 개별 Search Request Record 생성 +- ItemOptionSearchRequest 통합 Record 생성 +- AuctionHistorySearchRequest에 필드 추가 + +### 작업 순서 + +#### 3.1. 개별 Search Request Record 생성 (16개) + +**디렉토리:** +``` +src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ +``` + +**파일 목록 및 내용:** + +**1) BalanceSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "밸런스 검색 조건") +public record BalanceSearchRequest( + @Schema(description = "밸런스 값", example = "10") Integer balance, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String balanceStandard) {} +``` + +**2) CriticalSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "크리티컬 검색 조건") +public record CriticalSearchRequest( + @Schema(description = "크리티컬 값", example = "30") Integer critical, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String criticalStandard) {} +``` + +**3) DefenseSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "방어력 검색 조건") +public record DefenseSearchRequest( + @Schema(description = "방어력 값", example = "5") Integer defense, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") + String defenseStandard) {} +``` + +**4) ErgSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "에르그 검색 조건 (범위)") +public record ErgSearchRequest( + @Schema(description = "에르그 최소값", example = "10") Integer ergFrom, + @Schema(description = "에르그 최대값", example = "50") Integer ergTo) {} +``` + +**5) ErgRankSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "에르그 등급 검색 조건") +public record ErgRankSearchRequest( + @Schema(description = "에르그 등급", example = "S등급", allowableValues = {"S등급", "A등급", "B등급"}) + String ergRank) {} +``` + +**6) MagicDefenseSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "마법 방어력 검색 조건") +public record MagicDefenseSearchRequest( + @Schema(description = "마법 방어력 값", example = "3") Integer magicDefense, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String magicDefenseStandard) {} +``` + +**7) MagicProtectSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "마법 보호 검색 조건") +public record MagicProtectSearchRequest( + @Schema(description = "마법 보호 값", example = "2") Integer magicProtect, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String magicProtectStandard) {} +``` + +**8) MaxAttackSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "최대 공격력 검색 조건 (범위)") +public record MaxAttackSearchRequest( + @Schema(description = "최대 공격력 최소값", example = "50") Integer maxAttackFrom, + @Schema(description = "최대 공격력 최대값", example = "100") Integer maxAttackTo) {} +``` + +**9) MaximumDurabilitySearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "최대 내구력 검색 조건") +public record MaximumDurabilitySearchRequest( + @Schema(description = "최대 내구력 값", example = "20") Integer maximumDurability, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String maximumDurabilityStandard) {} +``` + +**10) MaxInjuryRateSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "최대 부상률 검색 조건 (범위)") +public record MaxInjuryRateSearchRequest( + @Schema(description = "최대 부상률 최소값", example = "10") Integer maxInjuryRateFrom, + @Schema(description = "최대 부상률 최대값", example = "30") Integer maxInjuryRateTo) {} +``` + +**11) ProficiencySearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "숙련도 검색 조건") +public record ProficiencySearchRequest( + @Schema(description = "숙련도 값", example = "15") Integer proficiency, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String proficiencyStandard) {} +``` + +**12) ProtectSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "보호 검색 조건") +public record ProtectSearchRequest( + @Schema(description = "보호 값", example = "1") Integer protect, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") + String protectStandard) {} +``` + +**13) RemainingTransactionCountSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "남은 거래 횟수 검색 조건") +public record RemainingTransactionCountSearchRequest( + @Schema(description = "남은 거래 횟수", example = "5") Integer remainingTransactionCount, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String remainingTransactionCountStandard) {} +``` + +**14) RemainingUnsealCountSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "남은 전용 해제 가능 횟수 검색 조건") +public record RemainingUnsealCountSearchRequest( + @Schema(description = "남은 전용 해제 가능 횟수", example = "3") Integer remainingUnsealCount, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String remainingUnsealCountStandard) {} +``` + +**15) RemainingUseCountSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "남은 사용 횟수 검색 조건") +public record RemainingUseCountSearchRequest( + @Schema(description = "남은 사용 횟수", example = "10") Integer remainingUseCount, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") + String remainingUseCountStandard) {} +``` + +**16) WearingRestrictionsSearchRequest.java** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "착용 제한 검색 조건") +public record WearingRestrictionsSearchRequest( + @Schema(description = "착용 제한", example = "자이언트 전용") String wearingRestrictions) {} +``` + +#### 3.2. ItemOptionSearchRequest 통합 Record 생성 + +**파일:** `src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ItemOptionSearchRequest.java` + +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "아이템 옵션 검색 조건 통합") +public record ItemOptionSearchRequest( + @Schema(description = "밸런스 검색 조건") BalanceSearchRequest balanceSearch, + @Schema(description = "크리티컬 검색 조건") CriticalSearchRequest criticalSearch, + @Schema(description = "방어력 검색 조건") DefenseSearchRequest defenseSearch, + @Schema(description = "에르그 검색 조건") ErgSearchRequest ergSearch, + @Schema(description = "에르그 등급 검색 조건") ErgRankSearchRequest ergRankSearch, + @Schema(description = "마법 방어력 검색 조건") + MagicDefenseSearchRequest magicDefenseSearch, + @Schema(description = "마법 보호 검색 조건") MagicProtectSearchRequest magicProtectSearch, + @Schema(description = "최대 공격력 검색 조건") MaxAttackSearchRequest maxAttackSearch, + @Schema(description = "최대 내구력 검색 조건") + MaximumDurabilitySearchRequest maximumDurabilitySearch, + @Schema(description = "최대 부상률 검색 조건") + MaxInjuryRateSearchRequest maxInjuryRateSearch, + @Schema(description = "숙련도 검색 조건") ProficiencySearchRequest proficiencySearch, + @Schema(description = "보호 검색 조건") ProtectSearchRequest protectSearch, + @Schema(description = "남은 거래 횟수 검색 조건") + RemainingTransactionCountSearchRequest remainingTransactionCountSearch, + @Schema(description = "남은 전용 해제 가능 횟수 검색 조건") + RemainingUnsealCountSearchRequest remainingUnsealCountSearch, + @Schema(description = "남은 사용 횟수 검색 조건") + RemainingUseCountSearchRequest remainingUseCountSearch, + @Schema(description = "착용 제한 검색 조건") + WearingRestrictionsSearchRequest wearingRestrictionsSearch) {} +``` + +#### 3.3. AuctionHistorySearchRequest 수정 + +**파일:** `src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java` + +**수정 내용:** +```java +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** 경매 히스토리 검색 조건 DTO - 페이지네이션 포함 */ +@Schema(description = "경매 거래내역 검색 조건") +public record AuctionHistorySearchRequest( + @Schema(description = "아이템 이름 (like 검색)", example = "페러시우스 타이탄 블레이드") + String itemName, + @Schema(description = "대분류 카테고리", example = "근거리 장비") String itemTopCategory, + @Schema(description = "소분류 카테고리", example = "검") String itemSubCategory, + @Schema(description = "거래 가격", example = "10000000") String auction_price_per_unit, + @Schema(description = "거래 일자", example = "2025-10-20") String date_auction_buy, + @Schema(description = "가격 검색 조건") PriceSearchRequest priceSearchRequest, + @Schema(description = "아이템 옵션 검색 조건") + ItemOptionSearchRequest itemOptionSearchRequest) {} +``` + +--- + +## Phase 4: QueryDSL 검색 로직 확장 + +### 목표 +- AuctionHistoryQueryDslRepository에 ItemOption 검색 조건 추가 +- 서브쿼리 패턴을 사용한 올바른 검색 로직 구현 +- 조건을 만족하는 거래내역의 **모든 옵션** 반환 + +### 🔍 쿼리 패턴 분석 및 선택 + +#### 요구사항 명확화 + +**검색 흐름:** +1. 특정 옵션 조건 + 거래내역 조건으로 검색 +2. 조건을 만족하는 경매장 거래 내역 찾기 +3. ⭐ **해당 거래내역의 모든 옵션을 함께 조회** (조건 만족 여부 무관) + +**예시:** +- 조건: "공격 +10 이상 OR 밸런스 +5 이상" +- 매칭된 거래내역: "페러시우스 타이탄 블레이드" + - 옵션 1: 공격 +12 ✅ (조건 만족) + - 옵션 2: 밸런스 +3 (조건 불만족이지만 반환) + - 옵션 3: 크리티컬 +15 (조건 불만족이지만 반환) + - 옵션 4: 내구력 30 (조건 불만족이지만 반환) + +**반환:** 거래내역 + 옵션 1, 2, 3, 4 **모두** + +#### 쿼리 패턴 비교 + +**❌ 패턴 1: INNER JOIN + 직접 WHERE 조건 (부적합)** +```sql +SELECT * +FROM auction_history ah +INNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id +WHERE ah.item_top_category = '근거리 장비' + AND ((io.option_type = '공격' AND io.option_value2 >= 2) + OR (io.option_type = '밸런스' AND io.option_value2 >= 5)) +``` + +**문제점:** +- `io`에 직접 WHERE 조건을 걸기 때문에 **조건을 만족하는 옵션만** 반환 +- 같은 거래내역의 다른 옵션들은 필터링되어 제외됨 +- EXPLAIN 결과: `io.filtered = 19%` (조건 만족 옵션만) + +**✅ 패턴 2: IN 서브쿼리 (권장)** +```sql +SELECT * +FROM auction_history ah +INNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id +WHERE ah.item_top_category = '근거리 장비' + AND ah.auction_buy_id IN ( + SELECT io2.auction_history_id + FROM auction_item_option io2 + WHERE ((io2.option_type = '공격' AND io2.option_value2 >= 2) + OR (io2.option_type = '밸런스' AND io2.option_value2 >= 5)) + ) +``` + +**장점:** +- 서브쿼리로 조건 만족하는 거래내역 ID만 찾기 +- 메인 쿼리에서 해당 거래내역의 **모든 옵션** 조회 (조건 없음!) +- EXPLAIN 결과: `io.filtered = 100%` (모든 옵션 반환) +- MySQL 옵티마이저가 FirstMatch로 최적화 + +#### 실제 데이터 검증 결과 + +동일한 거래내역 ID를 조회했을 때: + +| 쿼리 패턴 | option_count | 반환 옵션 | +|---------|-------------|---------| +| 패턴 1 (직접 WHERE) | 1 | 공격:20 만 | +| 패턴 2 (서브쿼리) | 7 | 공격:20, 아이템 색상, 내구력:11, 밸런스, 크리티컬 등 **모두** | + +**결론: 패턴 2 (IN 서브쿼리) 사용 필수** + +### 작업 순서 + +#### 4.1. AuctionHistoryQueryDslRepository 수정 (서브쿼리 패턴) + +**파일:** `src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java` + +**수정 내용:** + +```java +package until.the.eternity.auctionhistory.infrastructure.persistence; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; +import until.the.eternity.auctionhistory.domain.entity.QAuctionHistory; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.*; +import until.the.eternity.auctionitemoption.domain.entity.QAuctionItemOption; + +@Component +@RequiredArgsConstructor +class AuctionHistoryQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + /** + * 경매 거래내역 검색 (옵션 조건 포함) + * + * 검색 흐름: + * 1. 옵션 조건을 만족하는 거래내역 ID를 서브쿼리로 찾기 + * 2. 거래내역 조건으로 필터링 + * 3. 해당 거래내역의 모든 옵션을 함께 조회 (LEFT JOIN) + */ + public Page search( + AuctionHistorySearchRequest condition, Pageable pageable) { + QAuctionHistory ah = QAuctionHistory.auctionHistory; + QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; + + // 1단계: 거래내역 조건 빌드 + BooleanBuilder historyBuilder = buildHistoryPredicate(condition, ah); + + // 2단계: 옵션 조건이 있으면 서브쿼리 추가 + if (condition.itemOptionSearchRequest() != null) { + // 서브쿼리용 별도 QAuctionItemOption 인스턴스 + QAuctionItemOption subOption = new QAuctionItemOption("subOption"); + BooleanBuilder optionBuilder = buildItemOptionConditions( + condition.itemOptionSearchRequest(), + subOption + ); + + // 서브쿼리: 옵션 조건을 만족하는 auction_history_id 찾기 + JPAQuery subQuery = JPAExpressions + .select(subOption.auctionHistoryId) + .from(subOption) + .where(optionBuilder) + .distinct(); + + // 메인 쿼리에 서브쿼리 결과 적용 + historyBuilder.and(ah.auctionBuyId.in(subQuery)); + } + + // 3단계: 모든 옵션과 함께 조회 (LEFT JOIN - 조건 없음!) + List content = + queryFactory + .selectFrom(ah) + .leftJoin(ah.auctionItemOptions, aio) + .fetchJoin() + .where(historyBuilder) + .distinct() // 중복 제거 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // Count 쿼리 (JOIN 없이 실행) + Long total = + queryFactory + .select(ah.countDistinct()) + .from(ah) + .where(historyBuilder) + .fetchOne(); + + return new PageImpl<>(content, pageable, total == null ? 0L : total); + } + + /** + * 거래내역 기본 조건 빌드 (카테고리, 아이템명, 가격, 거래일자) + */ + private BooleanBuilder buildHistoryPredicate( + AuctionHistorySearchRequest c, + QAuctionHistory ah) { + BooleanBuilder builder = new BooleanBuilder(); + + // 기본 조건들 + if (c.itemTopCategory() != null && !c.itemTopCategory().isBlank()) { + builder.and(ah.itemTopCategory.eq(c.itemTopCategory())); + } + if (c.itemSubCategory() != null && !c.itemSubCategory().isBlank()) { + builder.and(ah.itemSubCategory.eq(c.itemSubCategory())); + } + if (c.itemName() != null && !c.itemName().isBlank()) { + builder.and(ah.itemName.containsIgnoreCase(c.itemName())); + } + + // 가격 조건 (PriceSearchRequest가 있으면) + if (c.priceSearchRequest() != null) { + PriceSearchRequest price = c.priceSearchRequest(); + if (price.priceFrom() != null) { + builder.and(ah.auctionPricePerUnit.goe(price.priceFrom())); + } + if (price.priceTo() != null) { + builder.and(ah.auctionPricePerUnit.loe(price.priceTo())); + } + } + + // 거래 일자 조건 + if (c.date_auction_buy() != null && !c.date_auction_buy().isBlank()) { + // 날짜 파싱 및 조건 추가 로직 + // TODO: 날짜 범위 검색 구현 + } + + return builder; + } + + /** + * 옵션 검색 조건 빌드 (서브쿼리용) + * + * 주의: 이 메서드는 서브쿼리에서만 사용됩니다. + * 반환된 BooleanBuilder는 메인 JOIN의 WHERE에 직접 사용하면 안 됩니다! + */ + private BooleanBuilder buildItemOptionConditions( + ItemOptionSearchRequest opt, + QAuctionItemOption aio) { + BooleanBuilder builder = new BooleanBuilder(); + + // 1. Balance + if (opt.balanceSearch() != null && opt.balanceSearch().balance() != null) { + addStandardCondition( + builder, + aio.balance, + opt.balanceSearch().balance(), + opt.balanceSearch().balanceStandard()); + } + + // 2. Critical + if (opt.criticalSearch() != null && opt.criticalSearch().critical() != null) { + addStandardCondition( + builder, + aio.critical, + opt.criticalSearch().critical(), + opt.criticalSearch().criticalStandard()); + } + + // 3. Defense + if (opt.defenseSearch() != null && opt.defenseSearch().defense() != null) { + addStandardCondition( + builder, + aio.defense, + opt.defenseSearch().defense(), + opt.defenseSearch().defenseStandard()); + } + + // 4. Erg (범위) + if (opt.ergSearch() != null) { + if (opt.ergSearch().ergFrom() != null) { + builder.and(aio.erg.goe(opt.ergSearch().ergFrom())); + } + if (opt.ergSearch().ergTo() != null) { + builder.and(aio.erg.loe(opt.ergSearch().ergTo())); + } + } + + // 5. ErgRank + if (opt.ergRankSearch() != null && opt.ergRankSearch().ergRank() != null) { + builder.and(aio.ergRank.eq(opt.ergRankSearch().ergRank())); + } + + // 6. MagicDefense + if (opt.magicDefenseSearch() != null + && opt.magicDefenseSearch().magicDefense() != null) { + addStandardCondition( + builder, + aio.magicDefense, + opt.magicDefenseSearch().magicDefense(), + opt.magicDefenseSearch().magicDefenseStandard()); + } + + // 7. MagicProtect + if (opt.magicProtectSearch() != null + && opt.magicProtectSearch().magicProtect() != null) { + addStandardCondition( + builder, + aio.magicProtect, + opt.magicProtectSearch().magicProtect(), + opt.magicProtectSearch().magicProtectStandard()); + } + + // 8. MaxAttack (범위) + if (opt.maxAttackSearch() != null) { + if (opt.maxAttackSearch().maxAttackFrom() != null) { + builder.and(aio.maxAttack.goe(opt.maxAttackSearch().maxAttackFrom())); + } + if (opt.maxAttackSearch().maxAttackTo() != null) { + builder.and(aio.maxAttack.loe(opt.maxAttackSearch().maxAttackTo())); + } + } + + // 9. MaximumDurability + if (opt.maximumDurabilitySearch() != null + && opt.maximumDurabilitySearch().maximumDurability() != null) { + addStandardCondition( + builder, + aio.maximumDurability, + opt.maximumDurabilitySearch().maximumDurability(), + opt.maximumDurabilitySearch().maximumDurabilityStandard()); + } + + // 10. MaxInjuryRate (범위) + if (opt.maxInjuryRateSearch() != null) { + if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null) { + builder.and( + aio.maxInjuryRate.goe( + opt.maxInjuryRateSearch().maxInjuryRateFrom())); + } + if (opt.maxInjuryRateSearch().maxInjuryRateTo() != null) { + builder.and( + aio.maxInjuryRate.loe(opt.maxInjuryRateSearch().maxInjuryRateTo())); + } + } + + // 11. Proficiency + if (opt.proficiencySearch() != null + && opt.proficiencySearch().proficiency() != null) { + addStandardCondition( + builder, + aio.proficiency, + opt.proficiencySearch().proficiency(), + opt.proficiencySearch().proficiencyStandard()); + } + + // 12. Protect + if (opt.protectSearch() != null && opt.protectSearch().protect() != null) { + addStandardCondition( + builder, + aio.protect, + opt.protectSearch().protect(), + opt.protectSearch().protectStandard()); + } + + // 13. RemainingTransactionCount + if (opt.remainingTransactionCountSearch() != null + && opt.remainingTransactionCountSearch().remainingTransactionCount() + != null) { + addStandardCondition( + builder, + aio.remainingTransactionCount, + opt.remainingTransactionCountSearch().remainingTransactionCount(), + opt.remainingTransactionCountSearch() + .remainingTransactionCountStandard()); + } + + // 14. RemainingUnsealCount + if (opt.remainingUnsealCountSearch() != null + && opt.remainingUnsealCountSearch().remainingUnsealCount() != null) { + addStandardCondition( + builder, + aio.remainingUnsealCount, + opt.remainingUnsealCountSearch().remainingUnsealCount(), + opt.remainingUnsealCountSearch().remainingUnsealCountStandard()); + } + + // 15. RemainingUseCount + if (opt.remainingUseCountSearch() != null + && opt.remainingUseCountSearch().remainingUseCount() != null) { + addStandardCondition( + builder, + aio.remainingUseCount, + opt.remainingUseCountSearch().remainingUseCount(), + opt.remainingUseCountSearch().remainingUseCountStandard()); + } + + // 16. WearingRestrictions + if (opt.wearingRestrictionsSearch() != null + && opt.wearingRestrictionsSearch().wearingRestrictions() != null) { + builder.and( + aio.wearingRestrictions.eq( + opt.wearingRestrictionsSearch().wearingRestrictions())); + } + + return builder; + } + + /** + * UP/DOWN 기준 조건 추가 헬퍼 메서드 + */ + private void addStandardCondition( + BooleanBuilder builder, + com.querydsl.core.types.dsl.NumberPath field, + Integer value, + String standard) { + if ("UP".equals(standard)) { + builder.and(field.goe(value)); // 이상 (>=) + } else if ("DOWN".equals(standard)) { + builder.and(field.loe(value)); // 이하 (<=) + } else { + builder.and(field.eq(value)); // 같음 + } + } +} +``` + +--- + +## Phase 5: 테스트 코드 작성 + +### 목표 +- 단위 테스트 및 통합 테스트 작성 +- Spring REST Docs 문서 생성 + +### 작업 순서 + +#### 5.1. AuctionSearchOptionService 단위 테스트 + +**파일:** `src/test/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionServiceTest.java` + +```java +package until.the.eternity.auctionsearchoption.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +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.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; +import until.the.eternity.auctionsearchoption.domain.repository.AuctionSearchOptionRepositoryPort; +import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; + +@ExtendWith(MockitoExtension.class) +class AuctionSearchOptionServiceTest { + + @Mock private AuctionSearchOptionRepositoryPort repositoryPort; + @InjectMocks private AuctionSearchOptionService service; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("활성화된 검색 옵션을 조회한다") + void getAllActiveSearchOptions_should_return_active_options() { + // given + AuctionSearchOptionMetadata entity = createMockEntity(); + when(repositoryPort.findAllActive()).thenReturn(List.of(entity)); + + // when + List result = service.getAllActiveSearchOptions(); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).searchOptionName()).isEqualTo("밸런스"); + verify(repositoryPort).findAllActive(); + } + + private AuctionSearchOptionMetadata createMockEntity() { + // Mock 객체 생성 로직 + return mock(AuctionSearchOptionMetadata.class); + } +} +``` + +#### 5.2. AuctionSearchOptionController 통합 테스트 (REST Docs) + +**파일:** `src/test/java/until/the/eternity/auctionsearchoption/interfaces/rest/AuctionSearchOptionControllerTest.java` + +```java +package until.the.eternity.auctionsearchoption.interfaces.rest; + +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import until.the.eternity.auctionsearchoption.application.service.AuctionSearchOptionService; +import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.FieldMetadata; +import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; + +@WebMvcTest(AuctionSearchOptionController.class) +@AutoConfigureRestDocs +class AuctionSearchOptionControllerTest { + + @Autowired private MockMvc mockMvc; + @MockBean private AuctionSearchOptionService service; + + @Test + @DisplayName("GET /api/search-option - 검색 옵션 메타데이터 조회") + void getSearchOptions_should_return_search_options() throws Exception { + // given + SearchOptionMetadataResponse response = + new SearchOptionMetadataResponse( + 1L, + "밸런스", + Map.of( + "Balance", + new FieldMetadata("tinyint", false, null), + "BalanceStandard", + new FieldMetadata( + "string", false, List.of("UP", "DOWN"))), + 1); + + when(service.getAllActiveSearchOptions()).thenReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/api/search-option")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data[0].searchOptionName").value("밸런스")) + .andDo(document("search-option-get")); + } +} +``` + +#### 5.3. AuctionHistoryQueryDslRepository 테스트 + +**파일:** `src/test/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepositoryTest.java` + +```java +// ItemOption 검색 조건 테스트 추가 +@Test +@DisplayName("ItemOption - Balance 조건으로 검색한다 (UP)") +void search_with_balance_up_condition() { + // given + BalanceSearchRequest balanceSearch = new BalanceSearchRequest(10, "UP"); + ItemOptionSearchRequest itemOptionSearch = + new ItemOptionSearchRequest( + balanceSearch, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + AuctionHistorySearchRequest request = + new AuctionHistorySearchRequest( + null, null, null, null, null, null, itemOptionSearch); + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = repository.search(request, pageable); + + // then + assertThat(result.getContent()) + .allMatch( + ah -> + ah.getAuctionItemOptions().stream() + .anyMatch(aio -> aio.getBalance() >= 10)); +} +``` + +--- + +## Phase 6: 문서화 및 마무리 + +### 목표 +- Swagger 문서 확인 +- Spring REST Docs 생성 +- README 업데이트 + +### 작업 순서 + +#### 6.1. Swagger UI 확인 + +**접속:** +``` +http://localhost:8080/swagger-ui/index.html +``` + +**확인 사항:** +- `/api/search-option` 엔드포인트 존재 +- Request/Response 스키마 정상 표시 +- 16개 Search Request DTO 스키마 정상 + +#### 6.2. Spring REST Docs 생성 + +**명령어:** +```bash +./gradlew asciidoctor +``` + +**생성 위치:** +``` +build/docs/asciidoc/index.html +``` + +#### 6.3. README 업데이트 + +**추가할 내용:** +```markdown +## 새로운 API 엔드포인트 + +### GET /api/search-option +경매 검색 옵션 메타데이터를 조회합니다. + +**Response:** +- 16개 검색 옵션 메타데이터 반환 +- 각 옵션의 파라미터 타입 및 허용 값 포함 + +### POST /api/auction-history/search +경매 거래내역을 검색합니다. + +**새로운 검색 조건:** +- `itemOptionSearchRequest`: 아이템 옵션 기반 검색 + - Balance, Critical, Defense 등 16개 옵션 지원 + - UP/DOWN 기준 검색 (이상/이하) + - 범위 검색 (From/To) +``` + +--- + +## 📊 구현 진행 체크리스트 + +### Phase 1: DB 및 Migration ✅ +- [ ] Flyway 버전 확인 +- [ ] V 스크립트 작성 및 실행 +- [ ] R 스크립트 작성 및 실행 +- [ ] DB 데이터 검증 + +### Phase 2: 메타데이터 API ✅ +- [ ] Entity 작성 +- [ ] Repository Port/Impl 작성 +- [ ] JPA Repository 작성 +- [ ] Response DTO 작성 +- [ ] Service 작성 +- [ ] Controller 작성 +- [ ] API 수동 테스트 + +### Phase 3: Request DTO ✅ +- [ ] 16개 개별 Search Request 작성 +- [ ] ItemOptionSearchRequest 통합 작성 +- [ ] AuctionHistorySearchRequest 수정 +- [ ] Spotless 포맷팅 적용 + +### Phase 4: QueryDSL ✅ +- [ ] buildPredicate 확장 +- [ ] addItemOptionConditions 구현 +- [ ] addStandardCondition 헬퍼 메서드 작성 +- [ ] JOIN 수정 +- [ ] 빌드 및 컴파일 확인 + +### Phase 5: 테스트 ✅ +- [ ] AuctionSearchOptionService 단위 테스트 +- [ ] AuctionSearchOptionController REST Docs 테스트 +- [ ] AuctionHistoryQueryDslRepository 검색 테스트 +- [ ] 전체 테스트 실행 및 통과 확인 + +### Phase 6: 문서화 ✅ +- [ ] Swagger UI 확인 +- [ ] Spring REST Docs 생성 +- [ ] README 업데이트 +- [ ] CHANGELOG 작성 + +--- + +## 🚀 실행 순서 요약 + +1. **Migration 실행** + ```bash + ./gradlew flywayMigrate + ``` + +2. **Phase별 순차 구현** + - Phase 1 → 2 → 3 → 4 → 5 → 6 + +3. **각 Phase 완료 후 빌드 및 테스트** + ```bash + ./gradlew clean build + ``` + +4. **Spotless 포맷팅 자동 적용** + ```bash + ./gradlew spotlessApply + ``` + +5. **최종 검증** + ```bash + ./gradlew test + ./gradlew bootRun + # Swagger UI 접속하여 API 확인 + ``` + +--- + +## 📊 개발 현황 (Implementation Status) + +> **최종 업데이트:** 2025-10-20 23:59 +> **진행 상황:** Phase 2 완료 (33% 완료) + +### ✅ 완료된 작업 + +#### Phase 1: DB 스키마 및 Migration ✅ (100% 완료) +**완료 일시:** 2025-10-20 23:47 + +- ✅ Flyway V11 스크립트 작성 (`V11__create_auction_search_option_metadata.sql`) +- ✅ Flyway R 스크립트 작성 (`R__insert_auction_search_option_metadata.sql`) +- ✅ Migration 실행 성공 (version v11) +- ✅ DB 데이터 검증 완료 (16개 검색 옵션 INSERT 확인) + +**생성된 파일:** +``` +src/main/resources/db/migration/ +├── V11__create_auction_search_option_metadata.sql +└── R__insert_auction_search_option_metadata.sql +``` + +**DB 검증 결과:** +- 테이블: `auction_search_option_metadata` 생성 완료 +- 데이터: 16개 레코드 (밸런스, 크리티컬, 방어력 등) +- JSON 구조: type, required, allowedValues 포함 + +--- + +#### Phase 2: 검색 조건 메타데이터 API 구현 ✅ (100% 완료) +**완료 일시:** 2025-10-20 23:59 + +**구현된 레이어:** + +1. **Domain Layer** + - ✅ Entity: `AuctionSearchOptionMetadata.java` + - ✅ Repository Port: `AuctionSearchOptionRepositoryPort.java` + +2. **Infrastructure Layer** + - ✅ JPA Repository: `AuctionSearchOptionJpaRepository.java` + - ✅ Repository PortImpl: `AuctionSearchOptionRepositoryPortImpl.java` + +3. **Application Layer** + - ✅ Service: `AuctionSearchOptionService.java` + - JSON 파싱 로직 (ObjectMapper) + - Entity → DTO 변환 + +4. **Interface Layer** + - ✅ Response DTO: `FieldMetadata.java`, `SearchOptionMetadataResponse.java` + - ✅ Controller: `AuctionSearchOptionController.java` + +**API 엔드포인트:** +``` +GET /api/search-option +``` + +**API 테스트 결과:** +- ✅ HTTP 200 OK 응답 +- ✅ 16개 검색 옵션 정상 반환 +- ✅ JSON 구조 정확 (type, required, allowedValues) +- ✅ displayOrder 순서대로 정렬 + +**생성된 파일:** +``` +src/main/java/until/the/eternity/auctionsearchoption/ +├── application/service/AuctionSearchOptionService.java +├── domain/ +│ ├── entity/AuctionSearchOptionMetadata.java +│ └── repository/AuctionSearchOptionRepositoryPort.java +├── infrastructure/persistence/ +│ ├── AuctionSearchOptionJpaRepository.java +│ └── AuctionSearchOptionRepositoryPortImpl.java +└── interfaces/rest/ + ├── AuctionSearchOptionController.java + └── dto/response/ + ├── FieldMetadata.java + └── SearchOptionMetadataResponse.java +``` + +--- + +### 🚧 진행 중인 작업 + +**현재 단계:** 없음 (다음 Phase 대기) + +--- + +### 📋 남은 작업 + +#### Phase 3: Item Option 검색 Request DTO 구현 (예정) +**예상 작업량:** 17개 파일 생성 + +- [ ] 16개 개별 Search Request Record 생성 + - [ ] BalanceSearchRequest.java + - [ ] CriticalSearchRequest.java + - [ ] DefenseSearchRequest.java + - [ ] ErgSearchRequest.java + - [ ] ErgRankSearchRequest.java + - [ ] MagicDefenseSearchRequest.java + - [ ] MagicProtectSearchRequest.java + - [ ] MaxAttackSearchRequest.java + - [ ] MaximumDurabilitySearchRequest.java + - [ ] MaxInjuryRateSearchRequest.java + - [ ] ProficiencySearchRequest.java + - [ ] ProtectSearchRequest.java + - [ ] RemainingTransactionCountSearchRequest.java + - [ ] RemainingUnsealCountSearchRequest.java + - [ ] RemainingUseCountSearchRequest.java + - [ ] WearingRestrictionsSearchRequest.java +- [ ] ItemOptionSearchRequest.java (통합 Record) +- [ ] AuctionHistorySearchRequest.java 수정 + +--- + +#### Phase 4: QueryDSL 검색 로직 확장 (예정) +- [ ] AuctionHistoryQueryDslRepository.buildPredicate() 확장 +- [ ] addItemOptionConditions() 메서드 구현 +- [ ] addStandardCondition() 헬퍼 메서드 작성 +- [ ] QAuctionItemOption JOIN 추가 + +--- + +#### Phase 5: 테스트 코드 작성 (예정) +- [ ] AuctionSearchOptionService 단위 테스트 +- [ ] AuctionSearchOptionController REST Docs 테스트 +- [ ] AuctionHistoryQueryDslRepository 검색 테스트 + +--- + +#### Phase 6: 문서화 및 마무리 (예정) +- [ ] Swagger UI 확인 +- [ ] Spring REST Docs 생성 +- [ ] README 업데이트 + +--- + +### 📝 다음 세션 작업 가이드 + +**다음 작업:** Phase 3 - Item Option 검색 Request DTO 구현 + +**시작 방법:** +```bash +# 1. 프로젝트 루트로 이동 +cd C:/Users/Desktop/devnogi/open-api-batch-server + +# 2. 구현 계획서 확인 +cat AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md | grep -A 50 "Phase 3" + +# 3. 16개 Search Request Record 생성 시작 +# 경로: src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ +``` + +**참고 문서:** +- 요구사항: `AUCTION_SEARCH_OPTION_REQUIREMENTS.md` +- 구현 계획: `AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md` (현재 문서) +- 원본 파라미터: `auction_history_search_param.txt` + +--- + +**작성자:** Claude Code +**최종 검토:** 2025-10-20 diff --git a/AUCTION_SEARCH_OPTION_REQUIREMENTS.md b/AUCTION_SEARCH_OPTION_REQUIREMENTS.md new file mode 100644 index 0000000..ff36a33 --- /dev/null +++ b/AUCTION_SEARCH_OPTION_REQUIREMENTS.md @@ -0,0 +1,639 @@ +# Auction Item Option 검색 기능 구현 요구사항 명세서 + +> 작성일: 2025-10-20 +> 프로젝트: open-api-batch-server +> 기능: 경매 거래내역 아이템 옵션 검색 및 검색 조건 메타데이터 제공 API + +--- + +## 📋 개요 + +경매 거래내역(AuctionHistory) 검색 시 아이템 옵션(AuctionItemOption)을 조건으로 검색할 수 있는 기능을 구현하고, 프론트엔드가 동적으로 검색 필터 UI를 구성할 수 있도록 검색 조건 메타데이터를 제공하는 API를 개발합니다. + +--- + +## 🎯 구현 목표 + +### 1. 검색 조건 메타데이터 제공 API +- DB 테이블에 검색 조건 정보 저장 (코드 하드코딩 ❌) +- REST API로 검색 조건 메타데이터 제공 +- 프론트엔드에서 동적 검색 필터 UI 구성 가능 + +### 2. Auction Item Option 기반 검색 기능 +- 경매 거래내역 검색 시 아이템 옵션을 조건으로 추가 +- QueryDSL을 활용한 동적 쿼리 구현 +- 기존 검색 조건과 결합 가능 + +--- + +## 💾 DB 테이블 설계 + +### 테이블명: `auction_search_option_metadata` + +**DDL (MySQL 8):** +```sql +CREATE TABLE `auction_search_option_metadata` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 ID', + `search_option_name` VARCHAR(100) NOT NULL COMMENT '검색 옵션명 (한글)', + `search_condition_json` JSON NOT NULL COMMENT '검색 조건 (파라미터명:타입)', + `display_order` INT NOT NULL UNIQUE COMMENT '정렬 순서 (고유값)', + `is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성화 여부', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시', + PRIMARY KEY (`id`), + INDEX `idx_display_order` (`display_order`), + INDEX `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='경매 검색 옵션 메타데이터'; +``` + +**JSON 필드 구조 (search_condition_json):** +```json +{ + "Balance": { + "type": "tinyint", + "required": false + }, + "BalanceStandard": { + "type": "string", + "allowedValues": ["UP", "DOWN"], + "required": false + } +} +``` + +**초기 데이터 예시:** +| id | search_option_name | search_condition_json | display_order | is_active | +|----|-------------------|----------------------|--------------|-----------| +| 1 | 밸런스 | `{"Balance":{"type":"tinyint","required":false},"BalanceStandard":{"type":"string","allowedValues":["UP","DOWN"],"required":false}}` | 1 | true | +| 2 | 크리티컬 | `{"Critical":{"type":"tinyint","required":false},"CriticalStandard":{"type":"string","allowedValues":["UP","DOWN"],"required":false}}` | 2 | true | + +--- + +## 🔄 Flyway Migration + +### V 스크립트 (Schema) +**파일명:** `V{next_version}__create_auction_search_option_metadata.sql` +- 테이블 생성 DDL + +### R 스크립트 (Repeatable - 초기 데이터) +**파일명:** `R__insert_auction_search_option_metadata.sql` +- 검색 조건 메타데이터 초기 데이터 INSERT +- 대상 항목 (auction_history_search_param.txt 1-18번): + 1. 밸런스 (Balance, BalanceStandard) + 2. 크리티컬 (Critical, CriticalStandard) + 3. 방어력 (Defense, DefenseStandard) + 4. 에르그 (ErgFrom, ErgTo) + 5. 에르그 등급 (ErgRank) + 6. 마법 방어력 (MagicDefense, MagicDefenseStandard) + 7. 마법 보호 (MagicProtect, MagicProtectStandard) + 8. 최대 공격력 (MaxAttackFrom, MaxAttackTo) + 9. 최대 내구력 (MaximumDurability, MaximumDurabilityStandard) + 10. 최대 부상률 (MaxInjuryRateFrom, MaxInjuryRateTo) + 11. 가격 (PriceFrom, PriceTo) - **제외 (이미 PriceSearchRequest 존재)** + 12. 숙련도 (Proficiency, ProficiencyStandard) + 13. 보호 (Protect, ProtectStandard) + 14. 남은 거래 횟수 (RemainingTransactionCount, RemainingTransactionCountStandard) + 15. 남은 전용 해제 가능 횟수 (RemainingUnsealCount, RemainingUnsealCountStandard) + 16. 남은 사용 횟수 (RemainingUseCount, RemainingUseCountStandard) + 17. 착용 제한 (WearingRestrictions) + +**참고:** 19-37번 미정의 항목은 **제외** + +--- + +## 🌐 API 명세 + +### GET /api/search-option + +**설명:** 경매 검색 옵션 메타데이터 조회 + +**Request:** +- Method: `GET` +- Path: `/api/search-option` +- Parameters: 없음 + +**Response:** +```json +{ + "success": true, + "code": "SUCCESS", + "message": "검색 옵션 조회 성공", + "data": [ + { + "id": 1, + "searchOptionName": "밸런스", + "searchCondition": { + "Balance": { + "type": "tinyint", + "required": false + }, + "BalanceStandard": { + "type": "string", + "allowedValues": ["UP", "DOWN"], + "required": false + } + }, + "displayOrder": 1 + }, + { + "id": 2, + "searchOptionName": "크리티컬", + "searchCondition": { + "Critical": { + "type": "tinyint", + "required": false + }, + "CriticalStandard": { + "type": "string", + "allowedValues": ["UP", "DOWN"], + "required": false + } + }, + "displayOrder": 2 + } + ], + "timestamp": "2025-10-20T12:00:00Z" +} +``` + +**프론트엔드 UI 구현 가이드:** +- `*Standard` 필드 (UP/DOWN): 텍스트가 아닌 **화살표 아이콘**으로 표시 + - UP: ↑ (위쪽 화살표) - "이상" 조건 + - DOWN: ↓ (아래쪽 화살표) - "이하" 조건 + - 클릭 시 UP ↔ DOWN 토글 + +--- + +## 🏗️ 구현 레이어 구조 + +### Clean Architecture 패턴 적용 + +``` +auctionsearchoption/ +├── application/ +│ └── service/ +│ └── AuctionSearchOptionService.java +├── domain/ +│ ├── entity/ +│ │ └── AuctionSearchOptionMetadata.java +│ └── repository/ +│ └── AuctionSearchOptionRepositoryPort.java // 인터페이스 +├── infrastructure/ +│ └── persistence/ +│ ├── AuctionSearchOptionJpaRepository.java +│ └── AuctionSearchOptionRepositoryPortImpl.java // Port 구현체 +└── interfaces/ + └── rest/ + ├── AuctionSearchOptionController.java + └── dto/ + └── response/ + └── SearchOptionMetadataResponse.java +``` + +**Repository 패턴:** +- Port (인터페이스): `AuctionSearchOptionRepositoryPort` +- Port 구현체: `AuctionSearchOptionRepositoryPortImpl` +- JPA Repository: `AuctionSearchOptionJpaRepository` (Spring Data JPA) + +--- + +## 📦 DTO 설계 + +### Request DTO + +**기존:** `AuctionHistorySearchRequest` +```java +public record AuctionHistorySearchRequest( + String itemName, + String itemTopCategory, + String itemSubCategory, + String auction_price_per_unit, + String date_auction_buy, + PriceSearchRequest priceSearchRequest, // 변경됨 (구 PricePerUnitSearchRequest) + ItemOptionSearchRequest itemOptionSearchRequest // 새로 추가 +) {} +``` + +**새로 추가:** 개별 옵션 검색 Request DTO들 (Record로 구현) + +각 옵션별로 별도 Record 생성: + +```java +// 1. 단일 값 + Standard (UP/DOWN) 패턴 +public record BalanceSearchRequest( + Integer balance, + String balanceStandard // "UP" | "DOWN" +) {} + +public record CriticalSearchRequest( + Integer critical, + String criticalStandard +) {} + +public record DefenseSearchRequest( + Integer defense, + String defenseStandard +) {} + +public record MagicDefenseSearchRequest( + Integer magicDefense, + String magicDefenseStandard +) {} + +public record MagicProtectSearchRequest( + Integer magicProtect, + String magicProtectStandard +) {} + +public record MaximumDurabilitySearchRequest( + Integer maximumDurability, + String maximumDurabilityStandard +) {} + +public record ProficiencySearchRequest( + Integer proficiency, + String proficiencyStandard +) {} + +public record ProtectSearchRequest( + Integer protect, + String protectStandard +) {} + +public record RemainingTransactionCountSearchRequest( + Integer remainingTransactionCount, + String remainingTransactionCountStandard +) {} + +public record RemainingUnsealCountSearchRequest( + Integer remainingUnsealCount, + String remainingUnsealCountStandard +) {} + +public record RemainingUseCountSearchRequest( + Integer remainingUseCount, + String remainingUseCountStandard +) {} + +// 2. 범위 검색 (From/To) 패턴 +public record ErgSearchRequest( + Integer ergFrom, + Integer ergTo +) {} + +public record MaxAttackSearchRequest( + Integer maxAttackFrom, + Integer maxAttackTo +) {} + +public record MaxInjuryRateSearchRequest( + Integer maxInjuryRateFrom, + Integer maxInjuryRateTo +) {} + +// PriceSearchRequest는 이미 존재 (기존 PricePerUnitSearchRequest에서 이름 변경) + +// 3. Enum 값 패턴 +public record ErgRankSearchRequest( + String ergRank // "S등급" | "A등급" | "B등급" +) {} + +// 4. 문자열 검색 패턴 +public record WearingRestrictionsSearchRequest( + String wearingRestrictions +) {} +``` + +**통합:** `ItemOptionSearchRequest` +```java +public record ItemOptionSearchRequest( + BalanceSearchRequest balanceSearch, + CriticalSearchRequest criticalSearch, + DefenseSearchRequest defenseSearch, + ErgSearchRequest ergSearch, + ErgRankSearchRequest ergRankSearch, + MagicDefenseSearchRequest magicDefenseSearch, + MagicProtectSearchRequest magicProtectSearch, + MaxAttackSearchRequest maxAttackSearch, + MaximumDurabilitySearchRequest maximumDurabilitySearch, + MaxInjuryRateSearchRequest maxInjuryRateSearch, + ProficiencySearchRequest proficiencySearch, + ProtectSearchRequest protectSearch, + RemainingTransactionCountSearchRequest remainingTransactionCountSearch, + RemainingUnsealCountSearchRequest remainingUnsealCountSearch, + RemainingUseCountSearchRequest remainingUseCountSearch, + WearingRestrictionsSearchRequest wearingRestrictionsSearch +) {} +``` + +**분리 이유:** 일부 검색 타입은 `List` 형태로 받아야 할 수도 있기 때문 + +### Response DTO + +```java +public record SearchOptionMetadataResponse( + Long id, + String searchOptionName, + Map searchCondition, // JSON 파싱 결과 + Integer displayOrder +) {} + +public record FieldMetadata( + String type, + Boolean required, + List allowedValues // Optional, Enum 타입일 경우만 +) {} +``` + +--- + +## 🔍 QueryDSL 검색 로직 + +### 검색 요구사항 명확화 + +**검색 흐름:** +1. 특정 옵션 조건 + 거래내역 조건으로 검색 +2. 조건을 만족하는 경매장 거래 내역 찾기 +3. ⭐ **해당 거래내역의 모든 옵션을 함께 조회** (조건 만족 여부 무관) + +**예시:** +- 조건: "공격 +10 이상 OR 밸런스 +5 이상" +- 매칭된 거래내역: "페러시우스 타이탄 블레이드" + - 옵션 1: 공격 +12 ✅ (조건 만족) + - 옵션 2: 밸런스 +3 (조건 불만족이지만 반환) + - 옵션 3: 크리티컬 +15 (조건 불만족이지만 반환) + +**반환:** 거래내역 + **모든 옵션** + +### 쿼리 패턴 비교 + +#### ❌ 잘못된 패턴: INNER JOIN + 직접 WHERE 조건 + +```java +// 잘못된 구현 - 조건 만족 옵션만 반환됨! +private BooleanBuilder buildPredicate(AuctionHistorySearchRequest c, QAuctionHistory ah) { + BooleanBuilder builder = new BooleanBuilder(); + + if (c.itemOptionSearchRequest() != null) { + QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; + // ❌ 문제: io에 직접 조건을 걸면 조건 만족 옵션만 반환됨 + builder.and(aio.balance.goe(10)); + } + + return builder; +} +``` + +**문제점:** +- 조건을 만족하는 옵션만 반환 +- 같은 거래내역의 다른 옵션들 필터링됨 +- EXPLAIN 결과: `filtered = 19%` + +#### ✅ 올바른 패턴: IN 서브쿼리 + +**SQL 예시:** +```sql +SELECT * +FROM auction_history ah +INNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id +WHERE ah.item_top_category = '근거리 장비' + -- 서브쿼리로 조건 만족하는 거래내역 ID만 찾기 + AND ah.auction_buy_id IN ( + SELECT io2.auction_history_id + FROM auction_item_option io2 + WHERE ((io2.option_type = '공격' AND io2.option_value2 >= 2) + OR (io2.option_type = '밸런스' AND io2.option_value2 >= 5)) + ); +-- 메인 JOIN은 조건 없이 모든 옵션 조회! +``` + +### AuctionHistoryQueryDslRepository 구현 + +**올바른 구현:** + +```java +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; + +public Page search( + AuctionHistorySearchRequest condition, Pageable pageable) { + QAuctionHistory ah = QAuctionHistory.auctionHistory; + QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; + + // 1단계: 거래내역 조건 빌드 + BooleanBuilder historyBuilder = buildHistoryPredicate(condition, ah); + + // 2단계: 옵션 조건이 있으면 서브쿼리 추가 + if (condition.itemOptionSearchRequest() != null) { + // 서브쿼리용 별도 QAuctionItemOption 인스턴스 + QAuctionItemOption subOption = new QAuctionItemOption("subOption"); + BooleanBuilder optionBuilder = buildItemOptionConditions( + condition.itemOptionSearchRequest(), + subOption + ); + + // 서브쿼리: 옵션 조건을 만족하는 auction_history_id 찾기 + JPAQuery subQuery = JPAExpressions + .select(subOption.auctionHistoryId) + .from(subOption) + .where(optionBuilder) + .distinct(); + + // 메인 쿼리에 서브쿼리 결과 적용 + historyBuilder.and(ah.auctionBuyId.in(subQuery)); + } + + // 3단계: 모든 옵션과 함께 조회 (LEFT JOIN - 조건 없음!) + List content = queryFactory + .selectFrom(ah) + .leftJoin(ah.auctionItemOptions, aio).fetchJoin() // 조건 없이 모든 옵션 조회 + .where(historyBuilder) + .distinct() + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(ah.countDistinct()) + .from(ah) + .where(historyBuilder) // JOIN 없이 count + .fetchOne(); + + return new PageImpl<>(content, pageable, total == null ? 0L : total); +} + +/** + * 옵션 검색 조건 빌드 (서브쿼리용) + * 주의: 메인 JOIN의 WHERE에 직접 사용 금지! + */ +private BooleanBuilder buildItemOptionConditions( + ItemOptionSearchRequest opt, + QAuctionItemOption aio) { + BooleanBuilder builder = new BooleanBuilder(); + + // 1. Balance + if (opt.balanceSearch() != null && opt.balanceSearch().balance() != null) { + if ("UP".equals(opt.balanceSearch().balanceStandard())) { + builder.and(aio.balance.goe(opt.balanceSearch().balance())); + } else if ("DOWN".equals(opt.balanceSearch().balanceStandard())) { + builder.and(aio.balance.loe(opt.balanceSearch().balance())); + } + } + + // ... 나머지 옵션들도 동일 패턴으로 구현 + + return builder; +} +``` + +### 성능 비교 + +| 항목 | 직접 WHERE | 서브쿼리 (권장) | +|------|-----------|---------------| +| **반환 옵션** | 조건 만족만 | ✅ 모든 옵션 | +| **요구사항 충족** | ❌ | ✅ | +| **MySQL 최적화** | 일반 JOIN | FirstMatch | +| **filtered** | 19% | 100% | +| **중복 제거** | 필요 | DISTINCT 사용 | + +--- + +## 📊 검색 조건 타입 정리 + +| 검색 옵션명 | 파라미터 | 타입 | Standard | 검색 로직 | +|-----------|---------|------|----------|----------| +| 밸런스 | Balance | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 크리티컬 | Critical | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 방어력 | Defense | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 에르그 | ErgFrom, ErgTo | tinyint | - | Between | +| 에르그 등급 | ErgRank | string | - | Equals ('S등급', 'A등급', 'B등급') | +| 마법 방어력 | MagicDefense | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 마법 보호 | MagicProtect | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 최대 공격력 | MaxAttackFrom, MaxAttackTo | int | - | Between | +| 최대 내구력 | MaximumDurability | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 최대 부상률 | MaxInjuryRateFrom, MaxInjuryRateTo | tinyint | - | Between | +| 숙련도 | Proficiency | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 보호 | Protect | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 남은 거래 횟수 | RemainingTransactionCount | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 남은 전용 해제 가능 횟수 | RemainingUnsealCount | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 남은 사용 횟수 | RemainingUseCount | tinyint | UP/DOWN | UP: >=, DOWN: <= | +| 착용 제한 | WearingRestrictions | string | - | Equals | + +**참고:** +- **UP (↑)**: 이상 (Greater Than or Equal, `>=`) +- **DOWN (↓)**: 이하 (Less Than or Equal, `<=`) +- **가격 (Price)**: 이미 `PriceSearchRequest`로 구현되어 있으므로 **제외** + +--- + +## ✅ 체크리스트 + +### 1단계: DB 및 Migration +- [ ] MySQL 8 DDL 작성 (`auction_search_option_metadata` 테이블) +- [ ] Flyway V 스크립트 작성 (테이블 생성) +- [ ] Flyway R 스크립트 작성 (초기 데이터 INSERT, 1-17번 항목) + +### 2단계: 검색 조건 메타데이터 제공 API +- [ ] Entity: `AuctionSearchOptionMetadata` 생성 +- [ ] Repository Port: `AuctionSearchOptionRepositoryPort` 인터페이스 작성 +- [ ] Repository PortImpl: `AuctionSearchOptionRepositoryPortImpl` 구현 +- [ ] JPA Repository: `AuctionSearchOptionJpaRepository` 작성 +- [ ] Service: `AuctionSearchOptionService` 구현 +- [ ] Response DTO: `SearchOptionMetadataResponse`, `FieldMetadata` 작성 +- [ ] Controller: `AuctionSearchOptionController` 구현 (`GET /api/search-option`) + +### 3단계: Item Option 검색 기능 +- [ ] Request DTO: 개별 옵션 Search Request Record 17개 생성 + - [ ] BalanceSearchRequest + - [ ] CriticalSearchRequest + - [ ] DefenseSearchRequest + - [ ] ErgSearchRequest + - [ ] ErgRankSearchRequest + - [ ] MagicDefenseSearchRequest + - [ ] MagicProtectSearchRequest + - [ ] MaxAttackSearchRequest + - [ ] MaximumDurabilitySearchRequest + - [ ] MaxInjuryRateSearchRequest + - [ ] ProficiencySearchRequest + - [ ] ProtectSearchRequest + - [ ] RemainingTransactionCountSearchRequest + - [ ] RemainingUnsealCountSearchRequest + - [ ] RemainingUseCountSearchRequest + - [ ] WearingRestrictionsSearchRequest + - [ ] (PriceSearchRequest는 이미 존재) +- [ ] Request DTO: `ItemOptionSearchRequest` 통합 Record 생성 +- [ ] `AuctionHistorySearchRequest`에 `ItemOptionSearchRequest` 필드 추가 +- [ ] `AuctionHistoryQueryDslRepository.buildPredicate()` 확장 + - [ ] QAuctionItemOption JOIN 추가 + - [ ] 각 옵션별 동적 조건 추가 (17개) +- [ ] `AuctionHistoryQueryDslRepository.search()` JOIN 수정 + +### 4단계: 테스트 +- [ ] `AuctionSearchOptionService` 단위 테스트 +- [ ] `AuctionSearchOptionController` 통합 테스트 (REST Docs) +- [ ] `AuctionHistoryQueryDslRepository` 검색 테스트 (ItemOption 조건) +- [ ] `AuctionHistoryService` 검색 테스트 업데이트 + +### 5단계: 문서화 +- [ ] Swagger/OpenAPI 문서 업데이트 +- [ ] Spring REST Docs 생성 +- [ ] README 업데이트 (새 API 엔드포인트 추가) + +--- + +## 🔗 관련 파일 경로 + +``` +open-api-batch-server/ +├── src/main/resources/db/migration/ +│ ├── V{next}__create_auction_search_option_metadata.sql // 새로 생성 +│ └── R__insert_auction_search_option_metadata.sql // 새로 생성 +├── src/main/java/until/the/eternity/ +│ ├── auctionsearchoption/ // 새로 생성 +│ │ ├── application/service/AuctionSearchOptionService.java +│ │ ├── domain/ +│ │ │ ├── entity/AuctionSearchOptionMetadata.java +│ │ │ └── repository/AuctionSearchOptionRepositoryPort.java +│ │ ├── infrastructure/persistence/ +│ │ │ ├── AuctionSearchOptionJpaRepository.java +│ │ │ └── AuctionSearchOptionRepositoryPortImpl.java +│ │ └── interfaces/rest/ +│ │ ├── AuctionSearchOptionController.java +│ │ └── dto/response/SearchOptionMetadataResponse.java +│ └── auctionhistory/ +│ ├── infrastructure/persistence/ +│ │ └── AuctionHistoryQueryDslRepository.java // 수정 +│ └── interfaces/rest/dto/request/ +│ ├── AuctionHistorySearchRequest.java // 수정 +│ └── ItemOptionSearchRequest.java // 새로 생성 +│ └── (17개 개별 Search Request) // 새로 생성 +└── auction_history_search_param.txt // 참고 문서 +``` + +--- + +## 🚨 주의사항 + +1. **display_order는 UNIQUE 제약** 조건 필수 +2. **Repository는 Port <-> PortImpl 패턴** 반드시 준수 +3. **PriceSearchRequest**는 기존 PricePerUnitSearchRequest에서 이름 변경된 것 (중복 구현 ❌) +4. **미정의 항목 (19-37번)** 제외하고 구현 +5. **Standard (UP/DOWN)**은 프론트에서 화살표 아이콘으로 표시 + - UP: ↑ (이상, `>=`) + - DOWN: ↓ (이하, `<=`) +6. **Flyway V, R 스크립트 모두 작성** 필수 +7. **JSON 필드는 required 포함** 버전 사용 + +--- + +## 📝 참고 자료 + +- 원본 파라미터 정의: `auction_history_search_param.txt` +- 프로젝트 구조: `CLAUDE.md` +- Clean Architecture 패턴: 기존 `auctionhistory` 도메인 참고 +- Flyway 컨벤션: `src/main/resources/db/migration/` 기존 스크립트 참고 + +--- + +**작성자:** Claude Code +**최종 승인:** 사용자 리뷰 완료 (2025-10-20) diff --git a/OPTION_DATA_STRUCTURE_ANALYSIS.md b/OPTION_DATA_STRUCTURE_ANALYSIS.md new file mode 100644 index 0000000..46e64fa --- /dev/null +++ b/OPTION_DATA_STRUCTURE_ANALYSIS.md @@ -0,0 +1,74 @@ +# Auction Item Option 데이터 구조 분석 + +## DB 테이블: `auction_item_option` + +### 테이블 구조 +- `option_type`: VARCHAR(100) - 옵션 종류 (예: "밸런스", "크리티컬", "공격") +- `option_sub_type`: VARCHAR(100) - 옵션 서브 타입 +- `option_value`: VARCHAR(255) - 옵션 값 (텍스트) +- `option_value2`: VARCHAR(255) - 옵션 값2 (숫자 또는 텍스트) + +### 통계 (실제 데이터 분석) + +| option_type | 레코드 수 | distinct_value2 | 설명 | +|-------------|-----------|-----------------|------| +| 공격 | 1,349 | 124 | option_value2에 숫자 저장 | +| 밸런스 | 1,326 | 0 | option_value에 저장 가능 | +| 크리티컬 | 1,257 | 0 | option_value에 저장 가능 | +| 내구력 | 1,959 | 43 | option_value2에 숫자 저장 | +| 부상률 | 225 | 14 | option_value2에 숫자 저장 | +| 방어력 | 522 | 0 | option_value에 저장 가능 | +| 보호 | 154 | 0 | option_value에 저장 가능 | +| 남은 거래 횟수 | 2,788 | 0 | option_value에 저장 가능 | + +## 요구사항 매핑: Request DTO → DB option_type + +| Request DTO 필드 | DB option_type | 값 저장 위치 | 검색 방식 | +|------------------|----------------|-------------|----------| +| Balance | "밸런스" | option_value OR option_value2 | CAST 후 비교 | +| Critical | "크리티컬" | option_value OR option_value2 | CAST 후 비교 | +| Defense | "방어력" | option_value OR option_value2 | CAST 후 비교 | +| Erg | ? | ? | 미확인 | +| ErgRank | "에코스톤 등급"? | option_value | 문자열 비교 | +| MagicDefense | "마법 방어력" | option_value OR option_value2 | CAST 후 비교 | +| MagicProtect | "마법 보호" | option_value OR option_value2 | CAST 후 비교 | +| MaxAttack | "공격" | option_value2 | CAST 후 비교 | +| MaximumDurability | "내구력" | option_value2 | CAST 후 비교 | +| MaxInjuryRate | "부상률" | option_value2 | CAST 후 비교 | +| Proficiency | "숙련" | option_value OR option_value2 | CAST 후 비교 | +| Protect | "보호" | option_value OR option_value2 | CAST 후 비교 | +| RemainingTransactionCount | "남은 거래 횟수" | option_value | 숫자 파싱 후 비교 | +| RemainingUnsealCount | "남은 전용 해제 가능 횟수" | option_value | 숫자 파싱 후 비교 | +| RemainingUseCount | "남은 사용 횟수" | option_value | 숫자 파싱 후 비교 | +| WearingRestrictions | ? | option_value | 문자열 비교 | + +## QueryDSL 검색 로직 전략 + +### 1. 숫자 비교 (UP/DOWN 기준) +```java +// option_value2가 우선, 없으면 option_value 파싱 +BooleanExpression condition = aio.optionType.eq("밸런스") + .and( + aio.optionValue2.isNotNull() + .and(aio.optionValue2.castToNum(Integer.class).goe(value)) + .or( + aio.optionValue.contains(value.toString()) + ) + ); +``` + +### 2. 범위 검색 (From/To) +```java +BooleanExpression condition = aio.optionType.eq("공격") + .and(aio.optionValue2.castToNum(Integer.class).between(from, to)); +``` + +### 3. 문자열 검색 +```java +BooleanExpression condition = aio.optionValue.eq("S등급"); +``` + +## 주의사항 +- option_value와 option_value2 둘 다 확인 필요 +- 숫자 변환 시 CAST 또는 문자열 파싱 필요 +- NULL 체크 필수 diff --git a/auction_history_search_param.txt b/auction_history_search_param.txt new file mode 100644 index 0000000..a122cb6 --- /dev/null +++ b/auction_history_search_param.txt @@ -0,0 +1,37 @@ +검색 옵션 파라미터명 파라미터 타입 파라미터명2 파라미터 타입3 +밸런스 Balance tinyint BalanceStandard string ('UP'|'DOWN') +크리티컬 Crtitical tinyint CrtiticalStandard string ('UP'|'DOWN') +방어력 Defense tinyint DefenseStandard string ('UP'|'DOWN') +에르그 ErgFrom tinyint ErgTo tinyint +에르그 등급 ErgRank string ('S등급'|'A등급'|'B등급') +마법 방어력 MagicDefense tinyint MagicDefenseStandard string ('UP'|'DOWN') +마법 보호 MagicProtect tinyint MagicProtectStandard string ('UP'|'DOWN') +최대 공격력 MaxAttackFrom int MaxAttackTo int +최대내구력 MaximumDurability tinyint MaximumDurabilityStandard string ('UP'|'DOWN') +최대 부상률 MaxInjuryRateFrom tinyint MaxInjuryRateTo tinyint +가격 PriceFrom long PriceTo long +숙련도 Proficiency tinyint ProficiencyStandard string ('UP'|'DOWN') +보호 Protect tinyint ProtectStandard string ('UP'|'DOWN') +남은 거래 횟수 RemainingTransactionCount tinyint RemainingTransactionCountStandard string ('UP'|'DOWN') +남은 전용 해제 가능 횟수 RemainingUnsealCount tinyint RemainingUnsealCountStandard string ('UP'|'DOWN') +남은 사용 횟수 RemainingUseCount tinyint RemainingUseCountStandard string ('UP'|'DOWN') +착용 제한 WearingRestrictions string +사용 효과 +색상 +세공 옵션 +세트 효과 +아이템 보호 +아이템 색상 +에코스톤 각성 능력 +에코스톤 고유 능력 +에코스톤 등급 +인챈트 +인챈트 불가능 +일반 개조 +장인 개조 +크기 +토템 효과 +특별 개조 +품질 +피어싱 레벨 +보석 개조 diff --git a/error.log b/error.log new file mode 100644 index 0000000..8581937 --- /dev/null +++ b/error.log @@ -0,0 +1,83 @@ +15:30:05,173 |-INFO in ch.qos.logback.classic.LoggerContext[default] - This is logback-classic version 1.5.18 +15:30:05,184 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - Here is a list of configurators discovered as a service, by rank: +15:30:05,184 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - org.springframework.boot.logging.logback.RootLogLevelConfigurator +15:30:05,184 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - They will be invoked in order until ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY is returned. +15:30:05,184 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - Constructed configurator of type class org.springframework.boot.logging.logback.RootLogLevelConfigurator +15:30:05,270 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - org.springframework.boot.logging.logback.RootLogLevelConfigurator.configure() call lasted 1 milliseconds. ExecutionStatus=INVOKE_NEXT_IF_ANY +15:30:05,270 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - Trying to configure with ch.qos.logback.classic.joran.SerializedModelConfigurator +15:30:05,275 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - Constructed configurator of type class ch.qos.logback.classic.joran.SerializedModelConfigurator +15:30:05,286 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.scmo] +15:30:05,364 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback.scmo] +15:30:05,364 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - ch.qos.logback.classic.joran.SerializedModelConfigurator.configure() call lasted 89 milliseconds. ExecutionStatus=INVOKE_NEXT_IF_ANY +15:30:05,364 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - Trying to configure with ch.qos.logback.classic.util.DefaultJoranConfigurator +15:30:05,366 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - Constructed configurator of type class ch.qos.logback.classic.util.DefaultJoranConfigurator +15:30:05,368 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.xml] +15:30:05,369 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback.xml] +15:30:05,369 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - ch.qos.logback.classic.util.DefaultJoranConfigurator.configure() call lasted 3 milliseconds. ExecutionStatus=INVOKE_NEXT_IF_ANY +15:30:05,370 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - Trying to configure with ch.qos.logback.classic.BasicConfigurator +15:30:05,375 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - Constructed configurator of type class ch.qos.logback.classic.BasicConfigurator +15:30:05,375 |-INFO in ch.qos.logback.classic.BasicConfigurator@76f7d241 - Setting up default configuration. +15:30:05,770 |-INFO in ch.qos.logback.core.ConsoleAppender[console] - BEWARE: Writing to the console can be very slow. Avoid logging to the +15:30:05,770 |-INFO in ch.qos.logback.core.ConsoleAppender[console] - console in production environments, especially in high volume systems. +15:30:05,770 |-INFO in ch.qos.logback.core.ConsoleAppender[console] - See also https://logback.qos.ch/codes.html#slowConsole +15:30:05,772 |-INFO in ch.qos.logback.classic.util.ContextInitializer@2783717b - ch.qos.logback.classic.BasicConfigurator.configure() call lasted 397 milliseconds. ExecutionStatus=NEUTRAL +15:30:10,275 |-INFO in ConfigurationWatchList(mainURL=jar:nested:/app.jar/!BOOT-INF/classes/!/logback/logback-display.xml, fileWatchList={}, urlWatchList=[}) - URL [jar:nested:/app.jar/!BOOT-INF/classes/!/logback/logback-display.xml] is not of type file +15:30:10,582 |-WARN in org.springframework.boot.logging.logback.SpringProfileIfNestedWithinSecondPhaseElementSanityChecker@3829ac1 - elements cannot be nested within an , or element +15:30:10,582 |-WARN in org.springframework.boot.logging.logback.SpringProfileIfNestedWithinSecondPhaseElementSanityChecker@3829ac1 - Element at line 5 contains a nested element at line 6 +15:30:10,582 |-WARN in org.springframework.boot.logging.logback.SpringProfileIfNestedWithinSecondPhaseElementSanityChecker@3829ac1 - Element at line 5 contains a nested element at line 9 +15:30:10,582 |-WARN in org.springframework.boot.logging.logback.SpringProfileIfNestedWithinSecondPhaseElementSanityChecker@3829ac1 - Element at line 5 contains a nested element at line 14 +15:30:10,872 |-INFO in ch.qos.logback.core.joran.util.ConfigurationWatchListUtil@4baf352a - Adding [jar:nested:/app.jar/!BOOT-INF/classes/!/logback/logback-appender.xml] to configuration watch list.15:30:10,873 |-INFO in ConfigurationWatchList(mainURL=jar:nested:/app.jar/!BOOT-INF/classes/!/logback/logback-display.xml, fileWatchList={}, urlWatchList=[}) - Cannot watch [jar:nested:/app.jar/!BOOT-INF/classes/!/logback/logback-appender.xml] as its protocol is not one of file, http or https. +15:30:10,883 |-INFO in ch.qos.logback.core.model.processor.ConversionRuleModelHandler - registering conversion word clr with class [org.springframework.boot.logging.logback.ColorConverter] +15:30:10,884 |-INFO in ch.qos.logback.core.model.processor.ConversionRuleModelHandler - registering conversion word wEx with class [org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter] +15:30:10,884 |-INFO in ch.qos.logback.core.model.processor.TimestampModelHandler - Using current interpretation time, i.e. now, as time reference. +15:30:10,886 |-INFO in ch.qos.logback.core.model.processor.TimestampModelHandler - Adding property to the context with key="BY_DATE" and value="2025-10-20" to the LOCAL scope +15:30:10,975 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@1bb1fde8 - value "[%d{yyyy-MM-dd HH:mm:ss.SSS, Asia/Seoul}] [%5p] [%15.15thread] [%-40.40logger{39}] - %m%n" substituted for "[%d{yyyy-MM-dd HH:mm:ss.SSS, ${logback.timezone:-Asia/Seoul}}] [%5p] [%15.15thread] [%-40.40logger{39}] - %m%n" +15:30:10,977 |-INFO in ch.qos.logback.core.model.processor.AppenderModelHandler - Processing appender named [CONSOLE] +15:30:10,978 |-INFO in ch.qos.logback.core.model.processor.AppenderModelHandler - About to instantiate appender of type [ch.qos.logback.core.ConsoleAppender] +15:30:11,089 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@1bb1fde8 - value "[%d{yyyy-MM-dd HH:mm:ss.SSS, Asia/Seoul}] [%5p] [%15.15thread] [%-40.40logger{39}] - %m%n" substituted for "${CONSOLE_LOG_PATTERN}" +15:30:11,091 |-WARN in ch.qos.logback.core.model.processor.ImplicitModelHandler - Ignoring unknown property [charset] in [ch.qos.logback.classic.PatternLayout] +15:30:11,174 |-INFO in ch.qos.logback.classic.pattern.DateConverter@15eebbff - Setting zoneId to "Asia/Seoul" +15:30:11,175 |-WARN in ch.qos.logback.core.ConsoleAppender[CONSOLE] - This appender no longer admits a layout as a sub-component, set an encoder instead. +15:30:11,175 |-WARN in ch.qos.logback.core.ConsoleAppender[CONSOLE] - To ensure compatibility, wrapping your layout in LayoutWrappingEncoder. +15:30:11,175 |-WARN in ch.qos.logback.core.ConsoleAppender[CONSOLE] - See also https://logback.qos.ch/codes.html#layoutInsteadOfEncoder for details +15:30:11,175 |-INFO in ch.qos.logback.core.ConsoleAppender[CONSOLE] - BEWARE: Writing to the console can be very slow. Avoid logging to the +15:30:11,175 |-INFO in ch.qos.logback.core.ConsoleAppender[CONSOLE] - console in production environments, especially in high volume systems. +15:30:11,175 |-INFO in ch.qos.logback.core.ConsoleAppender[CONSOLE] - See also https://logback.qos.ch/codes.html#slowConsole +15:30:11,175 |-INFO in ch.qos.logback.core.model.processor.AppenderModelHandler - Processing appender named [FILE] +15:30:11,176 |-INFO in ch.qos.logback.core.model.processor.AppenderModelHandler - About to instantiate appender of type [ch.qos.logback.core.rolling.RollingFileAppender] +15:30:11,181 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@1bb1fde8 - value "./logs/2025-10-20_log_data.log" substituted for "${LOG_FILE_PATH}/${BY_DATE}_${LOG_FILE_NAME}.log" +15:30:11,182 |-INFO in ch.qos.logback.core.model.processor.ImplicitModelHandler - Assuming default type [ch.qos.logback.classic.encoder.PatternLayoutEncoder] for [encoder] property +15:30:11,182 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@1bb1fde8 - value "[%d{yyyy-MM-dd HH:mm:ss.SSS, Asia/Seoul}] [%5p] [%15.15thread] [%-40.40logger{39}] - %m%n" substituted for "${CONSOLE_LOG_PATTERN}" +15:30:11,183 |-INFO in ch.qos.logback.classic.pattern.DateConverter@22d6f11 - Setting zoneId to "Asia/Seoul" +15:30:11,263 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@1bb1fde8 - value "./logs/archive/%d{yyyy-MM-dd, Asia/Seoul}_%i.log" substituted for "${ARCHIVE_FILE_PATH}/%d{yyyy-MM-dd, ${logback.timezone:-Asia/Seoul}}_%i.log" +15:30:11,271 |-INFO in c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@815336475 - setting totalSizeCap to 50 MB +15:30:11,276 |-INFO in c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@815336475 - Archive files will be limited to [50 MB] each. +15:30:11,276 |-INFO in c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@815336475 - No compression will be used +15:30:11,282 |-INFO in c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@815336475 - Will use the pattern ./logs/archive/%d{yyyy-MM-dd, Asia/Seoul}_%i.log for the active file +15:30:11,486 |-INFO in ch.qos.logback.core.rolling.SizeAndTimeBasedFileNamingAndTriggeringPolicy@2453f95d - The date pattern is 'yyyy-MM-dd' from file name pattern './logs/archive/%d{yyyy-MM-dd, Asia/Seoul}_%i.log'. +15:30:11,486 |-INFO in ch.qos.logback.core.rolling.SizeAndTimeBasedFileNamingAndTriggeringPolicy@2453f95d - Roll-over at midnight. +15:30:11,573 |-INFO in ch.qos.logback.core.rolling.SizeAndTimeBasedFileNamingAndTriggeringPolicy@2453f95d - Setting initial period to 2025-10-20T15:30:11.572Z +15:30:11,582 |-INFO in ch.qos.logback.core.rolling.RollingFileAppender[FILE] - Active log file name: ./logs/2025-10-20_log_data.log +15:30:11,583 |-INFO in ch.qos.logback.core.rolling.RollingFileAppender[FILE] - File property is set to [./logs/2025-10-20_log_data.log] +15:30:11,586 |-INFO in ch.qos.logback.core.model.processor.AppenderModelHandler - Processing appender named [ERROR] +15:30:11,586 |-INFO in ch.qos.logback.core.model.processor.AppenderModelHandler - About to instantiate appender of type [ch.qos.logback.core.rolling.RollingFileAppender] +15:30:11,667 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@1bb1fde8 - value "./logs/2025-10-20_log_data_error.log" substituted for "${LOG_FILE_PATH}/${BY_DATE}_${ERROR_LOG_FILE_NAME}.log" +15:30:11,668 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@1bb1fde8 - value "[%d{yyyy-MM-dd HH:mm:ss.SSS, Asia/Seoul}] [%5p] [%15.15thread] [%-40.40logger{39}] - %m%n" substituted for "${CONSOLE_LOG_PATTERN}" +15:30:11,668 |-INFO in ch.qos.logback.classic.pattern.DateConverter@44828f6b - Setting zoneId to "Asia/Seoul" +15:30:11,668 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@1bb1fde8 - value "./logs/archive/%d{yyyy-MM-dd, Asia/Seoul}_error_%i.log" substituted for "${ARCHIVE_FILE_PATH}/%d{yyyy-MM-dd, ${logback.timezone:-Asia/Seoul}}_error_%i.log" +15:30:11,670 |-INFO in c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@767436045 - setting totalSizeCap to 50 MB +15:30:11,670 |-INFO in c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@767436045 - Archive files will be limited to [50 MB] each. +15:30:11,670 |-INFO in c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@767436045 - No compression will be used +15:30:11,670 |-INFO in c.q.l.core.rolling.SizeAndTimeBasedRollingPolicy@767436045 - Will use the pattern ./logs/archive/%d{yyyy-MM-dd, Asia/Seoul}_error_%i.log for the active file +15:30:11,671 |-INFO in ch.qos.logback.core.rolling.SizeAndTimeBasedFileNamingAndTriggeringPolicy@553f1d75 - The date pattern is 'yyyy-MM-dd' from file name pattern './logs/archive/%d{yyyy-MM-dd, Asia/Seoul}_error_%i.log'. +15:30:11,671 |-INFO in ch.qos.logback.core.rolling.SizeAndTimeBasedFileNamingAndTriggeringPolicy@553f1d75 - Roll-over at midnight. +15:30:11,672 |-INFO in ch.qos.logback.core.rolling.SizeAndTimeBasedFileNamingAndTriggeringPolicy@553f1d75 - Setting initial period to 2025-10-20T15:30:11.672Z +15:30:11,673 |-INFO in ch.qos.logback.core.rolling.RollingFileAppender[ERROR] - Active log file name: ./logs/2025-10-20_log_data_error.log +15:30:11,673 |-INFO in ch.qos.logback.core.rolling.RollingFileAppender[ERROR] - File property is set to [./logs/2025-10-20_log_data_error.log] +15:30:11,674 |-INFO in ch.qos.logback.classic.model.processor.RootLoggerModelHandler - Setting level of ROOT logger to INFO +15:30:11,675 |-INFO in ch.qos.logback.classic.jul.LevelChangePropagator@6e1d8f9e - Propagating INFO level on Logger[ROOT] onto the JUL framework +15:30:11,677 |-INFO in ch.qos.logback.core.model.processor.AppenderRefModelHandler - Attaching appender named [CONSOLE] to Logger[ROOT] +15:30:11,677 |-INFO in ch.qos.logback.core.model.processor.AppenderRefModelHandler - Attaching appender named [FILE] to Logger[ROOT] +15:30:11,677 |-INFO in ch.qos.logback.core.model.processor.AppenderRefModelHandler - Attaching appender named [ERROR] to Logger[ROOT] +15:30:11,677 |-INFO in ch.qos.logback.core.model.processor.DefaultProcessor@3e34ace1 - End of configuration. +15:30:11,679 |-INFO in org.springframework.boot.logging.logback.SpringBootJoranConfigurator@62fe6067 - Registering current configuration as safe fallback point \ No newline at end of file diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index c4473a4..71ba114 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -1,6 +1,9 @@ package until.the.eternity.auctionhistory.infrastructure.persistence; import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberTemplate; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -10,7 +13,8 @@ import org.springframework.stereotype.Component; import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.domain.entity.QAuctionHistory; -import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.*; +import until.the.eternity.auctionitemoption.domain.entity.QAuctionItemOption; @Component @RequiredArgsConstructor @@ -18,27 +22,65 @@ class AuctionHistoryQueryDslRepository { private final JPAQueryFactory queryFactory; + /** + * 경매 거래내역 검색 (옵션 조건 포함) + * + *

검색 흐름: 1. 옵션 조건을 만족하는 거래내역 ID를 서브쿼리로 찾기 2. 거래내역 조건으로 필터링 3. 해당 거래내역의 모든 옵션을 함께 조회 (LEFT + * JOIN) + */ public Page search(AuctionHistorySearchRequest condition, Pageable pageable) { QAuctionHistory ah = QAuctionHistory.auctionHistory; - BooleanBuilder builder = buildPredicate(condition, ah); + QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; + // 1단계: 거래내역 조건 빌드 + BooleanBuilder historyBuilder = buildHistoryPredicate(condition, ah); + + // 2단계: 옵션 조건이 있으면 서브쿼리 추가 + if (condition.itemOptionSearchRequest() != null) { + // 서브쿼리용 별도 QAuctionItemOption 인스턴스 + QAuctionItemOption subOption = new QAuctionItemOption("subOption"); + BooleanBuilder optionBuilder = + buildItemOptionConditions(condition.itemOptionSearchRequest(), subOption); + + // 옵션 조건이 실제로 있는 경우에만 서브쿼리 추가 + if (optionBuilder.hasValue()) { + // 서브쿼리: 옵션 조건을 만족하는 auction_history_id 찾기 + var subQuery = + JPAExpressions.select(subOption.auctionHistory.auctionBuyId) + .from(subOption) + .where(optionBuilder) + .distinct(); + + // 메인 쿼리에 서브쿼리 결과 적용 + historyBuilder.and(ah.auctionBuyId.in(subQuery)); + } + } + + // 3단계: 모든 옵션과 함께 조회 (LEFT JOIN - 조건 없음!) List content = queryFactory .selectFrom(ah) - .leftJoin(ah.auctionItemOptions) + .leftJoin(ah.auctionItemOptions, aio) .fetchJoin() - .where(builder) + .where(historyBuilder) + .distinct() // 중복 제거 .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); - Long total = queryFactory.select(ah.count()).from(ah).where(builder).fetchOne(); + // Count 쿼리 (JOIN 없이 실행) + Long total = + queryFactory.select(ah.countDistinct()).from(ah).where(historyBuilder).fetchOne(); return new PageImpl<>(content, pageable, total == null ? 0L : total); } - private BooleanBuilder buildPredicate(AuctionHistorySearchRequest c, QAuctionHistory ah) { + /** 거래내역 기본 조건 빌드 (카테고리, 아이템명, 가격, 거래일자) */ + private BooleanBuilder buildHistoryPredicate( + AuctionHistorySearchRequest c, QAuctionHistory ah) { BooleanBuilder builder = new BooleanBuilder(); + + // 기본 조건들 if (c.itemTopCategory() != null && !c.itemTopCategory().isBlank()) { builder.and(ah.itemTopCategory.eq(c.itemTopCategory())); } @@ -48,6 +90,264 @@ private BooleanBuilder buildPredicate(AuctionHistorySearchRequest c, QAuctionHis if (c.itemName() != null && !c.itemName().isBlank()) { builder.and(ah.itemName.containsIgnoreCase(c.itemName())); } + + // 가격 조건 (PriceSearchRequest가 있으면) + if (c.priceSearchRequest() != null) { + PriceSearchRequest price = c.priceSearchRequest(); + if (price.priceFrom() != null) { + builder.and(ah.auctionPricePerUnit.goe(price.priceFrom())); + } + if (price.priceTo() != null) { + builder.and(ah.auctionPricePerUnit.loe(price.priceTo())); + } + } + + // 거래 일자 조건 + if (c.date_auction_buy() != null && !c.date_auction_buy().isBlank()) { + // 날짜 파싱 및 조건 추가 로직 + // TODO: 날짜 범위 검색 구현 + } + + return builder; + } + + /** + * 옵션 검색 조건 빌드 (서브쿼리용) + * + *

주의: 이 메서드는 서브쿼리에서만 사용됩니다. 반환된 BooleanBuilder는 메인 JOIN의 WHERE에 직접 사용하면 안 됩니다! + */ + private BooleanBuilder buildItemOptionConditions( + ItemOptionSearchRequest opt, QAuctionItemOption aio) { + BooleanBuilder builder = new BooleanBuilder(); + + // 1. Balance (밸런스) + if (opt.balanceSearch() != null && opt.balanceSearch().balance() != null) { + builder.or( + buildOptionCondition( + aio, + "밸런스", + opt.balanceSearch().balance(), + opt.balanceSearch().balanceStandard())); + } + + // 2. Critical (크리티컬) + if (opt.criticalSearch() != null && opt.criticalSearch().critical() != null) { + builder.or( + buildOptionCondition( + aio, + "크리티컬", + opt.criticalSearch().critical(), + opt.criticalSearch().criticalStandard())); + } + + // 3. Defense (방어력) + if (opt.defenseSearch() != null && opt.defenseSearch().defense() != null) { + builder.or( + buildOptionCondition( + aio, + "방어력", + opt.defenseSearch().defense(), + opt.defenseSearch().defenseStandard())); + } + + // 4. Erg (에르그) - 범위 검색 + if (opt.ergSearch() != null) { + BooleanBuilder ergBuilder = new BooleanBuilder(aio.optionType.eq("에르그")); + if (opt.ergSearch().ergFrom() != null && opt.ergSearch().ergTo() != null) { + ergBuilder.and( + castOptionValueToInt(aio) + .between(opt.ergSearch().ergFrom(), opt.ergSearch().ergTo())); + } else if (opt.ergSearch().ergFrom() != null) { + ergBuilder.and(castOptionValueToInt(aio).goe(opt.ergSearch().ergFrom())); + } else if (opt.ergSearch().ergTo() != null) { + ergBuilder.and(castOptionValueToInt(aio).loe(opt.ergSearch().ergTo())); + } + if (ergBuilder.hasValue()) { + builder.or(ergBuilder); + } + } + + // 5. ErgRank (에르그 등급) - 문자열 비교 + if (opt.ergRankSearch() != null && opt.ergRankSearch().ergRank() != null) { + builder.or( + aio.optionType + .eq("에르그") + .and(aio.optionValue.eq(opt.ergRankSearch().ergRank()))); + } + + // 6. MagicDefense (마법 방어력) + if (opt.magicDefenseSearch() != null && opt.magicDefenseSearch().magicDefense() != null) { + builder.or( + buildOptionCondition( + aio, + "마법 방어력", + opt.magicDefenseSearch().magicDefense(), + opt.magicDefenseSearch().magicDefenseStandard())); + } + + // 7. MagicProtect (마법 보호) + if (opt.magicProtectSearch() != null && opt.magicProtectSearch().magicProtect() != null) { + builder.or( + buildOptionCondition( + aio, + "마법 보호", + opt.magicProtectSearch().magicProtect(), + opt.magicProtectSearch().magicProtectStandard())); + } + + // 8. MaxAttack (공격) - 범위 검색 + if (opt.maxAttackSearch() != null) { + BooleanBuilder attackBuilder = new BooleanBuilder(aio.optionType.eq("공격")); + if (opt.maxAttackSearch().maxAttackFrom() != null + && opt.maxAttackSearch().maxAttackTo() != null) { + attackBuilder.and( + castOptionValueToInt(aio) + .between( + opt.maxAttackSearch().maxAttackFrom(), + opt.maxAttackSearch().maxAttackTo())); + } else if (opt.maxAttackSearch().maxAttackFrom() != null) { + attackBuilder.and( + castOptionValueToInt(aio).goe(opt.maxAttackSearch().maxAttackFrom())); + } else if (opt.maxAttackSearch().maxAttackTo() != null) { + attackBuilder.and( + castOptionValueToInt(aio).loe(opt.maxAttackSearch().maxAttackTo())); + } + if (attackBuilder.hasValue()) { + builder.or(attackBuilder); + } + } + + // 9. MaximumDurability (내구력) + if (opt.maximumDurabilitySearch() != null + && opt.maximumDurabilitySearch().maximumDurability() != null) { + builder.or( + buildOptionCondition( + aio, + "내구력", + opt.maximumDurabilitySearch().maximumDurability(), + opt.maximumDurabilitySearch().maximumDurabilityStandard())); + } + + // 10. MaxInjuryRate (부상률) - 범위 검색 + if (opt.maxInjuryRateSearch() != null) { + BooleanBuilder injuryBuilder = new BooleanBuilder(aio.optionType.eq("부상률")); + if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null + && opt.maxInjuryRateSearch().maxInjuryRateTo() != null) { + injuryBuilder.and( + castOptionValueToInt(aio) + .between( + opt.maxInjuryRateSearch().maxInjuryRateFrom(), + opt.maxInjuryRateSearch().maxInjuryRateTo())); + } else if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null) { + injuryBuilder.and( + castOptionValueToInt(aio) + .goe(opt.maxInjuryRateSearch().maxInjuryRateFrom())); + } else if (opt.maxInjuryRateSearch().maxInjuryRateTo() != null) { + injuryBuilder.and( + castOptionValueToInt(aio).loe(opt.maxInjuryRateSearch().maxInjuryRateTo())); + } + if (injuryBuilder.hasValue()) { + builder.or(injuryBuilder); + } + } + + // 11. Proficiency (숙련) + if (opt.proficiencySearch() != null && opt.proficiencySearch().proficiency() != null) { + builder.or( + buildOptionCondition( + aio, + "숙련", + opt.proficiencySearch().proficiency(), + opt.proficiencySearch().proficiencyStandard())); + } + + // 12. Protect (보호) + if (opt.protectSearch() != null && opt.protectSearch().protect() != null) { + builder.or( + buildOptionCondition( + aio, + "보호", + opt.protectSearch().protect(), + opt.protectSearch().protectStandard())); + } + + // 13. RemainingTransactionCount (남은 거래 횟수) + if (opt.remainingTransactionCountSearch() != null + && opt.remainingTransactionCountSearch().remainingTransactionCount() != null) { + builder.or( + buildOptionCondition( + aio, + "남은 거래 횟수", + opt.remainingTransactionCountSearch().remainingTransactionCount(), + opt.remainingTransactionCountSearch() + .remainingTransactionCountStandard())); + } + + // 14. RemainingUnsealCount (남은 전용 해제 가능 횟수) + if (opt.remainingUnsealCountSearch() != null + && opt.remainingUnsealCountSearch().remainingUnsealCount() != null) { + builder.or( + buildOptionCondition( + aio, + "남은 전용 해제 가능 횟수", + opt.remainingUnsealCountSearch().remainingUnsealCount(), + opt.remainingUnsealCountSearch().remainingUnsealCountStandard())); + } + + // 15. RemainingUseCount (남은 사용 횟수) + if (opt.remainingUseCountSearch() != null + && opt.remainingUseCountSearch().remainingUseCount() != null) { + builder.or( + buildOptionCondition( + aio, + "남은 사용 횟수", + opt.remainingUseCountSearch().remainingUseCount(), + opt.remainingUseCountSearch().remainingUseCountStandard())); + } + + // 16. WearingRestrictions (착용 제한) - 문자열 비교 + if (opt.wearingRestrictionsSearch() != null + && opt.wearingRestrictionsSearch().wearingRestrictions() != null) { + builder.or( + aio.optionValue.contains( + opt.wearingRestrictionsSearch().wearingRestrictions())); + } + return builder; } + + /** + * 옵션 조건 빌드 헬퍼 (option_type + 숫자 비교 + UP/DOWN) + * + * @param aio QueryDSL Q타입 + * @param optionType DB의 option_type 값 (예: "밸런스", "크리티컬") + * @param value 비교할 숫자 값 + * @param standard UP(이상) / DOWN(이하) / null(같음) + */ + private BooleanBuilder buildOptionCondition( + QAuctionItemOption aio, String optionType, Integer value, String standard) { + BooleanBuilder condition = new BooleanBuilder(aio.optionType.eq(optionType)); + + NumberTemplate numValue = castOptionValueToInt(aio); + + if ("UP".equals(standard)) { + condition.and(numValue.goe(value)); // 이상 (>=) + } else if ("DOWN".equals(standard)) { + condition.and(numValue.loe(value)); // 이하 (<=) + } else { + condition.and(numValue.eq(value)); // 같음 + } + + return condition; + } + + /** + * option_value2 또는 option_value를 Integer로 변환하는 NumberTemplate + * + *

COALESCE를 사용하여 null 처리 후 숫자로 비교 (MySQL은 자동 타입 변환 수행) + */ + private NumberTemplate castOptionValueToInt(QAuctionItemOption aio) { + return Expressions.numberTemplate( + Integer.class, "COALESCE({0}, {1}, 0)", aio.optionValue2, aio.optionValue); + } } diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/controller/AuctionHistoryController.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/controller/AuctionHistoryController.java index d51f0b0..613622f 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/controller/AuctionHistoryController.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/controller/AuctionHistoryController.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import until.the.eternity.auctionhistory.application.scheduler.AuctionHistoryScheduler; @@ -26,8 +27,8 @@ public class AuctionHistoryController { @GetMapping("/search") @Operation(summary = "경매장 거래 내역 검색", description = "Nexon Open API 경매장 거래 내역 검색") public ResponseEntity>> search( - @ModelAttribute PageRequestDto pageDto, - @ModelAttribute @Valid AuctionHistorySearchRequest requestDto) { + @ParameterObject @ModelAttribute PageRequestDto pageDto, + @ParameterObject @ModelAttribute @Valid AuctionHistorySearchRequest requestDto) { PageResponseDto> result = service.search(requestDto, pageDto); return ResponseEntity.ok(result); diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java index 4d96f84..3408870 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java @@ -3,10 +3,12 @@ import io.swagger.v3.oas.annotations.media.Schema; /** 경매 히스토리 검색 조건 DTO - 페이지네이션 포함 */ +@Schema(description = "경매 거래내역 검색 조건") public record AuctionHistorySearchRequest( @Schema(description = "아이템 이름 (like 검색)", example = "페러시우스 타이탄 블레이드") String itemName, @Schema(description = "대분류 카테고리", example = "근거리 장비") String itemTopCategory, @Schema(description = "소분류 카테고리", example = "검") String itemSubCategory, - // TODO: 거래 가격, 거래 일자를 범위 검색으로 변경, 옵션은 별도의 RequestDTO 구현 @Schema(description = "거래 가격", example = "10000000") String auction_price_per_unit, - @Schema(description = "거래 일자", example = "검") String date_auction_buy) {} + @Schema(description = "거래 일자", example = "2025-10-20") String date_auction_buy, + @Schema(description = "가격 검색 조건") PriceSearchRequest priceSearchRequest, + @Schema(description = "아이템 옵션 검색 조건") ItemOptionSearchRequest itemOptionSearchRequest) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/BalanceSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/BalanceSearchRequest.java new file mode 100644 index 0000000..238607b --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/BalanceSearchRequest.java @@ -0,0 +1,8 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "밸런스 검색 조건") +public record BalanceSearchRequest( + @Schema(description = "밸런스 값", example = "10") Integer balance, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") String balanceStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/CriticalSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/CriticalSearchRequest.java new file mode 100644 index 0000000..b01c46c --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/CriticalSearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "크리티컬 검색 조건") +public record CriticalSearchRequest( + @Schema(description = "크리티컬 값", example = "30") Integer critical, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String criticalStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DefenseSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DefenseSearchRequest.java new file mode 100644 index 0000000..61c2e11 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DefenseSearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "방어력 검색 조건") +public record DefenseSearchRequest( + @Schema(description = "방어력 값", example = "5") Integer defense, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") + String defenseStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ErgRankSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ErgRankSearchRequest.java new file mode 100644 index 0000000..e892480 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ErgRankSearchRequest.java @@ -0,0 +1,11 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "에르그 등급 검색 조건") +public record ErgRankSearchRequest( + @Schema( + description = "에르그 등급", + example = "S등급", + allowableValues = {"S등급", "A등급", "B등급"}) + String ergRank) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ErgSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ErgSearchRequest.java new file mode 100644 index 0000000..930869c --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ErgSearchRequest.java @@ -0,0 +1,8 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "에르그 검색 조건 (범위)") +public record ErgSearchRequest( + @Schema(description = "에르그 최소값", example = "10") Integer ergFrom, + @Schema(description = "에르그 최대값", example = "50") Integer ergTo) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ItemOptionSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ItemOptionSearchRequest.java new file mode 100644 index 0000000..742916f --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ItemOptionSearchRequest.java @@ -0,0 +1,27 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "아이템 옵션 검색 조건 통합") +public record ItemOptionSearchRequest( + @Schema(description = "밸런스 검색 조건") BalanceSearchRequest balanceSearch, + @Schema(description = "크리티컬 검색 조건") CriticalSearchRequest criticalSearch, + @Schema(description = "방어력 검색 조건") DefenseSearchRequest defenseSearch, + @Schema(description = "에르그 검색 조건") ErgSearchRequest ergSearch, + @Schema(description = "에르그 등급 검색 조건") ErgRankSearchRequest ergRankSearch, + @Schema(description = "마법 방어력 검색 조건") MagicDefenseSearchRequest magicDefenseSearch, + @Schema(description = "마법 보호 검색 조건") MagicProtectSearchRequest magicProtectSearch, + @Schema(description = "최대 공격력 검색 조건") MaxAttackSearchRequest maxAttackSearch, + @Schema(description = "최대 내구력 검색 조건") + MaximumDurabilitySearchRequest maximumDurabilitySearch, + @Schema(description = "최대 부상률 검색 조건") MaxInjuryRateSearchRequest maxInjuryRateSearch, + @Schema(description = "숙련도 검색 조건") ProficiencySearchRequest proficiencySearch, + @Schema(description = "보호 검색 조건") ProtectSearchRequest protectSearch, + @Schema(description = "남은 거래 횟수 검색 조건") + RemainingTransactionCountSearchRequest remainingTransactionCountSearch, + @Schema(description = "남은 전용 해제 가능 횟수 검색 조건") + RemainingUnsealCountSearchRequest remainingUnsealCountSearch, + @Schema(description = "남은 사용 횟수 검색 조건") + RemainingUseCountSearchRequest remainingUseCountSearch, + @Schema(description = "착용 제한 검색 조건") + WearingRestrictionsSearchRequest wearingRestrictionsSearch) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicDefenseSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicDefenseSearchRequest.java new file mode 100644 index 0000000..a1bd2e2 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicDefenseSearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "마법 방어력 검색 조건") +public record MagicDefenseSearchRequest( + @Schema(description = "마법 방어력 값", example = "3") Integer magicDefense, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String magicDefenseStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicProtectSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicProtectSearchRequest.java new file mode 100644 index 0000000..17bfbe3 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicProtectSearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "마법 보호 검색 조건") +public record MagicProtectSearchRequest( + @Schema(description = "마법 보호 값", example = "2") Integer magicProtect, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String magicProtectStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaxAttackSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaxAttackSearchRequest.java new file mode 100644 index 0000000..d7969d2 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaxAttackSearchRequest.java @@ -0,0 +1,8 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "최대 공격력 검색 조건 (범위)") +public record MaxAttackSearchRequest( + @Schema(description = "최대 공격력 최소값", example = "50") Integer maxAttackFrom, + @Schema(description = "최대 공격력 최대값", example = "100") Integer maxAttackTo) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaxInjuryRateSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaxInjuryRateSearchRequest.java new file mode 100644 index 0000000..94daf0d --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaxInjuryRateSearchRequest.java @@ -0,0 +1,8 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "최대 부상률 검색 조건 (범위)") +public record MaxInjuryRateSearchRequest( + @Schema(description = "최대 부상률 최소값", example = "10") Integer maxInjuryRateFrom, + @Schema(description = "최대 부상률 최대값", example = "30") Integer maxInjuryRateTo) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaximumDurabilitySearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaximumDurabilitySearchRequest.java new file mode 100644 index 0000000..62a0e48 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaximumDurabilitySearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "최대 내구력 검색 조건") +public record MaximumDurabilitySearchRequest( + @Schema(description = "최대 내구력 값", example = "20") Integer maximumDurability, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String maximumDurabilityStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/PriceSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/PriceSearchRequest.java index 9c82c49..bef7d92 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/PriceSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/PriceSearchRequest.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; +@Schema(description = "가격 검색 조건 (범위)") public record PriceSearchRequest( - @Schema(description = "가격 최소값", example = "0", defaultValue = "0") long PriceTo, - @Schema(description = "가격 최대값", example = "9999999999", defaultValue = "9999999999") - long PriceFrom) {} + @Schema(description = "가격 최소값", example = "0") Long priceFrom, + @Schema(description = "가격 최대값", example = "9999999999") Long priceTo) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProficiencySearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProficiencySearchRequest.java new file mode 100644 index 0000000..c504e1c --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProficiencySearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "숙련도 검색 조건") +public record ProficiencySearchRequest( + @Schema(description = "숙련도 값", example = "15") Integer proficiency, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String proficiencyStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProtectSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProtectSearchRequest.java new file mode 100644 index 0000000..8e4e67c --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProtectSearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "보호 검색 조건") +public record ProtectSearchRequest( + @Schema(description = "보호 값", example = "1") Integer protect, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") + String protectStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingTransactionCountSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingTransactionCountSearchRequest.java new file mode 100644 index 0000000..c02874d --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingTransactionCountSearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "남은 거래 횟수 검색 조건") +public record RemainingTransactionCountSearchRequest( + @Schema(description = "남은 거래 횟수", example = "5") Integer remainingTransactionCount, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String remainingTransactionCountStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUnsealCountSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUnsealCountSearchRequest.java new file mode 100644 index 0000000..c504c1f --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUnsealCountSearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "남은 전용 해제 가능 횟수 검색 조건") +public record RemainingUnsealCountSearchRequest( + @Schema(description = "남은 전용 해제 가능 횟수", example = "3") Integer remainingUnsealCount, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") + String remainingUnsealCountStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUseCountSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUseCountSearchRequest.java new file mode 100644 index 0000000..7ef7a2e --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUseCountSearchRequest.java @@ -0,0 +1,9 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "남은 사용 횟수 검색 조건") +public record RemainingUseCountSearchRequest( + @Schema(description = "남은 사용 횟수", example = "10") Integer remainingUseCount, + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") + String remainingUseCountStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/WearingRestrictionsSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/WearingRestrictionsSearchRequest.java new file mode 100644 index 0000000..d1a3a30 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/WearingRestrictionsSearchRequest.java @@ -0,0 +1,7 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "착용 제한 검색 조건") +public record WearingRestrictionsSearchRequest( + @Schema(description = "착용 제한", example = "자이언트 전용") String wearingRestrictions) {} diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java index 8c438c2..c9ad8af 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java @@ -42,7 +42,7 @@ class AuctionHistoryServiceTest { void search_should_return_paged_response() { // given AuctionHistorySearchRequest searchRequest = - new AuctionHistorySearchRequest(null, null, null, null, null); + new AuctionHistorySearchRequest(null, null, null, null, null, null, null); PageRequestDto pageRequestDto = mock(PageRequestDto.class); Pageable pageable = PageRequest.of(0, 10); when(pageRequestDto.toPageable()).thenReturn(pageable); From b2ef6d94f53e180e7164457009c86627568f9ab4 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Mon, 27 Oct 2025 22:35:17 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20auction=20history=20search=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=B3=84=20grouping=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryQueryDslRepository.java | 96 +++++++++++-------- 1 file changed, 58 insertions(+), 38 deletions(-) diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index 71ba114..b97ebea 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -1,6 +1,7 @@ package until.the.eternity.auctionhistory.infrastructure.persistence; import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberTemplate; import com.querydsl.jpa.JPAExpressions; @@ -152,27 +153,31 @@ private BooleanBuilder buildItemOptionConditions( // 4. Erg (에르그) - 범위 검색 if (opt.ergSearch() != null) { - BooleanBuilder ergBuilder = new BooleanBuilder(aio.optionType.eq("에르그")); + BooleanExpression ergTypeCondition = aio.optionType.eq("에르그"); + BooleanExpression ergValueCondition = null; + if (opt.ergSearch().ergFrom() != null && opt.ergSearch().ergTo() != null) { - ergBuilder.and( + ergValueCondition = castOptionValueToInt(aio) - .between(opt.ergSearch().ergFrom(), opt.ergSearch().ergTo())); + .between(opt.ergSearch().ergFrom(), opt.ergSearch().ergTo()); } else if (opt.ergSearch().ergFrom() != null) { - ergBuilder.and(castOptionValueToInt(aio).goe(opt.ergSearch().ergFrom())); + ergValueCondition = castOptionValueToInt(aio).goe(opt.ergSearch().ergFrom()); } else if (opt.ergSearch().ergTo() != null) { - ergBuilder.and(castOptionValueToInt(aio).loe(opt.ergSearch().ergTo())); + ergValueCondition = castOptionValueToInt(aio).loe(opt.ergSearch().ergTo()); } - if (ergBuilder.hasValue()) { - builder.or(ergBuilder); + + if (ergValueCondition != null) { + // 명시적으로 괄호를 추가 + BooleanExpression combined = ergTypeCondition.and(ergValueCondition); + builder.or(Expressions.booleanTemplate("({0})", combined)); } } // 5. ErgRank (에르그 등급) - 문자열 비교 if (opt.ergRankSearch() != null && opt.ergRankSearch().ergRank() != null) { - builder.or( - aio.optionType - .eq("에르그") - .and(aio.optionValue.eq(opt.ergRankSearch().ergRank()))); + BooleanExpression combined = + aio.optionType.eq("에르그").and(aio.optionValue.eq(opt.ergRankSearch().ergRank())); + builder.or(Expressions.booleanTemplate("({0})", combined)); } // 6. MagicDefense (마법 방어력) @@ -197,23 +202,28 @@ private BooleanBuilder buildItemOptionConditions( // 8. MaxAttack (공격) - 범위 검색 if (opt.maxAttackSearch() != null) { - BooleanBuilder attackBuilder = new BooleanBuilder(aio.optionType.eq("공격")); + BooleanExpression attackTypeCondition = aio.optionType.eq("공격"); + BooleanExpression attackValueCondition = null; + if (opt.maxAttackSearch().maxAttackFrom() != null && opt.maxAttackSearch().maxAttackTo() != null) { - attackBuilder.and( + attackValueCondition = castOptionValueToInt(aio) .between( opt.maxAttackSearch().maxAttackFrom(), - opt.maxAttackSearch().maxAttackTo())); + opt.maxAttackSearch().maxAttackTo()); } else if (opt.maxAttackSearch().maxAttackFrom() != null) { - attackBuilder.and( - castOptionValueToInt(aio).goe(opt.maxAttackSearch().maxAttackFrom())); + attackValueCondition = + castOptionValueToInt(aio).goe(opt.maxAttackSearch().maxAttackFrom()); } else if (opt.maxAttackSearch().maxAttackTo() != null) { - attackBuilder.and( - castOptionValueToInt(aio).loe(opt.maxAttackSearch().maxAttackTo())); + attackValueCondition = + castOptionValueToInt(aio).loe(opt.maxAttackSearch().maxAttackTo()); } - if (attackBuilder.hasValue()) { - builder.or(attackBuilder); + + if (attackValueCondition != null) { + // 명시적으로 괄호를 추가 + BooleanExpression combined = attackTypeCondition.and(attackValueCondition); + builder.or(Expressions.booleanTemplate("({0})", combined)); } } @@ -230,24 +240,29 @@ private BooleanBuilder buildItemOptionConditions( // 10. MaxInjuryRate (부상률) - 범위 검색 if (opt.maxInjuryRateSearch() != null) { - BooleanBuilder injuryBuilder = new BooleanBuilder(aio.optionType.eq("부상률")); + BooleanExpression injuryTypeCondition = aio.optionType.eq("부상률"); + BooleanExpression injuryValueCondition = null; + if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null && opt.maxInjuryRateSearch().maxInjuryRateTo() != null) { - injuryBuilder.and( + injuryValueCondition = castOptionValueToInt(aio) .between( opt.maxInjuryRateSearch().maxInjuryRateFrom(), - opt.maxInjuryRateSearch().maxInjuryRateTo())); + opt.maxInjuryRateSearch().maxInjuryRateTo()); } else if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null) { - injuryBuilder.and( + injuryValueCondition = castOptionValueToInt(aio) - .goe(opt.maxInjuryRateSearch().maxInjuryRateFrom())); + .goe(opt.maxInjuryRateSearch().maxInjuryRateFrom()); } else if (opt.maxInjuryRateSearch().maxInjuryRateTo() != null) { - injuryBuilder.and( - castOptionValueToInt(aio).loe(opt.maxInjuryRateSearch().maxInjuryRateTo())); + injuryValueCondition = + castOptionValueToInt(aio).loe(opt.maxInjuryRateSearch().maxInjuryRateTo()); } - if (injuryBuilder.hasValue()) { - builder.or(injuryBuilder); + + if (injuryValueCondition != null) { + // 명시적으로 괄호를 추가 + BooleanExpression combined = injuryTypeCondition.and(injuryValueCondition); + builder.or(Expressions.booleanTemplate("({0})", combined)); } } @@ -308,9 +323,9 @@ private BooleanBuilder buildItemOptionConditions( // 16. WearingRestrictions (착용 제한) - 문자열 비교 if (opt.wearingRestrictionsSearch() != null && opt.wearingRestrictionsSearch().wearingRestrictions() != null) { - builder.or( - aio.optionValue.contains( - opt.wearingRestrictionsSearch().wearingRestrictions())); + BooleanExpression condition = + aio.optionValue.contains(opt.wearingRestrictionsSearch().wearingRestrictions()); + builder.or(Expressions.booleanTemplate("({0})", condition)); } return builder; @@ -319,26 +334,31 @@ private BooleanBuilder buildItemOptionConditions( /** * 옵션 조건 빌드 헬퍼 (option_type + 숫자 비교 + UP/DOWN) * + *

명시적으로 괄호를 추가하여 가독성과 명확성을 높입니다. + * * @param aio QueryDSL Q타입 * @param optionType DB의 option_type 값 (예: "밸런스", "크리티컬") * @param value 비교할 숫자 값 * @param standard UP(이상) / DOWN(이하) / null(같음) */ - private BooleanBuilder buildOptionCondition( + private BooleanExpression buildOptionCondition( QAuctionItemOption aio, String optionType, Integer value, String standard) { - BooleanBuilder condition = new BooleanBuilder(aio.optionType.eq(optionType)); + BooleanExpression optionTypeCondition = aio.optionType.eq(optionType); NumberTemplate numValue = castOptionValueToInt(aio); + BooleanExpression valueCondition; if ("UP".equals(standard)) { - condition.and(numValue.goe(value)); // 이상 (>=) + valueCondition = numValue.goe(value); // 이상 (>=) } else if ("DOWN".equals(standard)) { - condition.and(numValue.loe(value)); // 이하 (<=) + valueCondition = numValue.loe(value); // 이하 (<=) } else { - condition.and(numValue.eq(value)); // 같음 + valueCondition = numValue.eq(value); // 같음 } - return condition; + // 명시적으로 괄호를 추가하여 쿼리의 가독성을 높입니다 + BooleanExpression combined = optionTypeCondition.and(valueCondition); + return Expressions.booleanTemplate("({0})", combined); } /** From c5cff34b76b5f1c7a8642e7ea9dc6f44c93bae6e Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Mon, 27 Oct 2025 23:02:00 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20auction=20history=20search=20?= =?UTF-8?q?=EC=8B=9C=20=EA=B2=80=EC=83=89=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=20=EB=A7=8C=EC=A1=B1=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B1=B4=EB=A7=8C=20=EC=B0=BE=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryQueryDslRepository.java | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index b97ebea..656f6fc 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -23,6 +23,9 @@ class AuctionHistoryQueryDslRepository { private final JPAQueryFactory queryFactory; + /** 옵션 조건 빌드 결과 (조건 BooleanBuilder + 추가된 조건 개수) */ + record OptionConditionResult(BooleanBuilder builder, int count) {} + /** * 경매 거래내역 검색 (옵션 조건 포함) * @@ -40,17 +43,19 @@ public Page search(AuctionHistorySearchRequest condition, Pageab if (condition.itemOptionSearchRequest() != null) { // 서브쿼리용 별도 QAuctionItemOption 인스턴스 QAuctionItemOption subOption = new QAuctionItemOption("subOption"); - BooleanBuilder optionBuilder = + OptionConditionResult optionResult = buildItemOptionConditions(condition.itemOptionSearchRequest(), subOption); // 옵션 조건이 실제로 있는 경우에만 서브쿼리 추가 - if (optionBuilder.hasValue()) { + if (optionResult.builder().hasValue() && optionResult.count() > 0) { // 서브쿼리: 옵션 조건을 만족하는 auction_history_id 찾기 + // GROUP BY + HAVING COUNT로 모든 조건을 만족하는 거래내역만 필터링 var subQuery = JPAExpressions.select(subOption.auctionHistory.auctionBuyId) .from(subOption) - .where(optionBuilder) - .distinct(); + .where(optionResult.builder()) + .groupBy(subOption.auctionHistory.auctionBuyId) + .having(subOption.count().eq((long) optionResult.count())); // 메인 쿼리에 서브쿼리 결과 적용 historyBuilder.and(ah.auctionBuyId.in(subQuery)); @@ -116,10 +121,14 @@ private BooleanBuilder buildHistoryPredicate( * 옵션 검색 조건 빌드 (서브쿼리용) * *

주의: 이 메서드는 서브쿼리에서만 사용됩니다. 반환된 BooleanBuilder는 메인 JOIN의 WHERE에 직접 사용하면 안 됩니다! + * + * @return OptionConditionResult - 조건 BooleanBuilder와 추가된 조건 개수 */ - private BooleanBuilder buildItemOptionConditions( + private OptionConditionResult buildItemOptionConditions( ItemOptionSearchRequest opt, QAuctionItemOption aio) { BooleanBuilder builder = new BooleanBuilder(); + int conditionCount = 0; + boolean ergConditionAdded = false; // 에르그 조건 추가 여부 (레벨/랭크 통합) // 1. Balance (밸런스) if (opt.balanceSearch() != null && opt.balanceSearch().balance() != null) { @@ -129,6 +138,7 @@ private BooleanBuilder buildItemOptionConditions( "밸런스", opt.balanceSearch().balance(), opt.balanceSearch().balanceStandard())); + conditionCount++; } // 2. Critical (크리티컬) @@ -139,6 +149,7 @@ private BooleanBuilder buildItemOptionConditions( "크리티컬", opt.criticalSearch().critical(), opt.criticalSearch().criticalStandard())); + conditionCount++; } // 3. Defense (방어력) @@ -149,6 +160,7 @@ private BooleanBuilder buildItemOptionConditions( "방어력", opt.defenseSearch().defense(), opt.defenseSearch().defenseStandard())); + conditionCount++; } // 4. Erg (에르그) - 범위 검색 @@ -170,6 +182,11 @@ private BooleanBuilder buildItemOptionConditions( // 명시적으로 괄호를 추가 BooleanExpression combined = ergTypeCondition.and(ergValueCondition); builder.or(Expressions.booleanTemplate("({0})", combined)); + // 에르그는 레벨/랭크 통합하여 1개로 카운트 + if (!ergConditionAdded) { + conditionCount++; + ergConditionAdded = true; + } } } @@ -178,6 +195,11 @@ private BooleanBuilder buildItemOptionConditions( BooleanExpression combined = aio.optionType.eq("에르그").and(aio.optionValue.eq(opt.ergRankSearch().ergRank())); builder.or(Expressions.booleanTemplate("({0})", combined)); + // 에르그는 레벨/랭크 통합하여 1개로 카운트 + if (!ergConditionAdded) { + conditionCount++; + ergConditionAdded = true; + } } // 6. MagicDefense (마법 방어력) @@ -188,6 +210,7 @@ private BooleanBuilder buildItemOptionConditions( "마법 방어력", opt.magicDefenseSearch().magicDefense(), opt.magicDefenseSearch().magicDefenseStandard())); + conditionCount++; } // 7. MagicProtect (마법 보호) @@ -198,6 +221,7 @@ private BooleanBuilder buildItemOptionConditions( "마법 보호", opt.magicProtectSearch().magicProtect(), opt.magicProtectSearch().magicProtectStandard())); + conditionCount++; } // 8. MaxAttack (공격) - 범위 검색 @@ -224,6 +248,7 @@ private BooleanBuilder buildItemOptionConditions( // 명시적으로 괄호를 추가 BooleanExpression combined = attackTypeCondition.and(attackValueCondition); builder.or(Expressions.booleanTemplate("({0})", combined)); + conditionCount++; } } @@ -236,6 +261,7 @@ private BooleanBuilder buildItemOptionConditions( "내구력", opt.maximumDurabilitySearch().maximumDurability(), opt.maximumDurabilitySearch().maximumDurabilityStandard())); + conditionCount++; } // 10. MaxInjuryRate (부상률) - 범위 검색 @@ -263,6 +289,7 @@ private BooleanBuilder buildItemOptionConditions( // 명시적으로 괄호를 추가 BooleanExpression combined = injuryTypeCondition.and(injuryValueCondition); builder.or(Expressions.booleanTemplate("({0})", combined)); + conditionCount++; } } @@ -274,6 +301,7 @@ private BooleanBuilder buildItemOptionConditions( "숙련", opt.proficiencySearch().proficiency(), opt.proficiencySearch().proficiencyStandard())); + conditionCount++; } // 12. Protect (보호) @@ -284,6 +312,7 @@ private BooleanBuilder buildItemOptionConditions( "보호", opt.protectSearch().protect(), opt.protectSearch().protectStandard())); + conditionCount++; } // 13. RemainingTransactionCount (남은 거래 횟수) @@ -296,6 +325,7 @@ private BooleanBuilder buildItemOptionConditions( opt.remainingTransactionCountSearch().remainingTransactionCount(), opt.remainingTransactionCountSearch() .remainingTransactionCountStandard())); + conditionCount++; } // 14. RemainingUnsealCount (남은 전용 해제 가능 횟수) @@ -307,6 +337,7 @@ private BooleanBuilder buildItemOptionConditions( "남은 전용 해제 가능 횟수", opt.remainingUnsealCountSearch().remainingUnsealCount(), opt.remainingUnsealCountSearch().remainingUnsealCountStandard())); + conditionCount++; } // 15. RemainingUseCount (남은 사용 횟수) @@ -318,6 +349,7 @@ private BooleanBuilder buildItemOptionConditions( "남은 사용 횟수", opt.remainingUseCountSearch().remainingUseCount(), opt.remainingUseCountSearch().remainingUseCountStandard())); + conditionCount++; } // 16. WearingRestrictions (착용 제한) - 문자열 비교 @@ -326,9 +358,10 @@ private BooleanBuilder buildItemOptionConditions( BooleanExpression condition = aio.optionValue.contains(opt.wearingRestrictionsSearch().wearingRestrictions()); builder.or(Expressions.booleanTemplate("({0})", condition)); + conditionCount++; } - return builder; + return new OptionConditionResult(builder, conditionCount); } /** From 64d9e43ade071fa40cc28a8737ee5858f92e1c39 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Wed, 29 Oct 2025 23:20:40 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EA=B2=BD=EB=A7=A4=EC=9E=A5=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=20=EB=82=B4=EC=97=AD=20QueryDSL=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryQueryDslRepository.java | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index 656f6fc..abb4489 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -26,12 +26,7 @@ class AuctionHistoryQueryDslRepository { /** 옵션 조건 빌드 결과 (조건 BooleanBuilder + 추가된 조건 개수) */ record OptionConditionResult(BooleanBuilder builder, int count) {} - /** - * 경매 거래내역 검색 (옵션 조건 포함) - * - *

검색 흐름: 1. 옵션 조건을 만족하는 거래내역 ID를 서브쿼리로 찾기 2. 거래내역 조건으로 필터링 3. 해당 거래내역의 모든 옵션을 함께 조회 (LEFT - * JOIN) - */ + /** 경매 거래내역 검색 (옵션 조건 포함) */ public Page search(AuctionHistorySearchRequest condition, Pageable pageable) { QAuctionHistory ah = QAuctionHistory.auctionHistory; QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; @@ -117,13 +112,7 @@ private BooleanBuilder buildHistoryPredicate( return builder; } - /** - * 옵션 검색 조건 빌드 (서브쿼리용) - * - *

주의: 이 메서드는 서브쿼리에서만 사용됩니다. 반환된 BooleanBuilder는 메인 JOIN의 WHERE에 직접 사용하면 안 됩니다! - * - * @return OptionConditionResult - 조건 BooleanBuilder와 추가된 조건 개수 - */ + /** 옵션 검색 조건 빌드 (서브쿼리용) */ private OptionConditionResult buildItemOptionConditions( ItemOptionSearchRequest opt, QAuctionItemOption aio) { BooleanBuilder builder = new BooleanBuilder(); @@ -364,16 +353,7 @@ private OptionConditionResult buildItemOptionConditions( return new OptionConditionResult(builder, conditionCount); } - /** - * 옵션 조건 빌드 헬퍼 (option_type + 숫자 비교 + UP/DOWN) - * - *

명시적으로 괄호를 추가하여 가독성과 명확성을 높입니다. - * - * @param aio QueryDSL Q타입 - * @param optionType DB의 option_type 값 (예: "밸런스", "크리티컬") - * @param value 비교할 숫자 값 - * @param standard UP(이상) / DOWN(이하) / null(같음) - */ + /** 옵션 조건 빌드 헬퍼 (option_type + 숫자 비교 + UP/DOWN) */ private BooleanExpression buildOptionCondition( QAuctionItemOption aio, String optionType, Integer value, String standard) { BooleanExpression optionTypeCondition = aio.optionType.eq(optionType); @@ -394,11 +374,7 @@ private BooleanExpression buildOptionCondition( return Expressions.booleanTemplate("({0})", combined); } - /** - * option_value2 또는 option_value를 Integer로 변환하는 NumberTemplate - * - *

COALESCE를 사용하여 null 처리 후 숫자로 비교 (MySQL은 자동 타입 변환 수행) - */ + /** option_value2 또는 option_value를 Integer로 변환하는 NumberTemplate */ private NumberTemplate castOptionValueToInt(QAuctionItemOption aio) { return Expressions.numberTemplate( Integer.class, "COALESCE({0}, {1}, 0)", aio.optionValue2, aio.optionValue); From 4148820a1051a7d8e5110df9f95b6dde8b170125 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Mon, 3 Nov 2025 23:16:54 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20mysql=EC=9D=84=20=EC=9C=84=ED=95=9C?= =?UTF-8?q?=20docker-compose=20=ED=8C=8C=EC=9D=BC=20=EC=9B=90=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose-mysql.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docker-compose-mysql.yaml diff --git a/docker-compose-mysql.yaml b/docker-compose-mysql.yaml new file mode 100644 index 0000000..699ff39 --- /dev/null +++ b/docker-compose-mysql.yaml @@ -0,0 +1,34 @@ +# local mysql server + +services: + mysql: + image: mysql:8.0 + container_name: open-api-batch-mysql + restart: unless-stopped + ports: + - "${DB_PORT}:3306" + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_SCHEMA} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + LANG: C.UTF_8 + TZ: Asia/Seoul + volumes: + - mysql_data:/var/lib/mysql + networks: + - my-network + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_0900_ai_ci + - --skip-character-set-client-handshake + - --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION + - --default-time-zone=+09:00 # MySQL 레벨 타임존 설정 + - --explicit_defaults_for_timestamp=1 # TIMESTAMP 기본값 명시 허용 + +volumes: + mysql_data: + +networks: + my-network: + driver: bridge \ No newline at end of file From be536f4744987256e2fb2ecf0a82b25d0f73465f Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Thu, 6 Nov 2025 21:55:29 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20auction=20history=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=8B=9C=20=EA=B1=B0=EB=9E=98=20=EC=9D=BC=EC=9E=90?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryQueryDslRepository.java | 25 ++++++++++++++++--- .../request/AuctionHistorySearchRequest.java | 3 +-- .../dto/request/DateAuctionBuyRequest.java | 8 ++++++ .../rest/dto/request/PriceSearchRequest.java | 2 +- .../service/AuctionHistoryServiceTest.java | 2 +- 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DateAuctionBuyRequest.java diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index abb4489..ef67933 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -6,6 +6,9 @@ import com.querydsl.core.types.dsl.NumberTemplate; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -103,10 +106,24 @@ private BooleanBuilder buildHistoryPredicate( } } - // 거래 일자 조건 - if (c.date_auction_buy() != null && !c.date_auction_buy().isBlank()) { - // 날짜 파싱 및 조건 추가 로직 - // TODO: 날짜 범위 검색 구현 + // 거래 일자 조건 (String 'yyyy-MM-dd' → Instant 변환) + if (c.dateAuctionBuyRequest() != null) { + DateAuctionBuyRequest date = c.dateAuctionBuyRequest(); + if (date.dateAuctionBuyFrom() != null && !date.dateAuctionBuyFrom().isBlank()) { + Instant fromInstant = + LocalDate.parse(date.dateAuctionBuyFrom()) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant(); + builder.and(ah.dateAuctionBuy.goe(fromInstant)); + } + if (date.dateAuctionBuyTo() != null && !date.dateAuctionBuyTo().isBlank()) { + Instant toInstant = + LocalDate.parse(date.dateAuctionBuyTo()) + .plusDays(1) // 다음 날 00:00:00으로 설정하여 해당 날짜 전체 포함 + .atStartOfDay(ZoneId.systemDefault()) + .toInstant(); + builder.and(ah.dateAuctionBuy.lt(toInstant)); + } } return builder; diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java index 3408870..f68928f 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java @@ -8,7 +8,6 @@ public record AuctionHistorySearchRequest( @Schema(description = "아이템 이름 (like 검색)", example = "페러시우스 타이탄 블레이드") String itemName, @Schema(description = "대분류 카테고리", example = "근거리 장비") String itemTopCategory, @Schema(description = "소분류 카테고리", example = "검") String itemSubCategory, - @Schema(description = "거래 가격", example = "10000000") String auction_price_per_unit, - @Schema(description = "거래 일자", example = "2025-10-20") String date_auction_buy, + @Schema(description = "거래 일자 조건") DateAuctionBuyRequest dateAuctionBuyRequest, @Schema(description = "가격 검색 조건") PriceSearchRequest priceSearchRequest, @Schema(description = "아이템 옵션 검색 조건") ItemOptionSearchRequest itemOptionSearchRequest) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DateAuctionBuyRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DateAuctionBuyRequest.java new file mode 100644 index 0000000..715a807 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DateAuctionBuyRequest.java @@ -0,0 +1,8 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "거래 일자 조건") +public record DateAuctionBuyRequest( + @Schema(description = "거래 일자 시작 범위", example = "2025-01-01") String dateAuctionBuyFrom, + @Schema(description = "거래 일자 종료 범위", example = "2025-12-31") String dateAuctionBuyTo) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/PriceSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/PriceSearchRequest.java index bef7d92..7cb007d 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/PriceSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/PriceSearchRequest.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "가격 검색 조건 (범위)") +@Schema(description = "가격 검색 조건") public record PriceSearchRequest( @Schema(description = "가격 최소값", example = "0") Long priceFrom, @Schema(description = "가격 최대값", example = "9999999999") Long priceTo) {} diff --git a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java index c9ad8af..ed2fea8 100644 --- a/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/application/service/AuctionHistoryServiceTest.java @@ -42,7 +42,7 @@ class AuctionHistoryServiceTest { void search_should_return_paged_response() { // given AuctionHistorySearchRequest searchRequest = - new AuctionHistorySearchRequest(null, null, null, null, null, null, null); + new AuctionHistorySearchRequest(null, null, null, null, null, null); PageRequestDto pageRequestDto = mock(PageRequestDto.class); Pageable pageable = PageRequest.of(0, 10); when(pageRequestDto.toPageable()).thenReturn(pageable); From fe5a77707dff8a70a81e58cf61b1cdebbe4bb8b6 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Thu, 6 Nov 2025 23:36:15 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20request=20dto=EC=9D=98=20sortBy?= =?UTF-8?q?,=20sortField=20Enum=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryQueryDslRepository.java | 15 +++-- .../rest/dto/enums/SearchStandard.java | 54 ++++++++++++++++++ .../rest/dto/enums/SortDirection.java | 48 ++++++++++++++++ .../dto/request/BalanceSearchRequest.java | 4 +- .../dto/request/CriticalSearchRequest.java | 5 +- .../dto/request/DefenseSearchRequest.java | 5 +- .../request/MagicDefenseSearchRequest.java | 5 +- .../request/MagicProtectSearchRequest.java | 5 +- .../MaximumDurabilitySearchRequest.java | 5 +- .../dto/request/ProficiencySearchRequest.java | 5 +- .../dto/request/ProtectSearchRequest.java | 5 +- ...emainingTransactionCountSearchRequest.java | 5 +- .../RemainingUnsealCountSearchRequest.java | 5 +- .../RemainingUseCountSearchRequest.java | 5 +- .../eternity/common/enums/SortDirection.java | 57 +++++++++++++++++++ .../the/eternity/common/enums/SortField.java | 42 ++++++++++++++ .../common/request/PageRequestDto.java | 27 +++++---- 17 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/enums/SearchStandard.java create mode 100644 src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/enums/SortDirection.java create mode 100644 src/main/java/until/the/eternity/common/enums/SortDirection.java create mode 100644 src/main/java/until/the/eternity/common/enums/SortField.java diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index ef67933..30fb72a 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -17,6 +17,7 @@ import org.springframework.stereotype.Component; import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.domain.entity.QAuctionHistory; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; import until.the.eternity.auctionhistory.interfaces.rest.dto.request.*; import until.the.eternity.auctionitemoption.domain.entity.QAuctionItemOption; @@ -119,7 +120,7 @@ private BooleanBuilder buildHistoryPredicate( if (date.dateAuctionBuyTo() != null && !date.dateAuctionBuyTo().isBlank()) { Instant toInstant = LocalDate.parse(date.dateAuctionBuyTo()) - .plusDays(1) // 다음 날 00:00:00으로 설정하여 해당 날짜 전체 포함 + .plusDays(1) .atStartOfDay(ZoneId.systemDefault()) .toInstant(); builder.and(ah.dateAuctionBuy.lt(toInstant)); @@ -370,20 +371,22 @@ private OptionConditionResult buildItemOptionConditions( return new OptionConditionResult(builder, conditionCount); } - /** 옵션 조건 빌드 헬퍼 (option_type + 숫자 비교 + UP/DOWN) */ + /** 옵션 조건 빌드 헬퍼 (option_type + 숫자 비교 + SearchStandard) */ private BooleanExpression buildOptionCondition( - QAuctionItemOption aio, String optionType, Integer value, String standard) { + QAuctionItemOption aio, String optionType, Integer value, SearchStandard standard) { BooleanExpression optionTypeCondition = aio.optionType.eq(optionType); NumberTemplate numValue = castOptionValueToInt(aio); BooleanExpression valueCondition; - if ("UP".equals(standard)) { + if (standard == null || standard.isEqual()) { + valueCondition = numValue.eq(value); // 같음 + } else if (standard.isUp()) { valueCondition = numValue.goe(value); // 이상 (>=) - } else if ("DOWN".equals(standard)) { + } else if (standard.isDown()) { valueCondition = numValue.loe(value); // 이하 (<=) } else { - valueCondition = numValue.eq(value); // 같음 + valueCondition = numValue.eq(value); // 기본값: 같음 } // 명시적으로 괄호를 추가하여 쿼리의 가독성을 높입니다 diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/enums/SearchStandard.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/enums/SearchStandard.java new file mode 100644 index 0000000..ef00567 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/enums/SearchStandard.java @@ -0,0 +1,54 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Arrays; + +/** 검색 기준 (이상/이하/같음) */ +@Schema(description = "검색 기준", enumAsRef = true) +public enum SearchStandard { + UP("UP", "이상"), + DOWN("DOWN", "이하"), + EQUAL("EQUAL", "같음"); + + private final String code; + private final String description; + + SearchStandard(String code, String description) { + this.code = code; + this.description = description; + } + + @JsonValue + public String getCode() { + return code; + } + + public String getDescription() { + return description; + } + + @JsonCreator + public static SearchStandard from(String code) { + return Arrays.stream(SearchStandard.values()) + .filter(standard -> standard.code.equalsIgnoreCase(code)) + .findFirst() + .orElse(null); + } + + /** UP 조건인지 확인 */ + public boolean isUp() { + return this == UP; + } + + /** DOWN 조건인지 확인 */ + public boolean isDown() { + return this == DOWN; + } + + /** EQUAL 조건인지 확인 */ + public boolean isEqual() { + return this == EQUAL; + } +} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/enums/SortDirection.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/enums/SortDirection.java new file mode 100644 index 0000000..8266606 --- /dev/null +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/enums/SortDirection.java @@ -0,0 +1,48 @@ +package until.the.eternity.auctionhistory.interfaces.rest.dto.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Arrays; + +/** 정렬 방향 (오름차순/내림차순) */ +@Schema(description = "정렬 방향", enumAsRef = true) +public enum SortDirection { + ASC("ASC", "오름차순"), + DESC("DESC", "내림차순"); + + private final String code; + private final String description; + + SortDirection(String code, String description) { + this.code = code; + this.description = description; + } + + @JsonValue + public String getCode() { + return code; + } + + public String getDescription() { + return description; + } + + @JsonCreator + public static SortDirection from(String code) { + return Arrays.stream(SortDirection.values()) + .filter(direction -> direction.code.equalsIgnoreCase(code)) + .findFirst() + .orElse(DESC); // 기본값: 내림차순 + } + + /** 오름차순인지 확인 */ + public boolean isAscending() { + return this == ASC; + } + + /** 내림차순인지 확인 */ + public boolean isDescending() { + return this == DESC; + } +} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/BalanceSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/BalanceSearchRequest.java index 238607b..17b96a6 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/BalanceSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/BalanceSearchRequest.java @@ -1,8 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "밸런스 검색 조건") public record BalanceSearchRequest( @Schema(description = "밸런스 값", example = "10") Integer balance, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") String balanceStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "UP") + SearchStandard balanceStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/CriticalSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/CriticalSearchRequest.java index b01c46c..9ce0400 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/CriticalSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/CriticalSearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "크리티컬 검색 조건") public record CriticalSearchRequest( @Schema(description = "크리티컬 값", example = "30") Integer critical, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String criticalStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "UP") + SearchStandard criticalStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DefenseSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DefenseSearchRequest.java index 61c2e11..ff23cbb 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DefenseSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/DefenseSearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "방어력 검색 조건") public record DefenseSearchRequest( @Schema(description = "방어력 값", example = "5") Integer defense, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") - String defenseStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "DOWN") + SearchStandard defenseStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicDefenseSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicDefenseSearchRequest.java index a1bd2e2..24f6c5d 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicDefenseSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicDefenseSearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "마법 방어력 검색 조건") public record MagicDefenseSearchRequest( @Schema(description = "마법 방어력 값", example = "3") Integer magicDefense, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String magicDefenseStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "UP") + SearchStandard magicDefenseStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicProtectSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicProtectSearchRequest.java index 17bfbe3..db85322 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicProtectSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MagicProtectSearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "마법 보호 검색 조건") public record MagicProtectSearchRequest( @Schema(description = "마법 보호 값", example = "2") Integer magicProtect, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String magicProtectStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "UP") + SearchStandard magicProtectStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaximumDurabilitySearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaximumDurabilitySearchRequest.java index 62a0e48..1ab5c1a 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaximumDurabilitySearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/MaximumDurabilitySearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "최대 내구력 검색 조건") public record MaximumDurabilitySearchRequest( @Schema(description = "최대 내구력 값", example = "20") Integer maximumDurability, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String maximumDurabilityStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "UP") + SearchStandard maximumDurabilityStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProficiencySearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProficiencySearchRequest.java index c504e1c..78f16ab 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProficiencySearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProficiencySearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "숙련도 검색 조건") public record ProficiencySearchRequest( @Schema(description = "숙련도 값", example = "15") Integer proficiency, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String proficiencyStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "UP") + SearchStandard proficiencyStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProtectSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProtectSearchRequest.java index 8e4e67c..f3bcd36 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProtectSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ProtectSearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "보호 검색 조건") public record ProtectSearchRequest( @Schema(description = "보호 값", example = "1") Integer protect, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") - String protectStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "DOWN") + SearchStandard protectStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingTransactionCountSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingTransactionCountSearchRequest.java index c02874d..1539799 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingTransactionCountSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingTransactionCountSearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "남은 거래 횟수 검색 조건") public record RemainingTransactionCountSearchRequest( @Schema(description = "남은 거래 횟수", example = "5") Integer remainingTransactionCount, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String remainingTransactionCountStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "UP") + SearchStandard remainingTransactionCountStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUnsealCountSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUnsealCountSearchRequest.java index c504c1f..d2ece9e 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUnsealCountSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUnsealCountSearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "남은 전용 해제 가능 횟수 검색 조건") public record RemainingUnsealCountSearchRequest( @Schema(description = "남은 전용 해제 가능 횟수", example = "3") Integer remainingUnsealCount, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String remainingUnsealCountStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "UP") + SearchStandard remainingUnsealCountStandard) {} diff --git a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUseCountSearchRequest.java b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUseCountSearchRequest.java index 7ef7a2e..c4fd7dd 100644 --- a/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUseCountSearchRequest.java +++ b/src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/RemainingUseCountSearchRequest.java @@ -1,9 +1,10 @@ package until.the.eternity.auctionhistory.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; @Schema(description = "남은 사용 횟수 검색 조건") public record RemainingUseCountSearchRequest( @Schema(description = "남은 사용 횟수", example = "10") Integer remainingUseCount, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") - String remainingUseCountStandard) {} + @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하, EQUAL: 같음)", example = "DOWN") + SearchStandard remainingUseCountStandard) {} diff --git a/src/main/java/until/the/eternity/common/enums/SortDirection.java b/src/main/java/until/the/eternity/common/enums/SortDirection.java new file mode 100644 index 0000000..5e688da --- /dev/null +++ b/src/main/java/until/the/eternity/common/enums/SortDirection.java @@ -0,0 +1,57 @@ +package until.the.eternity.common.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Arrays; +import org.springframework.data.domain.Sort; + +/** 정렬 방향 (오름차순/내림차순) */ +@Schema(description = "정렬 방향", enumAsRef = true) +public enum SortDirection { + ASC("ASC", "오름차순"), + DESC("DESC", "내림차순"); + + private final String code; + private final String description; + + SortDirection(String code, String description) { + this.code = code; + this.description = description; + } + + @JsonValue + public String getCode() { + return code; + } + + public String getDescription() { + return description; + } + + @JsonCreator + public static SortDirection from(String code) { + if (code == null) { + return DESC; // 기본값: 내림차순 + } + return Arrays.stream(SortDirection.values()) + .filter(direction -> direction.code.equalsIgnoreCase(code)) + .findFirst() + .orElse(DESC); + } + + /** 오름차순인지 확인 */ + public boolean isAscending() { + return this == ASC; + } + + /** 내림차순인지 확인 */ + public boolean isDescending() { + return this == DESC; + } + + /** Spring Data Sort.Direction으로 변환 */ + public Sort.Direction toSpringDirection() { + return this == ASC ? Sort.Direction.ASC : Sort.Direction.DESC; + } +} diff --git a/src/main/java/until/the/eternity/common/enums/SortField.java b/src/main/java/until/the/eternity/common/enums/SortField.java new file mode 100644 index 0000000..704d79d --- /dev/null +++ b/src/main/java/until/the/eternity/common/enums/SortField.java @@ -0,0 +1,42 @@ +package until.the.eternity.common.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Arrays; + +/** 정렬 필드 */ +@Schema(description = "정렬 필드", enumAsRef = true) +public enum SortField { + DATE_AUCTION_BUY("dateAuctionBuy", "거래 일자"), + AUCTION_PRICE_PER_UNIT("auctionPricePerUnit", "개당 가격"), + ITEM_NAME("itemName", "아이템 이름"); + + private final String fieldName; + private final String description; + + SortField(String fieldName, String description) { + this.fieldName = fieldName; + this.description = description; + } + + @JsonValue + public String getFieldName() { + return fieldName; + } + + public String getDescription() { + return description; + } + + @JsonCreator + public static SortField from(String fieldName) { + if (fieldName == null) { + return DATE_AUCTION_BUY; // 기본값: 거래 일자 + } + return Arrays.stream(SortField.values()) + .filter(field -> field.fieldName.equalsIgnoreCase(fieldName)) + .findFirst() + .orElse(DATE_AUCTION_BUY); + } +} diff --git a/src/main/java/until/the/eternity/common/request/PageRequestDto.java b/src/main/java/until/the/eternity/common/request/PageRequestDto.java index 95296ad..a2fbb81 100644 --- a/src/main/java/until/the/eternity/common/request/PageRequestDto.java +++ b/src/main/java/until/the/eternity/common/request/PageRequestDto.java @@ -6,31 +6,34 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import until.the.eternity.common.enums.SortDirection; +import until.the.eternity.common.enums.SortField; @Schema(description = "페이지 요청 파라미터") public record PageRequestDto( @Schema(description = "요청할 페이지 번호 (1부터 시작)", example = "1") @Min(1) Integer page, @Schema(description = "페이지당 항목 수", example = "20") @Min(1) @Max(100) Integer size, - @Schema(description = "정렬 필드 (예: createdAt)", example = "createdAt") String sortBy, - @Schema(description = "정렬 방향 (asc or desc)", example = "desc") String direction) { + @Schema( + description = "정렬 필드 (dateAuctionBuy, auctionPricePerUnit, itemName)", + example = "dateAuctionBuy") + SortField sortBy, + @Schema(description = "정렬 방향 (ASC, DESC)", example = "DESC") SortDirection direction) { private static final int DEFAULT_PAGE = 1; private static final int DEFAULT_SIZE = 20; - private static final String DEFAULT_SORT_BY = "id"; - private static final String DEFAULT_DIRECTION = "desc"; + private static final SortField DEFAULT_SORT_BY = SortField.DATE_AUCTION_BUY; + private static final SortDirection DEFAULT_DIRECTION = SortDirection.DESC; public Pageable toPageable() { int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE; int resolvedSize = this.size != null ? this.size : DEFAULT_SIZE; - String resolvedSortBy = this.sortBy != null ? this.sortBy : DEFAULT_SORT_BY; - Sort.Direction resolvedDirection = parseDirection(this.direction); + SortField resolvedSortBy = this.sortBy != null ? this.sortBy : DEFAULT_SORT_BY; + SortDirection resolvedDirection = + this.direction != null ? this.direction : DEFAULT_DIRECTION; return PageRequest.of( - resolvedPage, resolvedSize, Sort.by(resolvedDirection, resolvedSortBy)); - } - - private Sort.Direction parseDirection(String dir) { - if ("asc".equalsIgnoreCase(dir)) return Sort.Direction.ASC; - return Sort.Direction.DESC; + resolvedPage, + resolvedSize, + Sort.by(resolvedDirection.toSpringDirection(), resolvedSortBy.getFieldName())); } } From 25935028c9ca430820ee759a0f2dffe4e663f2e6 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Thu, 6 Nov 2025 23:54:00 +0900 Subject: [PATCH 8/8] fix: optimize imports --- AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md | 1787 ----------------- AUCTION_SEARCH_OPTION_REQUIREMENTS.md | 639 ------ OPTION_DATA_STRUCTURE_ANALYSIS.md | 74 - auction_history_search_param.txt | 37 - docker-compose-mysql.yaml | 34 - .../scheduler/AuctionHistoryScheduler.java | 6 +- .../AuctionHistoryQueryDslRepository.java | 47 +- .../iteminfo/domain/entity/ItemInfoId.java | 6 +- .../service/MetalwareInfoServiceTest.java | 3 +- 9 files changed, 48 insertions(+), 2585 deletions(-) delete mode 100644 AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md delete mode 100644 AUCTION_SEARCH_OPTION_REQUIREMENTS.md delete mode 100644 OPTION_DATA_STRUCTURE_ANALYSIS.md delete mode 100644 auction_history_search_param.txt delete mode 100644 docker-compose-mysql.yaml diff --git a/AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md b/AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md deleted file mode 100644 index 9505021..0000000 --- a/AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md +++ /dev/null @@ -1,1787 +0,0 @@ -# Auction Item Option 검색 기능 구현 계획서 - -> 작성일: 2025-10-20 -> 참조 문서: AUCTION_SEARCH_OPTION_REQUIREMENTS.md -> 프로젝트: open-api-batch-server - ---- - -## 📑 목차 - -1. [Phase 1: DB 스키마 및 Migration](#phase-1-db-스키마-및-migration) -2. [Phase 2: 검색 조건 메타데이터 API 구현](#phase-2-검색-조건-메타데이터-api-구현) -3. [Phase 3: Item Option 검색 Request DTO 구현](#phase-3-item-option-검색-request-dto-구현) -4. [Phase 4: QueryDSL 검색 로직 확장](#phase-4-querydsl-검색-로직-확장) -5. [Phase 5: 테스트 코드 작성](#phase-5-테스트-코드-작성) -6. [Phase 6: 문서화 및 마무리](#phase-6-문서화-및-마무리) - ---- - -## Phase 1: DB 스키마 및 Migration - -### 목표 -- `auction_search_option_metadata` 테이블 생성 -- 초기 데이터 INSERT (17개 검색 옵션) - -### 작업 순서 - -#### 1.1. Flyway 버전 확인 - -**명령어:** -```bash -ls src/main/resources/db/migration/ -``` - -**목적:** 다음 마이그레이션 버전 번호 확인 - -#### 1.2. V 스크립트 작성 (테이블 생성) - -**파일명:** `V{next_version}__create_auction_search_option_metadata.sql` -**예시:** `V7__create_auction_search_option_metadata.sql` (기존 V6까지 있다고 가정) - -**파일 경로:** -``` -src/main/resources/db/migration/V7__create_auction_search_option_metadata.sql -``` - -**파일 내용:** -```sql --- 경매 검색 옵션 메타데이터 테이블 생성 -CREATE TABLE `auction_search_option_metadata` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 ID', - `search_option_name` VARCHAR(100) NOT NULL COMMENT '검색 옵션명 (한글)', - `search_condition_json` JSON NOT NULL COMMENT '검색 조건 (파라미터명:타입)', - `display_order` INT NOT NULL UNIQUE COMMENT '정렬 순서 (고유값)', - `is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성화 여부', - `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시', - PRIMARY KEY (`id`), - INDEX `idx_display_order` (`display_order`), - INDEX `idx_is_active` (`is_active`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='경매 검색 옵션 메타데이터'; -``` - -#### 1.3. R 스크립트 작성 (초기 데이터) - -**파일명:** `R__insert_auction_search_option_metadata.sql` - -**파일 경로:** -``` -src/main/resources/db/migration/R__insert_auction_search_option_metadata.sql -``` - -**파일 내용:** -```sql --- 경매 검색 옵션 메타데이터 초기 데이터 --- Repeatable: 데이터 변경 시 자동 재실행 - --- 기존 데이터 삭제 (Repeatable 스크립트이므로) -DELETE FROM `auction_search_option_metadata`; - --- 1. 밸런스 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '밸런스', - JSON_OBJECT( - 'Balance', JSON_OBJECT('type', 'tinyint', 'required', false), - 'BalanceStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 1, - true -); - --- 2. 크리티컬 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '크리티컬', - JSON_OBJECT( - 'Critical', JSON_OBJECT('type', 'tinyint', 'required', false), - 'CriticalStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 2, - true -); - --- 3. 방어력 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '방어력', - JSON_OBJECT( - 'Defense', JSON_OBJECT('type', 'tinyint', 'required', false), - 'DefenseStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 3, - true -); - --- 4. 에르그 (범위) -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '에르그', - JSON_OBJECT( - 'ErgFrom', JSON_OBJECT('type', 'tinyint', 'required', false), - 'ErgTo', JSON_OBJECT('type', 'tinyint', 'required', false) - ), - 4, - true -); - --- 5. 에르그 등급 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '에르그 등급', - JSON_OBJECT( - 'ErgRank', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('S등급', 'A등급', 'B등급'), 'required', false) - ), - 5, - true -); - --- 6. 마법 방어력 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '마법 방어력', - JSON_OBJECT( - 'MagicDefense', JSON_OBJECT('type', 'tinyint', 'required', false), - 'MagicDefenseStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 6, - true -); - --- 7. 마법 보호 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '마법 보호', - JSON_OBJECT( - 'MagicProtect', JSON_OBJECT('type', 'tinyint', 'required', false), - 'MagicProtectStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 7, - true -); - --- 8. 최대 공격력 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '최대 공격력', - JSON_OBJECT( - 'MaxAttackFrom', JSON_OBJECT('type', 'int', 'required', false), - 'MaxAttackTo', JSON_OBJECT('type', 'int', 'required', false) - ), - 8, - true -); - --- 9. 최대 내구력 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '최대 내구력', - JSON_OBJECT( - 'MaximumDurability', JSON_OBJECT('type', 'tinyint', 'required', false), - 'MaximumDurabilityStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 9, - true -); - --- 10. 최대 부상률 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '최대 부상률', - JSON_OBJECT( - 'MaxInjuryRateFrom', JSON_OBJECT('type', 'tinyint', 'required', false), - 'MaxInjuryRateTo', JSON_OBJECT('type', 'tinyint', 'required', false) - ), - 10, - true -); - --- 11. 숙련도 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '숙련도', - JSON_OBJECT( - 'Proficiency', JSON_OBJECT('type', 'tinyint', 'required', false), - 'ProficiencyStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 11, - true -); - --- 12. 보호 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '보호', - JSON_OBJECT( - 'Protect', JSON_OBJECT('type', 'tinyint', 'required', false), - 'ProtectStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 12, - true -); - --- 13. 남은 거래 횟수 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '남은 거래 횟수', - JSON_OBJECT( - 'RemainingTransactionCount', JSON_OBJECT('type', 'tinyint', 'required', false), - 'RemainingTransactionCountStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 13, - true -); - --- 14. 남은 전용 해제 가능 횟수 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '남은 전용 해제 가능 횟수', - JSON_OBJECT( - 'RemainingUnsealCount', JSON_OBJECT('type', 'tinyint', 'required', false), - 'RemainingUnsealCountStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 14, - true -); - --- 15. 남은 사용 횟수 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '남은 사용 횟수', - JSON_OBJECT( - 'RemainingUseCount', JSON_OBJECT('type', 'tinyint', 'required', false), - 'RemainingUseCountStandard', JSON_OBJECT('type', 'string', 'allowedValues', JSON_ARRAY('UP', 'DOWN'), 'required', false) - ), - 15, - true -); - --- 16. 착용 제한 -INSERT INTO `auction_search_option_metadata` -(`search_option_name`, `search_condition_json`, `display_order`, `is_active`) -VALUES ( - '착용 제한', - JSON_OBJECT( - 'WearingRestrictions', JSON_OBJECT('type', 'string', 'required', false) - ), - 16, - true -); -``` - -#### 1.4. Migration 실행 및 검증 - -**명령어:** -```bash -./gradlew flywayMigrate -``` - -**검증 쿼리:** -```sql --- 테이블 생성 확인 -SHOW CREATE TABLE auction_search_option_metadata; - --- 데이터 확인 -SELECT id, search_option_name, display_order, is_active -FROM auction_search_option_metadata -ORDER BY display_order; - --- JSON 데이터 확인 -SELECT search_option_name, JSON_PRETTY(search_condition_json) -FROM auction_search_option_metadata -WHERE id = 1; -``` - ---- - -## Phase 2: 검색 조건 메타데이터 API 구현 - -### 목표 -- Clean Architecture 패턴으로 검색 조건 메타데이터 조회 API 구현 -- `GET /api/search-option` 엔드포인트 제공 - -### 작업 순서 - -#### 2.1. 디렉토리 구조 생성 - -**생성할 디렉토리:** -``` -src/main/java/until/the/eternity/auctionsearchoption/ -├── application/ -│ └── service/ -├── domain/ -│ ├── entity/ -│ └── repository/ -├── infrastructure/ -│ └── persistence/ -└── interfaces/ - └── rest/ - └── dto/ - └── response/ -``` - -#### 2.2. Entity 작성 - -**파일:** `src/main/java/until/the/eternity/auctionsearchoption/domain/entity/AuctionSearchOptionMetadata.java` - -```java -package until.the.eternity.auctionsearchoption.domain.entity; - -import jakarta.persistence.*; -import java.time.LocalDateTime; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -@Entity -@Table(name = "auction_search_option_metadata") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class AuctionSearchOptionMetadata { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "search_option_name", nullable = false, length = 100) - private String searchOptionName; - - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "search_condition_json", nullable = false, columnDefinition = "JSON") - private String searchConditionJson; - - @Column(name = "display_order", nullable = false, unique = true) - private Integer displayOrder; - - @Column(name = "is_active", nullable = false) - private Boolean isActive = true; - - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } -} -``` - -#### 2.3. Repository Port 인터페이스 작성 - -**파일:** `src/main/java/until/the/eternity/auctionsearchoption/domain/repository/AuctionSearchOptionRepositoryPort.java` - -```java -package until.the.eternity.auctionsearchoption.domain.repository; - -import java.util.List; -import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; - -public interface AuctionSearchOptionRepositoryPort { - - /** - * 모든 활성화된 검색 옵션 조회 (정렬 순서대로) - * - * @return 검색 옵션 메타데이터 리스트 - */ - List findAllActive(); - - /** - * 모든 검색 옵션 조회 (정렬 순서대로) - * - * @return 검색 옵션 메타데이터 리스트 - */ - List findAll(); -} -``` - -#### 2.4. JPA Repository 작성 - -**파일:** `src/main/java/until/the/eternity/auctionsearchoption/infrastructure/persistence/AuctionSearchOptionJpaRepository.java` - -```java -package until.the.eternity.auctionsearchoption.infrastructure.persistence; - -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; - -@Repository -interface AuctionSearchOptionJpaRepository - extends JpaRepository { - - List findByIsActiveTrueOrderByDisplayOrderAsc(); - - List findAllByOrderByDisplayOrderAsc(); -} -``` - -#### 2.5. Repository Port 구현체 작성 - -**파일:** `src/main/java/until/the/eternity/auctionsearchoption/infrastructure/persistence/AuctionSearchOptionRepositoryPortImpl.java` - -```java -package until.the.eternity.auctionsearchoption.infrastructure.persistence; - -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; -import until.the.eternity.auctionsearchoption.domain.repository.AuctionSearchOptionRepositoryPort; - -@Component -@RequiredArgsConstructor -class AuctionSearchOptionRepositoryPortImpl implements AuctionSearchOptionRepositoryPort { - - private final AuctionSearchOptionJpaRepository jpaRepository; - - @Override - public List findAllActive() { - return jpaRepository.findByIsActiveTrueOrderByDisplayOrderAsc(); - } - - @Override - public List findAll() { - return jpaRepository.findAllByOrderByDisplayOrderAsc(); - } -} -``` - -#### 2.6. Response DTO 작성 - -**파일 1:** `src/main/java/until/the/eternity/auctionsearchoption/interfaces/rest/dto/response/FieldMetadata.java` - -```java -package until.the.eternity.auctionsearchoption.interfaces.rest.dto.response; - -import com.fasterxml.jackson.annotation.JsonInclude; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; - -@Schema(description = "검색 조건 필드 메타데이터") -@JsonInclude(JsonInclude.Include.NON_NULL) -public record FieldMetadata( - @Schema(description = "필드 타입", example = "tinyint") String type, - @Schema(description = "필수 여부", example = "false") Boolean required, - @Schema(description = "허용된 값 목록 (Enum인 경우)", example = "[\"UP\", \"DOWN\"]") - List allowedValues) {} -``` - -**파일 2:** `src/main/java/until/the/eternity/auctionsearchoption/interfaces/rest/dto/response/SearchOptionMetadataResponse.java` - -```java -package until.the.eternity.auctionsearchoption.interfaces.rest.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.Map; - -@Schema(description = "검색 옵션 메타데이터 응답") -public record SearchOptionMetadataResponse( - @Schema(description = "검색 옵션 ID", example = "1") Long id, - @Schema(description = "검색 옵션명", example = "밸런스") String searchOptionName, - @Schema(description = "검색 조건 상세") - Map searchCondition, - @Schema(description = "정렬 순서", example = "1") Integer displayOrder) {} -``` - -#### 2.7. Service 작성 - -**파일:** `src/main/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionService.java` - -```java -package until.the.eternity.auctionsearchoption.application.service; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import until.the.eternity.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; -import until.the.eternity.auctionsearchoption.domain.repository.AuctionSearchOptionRepositoryPort; -import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.FieldMetadata; -import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; - -@Slf4j -@Service -@RequiredArgsConstructor -public class AuctionSearchOptionService { - - private final AuctionSearchOptionRepositoryPort repositoryPort; - private final ObjectMapper objectMapper; - - /** - * 모든 활성화된 검색 옵션 조회 - * - * @return 검색 옵션 메타데이터 리스트 - */ - @Transactional(readOnly = true) - public List getAllActiveSearchOptions() { - List entities = repositoryPort.findAllActive(); - - return entities.stream().map(this::toResponse).toList(); - } - - private SearchOptionMetadataResponse toResponse(AuctionSearchOptionMetadata entity) { - Map searchCondition = parseJsonToFieldMetadata(entity.getSearchConditionJson()); - - return new SearchOptionMetadataResponse( - entity.getId(), - entity.getSearchOptionName(), - searchCondition, - entity.getDisplayOrder()); - } - - private Map parseJsonToFieldMetadata(String json) { - try { - TypeReference> typeRef = new TypeReference<>() {}; - return objectMapper.readValue(json, typeRef); - } catch (Exception e) { - log.error("Failed to parse JSON to FieldMetadata: {}", json, e); - throw new IllegalStateException("JSON 파싱 실패", e); - } - } -} -``` - -#### 2.8. Controller 작성 - -**파일:** `src/main/java/until/the/eternity/auctionsearchoption/interfaces/rest/AuctionSearchOptionController.java` - -```java -package until.the.eternity.auctionsearchoption.interfaces.rest; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import until.the.eternity.auctionsearchoption.application.service.AuctionSearchOptionService; -import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; -import until.the.eternity.common.response.ApiResponse; - -@Tag(name = "Auction Search Option", description = "경매 검색 옵션 API") -@RestController -@RequestMapping("/api/search-option") -@RequiredArgsConstructor -public class AuctionSearchOptionController { - - private final AuctionSearchOptionService service; - - @Operation(summary = "검색 옵션 메타데이터 조회", description = "경매 검색에 사용 가능한 모든 옵션 메타데이터를 조회합니다.") - @GetMapping - public ResponseEntity>> getSearchOptions() { - List searchOptions = service.getAllActiveSearchOptions(); - - return ResponseEntity.ok( - ApiResponse.success(searchOptions, "검색 옵션 조회 성공")); - } -} -``` - -#### 2.9. API 테스트 - -**수동 테스트:** -```bash -curl -X GET http://localhost:8080/api/search-option -``` - -**예상 응답:** -```json -{ - "success": true, - "code": "SUCCESS", - "message": "검색 옵션 조회 성공", - "data": [ - { - "id": 1, - "searchOptionName": "밸런스", - "searchCondition": { - "Balance": { - "type": "tinyint", - "required": false - }, - "BalanceStandard": { - "type": "string", - "required": false, - "allowedValues": ["UP", "DOWN"] - } - }, - "displayOrder": 1 - } - ], - "timestamp": "2025-10-20T12:00:00Z" -} -``` - ---- - -## Phase 3: Item Option 검색 Request DTO 구현 - -### 목표 -- 16개 개별 Search Request Record 생성 -- ItemOptionSearchRequest 통합 Record 생성 -- AuctionHistorySearchRequest에 필드 추가 - -### 작업 순서 - -#### 3.1. 개별 Search Request Record 생성 (16개) - -**디렉토리:** -``` -src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ -``` - -**파일 목록 및 내용:** - -**1) BalanceSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "밸런스 검색 조건") -public record BalanceSearchRequest( - @Schema(description = "밸런스 값", example = "10") Integer balance, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String balanceStandard) {} -``` - -**2) CriticalSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "크리티컬 검색 조건") -public record CriticalSearchRequest( - @Schema(description = "크리티컬 값", example = "30") Integer critical, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String criticalStandard) {} -``` - -**3) DefenseSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "방어력 검색 조건") -public record DefenseSearchRequest( - @Schema(description = "방어력 값", example = "5") Integer defense, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") - String defenseStandard) {} -``` - -**4) ErgSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "에르그 검색 조건 (범위)") -public record ErgSearchRequest( - @Schema(description = "에르그 최소값", example = "10") Integer ergFrom, - @Schema(description = "에르그 최대값", example = "50") Integer ergTo) {} -``` - -**5) ErgRankSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "에르그 등급 검색 조건") -public record ErgRankSearchRequest( - @Schema(description = "에르그 등급", example = "S등급", allowableValues = {"S등급", "A등급", "B등급"}) - String ergRank) {} -``` - -**6) MagicDefenseSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "마법 방어력 검색 조건") -public record MagicDefenseSearchRequest( - @Schema(description = "마법 방어력 값", example = "3") Integer magicDefense, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String magicDefenseStandard) {} -``` - -**7) MagicProtectSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "마법 보호 검색 조건") -public record MagicProtectSearchRequest( - @Schema(description = "마법 보호 값", example = "2") Integer magicProtect, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String magicProtectStandard) {} -``` - -**8) MaxAttackSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "최대 공격력 검색 조건 (범위)") -public record MaxAttackSearchRequest( - @Schema(description = "최대 공격력 최소값", example = "50") Integer maxAttackFrom, - @Schema(description = "최대 공격력 최대값", example = "100") Integer maxAttackTo) {} -``` - -**9) MaximumDurabilitySearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "최대 내구력 검색 조건") -public record MaximumDurabilitySearchRequest( - @Schema(description = "최대 내구력 값", example = "20") Integer maximumDurability, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String maximumDurabilityStandard) {} -``` - -**10) MaxInjuryRateSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "최대 부상률 검색 조건 (범위)") -public record MaxInjuryRateSearchRequest( - @Schema(description = "최대 부상률 최소값", example = "10") Integer maxInjuryRateFrom, - @Schema(description = "최대 부상률 최대값", example = "30") Integer maxInjuryRateTo) {} -``` - -**11) ProficiencySearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "숙련도 검색 조건") -public record ProficiencySearchRequest( - @Schema(description = "숙련도 값", example = "15") Integer proficiency, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String proficiencyStandard) {} -``` - -**12) ProtectSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "보호 검색 조건") -public record ProtectSearchRequest( - @Schema(description = "보호 값", example = "1") Integer protect, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") - String protectStandard) {} -``` - -**13) RemainingTransactionCountSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "남은 거래 횟수 검색 조건") -public record RemainingTransactionCountSearchRequest( - @Schema(description = "남은 거래 횟수", example = "5") Integer remainingTransactionCount, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String remainingTransactionCountStandard) {} -``` - -**14) RemainingUnsealCountSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "남은 전용 해제 가능 횟수 검색 조건") -public record RemainingUnsealCountSearchRequest( - @Schema(description = "남은 전용 해제 가능 횟수", example = "3") Integer remainingUnsealCount, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "UP") - String remainingUnsealCountStandard) {} -``` - -**15) RemainingUseCountSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "남은 사용 횟수 검색 조건") -public record RemainingUseCountSearchRequest( - @Schema(description = "남은 사용 횟수", example = "10") Integer remainingUseCount, - @Schema(description = "검색 기준 (UP: 이상, DOWN: 이하)", example = "DOWN") - String remainingUseCountStandard) {} -``` - -**16) WearingRestrictionsSearchRequest.java** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "착용 제한 검색 조건") -public record WearingRestrictionsSearchRequest( - @Schema(description = "착용 제한", example = "자이언트 전용") String wearingRestrictions) {} -``` - -#### 3.2. ItemOptionSearchRequest 통합 Record 생성 - -**파일:** `src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ItemOptionSearchRequest.java` - -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "아이템 옵션 검색 조건 통합") -public record ItemOptionSearchRequest( - @Schema(description = "밸런스 검색 조건") BalanceSearchRequest balanceSearch, - @Schema(description = "크리티컬 검색 조건") CriticalSearchRequest criticalSearch, - @Schema(description = "방어력 검색 조건") DefenseSearchRequest defenseSearch, - @Schema(description = "에르그 검색 조건") ErgSearchRequest ergSearch, - @Schema(description = "에르그 등급 검색 조건") ErgRankSearchRequest ergRankSearch, - @Schema(description = "마법 방어력 검색 조건") - MagicDefenseSearchRequest magicDefenseSearch, - @Schema(description = "마법 보호 검색 조건") MagicProtectSearchRequest magicProtectSearch, - @Schema(description = "최대 공격력 검색 조건") MaxAttackSearchRequest maxAttackSearch, - @Schema(description = "최대 내구력 검색 조건") - MaximumDurabilitySearchRequest maximumDurabilitySearch, - @Schema(description = "최대 부상률 검색 조건") - MaxInjuryRateSearchRequest maxInjuryRateSearch, - @Schema(description = "숙련도 검색 조건") ProficiencySearchRequest proficiencySearch, - @Schema(description = "보호 검색 조건") ProtectSearchRequest protectSearch, - @Schema(description = "남은 거래 횟수 검색 조건") - RemainingTransactionCountSearchRequest remainingTransactionCountSearch, - @Schema(description = "남은 전용 해제 가능 횟수 검색 조건") - RemainingUnsealCountSearchRequest remainingUnsealCountSearch, - @Schema(description = "남은 사용 횟수 검색 조건") - RemainingUseCountSearchRequest remainingUseCountSearch, - @Schema(description = "착용 제한 검색 조건") - WearingRestrictionsSearchRequest wearingRestrictionsSearch) {} -``` - -#### 3.3. AuctionHistorySearchRequest 수정 - -**파일:** `src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/AuctionHistorySearchRequest.java` - -**수정 내용:** -```java -package until.the.eternity.auctionhistory.interfaces.rest.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; - -/** 경매 히스토리 검색 조건 DTO - 페이지네이션 포함 */ -@Schema(description = "경매 거래내역 검색 조건") -public record AuctionHistorySearchRequest( - @Schema(description = "아이템 이름 (like 검색)", example = "페러시우스 타이탄 블레이드") - String itemName, - @Schema(description = "대분류 카테고리", example = "근거리 장비") String itemTopCategory, - @Schema(description = "소분류 카테고리", example = "검") String itemSubCategory, - @Schema(description = "거래 가격", example = "10000000") String auction_price_per_unit, - @Schema(description = "거래 일자", example = "2025-10-20") String date_auction_buy, - @Schema(description = "가격 검색 조건") PriceSearchRequest priceSearchRequest, - @Schema(description = "아이템 옵션 검색 조건") - ItemOptionSearchRequest itemOptionSearchRequest) {} -``` - ---- - -## Phase 4: QueryDSL 검색 로직 확장 - -### 목표 -- AuctionHistoryQueryDslRepository에 ItemOption 검색 조건 추가 -- 서브쿼리 패턴을 사용한 올바른 검색 로직 구현 -- 조건을 만족하는 거래내역의 **모든 옵션** 반환 - -### 🔍 쿼리 패턴 분석 및 선택 - -#### 요구사항 명확화 - -**검색 흐름:** -1. 특정 옵션 조건 + 거래내역 조건으로 검색 -2. 조건을 만족하는 경매장 거래 내역 찾기 -3. ⭐ **해당 거래내역의 모든 옵션을 함께 조회** (조건 만족 여부 무관) - -**예시:** -- 조건: "공격 +10 이상 OR 밸런스 +5 이상" -- 매칭된 거래내역: "페러시우스 타이탄 블레이드" - - 옵션 1: 공격 +12 ✅ (조건 만족) - - 옵션 2: 밸런스 +3 (조건 불만족이지만 반환) - - 옵션 3: 크리티컬 +15 (조건 불만족이지만 반환) - - 옵션 4: 내구력 30 (조건 불만족이지만 반환) - -**반환:** 거래내역 + 옵션 1, 2, 3, 4 **모두** - -#### 쿼리 패턴 비교 - -**❌ 패턴 1: INNER JOIN + 직접 WHERE 조건 (부적합)** -```sql -SELECT * -FROM auction_history ah -INNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id -WHERE ah.item_top_category = '근거리 장비' - AND ((io.option_type = '공격' AND io.option_value2 >= 2) - OR (io.option_type = '밸런스' AND io.option_value2 >= 5)) -``` - -**문제점:** -- `io`에 직접 WHERE 조건을 걸기 때문에 **조건을 만족하는 옵션만** 반환 -- 같은 거래내역의 다른 옵션들은 필터링되어 제외됨 -- EXPLAIN 결과: `io.filtered = 19%` (조건 만족 옵션만) - -**✅ 패턴 2: IN 서브쿼리 (권장)** -```sql -SELECT * -FROM auction_history ah -INNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id -WHERE ah.item_top_category = '근거리 장비' - AND ah.auction_buy_id IN ( - SELECT io2.auction_history_id - FROM auction_item_option io2 - WHERE ((io2.option_type = '공격' AND io2.option_value2 >= 2) - OR (io2.option_type = '밸런스' AND io2.option_value2 >= 5)) - ) -``` - -**장점:** -- 서브쿼리로 조건 만족하는 거래내역 ID만 찾기 -- 메인 쿼리에서 해당 거래내역의 **모든 옵션** 조회 (조건 없음!) -- EXPLAIN 결과: `io.filtered = 100%` (모든 옵션 반환) -- MySQL 옵티마이저가 FirstMatch로 최적화 - -#### 실제 데이터 검증 결과 - -동일한 거래내역 ID를 조회했을 때: - -| 쿼리 패턴 | option_count | 반환 옵션 | -|---------|-------------|---------| -| 패턴 1 (직접 WHERE) | 1 | 공격:20 만 | -| 패턴 2 (서브쿼리) | 7 | 공격:20, 아이템 색상, 내구력:11, 밸런스, 크리티컬 등 **모두** | - -**결론: 패턴 2 (IN 서브쿼리) 사용 필수** - -### 작업 순서 - -#### 4.1. AuctionHistoryQueryDslRepository 수정 (서브쿼리 패턴) - -**파일:** `src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java` - -**수정 내용:** - -```java -package until.the.eternity.auctionhistory.infrastructure.persistence; - -import com.querydsl.core.BooleanBuilder; -import com.querydsl.jpa.JPAExpressions; -import com.querydsl.jpa.impl.JPAQuery; -import com.querydsl.jpa.impl.JPAQueryFactory; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; -import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; -import until.the.eternity.auctionhistory.domain.entity.QAuctionHistory; -import until.the.eternity.auctionhistory.interfaces.rest.dto.request.*; -import until.the.eternity.auctionitemoption.domain.entity.QAuctionItemOption; - -@Component -@RequiredArgsConstructor -class AuctionHistoryQueryDslRepository { - - private final JPAQueryFactory queryFactory; - - /** - * 경매 거래내역 검색 (옵션 조건 포함) - * - * 검색 흐름: - * 1. 옵션 조건을 만족하는 거래내역 ID를 서브쿼리로 찾기 - * 2. 거래내역 조건으로 필터링 - * 3. 해당 거래내역의 모든 옵션을 함께 조회 (LEFT JOIN) - */ - public Page search( - AuctionHistorySearchRequest condition, Pageable pageable) { - QAuctionHistory ah = QAuctionHistory.auctionHistory; - QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; - - // 1단계: 거래내역 조건 빌드 - BooleanBuilder historyBuilder = buildHistoryPredicate(condition, ah); - - // 2단계: 옵션 조건이 있으면 서브쿼리 추가 - if (condition.itemOptionSearchRequest() != null) { - // 서브쿼리용 별도 QAuctionItemOption 인스턴스 - QAuctionItemOption subOption = new QAuctionItemOption("subOption"); - BooleanBuilder optionBuilder = buildItemOptionConditions( - condition.itemOptionSearchRequest(), - subOption - ); - - // 서브쿼리: 옵션 조건을 만족하는 auction_history_id 찾기 - JPAQuery subQuery = JPAExpressions - .select(subOption.auctionHistoryId) - .from(subOption) - .where(optionBuilder) - .distinct(); - - // 메인 쿼리에 서브쿼리 결과 적용 - historyBuilder.and(ah.auctionBuyId.in(subQuery)); - } - - // 3단계: 모든 옵션과 함께 조회 (LEFT JOIN - 조건 없음!) - List content = - queryFactory - .selectFrom(ah) - .leftJoin(ah.auctionItemOptions, aio) - .fetchJoin() - .where(historyBuilder) - .distinct() // 중복 제거 - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); - - // Count 쿼리 (JOIN 없이 실행) - Long total = - queryFactory - .select(ah.countDistinct()) - .from(ah) - .where(historyBuilder) - .fetchOne(); - - return new PageImpl<>(content, pageable, total == null ? 0L : total); - } - - /** - * 거래내역 기본 조건 빌드 (카테고리, 아이템명, 가격, 거래일자) - */ - private BooleanBuilder buildHistoryPredicate( - AuctionHistorySearchRequest c, - QAuctionHistory ah) { - BooleanBuilder builder = new BooleanBuilder(); - - // 기본 조건들 - if (c.itemTopCategory() != null && !c.itemTopCategory().isBlank()) { - builder.and(ah.itemTopCategory.eq(c.itemTopCategory())); - } - if (c.itemSubCategory() != null && !c.itemSubCategory().isBlank()) { - builder.and(ah.itemSubCategory.eq(c.itemSubCategory())); - } - if (c.itemName() != null && !c.itemName().isBlank()) { - builder.and(ah.itemName.containsIgnoreCase(c.itemName())); - } - - // 가격 조건 (PriceSearchRequest가 있으면) - if (c.priceSearchRequest() != null) { - PriceSearchRequest price = c.priceSearchRequest(); - if (price.priceFrom() != null) { - builder.and(ah.auctionPricePerUnit.goe(price.priceFrom())); - } - if (price.priceTo() != null) { - builder.and(ah.auctionPricePerUnit.loe(price.priceTo())); - } - } - - // 거래 일자 조건 - if (c.date_auction_buy() != null && !c.date_auction_buy().isBlank()) { - // 날짜 파싱 및 조건 추가 로직 - // TODO: 날짜 범위 검색 구현 - } - - return builder; - } - - /** - * 옵션 검색 조건 빌드 (서브쿼리용) - * - * 주의: 이 메서드는 서브쿼리에서만 사용됩니다. - * 반환된 BooleanBuilder는 메인 JOIN의 WHERE에 직접 사용하면 안 됩니다! - */ - private BooleanBuilder buildItemOptionConditions( - ItemOptionSearchRequest opt, - QAuctionItemOption aio) { - BooleanBuilder builder = new BooleanBuilder(); - - // 1. Balance - if (opt.balanceSearch() != null && opt.balanceSearch().balance() != null) { - addStandardCondition( - builder, - aio.balance, - opt.balanceSearch().balance(), - opt.balanceSearch().balanceStandard()); - } - - // 2. Critical - if (opt.criticalSearch() != null && opt.criticalSearch().critical() != null) { - addStandardCondition( - builder, - aio.critical, - opt.criticalSearch().critical(), - opt.criticalSearch().criticalStandard()); - } - - // 3. Defense - if (opt.defenseSearch() != null && opt.defenseSearch().defense() != null) { - addStandardCondition( - builder, - aio.defense, - opt.defenseSearch().defense(), - opt.defenseSearch().defenseStandard()); - } - - // 4. Erg (범위) - if (opt.ergSearch() != null) { - if (opt.ergSearch().ergFrom() != null) { - builder.and(aio.erg.goe(opt.ergSearch().ergFrom())); - } - if (opt.ergSearch().ergTo() != null) { - builder.and(aio.erg.loe(opt.ergSearch().ergTo())); - } - } - - // 5. ErgRank - if (opt.ergRankSearch() != null && opt.ergRankSearch().ergRank() != null) { - builder.and(aio.ergRank.eq(opt.ergRankSearch().ergRank())); - } - - // 6. MagicDefense - if (opt.magicDefenseSearch() != null - && opt.magicDefenseSearch().magicDefense() != null) { - addStandardCondition( - builder, - aio.magicDefense, - opt.magicDefenseSearch().magicDefense(), - opt.magicDefenseSearch().magicDefenseStandard()); - } - - // 7. MagicProtect - if (opt.magicProtectSearch() != null - && opt.magicProtectSearch().magicProtect() != null) { - addStandardCondition( - builder, - aio.magicProtect, - opt.magicProtectSearch().magicProtect(), - opt.magicProtectSearch().magicProtectStandard()); - } - - // 8. MaxAttack (범위) - if (opt.maxAttackSearch() != null) { - if (opt.maxAttackSearch().maxAttackFrom() != null) { - builder.and(aio.maxAttack.goe(opt.maxAttackSearch().maxAttackFrom())); - } - if (opt.maxAttackSearch().maxAttackTo() != null) { - builder.and(aio.maxAttack.loe(opt.maxAttackSearch().maxAttackTo())); - } - } - - // 9. MaximumDurability - if (opt.maximumDurabilitySearch() != null - && opt.maximumDurabilitySearch().maximumDurability() != null) { - addStandardCondition( - builder, - aio.maximumDurability, - opt.maximumDurabilitySearch().maximumDurability(), - opt.maximumDurabilitySearch().maximumDurabilityStandard()); - } - - // 10. MaxInjuryRate (범위) - if (opt.maxInjuryRateSearch() != null) { - if (opt.maxInjuryRateSearch().maxInjuryRateFrom() != null) { - builder.and( - aio.maxInjuryRate.goe( - opt.maxInjuryRateSearch().maxInjuryRateFrom())); - } - if (opt.maxInjuryRateSearch().maxInjuryRateTo() != null) { - builder.and( - aio.maxInjuryRate.loe(opt.maxInjuryRateSearch().maxInjuryRateTo())); - } - } - - // 11. Proficiency - if (opt.proficiencySearch() != null - && opt.proficiencySearch().proficiency() != null) { - addStandardCondition( - builder, - aio.proficiency, - opt.proficiencySearch().proficiency(), - opt.proficiencySearch().proficiencyStandard()); - } - - // 12. Protect - if (opt.protectSearch() != null && opt.protectSearch().protect() != null) { - addStandardCondition( - builder, - aio.protect, - opt.protectSearch().protect(), - opt.protectSearch().protectStandard()); - } - - // 13. RemainingTransactionCount - if (opt.remainingTransactionCountSearch() != null - && opt.remainingTransactionCountSearch().remainingTransactionCount() - != null) { - addStandardCondition( - builder, - aio.remainingTransactionCount, - opt.remainingTransactionCountSearch().remainingTransactionCount(), - opt.remainingTransactionCountSearch() - .remainingTransactionCountStandard()); - } - - // 14. RemainingUnsealCount - if (opt.remainingUnsealCountSearch() != null - && opt.remainingUnsealCountSearch().remainingUnsealCount() != null) { - addStandardCondition( - builder, - aio.remainingUnsealCount, - opt.remainingUnsealCountSearch().remainingUnsealCount(), - opt.remainingUnsealCountSearch().remainingUnsealCountStandard()); - } - - // 15. RemainingUseCount - if (opt.remainingUseCountSearch() != null - && opt.remainingUseCountSearch().remainingUseCount() != null) { - addStandardCondition( - builder, - aio.remainingUseCount, - opt.remainingUseCountSearch().remainingUseCount(), - opt.remainingUseCountSearch().remainingUseCountStandard()); - } - - // 16. WearingRestrictions - if (opt.wearingRestrictionsSearch() != null - && opt.wearingRestrictionsSearch().wearingRestrictions() != null) { - builder.and( - aio.wearingRestrictions.eq( - opt.wearingRestrictionsSearch().wearingRestrictions())); - } - - return builder; - } - - /** - * UP/DOWN 기준 조건 추가 헬퍼 메서드 - */ - private void addStandardCondition( - BooleanBuilder builder, - com.querydsl.core.types.dsl.NumberPath field, - Integer value, - String standard) { - if ("UP".equals(standard)) { - builder.and(field.goe(value)); // 이상 (>=) - } else if ("DOWN".equals(standard)) { - builder.and(field.loe(value)); // 이하 (<=) - } else { - builder.and(field.eq(value)); // 같음 - } - } -} -``` - ---- - -## Phase 5: 테스트 코드 작성 - -### 목표 -- 단위 테스트 및 통합 테스트 작성 -- Spring REST Docs 문서 생성 - -### 작업 순서 - -#### 5.1. AuctionSearchOptionService 단위 테스트 - -**파일:** `src/test/java/until/the/eternity/auctionsearchoption/application/service/AuctionSearchOptionServiceTest.java` - -```java -package until.the.eternity.auctionsearchoption.application.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.LocalDateTime; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -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.auctionsearchoption.domain.entity.AuctionSearchOptionMetadata; -import until.the.eternity.auctionsearchoption.domain.repository.AuctionSearchOptionRepositoryPort; -import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; - -@ExtendWith(MockitoExtension.class) -class AuctionSearchOptionServiceTest { - - @Mock private AuctionSearchOptionRepositoryPort repositoryPort; - @InjectMocks private AuctionSearchOptionService service; - - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Test - @DisplayName("활성화된 검색 옵션을 조회한다") - void getAllActiveSearchOptions_should_return_active_options() { - // given - AuctionSearchOptionMetadata entity = createMockEntity(); - when(repositoryPort.findAllActive()).thenReturn(List.of(entity)); - - // when - List result = service.getAllActiveSearchOptions(); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).searchOptionName()).isEqualTo("밸런스"); - verify(repositoryPort).findAllActive(); - } - - private AuctionSearchOptionMetadata createMockEntity() { - // Mock 객체 생성 로직 - return mock(AuctionSearchOptionMetadata.class); - } -} -``` - -#### 5.2. AuctionSearchOptionController 통합 테스트 (REST Docs) - -**파일:** `src/test/java/until/the/eternity/auctionsearchoption/interfaces/rest/AuctionSearchOptionControllerTest.java` - -```java -package until.the.eternity.auctionsearchoption.interfaces.rest; - -import static org.mockito.Mockito.*; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.servlet.MockMvc; -import until.the.eternity.auctionsearchoption.application.service.AuctionSearchOptionService; -import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.FieldMetadata; -import until.the.eternity.auctionsearchoption.interfaces.rest.dto.response.SearchOptionMetadataResponse; - -@WebMvcTest(AuctionSearchOptionController.class) -@AutoConfigureRestDocs -class AuctionSearchOptionControllerTest { - - @Autowired private MockMvc mockMvc; - @MockBean private AuctionSearchOptionService service; - - @Test - @DisplayName("GET /api/search-option - 검색 옵션 메타데이터 조회") - void getSearchOptions_should_return_search_options() throws Exception { - // given - SearchOptionMetadataResponse response = - new SearchOptionMetadataResponse( - 1L, - "밸런스", - Map.of( - "Balance", - new FieldMetadata("tinyint", false, null), - "BalanceStandard", - new FieldMetadata( - "string", false, List.of("UP", "DOWN"))), - 1); - - when(service.getAllActiveSearchOptions()).thenReturn(List.of(response)); - - // when & then - mockMvc.perform(get("/api/search-option")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data[0].searchOptionName").value("밸런스")) - .andDo(document("search-option-get")); - } -} -``` - -#### 5.3. AuctionHistoryQueryDslRepository 테스트 - -**파일:** `src/test/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepositoryTest.java` - -```java -// ItemOption 검색 조건 테스트 추가 -@Test -@DisplayName("ItemOption - Balance 조건으로 검색한다 (UP)") -void search_with_balance_up_condition() { - // given - BalanceSearchRequest balanceSearch = new BalanceSearchRequest(10, "UP"); - ItemOptionSearchRequest itemOptionSearch = - new ItemOptionSearchRequest( - balanceSearch, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null); - AuctionHistorySearchRequest request = - new AuctionHistorySearchRequest( - null, null, null, null, null, null, itemOptionSearch); - Pageable pageable = PageRequest.of(0, 10); - - // when - Page result = repository.search(request, pageable); - - // then - assertThat(result.getContent()) - .allMatch( - ah -> - ah.getAuctionItemOptions().stream() - .anyMatch(aio -> aio.getBalance() >= 10)); -} -``` - ---- - -## Phase 6: 문서화 및 마무리 - -### 목표 -- Swagger 문서 확인 -- Spring REST Docs 생성 -- README 업데이트 - -### 작업 순서 - -#### 6.1. Swagger UI 확인 - -**접속:** -``` -http://localhost:8080/swagger-ui/index.html -``` - -**확인 사항:** -- `/api/search-option` 엔드포인트 존재 -- Request/Response 스키마 정상 표시 -- 16개 Search Request DTO 스키마 정상 - -#### 6.2. Spring REST Docs 생성 - -**명령어:** -```bash -./gradlew asciidoctor -``` - -**생성 위치:** -``` -build/docs/asciidoc/index.html -``` - -#### 6.3. README 업데이트 - -**추가할 내용:** -```markdown -## 새로운 API 엔드포인트 - -### GET /api/search-option -경매 검색 옵션 메타데이터를 조회합니다. - -**Response:** -- 16개 검색 옵션 메타데이터 반환 -- 각 옵션의 파라미터 타입 및 허용 값 포함 - -### POST /api/auction-history/search -경매 거래내역을 검색합니다. - -**새로운 검색 조건:** -- `itemOptionSearchRequest`: 아이템 옵션 기반 검색 - - Balance, Critical, Defense 등 16개 옵션 지원 - - UP/DOWN 기준 검색 (이상/이하) - - 범위 검색 (From/To) -``` - ---- - -## 📊 구현 진행 체크리스트 - -### Phase 1: DB 및 Migration ✅ -- [ ] Flyway 버전 확인 -- [ ] V 스크립트 작성 및 실행 -- [ ] R 스크립트 작성 및 실행 -- [ ] DB 데이터 검증 - -### Phase 2: 메타데이터 API ✅ -- [ ] Entity 작성 -- [ ] Repository Port/Impl 작성 -- [ ] JPA Repository 작성 -- [ ] Response DTO 작성 -- [ ] Service 작성 -- [ ] Controller 작성 -- [ ] API 수동 테스트 - -### Phase 3: Request DTO ✅ -- [ ] 16개 개별 Search Request 작성 -- [ ] ItemOptionSearchRequest 통합 작성 -- [ ] AuctionHistorySearchRequest 수정 -- [ ] Spotless 포맷팅 적용 - -### Phase 4: QueryDSL ✅ -- [ ] buildPredicate 확장 -- [ ] addItemOptionConditions 구현 -- [ ] addStandardCondition 헬퍼 메서드 작성 -- [ ] JOIN 수정 -- [ ] 빌드 및 컴파일 확인 - -### Phase 5: 테스트 ✅ -- [ ] AuctionSearchOptionService 단위 테스트 -- [ ] AuctionSearchOptionController REST Docs 테스트 -- [ ] AuctionHistoryQueryDslRepository 검색 테스트 -- [ ] 전체 테스트 실행 및 통과 확인 - -### Phase 6: 문서화 ✅ -- [ ] Swagger UI 확인 -- [ ] Spring REST Docs 생성 -- [ ] README 업데이트 -- [ ] CHANGELOG 작성 - ---- - -## 🚀 실행 순서 요약 - -1. **Migration 실행** - ```bash - ./gradlew flywayMigrate - ``` - -2. **Phase별 순차 구현** - - Phase 1 → 2 → 3 → 4 → 5 → 6 - -3. **각 Phase 완료 후 빌드 및 테스트** - ```bash - ./gradlew clean build - ``` - -4. **Spotless 포맷팅 자동 적용** - ```bash - ./gradlew spotlessApply - ``` - -5. **최종 검증** - ```bash - ./gradlew test - ./gradlew bootRun - # Swagger UI 접속하여 API 확인 - ``` - ---- - -## 📊 개발 현황 (Implementation Status) - -> **최종 업데이트:** 2025-10-20 23:59 -> **진행 상황:** Phase 2 완료 (33% 완료) - -### ✅ 완료된 작업 - -#### Phase 1: DB 스키마 및 Migration ✅ (100% 완료) -**완료 일시:** 2025-10-20 23:47 - -- ✅ Flyway V11 스크립트 작성 (`V11__create_auction_search_option_metadata.sql`) -- ✅ Flyway R 스크립트 작성 (`R__insert_auction_search_option_metadata.sql`) -- ✅ Migration 실행 성공 (version v11) -- ✅ DB 데이터 검증 완료 (16개 검색 옵션 INSERT 확인) - -**생성된 파일:** -``` -src/main/resources/db/migration/ -├── V11__create_auction_search_option_metadata.sql -└── R__insert_auction_search_option_metadata.sql -``` - -**DB 검증 결과:** -- 테이블: `auction_search_option_metadata` 생성 완료 -- 데이터: 16개 레코드 (밸런스, 크리티컬, 방어력 등) -- JSON 구조: type, required, allowedValues 포함 - ---- - -#### Phase 2: 검색 조건 메타데이터 API 구현 ✅ (100% 완료) -**완료 일시:** 2025-10-20 23:59 - -**구현된 레이어:** - -1. **Domain Layer** - - ✅ Entity: `AuctionSearchOptionMetadata.java` - - ✅ Repository Port: `AuctionSearchOptionRepositoryPort.java` - -2. **Infrastructure Layer** - - ✅ JPA Repository: `AuctionSearchOptionJpaRepository.java` - - ✅ Repository PortImpl: `AuctionSearchOptionRepositoryPortImpl.java` - -3. **Application Layer** - - ✅ Service: `AuctionSearchOptionService.java` - - JSON 파싱 로직 (ObjectMapper) - - Entity → DTO 변환 - -4. **Interface Layer** - - ✅ Response DTO: `FieldMetadata.java`, `SearchOptionMetadataResponse.java` - - ✅ Controller: `AuctionSearchOptionController.java` - -**API 엔드포인트:** -``` -GET /api/search-option -``` - -**API 테스트 결과:** -- ✅ HTTP 200 OK 응답 -- ✅ 16개 검색 옵션 정상 반환 -- ✅ JSON 구조 정확 (type, required, allowedValues) -- ✅ displayOrder 순서대로 정렬 - -**생성된 파일:** -``` -src/main/java/until/the/eternity/auctionsearchoption/ -├── application/service/AuctionSearchOptionService.java -├── domain/ -│ ├── entity/AuctionSearchOptionMetadata.java -│ └── repository/AuctionSearchOptionRepositoryPort.java -├── infrastructure/persistence/ -│ ├── AuctionSearchOptionJpaRepository.java -│ └── AuctionSearchOptionRepositoryPortImpl.java -└── interfaces/rest/ - ├── AuctionSearchOptionController.java - └── dto/response/ - ├── FieldMetadata.java - └── SearchOptionMetadataResponse.java -``` - ---- - -### 🚧 진행 중인 작업 - -**현재 단계:** 없음 (다음 Phase 대기) - ---- - -### 📋 남은 작업 - -#### Phase 3: Item Option 검색 Request DTO 구현 (예정) -**예상 작업량:** 17개 파일 생성 - -- [ ] 16개 개별 Search Request Record 생성 - - [ ] BalanceSearchRequest.java - - [ ] CriticalSearchRequest.java - - [ ] DefenseSearchRequest.java - - [ ] ErgSearchRequest.java - - [ ] ErgRankSearchRequest.java - - [ ] MagicDefenseSearchRequest.java - - [ ] MagicProtectSearchRequest.java - - [ ] MaxAttackSearchRequest.java - - [ ] MaximumDurabilitySearchRequest.java - - [ ] MaxInjuryRateSearchRequest.java - - [ ] ProficiencySearchRequest.java - - [ ] ProtectSearchRequest.java - - [ ] RemainingTransactionCountSearchRequest.java - - [ ] RemainingUnsealCountSearchRequest.java - - [ ] RemainingUseCountSearchRequest.java - - [ ] WearingRestrictionsSearchRequest.java -- [ ] ItemOptionSearchRequest.java (통합 Record) -- [ ] AuctionHistorySearchRequest.java 수정 - ---- - -#### Phase 4: QueryDSL 검색 로직 확장 (예정) -- [ ] AuctionHistoryQueryDslRepository.buildPredicate() 확장 -- [ ] addItemOptionConditions() 메서드 구현 -- [ ] addStandardCondition() 헬퍼 메서드 작성 -- [ ] QAuctionItemOption JOIN 추가 - ---- - -#### Phase 5: 테스트 코드 작성 (예정) -- [ ] AuctionSearchOptionService 단위 테스트 -- [ ] AuctionSearchOptionController REST Docs 테스트 -- [ ] AuctionHistoryQueryDslRepository 검색 테스트 - ---- - -#### Phase 6: 문서화 및 마무리 (예정) -- [ ] Swagger UI 확인 -- [ ] Spring REST Docs 생성 -- [ ] README 업데이트 - ---- - -### 📝 다음 세션 작업 가이드 - -**다음 작업:** Phase 3 - Item Option 검색 Request DTO 구현 - -**시작 방법:** -```bash -# 1. 프로젝트 루트로 이동 -cd C:/Users/Desktop/devnogi/open-api-batch-server - -# 2. 구현 계획서 확인 -cat AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md | grep -A 50 "Phase 3" - -# 3. 16개 Search Request Record 생성 시작 -# 경로: src/main/java/until/the/eternity/auctionhistory/interfaces/rest/dto/request/ -``` - -**참고 문서:** -- 요구사항: `AUCTION_SEARCH_OPTION_REQUIREMENTS.md` -- 구현 계획: `AUCTION_SEARCH_OPTION_IMPLEMENT_PLAN.md` (현재 문서) -- 원본 파라미터: `auction_history_search_param.txt` - ---- - -**작성자:** Claude Code -**최종 검토:** 2025-10-20 diff --git a/AUCTION_SEARCH_OPTION_REQUIREMENTS.md b/AUCTION_SEARCH_OPTION_REQUIREMENTS.md deleted file mode 100644 index ff36a33..0000000 --- a/AUCTION_SEARCH_OPTION_REQUIREMENTS.md +++ /dev/null @@ -1,639 +0,0 @@ -# Auction Item Option 검색 기능 구현 요구사항 명세서 - -> 작성일: 2025-10-20 -> 프로젝트: open-api-batch-server -> 기능: 경매 거래내역 아이템 옵션 검색 및 검색 조건 메타데이터 제공 API - ---- - -## 📋 개요 - -경매 거래내역(AuctionHistory) 검색 시 아이템 옵션(AuctionItemOption)을 조건으로 검색할 수 있는 기능을 구현하고, 프론트엔드가 동적으로 검색 필터 UI를 구성할 수 있도록 검색 조건 메타데이터를 제공하는 API를 개발합니다. - ---- - -## 🎯 구현 목표 - -### 1. 검색 조건 메타데이터 제공 API -- DB 테이블에 검색 조건 정보 저장 (코드 하드코딩 ❌) -- REST API로 검색 조건 메타데이터 제공 -- 프론트엔드에서 동적 검색 필터 UI 구성 가능 - -### 2. Auction Item Option 기반 검색 기능 -- 경매 거래내역 검색 시 아이템 옵션을 조건으로 추가 -- QueryDSL을 활용한 동적 쿼리 구현 -- 기존 검색 조건과 결합 가능 - ---- - -## 💾 DB 테이블 설계 - -### 테이블명: `auction_search_option_metadata` - -**DDL (MySQL 8):** -```sql -CREATE TABLE `auction_search_option_metadata` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 ID', - `search_option_name` VARCHAR(100) NOT NULL COMMENT '검색 옵션명 (한글)', - `search_condition_json` JSON NOT NULL COMMENT '검색 조건 (파라미터명:타입)', - `display_order` INT NOT NULL UNIQUE COMMENT '정렬 순서 (고유값)', - `is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성화 여부', - `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시', - `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시', - PRIMARY KEY (`id`), - INDEX `idx_display_order` (`display_order`), - INDEX `idx_is_active` (`is_active`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='경매 검색 옵션 메타데이터'; -``` - -**JSON 필드 구조 (search_condition_json):** -```json -{ - "Balance": { - "type": "tinyint", - "required": false - }, - "BalanceStandard": { - "type": "string", - "allowedValues": ["UP", "DOWN"], - "required": false - } -} -``` - -**초기 데이터 예시:** -| id | search_option_name | search_condition_json | display_order | is_active | -|----|-------------------|----------------------|--------------|-----------| -| 1 | 밸런스 | `{"Balance":{"type":"tinyint","required":false},"BalanceStandard":{"type":"string","allowedValues":["UP","DOWN"],"required":false}}` | 1 | true | -| 2 | 크리티컬 | `{"Critical":{"type":"tinyint","required":false},"CriticalStandard":{"type":"string","allowedValues":["UP","DOWN"],"required":false}}` | 2 | true | - ---- - -## 🔄 Flyway Migration - -### V 스크립트 (Schema) -**파일명:** `V{next_version}__create_auction_search_option_metadata.sql` -- 테이블 생성 DDL - -### R 스크립트 (Repeatable - 초기 데이터) -**파일명:** `R__insert_auction_search_option_metadata.sql` -- 검색 조건 메타데이터 초기 데이터 INSERT -- 대상 항목 (auction_history_search_param.txt 1-18번): - 1. 밸런스 (Balance, BalanceStandard) - 2. 크리티컬 (Critical, CriticalStandard) - 3. 방어력 (Defense, DefenseStandard) - 4. 에르그 (ErgFrom, ErgTo) - 5. 에르그 등급 (ErgRank) - 6. 마법 방어력 (MagicDefense, MagicDefenseStandard) - 7. 마법 보호 (MagicProtect, MagicProtectStandard) - 8. 최대 공격력 (MaxAttackFrom, MaxAttackTo) - 9. 최대 내구력 (MaximumDurability, MaximumDurabilityStandard) - 10. 최대 부상률 (MaxInjuryRateFrom, MaxInjuryRateTo) - 11. 가격 (PriceFrom, PriceTo) - **제외 (이미 PriceSearchRequest 존재)** - 12. 숙련도 (Proficiency, ProficiencyStandard) - 13. 보호 (Protect, ProtectStandard) - 14. 남은 거래 횟수 (RemainingTransactionCount, RemainingTransactionCountStandard) - 15. 남은 전용 해제 가능 횟수 (RemainingUnsealCount, RemainingUnsealCountStandard) - 16. 남은 사용 횟수 (RemainingUseCount, RemainingUseCountStandard) - 17. 착용 제한 (WearingRestrictions) - -**참고:** 19-37번 미정의 항목은 **제외** - ---- - -## 🌐 API 명세 - -### GET /api/search-option - -**설명:** 경매 검색 옵션 메타데이터 조회 - -**Request:** -- Method: `GET` -- Path: `/api/search-option` -- Parameters: 없음 - -**Response:** -```json -{ - "success": true, - "code": "SUCCESS", - "message": "검색 옵션 조회 성공", - "data": [ - { - "id": 1, - "searchOptionName": "밸런스", - "searchCondition": { - "Balance": { - "type": "tinyint", - "required": false - }, - "BalanceStandard": { - "type": "string", - "allowedValues": ["UP", "DOWN"], - "required": false - } - }, - "displayOrder": 1 - }, - { - "id": 2, - "searchOptionName": "크리티컬", - "searchCondition": { - "Critical": { - "type": "tinyint", - "required": false - }, - "CriticalStandard": { - "type": "string", - "allowedValues": ["UP", "DOWN"], - "required": false - } - }, - "displayOrder": 2 - } - ], - "timestamp": "2025-10-20T12:00:00Z" -} -``` - -**프론트엔드 UI 구현 가이드:** -- `*Standard` 필드 (UP/DOWN): 텍스트가 아닌 **화살표 아이콘**으로 표시 - - UP: ↑ (위쪽 화살표) - "이상" 조건 - - DOWN: ↓ (아래쪽 화살표) - "이하" 조건 - - 클릭 시 UP ↔ DOWN 토글 - ---- - -## 🏗️ 구현 레이어 구조 - -### Clean Architecture 패턴 적용 - -``` -auctionsearchoption/ -├── application/ -│ └── service/ -│ └── AuctionSearchOptionService.java -├── domain/ -│ ├── entity/ -│ │ └── AuctionSearchOptionMetadata.java -│ └── repository/ -│ └── AuctionSearchOptionRepositoryPort.java // 인터페이스 -├── infrastructure/ -│ └── persistence/ -│ ├── AuctionSearchOptionJpaRepository.java -│ └── AuctionSearchOptionRepositoryPortImpl.java // Port 구현체 -└── interfaces/ - └── rest/ - ├── AuctionSearchOptionController.java - └── dto/ - └── response/ - └── SearchOptionMetadataResponse.java -``` - -**Repository 패턴:** -- Port (인터페이스): `AuctionSearchOptionRepositoryPort` -- Port 구현체: `AuctionSearchOptionRepositoryPortImpl` -- JPA Repository: `AuctionSearchOptionJpaRepository` (Spring Data JPA) - ---- - -## 📦 DTO 설계 - -### Request DTO - -**기존:** `AuctionHistorySearchRequest` -```java -public record AuctionHistorySearchRequest( - String itemName, - String itemTopCategory, - String itemSubCategory, - String auction_price_per_unit, - String date_auction_buy, - PriceSearchRequest priceSearchRequest, // 변경됨 (구 PricePerUnitSearchRequest) - ItemOptionSearchRequest itemOptionSearchRequest // 새로 추가 -) {} -``` - -**새로 추가:** 개별 옵션 검색 Request DTO들 (Record로 구현) - -각 옵션별로 별도 Record 생성: - -```java -// 1. 단일 값 + Standard (UP/DOWN) 패턴 -public record BalanceSearchRequest( - Integer balance, - String balanceStandard // "UP" | "DOWN" -) {} - -public record CriticalSearchRequest( - Integer critical, - String criticalStandard -) {} - -public record DefenseSearchRequest( - Integer defense, - String defenseStandard -) {} - -public record MagicDefenseSearchRequest( - Integer magicDefense, - String magicDefenseStandard -) {} - -public record MagicProtectSearchRequest( - Integer magicProtect, - String magicProtectStandard -) {} - -public record MaximumDurabilitySearchRequest( - Integer maximumDurability, - String maximumDurabilityStandard -) {} - -public record ProficiencySearchRequest( - Integer proficiency, - String proficiencyStandard -) {} - -public record ProtectSearchRequest( - Integer protect, - String protectStandard -) {} - -public record RemainingTransactionCountSearchRequest( - Integer remainingTransactionCount, - String remainingTransactionCountStandard -) {} - -public record RemainingUnsealCountSearchRequest( - Integer remainingUnsealCount, - String remainingUnsealCountStandard -) {} - -public record RemainingUseCountSearchRequest( - Integer remainingUseCount, - String remainingUseCountStandard -) {} - -// 2. 범위 검색 (From/To) 패턴 -public record ErgSearchRequest( - Integer ergFrom, - Integer ergTo -) {} - -public record MaxAttackSearchRequest( - Integer maxAttackFrom, - Integer maxAttackTo -) {} - -public record MaxInjuryRateSearchRequest( - Integer maxInjuryRateFrom, - Integer maxInjuryRateTo -) {} - -// PriceSearchRequest는 이미 존재 (기존 PricePerUnitSearchRequest에서 이름 변경) - -// 3. Enum 값 패턴 -public record ErgRankSearchRequest( - String ergRank // "S등급" | "A등급" | "B등급" -) {} - -// 4. 문자열 검색 패턴 -public record WearingRestrictionsSearchRequest( - String wearingRestrictions -) {} -``` - -**통합:** `ItemOptionSearchRequest` -```java -public record ItemOptionSearchRequest( - BalanceSearchRequest balanceSearch, - CriticalSearchRequest criticalSearch, - DefenseSearchRequest defenseSearch, - ErgSearchRequest ergSearch, - ErgRankSearchRequest ergRankSearch, - MagicDefenseSearchRequest magicDefenseSearch, - MagicProtectSearchRequest magicProtectSearch, - MaxAttackSearchRequest maxAttackSearch, - MaximumDurabilitySearchRequest maximumDurabilitySearch, - MaxInjuryRateSearchRequest maxInjuryRateSearch, - ProficiencySearchRequest proficiencySearch, - ProtectSearchRequest protectSearch, - RemainingTransactionCountSearchRequest remainingTransactionCountSearch, - RemainingUnsealCountSearchRequest remainingUnsealCountSearch, - RemainingUseCountSearchRequest remainingUseCountSearch, - WearingRestrictionsSearchRequest wearingRestrictionsSearch -) {} -``` - -**분리 이유:** 일부 검색 타입은 `List` 형태로 받아야 할 수도 있기 때문 - -### Response DTO - -```java -public record SearchOptionMetadataResponse( - Long id, - String searchOptionName, - Map searchCondition, // JSON 파싱 결과 - Integer displayOrder -) {} - -public record FieldMetadata( - String type, - Boolean required, - List allowedValues // Optional, Enum 타입일 경우만 -) {} -``` - ---- - -## 🔍 QueryDSL 검색 로직 - -### 검색 요구사항 명확화 - -**검색 흐름:** -1. 특정 옵션 조건 + 거래내역 조건으로 검색 -2. 조건을 만족하는 경매장 거래 내역 찾기 -3. ⭐ **해당 거래내역의 모든 옵션을 함께 조회** (조건 만족 여부 무관) - -**예시:** -- 조건: "공격 +10 이상 OR 밸런스 +5 이상" -- 매칭된 거래내역: "페러시우스 타이탄 블레이드" - - 옵션 1: 공격 +12 ✅ (조건 만족) - - 옵션 2: 밸런스 +3 (조건 불만족이지만 반환) - - 옵션 3: 크리티컬 +15 (조건 불만족이지만 반환) - -**반환:** 거래내역 + **모든 옵션** - -### 쿼리 패턴 비교 - -#### ❌ 잘못된 패턴: INNER JOIN + 직접 WHERE 조건 - -```java -// 잘못된 구현 - 조건 만족 옵션만 반환됨! -private BooleanBuilder buildPredicate(AuctionHistorySearchRequest c, QAuctionHistory ah) { - BooleanBuilder builder = new BooleanBuilder(); - - if (c.itemOptionSearchRequest() != null) { - QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; - // ❌ 문제: io에 직접 조건을 걸면 조건 만족 옵션만 반환됨 - builder.and(aio.balance.goe(10)); - } - - return builder; -} -``` - -**문제점:** -- 조건을 만족하는 옵션만 반환 -- 같은 거래내역의 다른 옵션들 필터링됨 -- EXPLAIN 결과: `filtered = 19%` - -#### ✅ 올바른 패턴: IN 서브쿼리 - -**SQL 예시:** -```sql -SELECT * -FROM auction_history ah -INNER JOIN auction_item_option io ON ah.auction_buy_id = io.auction_history_id -WHERE ah.item_top_category = '근거리 장비' - -- 서브쿼리로 조건 만족하는 거래내역 ID만 찾기 - AND ah.auction_buy_id IN ( - SELECT io2.auction_history_id - FROM auction_item_option io2 - WHERE ((io2.option_type = '공격' AND io2.option_value2 >= 2) - OR (io2.option_type = '밸런스' AND io2.option_value2 >= 5)) - ); --- 메인 JOIN은 조건 없이 모든 옵션 조회! -``` - -### AuctionHistoryQueryDslRepository 구현 - -**올바른 구현:** - -```java -import com.querydsl.jpa.JPAExpressions; -import com.querydsl.jpa.impl.JPAQuery; - -public Page search( - AuctionHistorySearchRequest condition, Pageable pageable) { - QAuctionHistory ah = QAuctionHistory.auctionHistory; - QAuctionItemOption aio = QAuctionItemOption.auctionItemOption; - - // 1단계: 거래내역 조건 빌드 - BooleanBuilder historyBuilder = buildHistoryPredicate(condition, ah); - - // 2단계: 옵션 조건이 있으면 서브쿼리 추가 - if (condition.itemOptionSearchRequest() != null) { - // 서브쿼리용 별도 QAuctionItemOption 인스턴스 - QAuctionItemOption subOption = new QAuctionItemOption("subOption"); - BooleanBuilder optionBuilder = buildItemOptionConditions( - condition.itemOptionSearchRequest(), - subOption - ); - - // 서브쿼리: 옵션 조건을 만족하는 auction_history_id 찾기 - JPAQuery subQuery = JPAExpressions - .select(subOption.auctionHistoryId) - .from(subOption) - .where(optionBuilder) - .distinct(); - - // 메인 쿼리에 서브쿼리 결과 적용 - historyBuilder.and(ah.auctionBuyId.in(subQuery)); - } - - // 3단계: 모든 옵션과 함께 조회 (LEFT JOIN - 조건 없음!) - List content = queryFactory - .selectFrom(ah) - .leftJoin(ah.auctionItemOptions, aio).fetchJoin() // 조건 없이 모든 옵션 조회 - .where(historyBuilder) - .distinct() - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); - - Long total = queryFactory - .select(ah.countDistinct()) - .from(ah) - .where(historyBuilder) // JOIN 없이 count - .fetchOne(); - - return new PageImpl<>(content, pageable, total == null ? 0L : total); -} - -/** - * 옵션 검색 조건 빌드 (서브쿼리용) - * 주의: 메인 JOIN의 WHERE에 직접 사용 금지! - */ -private BooleanBuilder buildItemOptionConditions( - ItemOptionSearchRequest opt, - QAuctionItemOption aio) { - BooleanBuilder builder = new BooleanBuilder(); - - // 1. Balance - if (opt.balanceSearch() != null && opt.balanceSearch().balance() != null) { - if ("UP".equals(opt.balanceSearch().balanceStandard())) { - builder.and(aio.balance.goe(opt.balanceSearch().balance())); - } else if ("DOWN".equals(opt.balanceSearch().balanceStandard())) { - builder.and(aio.balance.loe(opt.balanceSearch().balance())); - } - } - - // ... 나머지 옵션들도 동일 패턴으로 구현 - - return builder; -} -``` - -### 성능 비교 - -| 항목 | 직접 WHERE | 서브쿼리 (권장) | -|------|-----------|---------------| -| **반환 옵션** | 조건 만족만 | ✅ 모든 옵션 | -| **요구사항 충족** | ❌ | ✅ | -| **MySQL 최적화** | 일반 JOIN | FirstMatch | -| **filtered** | 19% | 100% | -| **중복 제거** | 필요 | DISTINCT 사용 | - ---- - -## 📊 검색 조건 타입 정리 - -| 검색 옵션명 | 파라미터 | 타입 | Standard | 검색 로직 | -|-----------|---------|------|----------|----------| -| 밸런스 | Balance | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 크리티컬 | Critical | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 방어력 | Defense | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 에르그 | ErgFrom, ErgTo | tinyint | - | Between | -| 에르그 등급 | ErgRank | string | - | Equals ('S등급', 'A등급', 'B등급') | -| 마법 방어력 | MagicDefense | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 마법 보호 | MagicProtect | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 최대 공격력 | MaxAttackFrom, MaxAttackTo | int | - | Between | -| 최대 내구력 | MaximumDurability | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 최대 부상률 | MaxInjuryRateFrom, MaxInjuryRateTo | tinyint | - | Between | -| 숙련도 | Proficiency | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 보호 | Protect | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 남은 거래 횟수 | RemainingTransactionCount | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 남은 전용 해제 가능 횟수 | RemainingUnsealCount | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 남은 사용 횟수 | RemainingUseCount | tinyint | UP/DOWN | UP: >=, DOWN: <= | -| 착용 제한 | WearingRestrictions | string | - | Equals | - -**참고:** -- **UP (↑)**: 이상 (Greater Than or Equal, `>=`) -- **DOWN (↓)**: 이하 (Less Than or Equal, `<=`) -- **가격 (Price)**: 이미 `PriceSearchRequest`로 구현되어 있으므로 **제외** - ---- - -## ✅ 체크리스트 - -### 1단계: DB 및 Migration -- [ ] MySQL 8 DDL 작성 (`auction_search_option_metadata` 테이블) -- [ ] Flyway V 스크립트 작성 (테이블 생성) -- [ ] Flyway R 스크립트 작성 (초기 데이터 INSERT, 1-17번 항목) - -### 2단계: 검색 조건 메타데이터 제공 API -- [ ] Entity: `AuctionSearchOptionMetadata` 생성 -- [ ] Repository Port: `AuctionSearchOptionRepositoryPort` 인터페이스 작성 -- [ ] Repository PortImpl: `AuctionSearchOptionRepositoryPortImpl` 구현 -- [ ] JPA Repository: `AuctionSearchOptionJpaRepository` 작성 -- [ ] Service: `AuctionSearchOptionService` 구현 -- [ ] Response DTO: `SearchOptionMetadataResponse`, `FieldMetadata` 작성 -- [ ] Controller: `AuctionSearchOptionController` 구현 (`GET /api/search-option`) - -### 3단계: Item Option 검색 기능 -- [ ] Request DTO: 개별 옵션 Search Request Record 17개 생성 - - [ ] BalanceSearchRequest - - [ ] CriticalSearchRequest - - [ ] DefenseSearchRequest - - [ ] ErgSearchRequest - - [ ] ErgRankSearchRequest - - [ ] MagicDefenseSearchRequest - - [ ] MagicProtectSearchRequest - - [ ] MaxAttackSearchRequest - - [ ] MaximumDurabilitySearchRequest - - [ ] MaxInjuryRateSearchRequest - - [ ] ProficiencySearchRequest - - [ ] ProtectSearchRequest - - [ ] RemainingTransactionCountSearchRequest - - [ ] RemainingUnsealCountSearchRequest - - [ ] RemainingUseCountSearchRequest - - [ ] WearingRestrictionsSearchRequest - - [ ] (PriceSearchRequest는 이미 존재) -- [ ] Request DTO: `ItemOptionSearchRequest` 통합 Record 생성 -- [ ] `AuctionHistorySearchRequest`에 `ItemOptionSearchRequest` 필드 추가 -- [ ] `AuctionHistoryQueryDslRepository.buildPredicate()` 확장 - - [ ] QAuctionItemOption JOIN 추가 - - [ ] 각 옵션별 동적 조건 추가 (17개) -- [ ] `AuctionHistoryQueryDslRepository.search()` JOIN 수정 - -### 4단계: 테스트 -- [ ] `AuctionSearchOptionService` 단위 테스트 -- [ ] `AuctionSearchOptionController` 통합 테스트 (REST Docs) -- [ ] `AuctionHistoryQueryDslRepository` 검색 테스트 (ItemOption 조건) -- [ ] `AuctionHistoryService` 검색 테스트 업데이트 - -### 5단계: 문서화 -- [ ] Swagger/OpenAPI 문서 업데이트 -- [ ] Spring REST Docs 생성 -- [ ] README 업데이트 (새 API 엔드포인트 추가) - ---- - -## 🔗 관련 파일 경로 - -``` -open-api-batch-server/ -├── src/main/resources/db/migration/ -│ ├── V{next}__create_auction_search_option_metadata.sql // 새로 생성 -│ └── R__insert_auction_search_option_metadata.sql // 새로 생성 -├── src/main/java/until/the/eternity/ -│ ├── auctionsearchoption/ // 새로 생성 -│ │ ├── application/service/AuctionSearchOptionService.java -│ │ ├── domain/ -│ │ │ ├── entity/AuctionSearchOptionMetadata.java -│ │ │ └── repository/AuctionSearchOptionRepositoryPort.java -│ │ ├── infrastructure/persistence/ -│ │ │ ├── AuctionSearchOptionJpaRepository.java -│ │ │ └── AuctionSearchOptionRepositoryPortImpl.java -│ │ └── interfaces/rest/ -│ │ ├── AuctionSearchOptionController.java -│ │ └── dto/response/SearchOptionMetadataResponse.java -│ └── auctionhistory/ -│ ├── infrastructure/persistence/ -│ │ └── AuctionHistoryQueryDslRepository.java // 수정 -│ └── interfaces/rest/dto/request/ -│ ├── AuctionHistorySearchRequest.java // 수정 -│ └── ItemOptionSearchRequest.java // 새로 생성 -│ └── (17개 개별 Search Request) // 새로 생성 -└── auction_history_search_param.txt // 참고 문서 -``` - ---- - -## 🚨 주의사항 - -1. **display_order는 UNIQUE 제약** 조건 필수 -2. **Repository는 Port <-> PortImpl 패턴** 반드시 준수 -3. **PriceSearchRequest**는 기존 PricePerUnitSearchRequest에서 이름 변경된 것 (중복 구현 ❌) -4. **미정의 항목 (19-37번)** 제외하고 구현 -5. **Standard (UP/DOWN)**은 프론트에서 화살표 아이콘으로 표시 - - UP: ↑ (이상, `>=`) - - DOWN: ↓ (이하, `<=`) -6. **Flyway V, R 스크립트 모두 작성** 필수 -7. **JSON 필드는 required 포함** 버전 사용 - ---- - -## 📝 참고 자료 - -- 원본 파라미터 정의: `auction_history_search_param.txt` -- 프로젝트 구조: `CLAUDE.md` -- Clean Architecture 패턴: 기존 `auctionhistory` 도메인 참고 -- Flyway 컨벤션: `src/main/resources/db/migration/` 기존 스크립트 참고 - ---- - -**작성자:** Claude Code -**최종 승인:** 사용자 리뷰 완료 (2025-10-20) diff --git a/OPTION_DATA_STRUCTURE_ANALYSIS.md b/OPTION_DATA_STRUCTURE_ANALYSIS.md deleted file mode 100644 index 46e64fa..0000000 --- a/OPTION_DATA_STRUCTURE_ANALYSIS.md +++ /dev/null @@ -1,74 +0,0 @@ -# Auction Item Option 데이터 구조 분석 - -## DB 테이블: `auction_item_option` - -### 테이블 구조 -- `option_type`: VARCHAR(100) - 옵션 종류 (예: "밸런스", "크리티컬", "공격") -- `option_sub_type`: VARCHAR(100) - 옵션 서브 타입 -- `option_value`: VARCHAR(255) - 옵션 값 (텍스트) -- `option_value2`: VARCHAR(255) - 옵션 값2 (숫자 또는 텍스트) - -### 통계 (실제 데이터 분석) - -| option_type | 레코드 수 | distinct_value2 | 설명 | -|-------------|-----------|-----------------|------| -| 공격 | 1,349 | 124 | option_value2에 숫자 저장 | -| 밸런스 | 1,326 | 0 | option_value에 저장 가능 | -| 크리티컬 | 1,257 | 0 | option_value에 저장 가능 | -| 내구력 | 1,959 | 43 | option_value2에 숫자 저장 | -| 부상률 | 225 | 14 | option_value2에 숫자 저장 | -| 방어력 | 522 | 0 | option_value에 저장 가능 | -| 보호 | 154 | 0 | option_value에 저장 가능 | -| 남은 거래 횟수 | 2,788 | 0 | option_value에 저장 가능 | - -## 요구사항 매핑: Request DTO → DB option_type - -| Request DTO 필드 | DB option_type | 값 저장 위치 | 검색 방식 | -|------------------|----------------|-------------|----------| -| Balance | "밸런스" | option_value OR option_value2 | CAST 후 비교 | -| Critical | "크리티컬" | option_value OR option_value2 | CAST 후 비교 | -| Defense | "방어력" | option_value OR option_value2 | CAST 후 비교 | -| Erg | ? | ? | 미확인 | -| ErgRank | "에코스톤 등급"? | option_value | 문자열 비교 | -| MagicDefense | "마법 방어력" | option_value OR option_value2 | CAST 후 비교 | -| MagicProtect | "마법 보호" | option_value OR option_value2 | CAST 후 비교 | -| MaxAttack | "공격" | option_value2 | CAST 후 비교 | -| MaximumDurability | "내구력" | option_value2 | CAST 후 비교 | -| MaxInjuryRate | "부상률" | option_value2 | CAST 후 비교 | -| Proficiency | "숙련" | option_value OR option_value2 | CAST 후 비교 | -| Protect | "보호" | option_value OR option_value2 | CAST 후 비교 | -| RemainingTransactionCount | "남은 거래 횟수" | option_value | 숫자 파싱 후 비교 | -| RemainingUnsealCount | "남은 전용 해제 가능 횟수" | option_value | 숫자 파싱 후 비교 | -| RemainingUseCount | "남은 사용 횟수" | option_value | 숫자 파싱 후 비교 | -| WearingRestrictions | ? | option_value | 문자열 비교 | - -## QueryDSL 검색 로직 전략 - -### 1. 숫자 비교 (UP/DOWN 기준) -```java -// option_value2가 우선, 없으면 option_value 파싱 -BooleanExpression condition = aio.optionType.eq("밸런스") - .and( - aio.optionValue2.isNotNull() - .and(aio.optionValue2.castToNum(Integer.class).goe(value)) - .or( - aio.optionValue.contains(value.toString()) - ) - ); -``` - -### 2. 범위 검색 (From/To) -```java -BooleanExpression condition = aio.optionType.eq("공격") - .and(aio.optionValue2.castToNum(Integer.class).between(from, to)); -``` - -### 3. 문자열 검색 -```java -BooleanExpression condition = aio.optionValue.eq("S등급"); -``` - -## 주의사항 -- option_value와 option_value2 둘 다 확인 필요 -- 숫자 변환 시 CAST 또는 문자열 파싱 필요 -- NULL 체크 필수 diff --git a/auction_history_search_param.txt b/auction_history_search_param.txt deleted file mode 100644 index a122cb6..0000000 --- a/auction_history_search_param.txt +++ /dev/null @@ -1,37 +0,0 @@ -검색 옵션 파라미터명 파라미터 타입 파라미터명2 파라미터 타입3 -밸런스 Balance tinyint BalanceStandard string ('UP'|'DOWN') -크리티컬 Crtitical tinyint CrtiticalStandard string ('UP'|'DOWN') -방어력 Defense tinyint DefenseStandard string ('UP'|'DOWN') -에르그 ErgFrom tinyint ErgTo tinyint -에르그 등급 ErgRank string ('S등급'|'A등급'|'B등급') -마법 방어력 MagicDefense tinyint MagicDefenseStandard string ('UP'|'DOWN') -마법 보호 MagicProtect tinyint MagicProtectStandard string ('UP'|'DOWN') -최대 공격력 MaxAttackFrom int MaxAttackTo int -최대내구력 MaximumDurability tinyint MaximumDurabilityStandard string ('UP'|'DOWN') -최대 부상률 MaxInjuryRateFrom tinyint MaxInjuryRateTo tinyint -가격 PriceFrom long PriceTo long -숙련도 Proficiency tinyint ProficiencyStandard string ('UP'|'DOWN') -보호 Protect tinyint ProtectStandard string ('UP'|'DOWN') -남은 거래 횟수 RemainingTransactionCount tinyint RemainingTransactionCountStandard string ('UP'|'DOWN') -남은 전용 해제 가능 횟수 RemainingUnsealCount tinyint RemainingUnsealCountStandard string ('UP'|'DOWN') -남은 사용 횟수 RemainingUseCount tinyint RemainingUseCountStandard string ('UP'|'DOWN') -착용 제한 WearingRestrictions string -사용 효과 -색상 -세공 옵션 -세트 효과 -아이템 보호 -아이템 색상 -에코스톤 각성 능력 -에코스톤 고유 능력 -에코스톤 등급 -인챈트 -인챈트 불가능 -일반 개조 -장인 개조 -크기 -토템 효과 -특별 개조 -품질 -피어싱 레벨 -보석 개조 diff --git a/docker-compose-mysql.yaml b/docker-compose-mysql.yaml deleted file mode 100644 index 699ff39..0000000 --- a/docker-compose-mysql.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# local mysql server - -services: - mysql: - image: mysql:8.0 - container_name: open-api-batch-mysql - restart: unless-stopped - ports: - - "${DB_PORT}:3306" - environment: - MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} - MYSQL_DATABASE: ${DB_SCHEMA} - MYSQL_USER: ${DB_USER} - MYSQL_PASSWORD: ${DB_PASSWORD} - LANG: C.UTF_8 - TZ: Asia/Seoul - volumes: - - mysql_data:/var/lib/mysql - networks: - - my-network - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_0900_ai_ci - - --skip-character-set-client-handshake - - --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION - - --default-time-zone=+09:00 # MySQL 레벨 타임존 설정 - - --explicit_defaults_for_timestamp=1 # TIMESTAMP 기본값 명시 허용 - -volumes: - mysql_data: - -networks: - my-network: - driver: bridge \ No newline at end of file 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 8ff81bb..9de88c8 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 @@ -1,10 +1,6 @@ package until.the.eternity.auctionhistory.application.scheduler; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java index 30fb72a..3e4cad2 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryQueryDslRepository.java @@ -1,6 +1,8 @@ package until.the.eternity.auctionhistory.infrastructure.persistence; import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberTemplate; @@ -9,16 +11,21 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import until.the.eternity.auctionhistory.domain.entity.AuctionHistory; import until.the.eternity.auctionhistory.domain.entity.QAuctionHistory; import until.the.eternity.auctionhistory.interfaces.rest.dto.enums.SearchStandard; -import until.the.eternity.auctionhistory.interfaces.rest.dto.request.*; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.AuctionHistorySearchRequest; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.DateAuctionBuyRequest; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.ItemOptionSearchRequest; +import until.the.eternity.auctionhistory.interfaces.rest.dto.request.PriceSearchRequest; import until.the.eternity.auctionitemoption.domain.entity.QAuctionItemOption; @Component @@ -61,13 +68,17 @@ public Page search(AuctionHistorySearchRequest condition, Pageab } } - // 3단계: 모든 옵션과 함께 조회 (LEFT JOIN - 조건 없음!) + // 3단계: 정렬 조건 빌드 + List> orderSpecifiers = buildOrderSpecifiers(pageable, ah); + + // 4단계: 모든 옵션과 함께 조회 (LEFT JOIN - 조건 없음!) List content = queryFactory .selectFrom(ah) .leftJoin(ah.auctionItemOptions, aio) .fetchJoin() .where(historyBuilder) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) .distinct() // 중복 제거 .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -389,7 +400,7 @@ private BooleanExpression buildOptionCondition( valueCondition = numValue.eq(value); // 기본값: 같음 } - // 명시적으로 괄호를 추가하여 쿼리의 가독성을 높입니다 + // 명시적 괄호 추가 BooleanExpression combined = optionTypeCondition.and(valueCondition); return Expressions.booleanTemplate("({0})", combined); } @@ -399,4 +410,34 @@ private NumberTemplate castOptionValueToInt(QAuctionItemOption aio) { return Expressions.numberTemplate( Integer.class, "COALESCE({0}, {1}, 0)", aio.optionValue2, aio.optionValue); } + + /** Pageable의 Sort를 QueryDSL OrderSpecifier로 변환 */ + private List> buildOrderSpecifiers(Pageable pageable, QAuctionHistory ah) { + List> orders = new ArrayList<>(); + + if (pageable.getSort().isSorted()) { + for (Sort.Order order : pageable.getSort()) { + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + String property = order.getProperty(); + + // 필드명에 따라 QueryDSL 정렬 표현식 생성 + OrderSpecifier orderSpecifier = + switch (property) { + case "dateAuctionBuy" -> + new OrderSpecifier<>(direction, ah.dateAuctionBuy); + case "auctionPricePerUnit" -> + new OrderSpecifier<>(direction, ah.auctionPricePerUnit); + case "itemName" -> new OrderSpecifier<>(direction, ah.itemName); + default -> new OrderSpecifier<>(Order.DESC, ah.dateAuctionBuy); // 기본값 + }; + + orders.add(orderSpecifier); + } + } else { + // 정렬 조건이 없으면 기본 정렬은 최신 거래일자순: dateAuctionBuy DESC + orders.add(new OrderSpecifier<>(Order.DESC, ah.dateAuctionBuy)); + } + + return orders; + } } diff --git a/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfoId.java b/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfoId.java index 9e8db8e..d53cd3b 100644 --- a/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfoId.java +++ b/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfoId.java @@ -3,11 +3,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import java.io.Serializable; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Embeddable @Getter diff --git a/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java b/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java index 45f7b8d..7de499a 100644 --- a/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java +++ b/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java @@ -1,7 +1,8 @@ package until.the.eternity.metalwareinfo.application.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.DisplayName;