diff --git a/src/main/java/agridata/spring/controller/PriceController.java b/src/main/java/agridata/spring/controller/PriceController.java index 34b2740..c8a6ba4 100644 --- a/src/main/java/agridata/spring/controller/PriceController.java +++ b/src/main/java/agridata/spring/controller/PriceController.java @@ -20,7 +20,7 @@ @Slf4j @RestController @RequiredArgsConstructor -@RequestMapping("/retail") +@RequestMapping("/kamis") // /retail -> /kamis로 변경 (Kamis 파일들은 OpenAPI 기본 틀로 놔둘 예정) public class PriceController { private final KamisApiService kamisApiService; diff --git a/src/main/java/agridata/spring/controller/RetailPriceController.java b/src/main/java/agridata/spring/controller/RetailPriceController.java new file mode 100644 index 0000000..95d7786 --- /dev/null +++ b/src/main/java/agridata/spring/controller/RetailPriceController.java @@ -0,0 +1,130 @@ +package agridata.spring.controller; + +import agridata.spring.dto.response.RetailPriceResponseDTO; +import agridata.spring.global.ApiResponse; +import agridata.spring.service.RetailPriceApiService; +import agridata.spring.service.util.KamisCodeLoader; +import agridata.spring.service.util.KamisCodeMapper; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/retail") +public class RetailPriceController { + + private final RetailPriceApiService retailPriceApiService; + private final KamisCodeLoader kamisCodeLoader; + + @Operation(summary = "소매 가격 불러오기 API(품목 조회하기)", description = "소매 가격 데이터를 조회합니다. 품목, 지역(코드), 시작일, 마지막일을 받아 도매 가격 리스트를 반환합니다.") + @GetMapping("/prices") // 품목별 소매 가격 조회 + public ApiResponse> getRetailPrice( + @RequestParam String itemName, + @RequestParam(defaultValue = "") String countryCode, + @RequestParam String startDate, + @RequestParam String endDate + ) + + { + log.info("📥 소매 가격 조회 요청: itemName={}, countryCode={}, startDate={}, endDate={}", + itemName, countryCode, startDate, endDate); + + KamisCodeMapper.KamisCode code = kamisCodeLoader.getCode(itemName); + if (code == null) { + log.warn("🥬 지원하지 않는 품목명: '{}'", itemName); + return ApiResponse.onFailure("404", "지원하지 않는 품목명입니다: " + itemName, null); + } + + log.info("✅ 매핑된 코드: itemCode={}, kindCode={}, categoryCode={}, rankCode={}", + code.itemCode(), code.kindCode(), code.itemCategoryCode(), code.rankCode()); + + String xmlResponse = retailPriceApiService.getPriceData( + code.itemCode(), code.kindCode(), code.itemCategoryCode(), code.rankCode(), + countryCode, startDate, endDate + ); + + // 응답 원문 로그(Xml - ver) 출력 + log.debug("📄 응답 원문:\n{}", xmlResponse); + + try { + return ApiResponse.onSuccess(parseRetailPrice(xmlResponse)); + } catch (Exception e) { + log.error("❌ XML 파싱 실패", e); + return ApiResponse.onFailure("500", "XML 파싱 실패: " + e.getMessage(), null); + } + } + + private List parseRetailPrice(String xml){ + Document doc = Jsoup.parse(xml, "", org.jsoup.parser.Parser.xmlParser()); + + String condition = getText(doc, "condition", "N/A"); + String message = getText(doc, "error_code", "N/A"); + log.info("📡 KAMIS 응답 상태: {}, 메시지: {}", condition, message); + + Elements items = doc.getElementsByTag("item"); + log.info("📦 파싱된 item 개수: {}", items.size()); + + List resultList = new ArrayList<>(); + + for (Element item : items) { + String price = getTagText(item, "price"); + + if (price == null || price.isBlank()) { + log.debug("⛔️ price 누락 항목: {}", item.outerHtml()); + continue; + } + + String itemname = getTagText(item, "itemname"); + if (itemname == null || itemname.isBlank()) { + log.warn("⚠️ itemname 누락 항목 존재: {}", item.outerHtml()); + } + + String countyname = getTagText(item, "countyname"); + if(countyname == null || countyname.isBlank() || countyname.equals("평년") || countyname.equals("평균")) { + log.debug("지역 누락 항목:\n{}", item.outerHtml()); + continue; + } + + RetailPriceResponseDTO.RetailBasicDTO dto = RetailPriceResponseDTO.RetailBasicDTO.builder() + .itemname(getTagText(item, "itemname")) + .kindname(getTagText(item, "kindname")) + .countyname(getTagText(item, "countyname")) + .marketname(getTagText(item, "marketname")) + .yyyy(getTagText(item, "yyyy")) + .regday(getTagText(item, "regday")) + .price(price) + .build(); + resultList.add(dto); + } + log.info("최종 응답 항목 수: {}", resultList.size()); + return resultList; + } + + + private String getText(Document doc, String tag, String defaultValue) { + Element el = doc.selectFirst(tag); + return el != null ? el.text() : defaultValue; + } + + private String getTagText(Element element, String tag) { + Element el = element.selectFirst(tag); + return el != null ? el.text() : null; + } + + + + +} diff --git a/src/main/java/agridata/spring/controller/WholesalePriceController.java b/src/main/java/agridata/spring/controller/WholesalePriceController.java index cacf1a2..3d99461 100644 --- a/src/main/java/agridata/spring/controller/WholesalePriceController.java +++ b/src/main/java/agridata/spring/controller/WholesalePriceController.java @@ -50,7 +50,9 @@ public ApiResponse> getWholesalePrice( @RequestParam(defaultValue = "") String countryCode, @RequestParam String startDate, @RequestParam String endDate - ) { + ) + + { KamisCodeMapper.KamisCode code = kamisCodeLoader.getCode(itemName); if (code == null) { log.warn("지원하지 않는 품목명: '{}'", itemName); diff --git a/src/main/java/agridata/spring/domain/User.java b/src/main/java/agridata/spring/domain/User.java index eb6730b..8758239 100644 --- a/src/main/java/agridata/spring/domain/User.java +++ b/src/main/java/agridata/spring/domain/User.java @@ -19,6 +19,7 @@ public class User extends BaseEntity { private String nickname; private String password; + private String email; // 사용자 지역 diff --git a/src/main/java/agridata/spring/domain/enums/Region.java b/src/main/java/agridata/spring/domain/enums/Region.java index ff6853c..6b1db36 100644 --- a/src/main/java/agridata/spring/domain/enums/Region.java +++ b/src/main/java/agridata/spring/domain/enums/Region.java @@ -1,5 +1,5 @@ package agridata.spring.domain.enums; public enum Region { - 수도권, 경상권, 전남권 + 수도권, 관동권, 호서권, 호남권, 영남권, 제주권 } \ No newline at end of file diff --git a/src/main/java/agridata/spring/dto/response/RetailPriceResponseDTO.java b/src/main/java/agridata/spring/dto/response/RetailPriceResponseDTO.java new file mode 100644 index 0000000..ca1eebd --- /dev/null +++ b/src/main/java/agridata/spring/dto/response/RetailPriceResponseDTO.java @@ -0,0 +1,37 @@ +package agridata.spring.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +// Retail(소매) 응답 DTO 입니다. +public class RetailPriceResponseDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class RetailBasicDTO { + private String itemname; // 품목명 + private String kindname; // 품종명 + private String countyname; // 시군구 + private String marketname; // 마켓명 + private String yyyy; // 연도 + private String regday; // 날짜 + private String price; // 가격 + } + + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + // 응답이 배열 형태라면 리스트로 감싸야 하므로, 다음처럼도 사용 가능 + public static class KamisRetailWrapperDTO { + private List data; + } + +} diff --git a/src/main/java/agridata/spring/service/RetailPriceApiService.java b/src/main/java/agridata/spring/service/RetailPriceApiService.java new file mode 100644 index 0000000..169f038 --- /dev/null +++ b/src/main/java/agridata/spring/service/RetailPriceApiService.java @@ -0,0 +1,9 @@ +package agridata.spring.service; + +public interface RetailPriceApiService { + + public String getPriceData(String itemCode, String kindCode, String itemCategorycode, String rankCode, String countryCode, + String startDate, String endDate); + + +} diff --git a/src/main/java/agridata/spring/service/impl/RetailPriceApiServiceImpl.java b/src/main/java/agridata/spring/service/impl/RetailPriceApiServiceImpl.java new file mode 100644 index 0000000..667624f --- /dev/null +++ b/src/main/java/agridata/spring/service/impl/RetailPriceApiServiceImpl.java @@ -0,0 +1,68 @@ +package agridata.spring.service.impl; + +import agridata.spring.service.RetailPriceApiService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class RetailPriceApiServiceImpl implements RetailPriceApiService { + + private final RestTemplate restTemplate; + + @Value("${kamis.api-key}") + private String apiKey; + + @Value("${kamis.cert-id}") + private String certId; + + @Override + public String getPriceData(String itemCode, String kindCode, String itemCategoryCode, String rankCode, + String countryCode, String startDate, String endDate) { + + StringBuilder urlBuilder = new StringBuilder("https://www.kamis.or.kr/service/price/xml.do"); + urlBuilder.append("?action=periodProductList") + .append("&p_cert_key=").append(apiKey) + .append("&p_cert_id=").append(certId) + .append("&p_returntype=xml") + .append("&p_productclscode=01") // 01: 소매, 02: 도매 + .append("&p_itemcategorycode=").append(itemCategoryCode) + .append("&p_itemcode=").append(itemCode) + .append("&p_countrycode=").append(countryCode) + .append("&p_convert_kg_yn=Y") + .append("&p_startday=").append(formatDate(startDate)) + .append("&p_endday=").append(formatDate(endDate)); + + if (kindCode != null && !kindCode.isBlank()) { + urlBuilder.append("&p_kindcode=").append(kindCode); + } + if (rankCode != null && !rankCode.isBlank()) { + urlBuilder.append("&p_productrankcode=").append(rankCode); + } + + String url = urlBuilder.toString(); + log.info("📡 KAMIS 요청 URL: {}", url); + + HttpHeaders headers = new HttpHeaders(); + headers.set("User-Agent", "Mozilla/5.0"); + + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + return response.getBody(); + } + + private String formatDate(String yyyymmdd) { + return yyyymmdd.substring(0, 4) + "-" + yyyymmdd.substring(4, 6) + "-" + yyyymmdd.substring(6, 8); + } +}