diff --git a/docs/ISSUE_MANAGEMENT.md b/docs/ISSUE_MANAGEMENT.md new file mode 100644 index 00000000..89b58664 --- /dev/null +++ b/docs/ISSUE_MANAGEMENT.md @@ -0,0 +1,109 @@ +# 이슈 운영 가이드 + +이슈가 늘어나도 분류 기준이 흔들리지 않도록 라벨·타입·마일스톤·관계의 사용 규칙을 고정한다. + +## 제목 + +`.github/ISSUE_TEMPLATE` 규칙을 따른다. + +``` +[ 영역 ] 내용 +``` + +- 대괄호 안에 **공백**을 넣는다. `[ Auth ]` (O), `[Auth]` (X) +- 영역은 도메인 또는 관심사. `Core` `Auth` `Infra` `Booking` `Notification` `Child` `Policy` `Search` `PublicData` `CI` +- 커밋 메시지 규칙(`TYPE : 한글 설명 (#이슈번호)`)과는 별개다. 섞지 않는다. + +## Type (GitHub Issue Type) + +한 이슈에 하나만 붙는다. 라벨 `type:*` 과 중복돼 보이지만, Type 은 **GitHub 전역 집계·필터**에 쓰이고 `type:*` 라벨은 저장소 안에서 더 세분화된 성격을 나타낸다. + +| Type | 기준 | +|---|---| +| `Bug` | 지금 동작이 잘못됐다. 기동 실패, 잘못된 결과, 보안 결함, 설정이 효력 없음 | +| `Feature` | 없던 것을 만든다 | +| `Task` | 동작은 그대로인데 주변을 정비한다. 테스트, CI, 문서, 리팩토링, 트래킹 | + +성능 결함은 **의도한 결과가 나오지 않는 것**이면 `Bug`, 단순히 더 빠르게 만드는 것이면 `Task` 다. + +## 라벨 + +### priority + +실행 순서를 정하는 유일한 기준이다. 담당자가 다음에 뭘 할지 고를 때 이것만 본다. + +| 라벨 | 기준 | 대응 | +|---|---|---| +| `priority:P0` | 서비스가 지금 망가져 있다. 기동 불가, 인가 우회, 데이터 유출 | 즉시, 다른 작업 중단 | +| `priority:P1` | 사용자가 체감하는 오류이거나 배포를 막는다 | 현재 스프린트 안 | +| `priority:P2` | 개선하면 좋지만 미루어도 손해가 누적되지 않는다 | 여유 있을 때 | + +판단이 갈리면 **"내일 아침 배포를 막을 일인가"** 를 묻는다. 막으면 P0/P1. + +### domain + +건드리는 코드의 위치. 한 이슈가 여러 도메인에 걸치면 **가장 무게가 실린 쪽 하나**만 붙인다. + +| 라벨 | 범위 | +|---|---| +| `domain:core` | 공통 유틸, 보안 필터, 전역 예외, 설정 클래스 | +| `domain:api` | 컨트롤러 계약, 요청/응답 스키마, 페이징 | +| `domain:infra` | 빌드, CI/CD, 배포, Docker, 마이그레이션 실행 | +| `domain:data` | 시드 데이터, 외부 데이터 연동, 마이그레이션 내용 | +| `domain:health` | 건강 기록, 예방접종, 성장, 병원 | +| `domain:search` | 검색, 추천, 랭킹 | + +### type + +Type 필드를 세분화한다. 복수 부착 가능. + +`type:bug` `type:feature` `type:perf` `type:security` `type:test` `type:tech-debt` + +`type:security` 는 항상 `priority:P0` 또는 `P1` 이다. 보안 결함에 P2 를 붙이지 않는다. + +## 마일스톤 + +**언제 나가야 하는가**로 나눈다. 크기나 난이도가 아니다. + +| 마일스톤 | 기준 | +|---|---| +| `Hotfix` | 다음 배포를 기다릴 수 없다. P0 는 여기로 | +| `API Stabilization` | 클라이언트가 붙기 전에 계약이 확정돼야 한다 | +| `Refactor Sprint` | 동작을 바꾸지 않고 구조를 정리한다 | +| `Production Readiness` | 운영에 올리기 전에 갖춰야 한다. 관측, 배포, 테스트, 데이터 | + +## Relationships (하위 이슈) + +여러 이슈에 걸친 작업은 **상위 트래킹 이슈**를 만들고 하위 이슈로 연결한다. 본문에 번호를 나열만 하지 말고 실제 sub-issue 로 등록한다 — 진행률이 자동 집계된다. + +- 상위 이슈는 `Task` 타입 +- 상위 이슈 본문의 마지막 To Do 는 항상 `PR 머지 후 본 이슈 종료` +- 하위 이슈는 **독립적으로 닫을 수 있는 단위**여야 한다. 나눌 수 없으면 하나로 둔다 + +예: #40 (검토 제안 트래킹) ← #41~#52 / #64 (백엔드 전수 점검) ← #53~#59, #61, #63 + +## 신규 이슈 체크리스트 + +- [ ] 제목이 `[ 영역 ] 내용` 형식인가 +- [ ] Type 을 지정했는가 +- [ ] `priority:*` 를 붙였는가 (없으면 실행 순서에서 누락된다) +- [ ] `domain:*` 을 하나 붙였는가 +- [ ] 마일스톤을 배정했는가 +- [ ] 프로젝트 보드에 올렸는가 +- [ ] 상위 작업의 일부라면 sub-issue 로 연결했는가 +- [ ] To Do 가 **검증 가능한 단위**로 쪼개져 있는가 + +## 종료 기준 + +- 계획한 To Do 가 전부 체크됐고 코드가 기본 브랜치에 올라갔을 때 닫는다 +- PR 로 해소되는 이슈는 PR 본문에 `Closes #번호` 를 넣어 **머지 시 자동으로 닫히게** 한다. 수동으로 미리 닫지 않는다 +- **부분 완료는 닫지 않는다.** 완료된 To Do 를 체크하고 남은 범위를 코멘트로 명시한 뒤 열어 둔다 +- 다른 이슈가 같은 범위를 완전히 흡수했으면 `duplicate` 사유로 닫고 흡수한 이슈를 링크한다 + +## 정기 점검 + +월 1회 다음을 확인한다. + +- Type/`priority:*`/마일스톤이 비어 있는 열린 이슈 +- 90일 이상 갱신 없는 이슈 — 여전히 유효한지, 아니면 `not planned` 로 닫을지 +- 상위 트래킹 이슈의 하위 진행률과 본문 To Do 가 어긋나지 않는지 diff --git a/src/main/java/com/carecode/core/client/XmlResponseParser.java b/src/main/java/com/carecode/core/client/XmlResponseParser.java new file mode 100644 index 00000000..639c1429 --- /dev/null +++ b/src/main/java/com/carecode/core/client/XmlResponseParser.java @@ -0,0 +1,93 @@ +package com.carecode.core.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.StringReader; + +/** XML 응답을 JsonNode 로 변환한다. */ +@Component +public class XmlResponseParser { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** XML 문자열을 JsonNode 트리로 변환한다. */ + public JsonNode parse(String xml) { + if (xml == null || xml.isBlank()) { + return null; + } + try { + Document document = newSecureBuilder().parse(new InputSource(new StringReader(xml))); + document.getDocumentElement().normalize(); + return toNode(document.getDocumentElement()); + } catch (Exception e) { + return null; + } + } + + /** 외부 엔티티 참조(XXE)를 차단한 DocumentBuilder. */ + private DocumentBuilder newSecureBuilder() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder(); + } + + private JsonNode toNode(Element element) { + NodeList children = element.getChildNodes(); + + boolean hasElementChild = false; + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + hasElementChild = true; + break; + } + } + + // 자식 엘리먼트가 없으면 텍스트 값 노드 + if (!hasElementChild) { + String text = element.getTextContent(); + return objectMapper.getNodeFactory().textNode(text != null ? text.trim() : ""); + } + + ObjectNode node = objectMapper.createObjectNode(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + Element childElement = (Element) child; + String name = childElement.getNodeName(); + JsonNode childNode = toNode(childElement); + + JsonNode existing = node.get(name); + if (existing == null) { + node.set(name, childNode); + } else if (existing.isArray()) { + // 같은 이름이 반복되면 배열로 모은다 (......) + ((ArrayNode) existing).add(childNode); + } else { + ArrayNode array = objectMapper.createArrayNode(); + array.add(existing); + array.add(childNode); + node.set(name, array); + } + } + return node; + } +} diff --git a/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java b/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java index 883d4d50..56bc0129 100644 --- a/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java +++ b/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java @@ -1,82 +1,55 @@ package com.carecode.core.client.constants; -/** - * 공공데이터 API 관련 상수 - * API 엔드포인트, 파라미터명, 응답 코드 등을 상수로 관리 - */ -public class PublicDataApiConstants { +import java.util.Map; - // API 응답 코드 - public static final String SUCCESS_CODE = "00"; - public static final String ERROR_CODE = "99"; +/** 공공데이터 API 공통 상수. */ +public final class PublicDataApiConstants { - // API 파라미터명 - public static final String PARAM_SERVICE_KEY = "serviceKey"; - public static final String PARAM_PAGE_NO = "pageNo"; - public static final String PARAM_NUM_OF_ROWS = "numOfRows"; - public static final String PARAM_TYPE = "type"; - public static final String PARAM_SIDO_CD = "sidoCd"; - public static final String PARAM_POLICY_TYPE = "policyType"; - public static final String PARAM_EDUCATION_TYPE = "educationType"; + /** 공공데이터포털 공통 응답 코드. */ + public static final String RESULT_CODE_SUCCESS = "00"; - // API 응답 타입 - public static final String RESPONSE_TYPE_JSON = "json"; - public static final String RESPONSE_TYPE_XML = "xml"; - - // 육아 관련 API 엔드포인트 - public static final String ENDPOINT_CHILDCARE_FACILITIES = "/getChildcareFacilities"; - public static final String ENDPOINT_CHILDCARE_POLICIES = "/getChildcarePolicies"; - public static final String ENDPOINT_PEDIATRIC_HOSPITALS = "/getPediatricHospitals"; - public static final String ENDPOINT_CHILDCARE_SUBSIDIES = "/getChildcareSubsidies"; - public static final String ENDPOINT_CHILDCARE_EDUCATION = "/getChildcareEducation"; - - // 지역 코드 - public static final String REGION_SEOUL = "11"; - public static final String REGION_BUSAN = "21"; - public static final String REGION_DAEGU = "22"; - public static final String REGION_INCHEON = "23"; - public static final String REGION_GWANGJU = "24"; - public static final String REGION_DAEJEON = "25"; - public static final String REGION_ULSAN = "26"; - public static final String REGION_SEJONG = "29"; - public static final String REGION_GYEONGGI = "31"; - public static final String REGION_GANGWON = "32"; - public static final String REGION_CHUNGBUK = "33"; - public static final String REGION_CHUNGNAM = "34"; - public static final String REGION_JEONBUK = "35"; - public static final String REGION_JEONNAM = "36"; - public static final String REGION_GYEONGBUK = "37"; - public static final String REGION_GYEONGNAM = "38"; - public static final String REGION_JEJU = "39"; - - // 정책 유형 - public static final String POLICY_TYPE_SUBSIDY = "subsidy"; - public static final String POLICY_TYPE_FACILITY = "facility"; - public static final String POLICY_TYPE_EDUCATION = "education"; - public static final String POLICY_TYPE_MEDICAL = "medical"; - - // 교육 유형 - public static final String EDUCATION_TYPE_PARENTING = "parenting"; - public static final String EDUCATION_TYPE_CHILDCARE = "childcare"; - public static final String EDUCATION_TYPE_SAFETY = "safety"; - public static final String EDUCATION_TYPE_HEALTH = "health"; - - // 기본 페이지 설정 - public static final int DEFAULT_PAGE_NO = 1; - public static final int DEFAULT_NUM_OF_ROWS = 10; + /** 한 번에 요청할 수 있는 최대 건수 (서울 열린데이터광장 기준). */ public static final int MAX_NUM_OF_ROWS = 1000; - // 타임아웃 설정 (밀리초) - public static final int CONNECT_TIMEOUT = 10000; - public static final int READ_TIMEOUT = 30000; - - // 에러 메시지 - public static final String ERROR_MSG_API_CALL_FAILED = "API 호출에 실패했습니다."; - public static final String ERROR_MSG_INVALID_RESPONSE = "잘못된 응답입니다."; - public static final String ERROR_MSG_TIMEOUT = "요청 시간이 초과되었습니다."; - public static final String ERROR_MSG_NETWORK_ERROR = "네트워크 오류가 발생했습니다."; + /** 시도명 → 법정동 시도 코드. */ + public static final Map SIDO_CODES = Map.ofEntries( + Map.entry("서울특별시", "11"), + Map.entry("부산광역시", "21"), + Map.entry("대구광역시", "22"), + Map.entry("인천광역시", "23"), + Map.entry("광주광역시", "24"), + Map.entry("대전광역시", "25"), + Map.entry("울산광역시", "26"), + Map.entry("세종특별자치시", "29"), + Map.entry("경기도", "31"), + Map.entry("강원특별자치도", "32"), + Map.entry("충청북도", "33"), + Map.entry("충청남도", "34"), + Map.entry("전북특별자치도", "35"), + Map.entry("전라남도", "36"), + Map.entry("경상북도", "37"), + Map.entry("경상남도", "38"), + Map.entry("제주특별자치도", "39")); private PublicDataApiConstants() { // 유틸리티 클래스이므로 인스턴스화 방지 } -} \ No newline at end of file + + /** 시도명으로 시도 코드를 찾는다. */ + public static String findSidoCode(String sidoName) { + if (sidoName == null || sidoName.isBlank()) { + return null; + } + String normalized = sidoName.trim(); + + String exact = SIDO_CODES.get(normalized); + if (exact != null) { + return exact; + } + return SIDO_CODES.entrySet().stream() + .filter(e -> e.getKey().startsWith(normalized) || normalized.startsWith(e.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java new file mode 100644 index 00000000..39ff9203 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java @@ -0,0 +1,99 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** 공공데이터포털(data.go.kr) 공급자. */ +@Slf4j +@Component +public class DataGoKrProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "DATA_GO_KR"; + + private final RestTemplate restTemplate; + private final String serviceKey; + private final String baseUrl; + + public DataGoKrProvider(RestTemplate restTemplate, + @Value("${public.data.datagokr.service-key:}") String serviceKey, + @Value("${public.data.datagokr.base-url:https://apis.data.go.kr}") String baseUrl) { + this.restTemplate = restTemplate; + this.serviceKey = serviceKey; + this.baseUrl = stripTrailingSlash(baseUrl); + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return serviceKey != null && !serviceKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("공공데이터포털 서비스 키가 설정되지 않았습니다."); + } + + UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl(toAbsoluteUrl(resource)) + .queryParam("pageNo", pageNo) + .queryParam("numOfRows", numOfRows); + + // 응답 포맷 파라미터는 데이터셋마다 이름이 다르다(type / _type). + // 호출부가 지정하지 않았을 때만 기본값을 넣는다 — 지정했는데 또 넣으면 파라미터가 중복된다. + boolean formatSpecified = params != null + && (params.containsKey("type") || params.containsKey("_type")); + if (!formatSpecified) { + builder.queryParam("type", "json"); + } + + if (params != null) { + params.forEach((k, v) -> { + if (v != null && !v.isBlank()) { + builder.queryParam(k, v); + } + }); + } + + // serviceKey 는 이미 인코딩된 값이므로 빌더를 거치지 않고 직접 이어 붙인다. + String encoded = builder.encode(StandardCharsets.UTF_8).toUriString(); + String url = encoded + "&serviceKey=" + serviceKey; + + log.debug("공공데이터포털 호출: resource={}, page={}, rows={}", resource, pageNo, numOfRows); + + try { + return restTemplate.getForObject(URI.create(url), String.class); + } catch (Exception e) { + throw new PublicDataApiException( + "공공데이터포털 호출 실패: resource=" + resource + ", 사유=" + e.getMessage(), e); + } + } + + /** 표준데이터처럼 다른 호스트에 있는 데이터셋은 절대 URL 로 지정할 수 있게 한다. */ + private String toAbsoluteUrl(String resource) { + if (resource != null && (resource.startsWith("http://") || resource.startsWith("https://"))) { + return resource; + } + return baseUrl + "/" + stripLeadingSlash(resource); + } + + private String stripTrailingSlash(String url) { + return url != null && url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private String stripLeadingSlash(String path) { + return path != null && path.startsWith("/") ? path.substring(1) : path; + } +} diff --git a/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java b/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java new file mode 100644 index 00000000..00c4b669 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java @@ -0,0 +1,15 @@ +package com.carecode.core.client.provider; + +import java.util.Map; + +/** 공공데이터 공급자 추상화. 공급자별 인증 방식과 URL 규격 차이를 구현체가 흡수한다. */ +public interface PublicDataProvider { + + String getProviderName(); + + /** API 키가 없으면 false. 호출부가 조용히 건너뛴다. */ + boolean isAvailable(); + + /** 원본 응답 본문을 반환한다. pageNo는 1부터 시작. */ + String fetch(String resource, int pageNo, int numOfRows, Map params); +} diff --git a/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java b/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java new file mode 100644 index 00000000..57a038c2 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java @@ -0,0 +1,67 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** 서울 열린데이터광장 공급자. 페이지 번호 대신 시작·종료 인덱스를 URL 경로에 넣는다. */ +@Slf4j +@Component +public class SeoulOpenDataProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "SEOUL_OPEN_DATA"; + + private static final int MAX_ROWS = 1000; + + private final RestTemplate restTemplate; + private final String apiKey; + private final String baseUrl; + + public SeoulOpenDataProvider(RestTemplate restTemplate, + @Value("${public.data.seoul.api-key:${public.data.api.key:}}") String apiKey, + @Value("${public.data.seoul.base-url:http://openapi.seoul.go.kr:8088}") String baseUrl) { + this.restTemplate = restTemplate; + this.apiKey = apiKey; + this.baseUrl = stripTrailingSlash(baseUrl); + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return apiKey != null && !apiKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("서울 열린데이터광장 API 키가 설정되지 않았습니다."); + } + + int rows = Math.min(numOfRows, MAX_ROWS); + int startIndex = (pageNo - 1) * rows + 1; + int endIndex = pageNo * rows; + + String url = String.format("%s/%s/json/%s/%d/%d/", baseUrl, apiKey, resource, startIndex, endIndex); + log.debug("서울 열린데이터광장 호출: service={}, {}~{}", resource, startIndex, endIndex); + + try { + return restTemplate.getForObject(url, String.class); + } catch (Exception e) { + // API 키가 URL 경로에 들어가므로 예외 메시지에 URL 을 넣지 않는다. + throw new PublicDataApiException( + "서울 열린데이터광장 호출 실패: service=" + resource + ", 사유=" + e.getMessage(), e); + } + } + + private String stripTrailingSlash(String url) { + return url != null && url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java new file mode 100644 index 00000000..bccd1766 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java @@ -0,0 +1,131 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** 시설 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CareFacilityUpsertService { + + private final CareFacilityRepository careFacilityRepository; + + /** 시설 코드 기준 upsert. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row) { + String facilityCode = text(row, "STCODE", "crcodeCd", "crcode"); + if (facilityCode == null) { + throw new IllegalArgumentException("시설 코드가 없는 응답입니다."); + } + + CareFacility facility = careFacilityRepository.findByFacilityCode(facilityCode).orElse(null); + boolean isNew = facility == null; + if (isNew) { + facility = CareFacility.builder() + .facilityCode(facilityCode) + .isActive(true) + .viewCount(0) + .build(); + } + + applyIfPresent(text(row, "CRNAME", "crname"), facility::setName); + applyIfPresent(text(row, "CRADDR", "craddr"), facility::setAddress); + applyIfPresent(text(row, "CRTELNO", "crtelno"), facility::setPhone); + applyIfPresent(text(row, "CRHOME", "crhome"), facility::setWebsite); + + String typeName = text(row, "CRTYPENAME", "crtypeName"); + if (typeName != null) { + facility.setFacilityType(resolveType(typeName)); + } else if (isNew) { + facility.setFacilityType(FacilityType.DAYCARE); + } + + Integer capacity = integer(row, "CRCAPAT", "crcapat"); + if (capacity != null) { + facility.setCapacity(capacity); + } + Integer enrollment = integer(row, "CRCHCNT", "crchcnt"); + if (enrollment != null) { + facility.setCurrentEnrollment(enrollment); + Integer effectiveCapacity = capacity != null ? capacity : facility.getCapacity(); + if (effectiveCapacity != null) { + facility.setAvailableSpots(Math.max(0, effectiveCapacity - enrollment)); + } + } + + Double lat = decimal(row, "LA", "la", "LAT"); + Double lng = decimal(row, "LO", "lo", "LNG"); + if (lat != null && lng != null) { + facility.setLatitude(lat); + facility.setLongitude(lng); + } + + facility.setUpdatedAt(LocalDateTime.now()); + careFacilityRepository.save(facility); + return isNew; + } + + private void applyIfPresent(String value, java.util.function.Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + /** 응답 필드명이 대문자/카멜케이스로 섞여 오는 경우가 있어 후보를 순서대로 본다. */ + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Integer integer(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value.replaceAll("[^0-9-]", "")); + } catch (NumberFormatException e) { + return null; + } + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } + + private FacilityType resolveType(String typeName) { + if (typeName.contains("유치원")) { + return FacilityType.KINDERGARTEN; + } + if (typeName.contains("어린이집")) { + return FacilityType.DAYCARE; + } + return FacilityType.OTHER; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java new file mode 100644 index 00000000..f68e6ab6 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java @@ -0,0 +1,64 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** 정부 지원 서비스(보조금24) 동기화. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GovernmentBenefitSyncService { + + private static final int ROWS_PER_PAGE = 100; + + /** 육아 관련 서비스만 적재하기 위한 키워드. 하나라도 포함되면 대상으로 본다. */ + private static final List CARE_KEYWORDS = List.of( + "육아", "출산", "임신", "보육", "양육", "아동", "어린이", "영유아", + "유아", "산모", "신생아", "돌봄", "child", "어린이집", "유치원"); + + private final DataGoKrProvider provider; + private final PolicyUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + @Value("${public.data.resource.benefit:1741000/publicServiceInformations/publicServiceInformation}") + private String resource; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("정부지원서비스") + .rowsPerPage(ROWS_PER_PAGE) + .filter(this::isCareRelated) + .upsert(upsertService::upsert) + .build()); + } + + /** 서비스명·요약·분야 중 하나라도 육아 키워드를 포함하면 적재 대상. */ + private boolean isCareRelated(JsonNode row) { + String haystack = String.join(" ", + text(row, "서비스명", "servNm", "SVC_NM"), + text(row, "서비스목적요약", "servDgst", "SVC_DGST"), + text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM"), + text(row, "지원대상", "trgterIndvdlArray")) + .toLowerCase(); + + return CARE_KEYWORDS.stream().anyMatch(k -> haystack.contains(k.toLowerCase())); + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + return node.asText().trim(); + } + } + return ""; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java new file mode 100644 index 00000000..cf4cf97f --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java @@ -0,0 +1,90 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.health.entity.Hospital; +import com.carecode.domain.health.repository.HospitalRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 병원 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HospitalUpsertService { + + private final HospitalRepository hospitalRepository; + + /** 요양기호(ykiho) 기준 upsert. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row, String defaultType) { + String ykiho = text(row, "ykiho", "YKIHO"); + if (ykiho == null) { + throw new IllegalArgumentException("요양기호(ykiho)가 없는 응답입니다."); + } + + Hospital hospital = hospitalRepository.findByExternalCode(ykiho).orElse(null); + boolean isNew = hospital == null; + if (isNew) { + hospital = Hospital.builder().externalCode(ykiho).build(); + } + + String name = text(row, "yadmNm", "YADMNM"); + if (name != null) { + hospital.setName(name); + } else if (isNew) { + // name 은 NOT NULL 이라 값이 없으면 저장할 수 없다. + throw new IllegalArgumentException("요양기관명이 없는 응답입니다: ykiho=" + ykiho); + } + + applyIfPresent(text(row, "addr", "ADDR"), hospital::setAddress); + applyIfPresent(text(row, "telno", "TELNO"), hospital::setPhone); + + String clCdNm = text(row, "clCdNm", "CLCDNM"); + hospital.setType(clCdNm != null ? clCdNm : defaultType); + + // 심평원 좌표는 XPos=경도, YPos=위도 순서다. 뒤집으면 지도에서 엉뚱한 위치가 나온다. + Double lng = decimal(row, "XPos", "XPOS"); + Double lat = decimal(row, "YPos", "YPOS"); + if (lat != null && lng != null) { + hospital.setLatitude(lat); + hospital.setLongitude(lng); + } + + hospitalRepository.save(hospital); + return isNew; + } + + private void applyIfPresent(String value, java.util.function.Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java b/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java new file mode 100644 index 00000000..502b8a92 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java @@ -0,0 +1,35 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** 전국 유치원 표준데이터 동기화. 어린이집만 있고 유치원이 비어 있던 공백을 메운다. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KindergartenSyncService { + + private static final int ROWS_PER_PAGE = 500; + + private final DataGoKrProvider provider; + private final KindergartenUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + /** 표준데이터는 apis.data.go.kr 이 아닌 별도 호스트라 절대 URL 로 지정한다. */ + @Value("${public.data.resource.kindergarten:" + + "http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api}") + private String resource; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("전국유치원") + .rowsPerPage(ROWS_PER_PAGE) + .upsert(upsertService::upsert) + .build()); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java new file mode 100644 index 00000000..3c966788 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java @@ -0,0 +1,155 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.function.Consumer; + +/** 유치원 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KindergartenUpsertService { + + /** 어린이집 시설 코드와 충돌하지 않도록 구분한다. */ + public static final String CODE_PREFIX = "KG-"; + + private final CareFacilityRepository careFacilityRepository; + + /** 시설 코드 기준 upsert. 표준데이터에 고유 코드가 없으면 이름+주소로 만들어 쓴다. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row) { + String name = text(row, "유치원명", "kindrgrtnNm", "KINDER_NM"); + String address = text(row, "소재지도로명주소", "rdnmadr", "소재지지번주소", "lnmadr", "ADDR"); + if (name == null) { + throw new IllegalArgumentException("유치원명이 없는 응답입니다."); + } + + String facilityCode = resolveCode(row, name, address); + CareFacility facility = careFacilityRepository.findByFacilityCode(facilityCode).orElse(null); + boolean isNew = facility == null; + if (isNew) { + facility = CareFacility.builder() + .facilityCode(facilityCode) + .facilityType(FacilityType.KINDERGARTEN) + .isActive(true) + .viewCount(0) + .build(); + } + + facility.setName(name); + applyIfPresent(address, facility::setAddress); + applyIfPresent(text(row, "전화번호", "telno", "TEL"), facility::setPhone); + applyIfPresent(text(row, "홈페이지주소", "homepageAddr", "HOMEPAGE"), facility::setWebsite); + applyIfPresent(text(row, "운영시간", "operPdCn"), facility::setOperatingHours); + applyIfPresent(text(row, "시도명", "ctprvnNm"), facility::setCity); + applyIfPresent(text(row, "시군구명", "signguNm"), facility::setDistrict); + + // 국공립 여부는 설립유형에서 판단한다. 사립은 비용 부담이 달라 사용자에게 중요한 구분이다. + String establishment = text(row, "설립유형", "estblshSe", "설립구분"); + if (establishment != null) { + facility.setIsPublic(establishment.contains("공립") || establishment.contains("국립")); + } + + Integer capacity = integer(row, "정원", "fixnum", "TOTAL_CAPACITY"); + if (capacity != null) { + facility.setCapacity(capacity); + } + Integer enrollment = integer(row, "현원", "nowNmpr", "CURRENT_CNT"); + if (enrollment != null) { + facility.setCurrentEnrollment(enrollment); + Integer effectiveCapacity = capacity != null ? capacity : facility.getCapacity(); + if (effectiveCapacity != null) { + facility.setAvailableSpots(Math.max(0, effectiveCapacity - enrollment)); + } + } + + Double lat = decimal(row, "위도", "latitude", "LAT"); + Double lng = decimal(row, "경도", "longitude", "LNG"); + if (lat != null && lng != null) { + facility.setLatitude(lat); + facility.setLongitude(lng); + } + + facility.setUpdatedAt(LocalDateTime.now()); + careFacilityRepository.save(facility); + return isNew; + } + + /** + * 표준데이터는 고유 식별자를 주지 않는 경우가 많다. + * 이름+주소 해시를 코드로 쓰면 재동기화 때 같은 유치원이 중복 생성되지 않는다. + */ + private String resolveCode(JsonNode row, String name, String address) { + String external = text(row, "유치원코드", "kindrgrtnCode", "KINDER_CD"); + if (external != null) { + return CODE_PREFIX + external; + } + String naturalKey = name + "|" + (address != null ? address : ""); + return CODE_PREFIX + hash(naturalKey); + } + + private String hash(String value) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest, 0, 12); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 을 사용할 수 없습니다.", e); + } + } + + private void applyIfPresent(String value, Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + /** 표준데이터는 한글 필드명, 오픈API 는 영문 필드명을 쓰므로 후보를 순서대로 본다. */ + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Integer integer(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value.replaceAll("[^0-9-]", "")); + } catch (NumberFormatException e) { + return null; + } + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java new file mode 100644 index 00000000..cf49d9d8 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java @@ -0,0 +1,34 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** 전국 어린이집 정보 동기화. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NationwideChildcareFacilitySyncService { + + private static final int ROWS_PER_PAGE = 500; + + private final DataGoKrProvider provider; + private final CareFacilityUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + /** 데이터셋 경로는 개편될 수 있어 재배포 없이 바꿀 수 있게 프로퍼티로 둔다. */ + @Value("${public.data.resource.childcare:B551014/CCEF/childcare}") + private String resource; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("전국어린이집") + .rowsPerPage(ROWS_PER_PAGE) + .upsert(upsertService::upsert) + .build()); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java b/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java new file mode 100644 index 00000000..5df96fad --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java @@ -0,0 +1,160 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.XmlResponseParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** 공공데이터 페이징 수집 공통 절차. 데이터셋별 차이는 SyncSpec 으로 받는다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PagedSyncTemplate { + + private final ObjectMapper objectMapper; + private final XmlResponseParser xmlResponseParser; + + /** 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다 — 조용한 중단은 하지 않는다. */ + @Value("${public.data.sync.max-pages:200}") + private int maxPages; + + public SyncResult run(SyncSpec spec) { + SyncResult result = new SyncResult(spec.getProvider().getProviderName(), spec.getLabel()); + + if (!spec.getProvider().isAvailable()) { + result.stop("서비스 키 미설정"); + log.info("{} 동기화 건너뜀 - 서비스 키가 없습니다.", spec.getLabel()); + return result; + } + + for (int page = 1; page <= maxPages; page++) { + JsonNode rows; + try { + String body = spec.getProvider() + .fetch(spec.getResource(), page, spec.getRowsPerPage(), spec.getParams()); + rows = extractRows(body); + } catch (Exception e) { + log.error("{} 조회 실패 - page={}", spec.getLabel(), page, e); + result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); + return result; + } + + if (rows == null || !rows.isArray() || rows.isEmpty()) { + return result; // 더 이상 데이터 없음 — 정상 종료 + } + if (page == 1) { + logFieldNames(spec.getLabel(), rows.get(0)); + } + + int attempted = 0; + int failedInPage = 0; + for (JsonNode row : rows) { + if (!spec.getFilter().test(row)) { + result.countSkipped(); + continue; + } + attempted++; + try { + if (Boolean.TRUE.equals(spec.getUpsert().apply(row))) { + result.countCreated(); + } else { + result.countUpdated(); + } + } catch (Exception e) { + // 한 건 실패가 배치 전체를 중단시키지 않는다. + result.countFailed(); + failedInPage++; + log.warn("{} 저장 실패: {}", spec.getLabel(), e.getMessage()); + } + } + result.countPage(); + + // 첫 페이지가 전멸이면 응답 스펙이 바뀐 것이다. 200페이지를 헛돌지 않고 멈춘다. + if (page == 1 && attempted > 0 && failedInPage == attempted) { + result.stop("첫 페이지 전건 실패 - 응답 필드 매핑 불일치 의심"); + log.error("{} 동기화 중단 - 첫 페이지 {}건이 모두 실패했습니다. 응답 필드명을 확인하세요.", + spec.getLabel(), attempted); + return result; + } + + if (rows.size() < spec.getRowsPerPage()) { + return result; // 마지막 페이지 + } + } + + result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); + log.warn("{} 동기화가 페이지 상한에 걸렸습니다. public.data.sync.max-pages 설정을 확인하세요.", spec.getLabel()); + return result; + } + + /** 매핑이 어긋났을 때 원인을 바로 찾을 수 있도록 실제 응답 필드명을 남긴다. */ + private void logFieldNames(String label, JsonNode firstRow) { + if (firstRow == null || !log.isDebugEnabled()) { + return; + } + List names = new ArrayList<>(); + firstRow.fieldNames().forEachRemaining(names::add); + log.debug("{} 응답 필드: {}", label, names); + } + + /** 공공데이터 응답은 래핑 구조가 제각각이라 알려진 위치를 순서대로 확인한다. */ + private JsonNode extractRows(String body) { + JsonNode root = parseBody(body); + if (root == null) { + return null; + } + if (root.isArray()) { + return root; + } + + // XML 로 받으면 최상위 response 가 벗겨져 body 부터 시작한다. + for (JsonNode items : List.of( + root.path("response").path("body").path("items"), + root.path("body").path("items"), + root.path("data"), + root.path("items"))) { + JsonNode rows = toArray(items); + if (rows != null) { + return rows; + } + } + return null; + } + + /** items 가 배열이거나, item 을 한 겹 더 감싸거나, 단건이면 객체로 온다. */ + private JsonNode toArray(JsonNode items) { + if (items.isArray()) { + return items; + } + JsonNode item = items.path("item"); + if (item.isArray()) { + return item; + } + if (item.isObject()) { + return objectMapper.createArrayNode().add(item); + } + return null; + } + + /** JSON 을 먼저 시도하고, 아니면 XML 로 파싱한다. 데이터셋마다 기본 포맷이 다르다. */ + private JsonNode parseBody(String body) { + if (body == null || body.isBlank()) { + return null; + } + String trimmed = body.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return objectMapper.readTree(trimmed); + } catch (Exception e) { + log.debug("JSON 파싱 실패, XML 로 재시도합니다: {}", e.getMessage()); + } + } + return xmlResponseParser.parse(trimmed); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java new file mode 100644 index 00000000..851d2278 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java @@ -0,0 +1,52 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 소아청소년과 병원 동기화. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PediatricHospitalSyncService { + + private static final int ROWS_PER_PAGE = 100; + + private final DataGoKrProvider provider; + private final HospitalUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + @Value("${public.data.resource.hospital:B551182/hospInfoServicev2/getHospBasisList}") + private String resource; + + /** 진료과목 코드. */ + @Value("${public.data.hospital.pediatric-subject-code:10}") + private String pediatricSubjectCode; + + @Value("${public.data.hospital.subject-name:소아청소년과}") + private String subjectName; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("소아청소년과병원") + .rowsPerPage(ROWS_PER_PAGE) + .params(buildParams()) + .upsert(row -> upsertService.upsert(row, subjectName)) + .build()); + } + + private Map buildParams() { + Map params = new LinkedHashMap<>(); + params.put("dgsbjtCd", pediatricSubjectCode); + // 이 데이터셋은 XML 이 기본이다. JSON 을 지원하면 _type 으로 받고, 아니면 XML 로 파싱된다. + params.put("_type", "json"); + return params; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java new file mode 100644 index 00000000..2c139869 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java @@ -0,0 +1,91 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.util.AgeRangeParser; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** 정책 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyUpsertService { + + /** 공공데이터 출처 정책임을 나타내는 코드 접두어. 관리자 수기 등록 정책과 구분한다. */ + public static final String EXTERNAL_CODE_PREFIX = "GOV-"; + + private final PolicyRepository policyRepository; + + /** 서비스 ID 기준 upsert. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @CacheEvict(cacheNames = "policy", allEntries = true) + public boolean upsert(JsonNode row) { + String serviceId = text(row, "서비스ID", "servId", "SVC_ID"); + if (serviceId == null) { + throw new IllegalArgumentException("서비스 ID가 없는 응답입니다."); + } + + String policyCode = EXTERNAL_CODE_PREFIX + serviceId; + Policy policy = policyRepository.findByPolicyCode(policyCode).orElse(null); + boolean isNew = policy == null; + if (isNew) { + policy = new Policy(); + policy.setPolicyCode(policyCode); + policy.setViewCount(0); + policy.setIsActive(true); + } + + policy.setTitle(text(row, "서비스명", "servNm", "SVC_NM")); + policy.setDescription(text(row, "서비스목적요약", "servDgst", "SVC_DGST")); + policy.setPolicyType(text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM")); + policy.setTargetRegion(text(row, "소관기관명", "jurMnofNm", "JURISDICTION")); + policy.setApplicationUrl(text(row, "상세조회URL", "servDtlLink", "DETAIL_URL")); + policy.setContactInfo(text(row, "전화문의", "rprsCtadr", "CONTACT")); + policy.setRequiredDocuments(text(row, "구비서류", "docCn")); + applyAgeRange(policy, row); + policy.setUpdatedAt(LocalDateTime.now()); + + policyRepository.save(policy); + return isNew; + } + + /** 자유 텍스트에서 연령 조건을 개월로 환산해 채운다. 못 찾으면 기존 값을 건드리지 않는다. */ + private void applyAgeRange(Policy policy, JsonNode row) { + String source = String.join(" ", + nullToEmpty(text(row, "지원대상", "trgterIndvdlArray")), + nullToEmpty(text(row, "선정기준", "slctCritCn")), + nullToEmpty(text(row, "서비스목적요약", "servDgst", "SVC_DGST"))); + + AgeRangeParser.AgeRange range = AgeRangeParser.parse(source); + if (range == null) { + return; + } + policy.setTargetAgeMin(range.minMonths()); + policy.setTargetAgeMax(range.maxMonths()); + } + + private String nullToEmpty(String value) { + return value != null ? value : ""; + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/SyncResult.java b/src/main/java/com/carecode/core/client/sync/SyncResult.java new file mode 100644 index 00000000..ec886390 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/SyncResult.java @@ -0,0 +1,65 @@ +package com.carecode.core.client.sync; + +import lombok.Getter; + +/** 공공데이터 동기화 결과. */ +@Getter +public class SyncResult { + + private final String provider; + private final String resource; + private int created; + private int updated; + private int failed; + + /** 필터에 걸려 적재하지 않은 건수. 실패와 구분해야 필터가 과도한지 알 수 있다. */ + private int skipped; + private int pagesProcessed; + + /** 정상 완료가 아니면 중단 사유. 정상이면 null. */ + private String stoppedReason; + + public SyncResult(String provider, String resource) { + this.provider = provider; + this.resource = resource; + } + + public void countCreated() { + created++; + } + + public void countUpdated() { + updated++; + } + + public void countFailed() { + failed++; + } + + public void countSkipped() { + skipped++; + } + + public void countPage() { + pagesProcessed++; + } + + public void stop(String reason) { + this.stoppedReason = reason; + } + + public boolean isCompleted() { + return stoppedReason == null; + } + + public int getTotalProcessed() { + return created + updated; + } + + @Override + public String toString() { + return String.format("[%s/%s] 신규=%d, 갱신=%d, 실패=%d, 제외=%d, 페이지=%d%s", + provider, resource, created, updated, failed, skipped, pagesProcessed, + stoppedReason != null ? ", 중단사유=" + stoppedReason : ""); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/SyncSpec.java b/src/main/java/com/carecode/core/client/sync/SyncSpec.java new file mode 100644 index 00000000..339c4469 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/SyncSpec.java @@ -0,0 +1,36 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.PublicDataProvider; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +/** 페이징 동기화 한 건의 명세. 데이터셋마다 다른 부분만 여기에 담는다. */ +@Getter +@Builder +public class SyncSpec { + + private final PublicDataProvider provider; + + /** 공급자 기준 상대 경로 또는 절대 URL. */ + private final String resource; + + /** 로그·응답에 쓰는 사람이 읽는 이름. */ + private final String label; + + private final int rowsPerPage; + + @Builder.Default + private final Map params = Map.of(); + + /** 적재 대상이 아닌 행을 걸러낸다. 기본은 전부 적재. */ + @Builder.Default + private final Predicate filter = row -> true; + + /** 한 건 저장. 신규면 true, 갱신이면 false 를 반환한다. */ + private final Function upsert; +} diff --git a/src/main/java/com/carecode/core/config/QueryCountConfig.java b/src/main/java/com/carecode/core/config/QueryCountConfig.java new file mode 100644 index 00000000..2049b476 --- /dev/null +++ b/src/main/java/com/carecode/core/config/QueryCountConfig.java @@ -0,0 +1,42 @@ +package com.carecode.core.config; + +import com.carecode.core.monitoring.QueryCountFilter; +import com.carecode.core.monitoring.QueryCountInspector; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import java.util.Map; + +/** 쿼리 수 측정. 기본은 꺼져 있고 app.monitoring.query-count.enabled=true 일 때만 동작한다. */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "app.monitoring.query-count.enabled", havingValue = "true") +public class QueryCountConfig { + + @Value("${app.monitoring.query-count.threshold:20}") + private int threshold; + + /** StatementInspector 는 SessionFactory 생성 시점에 주입해야 하므로 프로퍼티로 넣는다. */ + @Bean + public HibernatePropertiesCustomizer queryCountInspectorCustomizer() { + log.info("쿼리 수 측정 활성화 - 임계치 {}건", threshold); + return (Map props) -> + props.put("hibernate.session_factory.statement_inspector", new QueryCountInspector()); + } + + /** 가장 바깥에서 감싸야 요청 전체의 쿼리를 센다. */ + @Bean + public FilterRegistrationBean queryCountFilterRegistration() { + FilterRegistrationBean registration = + new FilterRegistrationBean<>(new QueryCountFilter(threshold)); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); + registration.addUrlPatterns("/*"); + return registration; + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java b/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java new file mode 100644 index 00000000..dca1734c --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java @@ -0,0 +1,46 @@ +package com.carecode.core.monitoring; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** 요청별 쿼리 수를 집계해 임계치를 넘으면 경고한다. 어느 엔드포인트가 N+1 인지 로그로 드러난다. */ +@Slf4j +@RequiredArgsConstructor +public class QueryCountFilter extends OncePerRequestFilter { + + private final int threshold; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + QueryCountHolder.start(); + long startedAt = System.nanoTime(); + try { + chain.doFilter(request, response); + } finally { + int queryCount = QueryCountHolder.get(); + QueryCountHolder.clear(); + + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000; + String endpoint = request.getMethod() + " " + request.getRequestURI(); + if (queryCount >= threshold) { + log.warn("N+1 의심 - {} 쿼리 {}건, {}ms (임계치 {}건)", endpoint, queryCount, elapsedMs, threshold); + } else if (queryCount > 0) { + log.debug("쿼리 측정 - {} 쿼리 {}건, {}ms", endpoint, queryCount, elapsedMs); + } + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/actuator") || path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs"); + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java b/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java new file mode 100644 index 00000000..b7866ded --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java @@ -0,0 +1,31 @@ +package com.carecode.core.monitoring; + +/** 요청 하나가 실행한 SQL 수를 센다. N+1 을 추정이 아니라 숫자로 확인하기 위한 도구다. */ +public final class QueryCountHolder { + + private static final ThreadLocal COUNTER = new ThreadLocal<>(); + + private QueryCountHolder() { + } + + public static void start() { + COUNTER.set(new int[1]); + } + + public static void increment() { + int[] counter = COUNTER.get(); + if (counter != null) { + counter[0]++; + } + } + + public static int get() { + int[] counter = COUNTER.get(); + return counter == null ? 0 : counter[0]; + } + + /** 스레드 풀 재사용 시 카운트가 누적되지 않도록 요청 종료 시 반드시 호출한다. */ + public static void clear() { + COUNTER.remove(); + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java b/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java new file mode 100644 index 00000000..39e09634 --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java @@ -0,0 +1,13 @@ +package com.carecode.core.monitoring; + +import org.hibernate.resource.jdbc.spi.StatementInspector; + +/** Hibernate 가 SQL 을 실행할 때마다 호출된다. SQL 자체는 바꾸지 않고 개수만 센다. */ +public class QueryCountInspector implements StatementInspector { + + @Override + public String inspect(String sql) { + QueryCountHolder.increment(); + return sql; + } +} diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java new file mode 100644 index 00000000..3c9be021 --- /dev/null +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -0,0 +1,63 @@ +package com.carecode.core.scheduler; + +import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.KindergartenSyncService; +import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; +import com.carecode.core.client.sync.PediatricHospitalSyncService; +import com.carecode.core.client.sync.SyncResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** 공공데이터 주기 동기화. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PublicDataSyncScheduler { + + private final NationwideChildcareFacilitySyncService facilitySyncService; + private final KindergartenSyncService kindergartenSyncService; + private final GovernmentBenefitSyncService benefitSyncService; + private final PediatricHospitalSyncService hospitalSyncService; + + /** 전국 어린이집 동기화. */ + @Scheduled(cron = "${app.scheduler.public-data.facility-cron:0 0 3 * * MON}", zone = "Asia/Seoul") + public void syncChildcareFacilities() { + SyncResult result = facilitySyncService.sync(); + logResult("전국 어린이집", result); + } + + /** 전국 유치원 동기화. 어린이집 작업과 겹치지 않게 시간을 벌린다. */ + @Scheduled(cron = "${app.scheduler.public-data.kindergarten-cron:0 0 4 * * MON}", zone = "Asia/Seoul") + public void syncKindergartens() { + SyncResult result = kindergartenSyncService.sync(); + logResult("전국 유치원", result); + } + + /** 정부 지원 서비스(보조금24) 동기화. */ + @Scheduled(cron = "${app.scheduler.public-data.benefit-cron:0 30 3 * * *}", zone = "Asia/Seoul") + public void syncGovernmentBenefits() { + SyncResult result = benefitSyncService.sync(); + logResult("정부 지원 서비스", result); + } + + /** 소아청소년과 병원 동기화. */ + @Scheduled(cron = "${app.scheduler.public-data.hospital-cron:0 0 3 * * TUE}", zone = "Asia/Seoul") + public void syncPediatricHospitals() { + SyncResult result = hospitalSyncService.sync(); + logResult("소아청소년과 병원", result); + } + + private void logResult(String label, SyncResult result) { + if (!result.isCompleted()) { + log.warn("{} 동기화 미완료 - {}", label, result); + return; + } + if (result.getTotalProcessed() == 0 && result.getFailed() == 0) { + log.debug("{} 동기화: 변경 없음", label); + return; + } + log.info("{} 동기화 완료 - {}", label, result); + } +} diff --git a/src/main/java/com/carecode/core/search/FullTextSearchSupport.java b/src/main/java/com/carecode/core/search/FullTextSearchSupport.java new file mode 100644 index 00000000..667bd810 --- /dev/null +++ b/src/main/java/com/carecode/core/search/FullTextSearchSupport.java @@ -0,0 +1,38 @@ +package com.carecode.core.search; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** 전문 검색 사용 가능 여부와 키워드 정규화. H2 등 MATCH AGAINST 미지원 DB에서는 끌 수 있다. */ +@Slf4j +@Component +public class FullTextSearchSupport { + + /** FULLTEXT 인덱스는 너무 짧은 토큰을 무시하므로 그 이하는 LIKE 로 처리한다. */ + private static final int MIN_KEYWORD_LENGTH = 2; + + private final boolean enabled; + + public FullTextSearchSupport(@Value("${app.search.fulltext-enabled:true}") boolean enabled) { + this.enabled = enabled; + if (!enabled) { + log.info("전문 검색이 비활성화되어 LIKE 검색으로 동작합니다."); + } + } + + /** 이 키워드를 전문 검색으로 처리할 수 있는지. */ + public boolean canUseFullText(String keyword) { + return enabled && normalize(keyword) != null; + } + + /** 검색 연산자로 해석될 수 있는 문자를 제거한다. 제거 후 유효하지 않으면 null. */ + public String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + // +, -, *, ", ~, <, >, ( ) 는 불리언 모드 연산자이거나 파싱을 깨뜨린다. + String cleaned = keyword.replaceAll("[+\\-*\"~<>()@]", " ").replaceAll("\\s+", " ").trim(); + return cleaned.length() >= MIN_KEYWORD_LENGTH ? cleaned : null; + } +} diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 6397b835..d474bcf5 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -147,7 +147,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/hospitals/*/like").authenticated() .requestMatchers(HttpMethod.DELETE, "/hospitals/*/like").authenticated() - // 정책 API 엔드포인트 (공개 접근) + // 정책 API: 개인화·북마크는 인증 필요, 나머지 조회는 공개 + // 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. + .requestMatchers("/policies/recommendations").authenticated() + .requestMatchers("/policies/bookmarks").authenticated() + .requestMatchers("/policies/*/bookmarks").authenticated() .requestMatchers("/policies").permitAll() .requestMatchers("/policies/search").permitAll() .requestMatchers("/policies/categories").permitAll() diff --git a/src/main/java/com/carecode/core/util/AgeRangeParser.java b/src/main/java/com/carecode/core/util/AgeRangeParser.java new file mode 100644 index 00000000..7c7cbc2d --- /dev/null +++ b/src/main/java/com/carecode/core/util/AgeRangeParser.java @@ -0,0 +1,114 @@ +package com.carecode.core.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 공공데이터의 자유 텍스트 지원대상에서 연령 조건을 개월 단위로 뽑아낸다. */ +public final class AgeRangeParser { + + /** 정책 연령 필드의 단위는 개월이다. 세 단위 표기는 모두 여기서 개월로 환산한다. */ + private static final int MONTHS_PER_YEAR = 12; + + /** 육아 정책의 상한. 이보다 큰 숫자는 연령이 아니라 금액·연도일 가능성이 높다. */ + private static final int MAX_PLAUSIBLE_YEAR = 19; + private static final int MAX_PLAUSIBLE_MONTH = MAX_PLAUSIBLE_YEAR * MONTHS_PER_YEAR; + + // "생후 12~23개월", "0~23 개월" + private static final Pattern MONTH_RANGE = Pattern.compile("(\\d{1,3})\\s*[~\\-–]\\s*(\\d{1,3})\\s*개월"); + // "24개월 미만", "36개월 이하", "12개월 이상" + private static final Pattern MONTH_BOUND = Pattern.compile("(\\d{1,3})\\s*개월\\s*(미만|이하|이상)"); + // "만 3~5세", "만 3세 ~ 만 5세", "3세~5세" + private static final Pattern YEAR_RANGE = + Pattern.compile("(?:만\\s*)?(\\d{1,2})\\s*세?\\s*[~\\-–]\\s*(?:만\\s*)?(\\d{1,2})\\s*세"); + // "만 7세 미만", "만 8세 이하", "만 6세 이상" + private static final Pattern YEAR_BOUND = Pattern.compile("(?:만\\s*)?(\\d{1,2})\\s*세\\s*(미만|이하|이상)"); + + private AgeRangeParser() { + } + + /** 연령 조건. 경계가 없으면 해당 값이 null 이다. */ + public record AgeRange(Integer minMonths, Integer maxMonths) { + } + + /** 연령 조건을 찾지 못하면 null 을 반환한다. 억지로 추정하지 않는다. */ + public static AgeRange parse(String text) { + if (text == null || text.isBlank()) { + return null; + } + + // 개월 표기가 더 정확하므로 먼저 본다. + AgeRange range = matchMonthRange(text); + if (range != null) { + return range; + } + range = matchYearRange(text); + if (range != null) { + return range; + } + range = matchMonthBound(text); + if (range != null) { + return range; + } + return matchYearBound(text); + } + + private static AgeRange matchMonthRange(String text) { + Matcher m = MONTH_RANGE.matcher(text); + if (!m.find()) { + return null; + } + int min = Integer.parseInt(m.group(1)); + int max = Integer.parseInt(m.group(2)); + return isValid(min, max, MAX_PLAUSIBLE_MONTH) ? new AgeRange(min, max) : null; + } + + private static AgeRange matchYearRange(String text) { + Matcher m = YEAR_RANGE.matcher(text); + if (!m.find()) { + return null; + } + int minYear = Integer.parseInt(m.group(1)); + int maxYear = Integer.parseInt(m.group(2)); + if (!isValid(minYear, maxYear, MAX_PLAUSIBLE_YEAR)) { + return null; + } + // "만 3~5세" 는 5세 생일부터 6세 생일 전날까지 포함하므로 상한은 71개월이다. + return new AgeRange(minYear * MONTHS_PER_YEAR, maxYear * MONTHS_PER_YEAR + MONTHS_PER_YEAR - 1); + } + + private static AgeRange matchMonthBound(String text) { + Matcher m = MONTH_BOUND.matcher(text); + if (!m.find()) { + return null; + } + int months = Integer.parseInt(m.group(1)); + if (months > MAX_PLAUSIBLE_MONTH) { + return null; + } + return switch (m.group(2)) { + case "미만" -> new AgeRange(0, months - 1); + case "이하" -> new AgeRange(0, months); + default -> new AgeRange(months, null); + }; + } + + private static AgeRange matchYearBound(String text) { + Matcher m = YEAR_BOUND.matcher(text); + if (!m.find()) { + return null; + } + int years = Integer.parseInt(m.group(1)); + if (years > MAX_PLAUSIBLE_YEAR) { + return null; + } + return switch (m.group(2)) { + case "미만" -> new AgeRange(0, years * MONTHS_PER_YEAR - 1); + case "이하" -> new AgeRange(0, years * MONTHS_PER_YEAR + MONTHS_PER_YEAR - 1); + default -> new AgeRange(years * MONTHS_PER_YEAR, null); + }; + } + + private static boolean isValid(int min, int max, int upperBound) { + return min <= max && max <= upperBound; + } +} diff --git a/src/main/java/com/carecode/core/util/BoundingBox.java b/src/main/java/com/carecode/core/util/BoundingBox.java new file mode 100644 index 00000000..7b997873 --- /dev/null +++ b/src/main/java/com/carecode/core/util/BoundingBox.java @@ -0,0 +1,27 @@ +package com.carecode.core.util; + +/** 반경 검색용 위경도 사각 범위. 인덱스를 탈 수 있는 BETWEEN 조건으로 후보를 좁히는 데 쓴다. */ +public record BoundingBox(double minLat, double maxLat, double minLng, double maxLng) { + + /** 위도 1도당 거리(km). 경도와 달리 위치에 관계없이 거의 일정하다. */ + private static final double KM_PER_LAT_DEGREE = 111.045; + + /** 중심점과 반경(km)으로 사각 범위를 만든다. */ + public static BoundingBox around(double latitude, double longitude, double radiusKm) { + double latDelta = radiusKm / KM_PER_LAT_DEGREE; + + // 경도 1도의 거리는 위도가 높을수록 짧아진다. 극지방에서 0으로 나누지 않도록 하한을 둔다. + double cosLat = Math.max(Math.cos(Math.toRadians(latitude)), 0.01); + double lngDelta = radiusKm / (KM_PER_LAT_DEGREE * cosLat); + + return new BoundingBox( + clampLat(latitude - latDelta), + clampLat(latitude + latDelta), + longitude - lngDelta, + longitude + lngDelta); + } + + private static double clampLat(double lat) { + return Math.max(-90.0, Math.min(90.0, lat)); + } +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java new file mode 100644 index 00000000..f2ed8dd0 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -0,0 +1,68 @@ +package com.carecode.domain.admin.controller; + +import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.KindergartenSyncService; +import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; +import com.carecode.core.client.sync.PediatricHospitalSyncService; +import com.carecode.core.client.sync.SyncResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 공공데이터 수동 동기화 API. */ +@RestController +@RequestMapping("/api/admin/public-data") +@RequiredArgsConstructor +@Tag(name = "어드민 - 공공데이터", description = "공공데이터 수동 동기화 API") +public class AdminPublicDataController { + + private final NationwideChildcareFacilitySyncService facilitySyncService; + private final KindergartenSyncService kindergartenSyncService; + private final GovernmentBenefitSyncService benefitSyncService; + private final PediatricHospitalSyncService hospitalSyncService; + + @PostMapping("/facilities/sync") + @Operation(summary = "전국 어린이집 동기화", description = "시설 코드 기준으로 갱신") + public ResponseEntity> syncFacilities() { + return ResponseEntity.ok(toResponse(facilitySyncService.sync())); + } + + @PostMapping("/kindergartens/sync") + @Operation(summary = "전국 유치원 동기화", description = "유치원명·주소 기준으로 갱신") + public ResponseEntity> syncKindergartens() { + return ResponseEntity.ok(toResponse(kindergartenSyncService.sync())); + } + + @PostMapping("/benefits/sync") + @Operation(summary = "정부 지원 서비스 동기화", description = "육아 관련 서비스만 정책으로 갱신") + public ResponseEntity> syncBenefits() { + return ResponseEntity.ok(toResponse(benefitSyncService.sync())); + } + + @PostMapping("/hospitals/sync") + @Operation(summary = "소아청소년과 병원 동기화", description = "요양기호 기준으로 갱신") + public ResponseEntity> syncHospitals() { + return ResponseEntity.ok(toResponse(hospitalSyncService.sync())); + } + + private Map toResponse(SyncResult result) { + Map body = new LinkedHashMap<>(); + body.put("provider", result.getProvider()); + body.put("resource", result.getResource()); + body.put("completed", result.isCompleted()); + body.put("created", result.getCreated()); + body.put("updated", result.getUpdated()); + body.put("failed", result.getFailed()); + body.put("skipped", result.getSkipped()); + body.put("pagesProcessed", result.getPagesProcessed()); + body.put("stoppedReason", result.getStoppedReason()); + return body; + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java index 868845c6..4965e157 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java @@ -124,18 +124,35 @@ List searchFacilities(@Param("facilityType") FacilityType facility List findNewFacilities(org.springframework.data.domain.Pageable pageable); - // 반경 내 시설 조회 - @Query(value = "SELECT cf.*, " + - "(6371 * acos(cos(radians(:latitude)) * cos(radians(cf.latitude)) * " + - "cos(radians(cf.longitude) - radians(:longitude)) + " + - "sin(radians(:latitude)) * sin(radians(cf.latitude)))) AS distance " + - "FROM care_facilities cf " + - "WHERE cf.is_active = true " + - "HAVING distance <= :radius " + - "ORDER BY distance", nativeQuery = true) - List findWithinRadius(@Param("latitude") Double latitude, - @Param("longitude") Double longitude, - @Param("radius") Double radius); + /** 반경 내 시설 조회. 바운딩 박스로 후보를 좁힌 뒤 정확한 거리를 계산한다. */ + @Query(value = "SELECT cf.* FROM (" + + " SELECT c.*, (6371 * acos(LEAST(1, " + + " cos(radians(:latitude)) * cos(radians(c.LATITUDE)) * " + + " cos(radians(c.LONGITUDE) - radians(:longitude)) + " + + " sin(radians(:latitude)) * sin(radians(c.LATITUDE))))) AS distance " + + " FROM TBL_CARE_FACILITIES c " + + " WHERE c.IS_ACTIVE = true " + + " AND c.LATITUDE BETWEEN :minLat AND :maxLat " + + " AND c.LONGITUDE BETWEEN :minLng AND :maxLng" + + ") cf WHERE cf.distance <= :radius ORDER BY cf.distance", + nativeQuery = true) + List findWithinBoundingBox(@Param("latitude") double latitude, + @Param("longitude") double longitude, + @Param("radius") double radius, + @Param("minLat") double minLat, + @Param("maxLat") double maxLat, + @Param("minLng") double minLng, + @Param("maxLng") double maxLng); + + /** 전문 검색. LIKE '%키워드%' 와 달리 인덱스를 타고 관련도 순으로 정렬된다. */ + @Query(value = "SELECT * FROM TBL_CARE_FACILITIES " + + "WHERE IS_ACTIVE = true " + + "AND MATCH(NAME, ADDRESS) AGAINST (:keyword IN NATURAL LANGUAGE MODE)", + countQuery = "SELECT COUNT(*) FROM TBL_CARE_FACILITIES " + + "WHERE IS_ACTIVE = true " + + "AND MATCH(NAME, ADDRESS) AGAINST (:keyword IN NATURAL LANGUAGE MODE)", + nativeQuery = true) + Page searchByFullText(@Param("keyword") String keyword, Pageable pageable); // 검색 조건으로 시설 검색 (페이징) @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true " + diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java index 72e063dd..714c2415 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java @@ -2,6 +2,8 @@ import org.springframework.cache.annotation.Cacheable; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.util.BoundingBox; +import com.carecode.core.search.FullTextSearchSupport; import com.carecode.core.annotation.ValidateLocation; import com.carecode.core.exception.CareFacilityNotFoundException; import com.carecode.domain.careFacility.dto.request.CareFacilitySearchRequest; @@ -49,6 +51,7 @@ public class CareFacilityService { private final ReviewRepository reviewRepository; private final UserRepository userRepository; private final CareFacilityMapper careFacilityMapper; + private final FullTextSearchSupport fullTextSearchSupport; // 공공데이터 API에서 받아온 보육시설 데이터를 DB에 저장 @@ -272,14 +275,20 @@ public CareFacilityListResponse searchCareFacilities(CareFacilitySearchRequest r Sort.Direction.ASC ); Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), sort); - - Page facilityPage = careFacilityRepository.findBySearchCriteria( - request.getKeyword(), - null, - request.getCity(), - pageable - ); - + + // 키워드만 있는 검색은 전문 검색으로 처리한다. LIKE '%키워드%' 는 인덱스를 못 탄다. + boolean keywordOnly = request.getCity() == null || request.getCity().isBlank(); + Page facilityPage; + if (keywordOnly && fullTextSearchSupport.canUseFullText(request.getKeyword())) { + String normalized = fullTextSearchSupport.normalize(request.getKeyword()); + facilityPage = careFacilityRepository.searchByFullText(normalized, + PageRequest.of(request.getPage(), request.getSize())); + } else { + facilityPage = careFacilityRepository.findBySearchCriteria( + request.getKeyword(), null, request.getCity(), pageable); + } + + List facilities = facilityPage.getContent().stream() .map(careFacilityMapper::toResponse) .collect(Collectors.toList()); @@ -323,7 +332,10 @@ public List getCareFacilitiesByLocation(String location) { @LogExecutionTime @ValidateLocation public List getCareFacilitiesWithinRadius(Double latitude, Double longitude, Double radius) { - List facilities = careFacilityRepository.findWithinRadius(latitude, longitude, radius); + BoundingBox box = BoundingBox.around(latitude, longitude, radius); + List facilities = careFacilityRepository.findWithinBoundingBox( + latitude, longitude, radius, + box.minLat(), box.maxLat(), box.minLng(), box.maxLng()); return facilities.stream() .map(careFacilityMapper::toResponse) .collect(Collectors.toList()); diff --git a/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java b/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java index 146e0bf0..07d2ac7f 100644 --- a/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java +++ b/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java @@ -11,17 +11,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; -/** - * 챗봇 답변 근거 검색기 (RAG 의 R). - * - *

사용자 질문에서 키워드를 뽑아 DB 의 정책·시설 데이터를 조회한다. - * 이렇게 하면 챗봇이 일반론 대신 "우리 서비스에 실제로 등록된" 내용을 근거로 답할 수 있다. - * - *

지금은 키워드 LIKE 검색이다. 데이터가 커지면 임베딩 기반 벡터 검색으로 - * 이 클래스 내부만 교체하면 된다 — 호출부는 {@link RetrievedContext} 에만 의존한다. - */ +/** 챗봇 답변 근거 검색기(RAG 의 R). 임베딩 검색으로 바꾸려면 이 클래스 내부만 교체하면 된다. */ @Slf4j @Component @RequiredArgsConstructor @@ -29,29 +22,42 @@ public class CareKnowledgeRetriever { private static final int MAX_PER_SOURCE = 3; private static final int MAX_CONTENT_LENGTH = 400; + private static final int MAX_KEYWORDS = 3; - /** 검색어에서 제외할 조사·의문사. 이걸 키워드로 쓰면 아무거나 매칭된다. */ + /** 검색어에서 제외할 의문사·범용어. 이걸로 검색하면 아무거나 매칭된다. */ private static final List STOP_WORDS = List.of( "알려줘", "알려주세요", "어디", "무엇", "뭐가", "뭔가요", "있나요", "있어", - "해줘", "하고", "그리고", "관련", "대해", "대한", "정보", "추천"); + "해줘", "하고", "그리고", "관련", "대해", "대한", "정보", "추천", + "우리", "저희", "지금", "가까운", "근처"); + + /** 길이 내림차순. 긴 것부터 떼어내야 "에서" 가 "서" 로 잘리지 않는다. */ + private static final List JOSA = List.of( + "에서는", "으로는", "에게는", "에서", "으로", "에게", "부터", "까지", "이랑", + "은", "는", "이", "가", "을", "를", "의", "에", "도", "와", "과", "로"); private final PolicyRepository policyRepository; private final CareFacilityRepository careFacilityRepository; @Transactional(readOnly = true) public RetrievedContext retrieve(String question) { - String keyword = extractKeyword(question); - if (keyword == null) { + List keywords = extractKeywords(question); + if (keywords.isEmpty()) { return RetrievedContext.builder().snippets(List.of()).build(); } + // 변별력 높은 키워드부터 시도하고, 근거를 찾으면 멈춘다. List snippets = new ArrayList<>(); - try { - snippets.addAll(searchPolicies(keyword)); - snippets.addAll(searchFacilities(keyword)); - } catch (Exception e) { - // 검색 실패가 대화 자체를 막지 않도록 한다. 근거 없이 일반 답변으로 진행한다. - log.error("챗봇 근거 검색 실패 - keyword={}", keyword, e); + for (String keyword : keywords) { + try { + snippets.addAll(searchPolicies(keyword)); + snippets.addAll(searchFacilities(keyword)); + } catch (Exception e) { + // 검색 실패가 대화를 막지 않도록 한다. 근거 없이 일반 답변으로 진행한다. + log.error("챗봇 근거 검색 실패 - keyword={}", keyword, e); + } + if (!snippets.isEmpty()) { + break; + } } return RetrievedContext.builder().snippets(snippets).build(); @@ -100,26 +106,36 @@ private String buildPolicyContent(Policy policy) { return truncate(sb.toString()); } - /** - * 질문에서 검색에 쓸 핵심 키워드를 뽑는다. - * 불용어를 제거하고 가장 긴 토큰을 사용한다 — 짧은 토큰일수록 노이즈가 많다. - */ - private String extractKeyword(String question) { + /** 질문에서 검색어 후보를 길이 내림차순으로 뽑는다. 긴 토큰일수록 변별력이 높다. */ + List extractKeywords(String question) { if (question == null || question.isBlank()) { - return null; + return List.of(); } String cleaned = question.replaceAll("[^가-힣a-zA-Z0-9\\s]", " "); - String best = null; + List keywords = new ArrayList<>(); for (String token : cleaned.split("\\s+")) { - if (token.length() < 2 || STOP_WORDS.contains(token)) { + String normalized = stripJosa(token); + if (normalized.length() < 2 || STOP_WORDS.contains(normalized) || keywords.contains(normalized)) { continue; } - if (best == null || token.length() > best.length()) { - best = token; + keywords.add(normalized); + } + keywords.sort(Comparator.comparingInt(String::length).reversed()); + return keywords.size() > MAX_KEYWORDS ? keywords.subList(0, MAX_KEYWORDS) : keywords; + } + + /** + * 흔한 조사를 떼어낸다. 형태소 분석기 없이 "어린이집은" → "어린이집" 정도만 맞춘다. + * 조사를 떼면 2자 미만이 되는 짧은 단어는 원형을 유지한다. + */ + private String stripJosa(String token) { + for (String josa : JOSA) { + if (token.length() > josa.length() + 1 && token.endsWith(josa)) { + return token.substring(0, token.length() - josa.length()); } } - return best; + return token; } private String truncate(String text) { diff --git a/src/main/java/com/carecode/domain/health/entity/Hospital.java b/src/main/java/com/carecode/domain/health/entity/Hospital.java index f378aa60..c6fdb7a4 100644 --- a/src/main/java/com/carecode/domain/health/entity/Hospital.java +++ b/src/main/java/com/carecode/domain/health/entity/Hospital.java @@ -20,6 +20,10 @@ public class Hospital { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** 심평원 암호화 요양기호(ykiho). */ + @Column(name = "external_code", unique = true, length = 100) + private String externalCode; + @Column(nullable = false) private String name; diff --git a/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java b/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java index 53f0a20d..1c0638cb 100644 --- a/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java +++ b/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java @@ -6,10 +6,14 @@ import org.springframework.data.repository.query.Param; import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Optional; public interface HospitalRepository extends JpaRepository { List findByType(String type); + /** 공공데이터 동기화 시 중복 적재 방지용 조회. */ + Optional findByExternalCode(String externalCode); + @Query("SELECT h FROM Hospital h WHERE FUNCTION('ST_Distance_Sphere', point(h.longitude, h.latitude), point(:lng, :lat)) <= :radius") List findNearby(@Param("lat") double lat, @Param("lng") double lng, @Param("radius") double radius); diff --git a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java index dfe4acca..7803f8e3 100644 --- a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java +++ b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java @@ -1,10 +1,12 @@ package com.carecode.domain.policy.app; import com.carecode.domain.policy.dto.request.PolicySearchRequest; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.PolicyBookmarkResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.response.PolicyListResponse; import com.carecode.domain.policy.dto.response.PolicyStatsSimpleResponse; +import com.carecode.domain.policy.service.PolicyRecommendationService; import com.carecode.domain.policy.service.PolicyService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,6 +19,7 @@ public class PolicyFacade { private final PolicyService policyService; + private final PolicyRecommendationService policyRecommendationService; @Transactional(readOnly = true) public List getAllPolicies(int page, int size) { return policyService.getAllPolicies(page, size); } @@ -71,6 +74,11 @@ public List getBookmarks(String userIdOrEmail) { public void removeBookmark(String userIdOrEmail, Long policyId) { policyService.removeBookmark(userIdOrEmail, policyId); } + + @Transactional(readOnly = true) + public List recommendPolicies(int limit) { + return policyRecommendationService.recommendForCurrentUser(limit); + } } diff --git a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java index 916c3a4c..c29ec226 100644 --- a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java +++ b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java @@ -7,6 +7,7 @@ import com.carecode.core.security.CurrentUserFacade; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.request.PolicySearchRequest; import com.carecode.domain.policy.dto.response.PolicyListResponse; @@ -134,10 +135,10 @@ public ResponseEntity> getPoliciesByLocation( // 연령대별 정책 조회 @GetMapping("/age") @LogExecutionTime - @Operation(summary = "연령대별 정책 조회", description = "특정 연령대에 해당하는 육아 정책 목록을 조회합니다.") + @Operation(summary = "연령대별 정책 조회", description = "월령 범위에 해당하는 정책을 조회합니다.") public ResponseEntity> getPoliciesByAgeRange( - @Parameter(description = "최소 연령", required = true) @RequestParam Integer minAge, - @Parameter(description = "최대 연령", required = true) @RequestParam Integer maxAge) { + @Parameter(description = "최소 월령", example = "0", required = true) @RequestParam Integer minAge, + @Parameter(description = "최대 월령", example = "71", required = true) @RequestParam Integer maxAge) { log.info("연령대별 정책 조회: 최소연령={}, 최대연령={}", minAge, maxAge); try { @@ -220,8 +221,9 @@ public ResponseEntity getPolicyStatistics() { // 아이 연령별 정책 조회 @GetMapping("/child-age") @LogExecutionTime - @Operation(summary = "아이 연령별 정책 조회", description = "특정 연령의 아이에게 해당하는 정책을 조회합니다.") - public ResponseEntity> getPoliciesByChildAge(@Parameter(description = "아이 연령", required = true) @RequestParam Integer childAge) { + @Operation(summary = "아이 연령별 정책 조회", description = "해당 월령의 아이가 받을 수 있는 정책을 조회합니다.") + public ResponseEntity> getPoliciesByChildAge( + @Parameter(description = "아이 월령", example = "24", required = true) @RequestParam Integer childAge) { List policies = policyFacade.getPoliciesByChildAge(childAge); return ResponseEntity.ok(policies); @@ -262,6 +264,15 @@ public ResponseEntity removeBookmark( return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("북마크가 삭제되었습니다.").build()); } + // 개인화 정책 추천 + @GetMapping("/recommendations") + @LogExecutionTime + @Operation(summary = "맞춤 정책 추천", description = "자녀 월령과 거주지에 맞는 정책을 추천합니다.") + public ResponseEntity> getRecommendations( + @Parameter(description = "추천 개수", example = "10") @RequestParam(defaultValue = "10") Integer limit) { + return ResponseEntity.ok(policyFacade.recommendPolicies(PageRequestUtil.normalizeSize(limit))); + } + private String getAuthenticatedUserCode() { return currentUserFacade.requireCurrentUserId(); } diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java new file mode 100644 index 00000000..68378979 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java @@ -0,0 +1,15 @@ +package com.carecode.domain.policy.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** 개인화 추천 결과 한 건. 왜 추천됐는지 근거를 함께 내려 준다. */ +@Getter +@Builder +public class PersonalizedPolicyResponse { + private PolicyDto policy; + private int score; + private List reasons; +} diff --git a/src/main/java/com/carecode/domain/policy/entity/Policy.java b/src/main/java/com/carecode/domain/policy/entity/Policy.java index c6472de3..751d4ceb 100644 --- a/src/main/java/com/carecode/domain/policy/entity/Policy.java +++ b/src/main/java/com/carecode/domain/policy/entity/Policy.java @@ -41,9 +41,11 @@ public class Policy { @Column(name = "policy_type") private String policyType; + /** 대상 연령 하한(개월). 세 단위 표기는 AgeRangeParser 로 환산해 넣는다. */ @Column(name = "target_age_min") private Integer targetAgeMin; - + + /** 대상 연령 상한(개월). null 이면 상한 없음. */ @Column(name = "target_age_max") private Integer targetAgeMax; diff --git a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java index 80086340..326bae72 100644 --- a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java +++ b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java @@ -26,6 +26,9 @@ public interface PolicyRepository extends JpaRepository { // 활성화된 정책 목록 조회 List findByIsActiveTrue(); + /** 추천 후보 조회. 우선순위 높은 정책부터 가져와 상위 N건만 채점한다. */ + Page findByIsActiveTrueOrderByPriorityDescViewCountDesc(Pageable pageable); + // 정책 유형별 조회 List findByPolicyType(String policyType); diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java new file mode 100644 index 00000000..e0b0e2af --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java @@ -0,0 +1,141 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.mapper.PolicyMapper; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** 아이 월령과 거주지로 정책을 추천한다. 협업 필터링은 사용자 로그가 쌓인 뒤에나 의미가 있다. */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyRecommendationService { + + private static final int CANDIDATE_SIZE = 200; + private static final int SCORE_AGE_MATCH = 5; + private static final int SCORE_REGION_MATCH = 3; + private static final int SCORE_DEADLINE_SOON = 2; + private static final int DEADLINE_SOON_DAYS = 30; + + private final PolicyRepository policyRepository; + private final ChildRepository childRepository; + private final PolicyMapper policyMapper; + private final CurrentUserFacade currentUserFacade; + + /** 로그인 사용자에게 맞는 정책을 점수 순으로 반환한다. */ + public List recommendForCurrentUser(int limit) { + User user = currentUserFacade.requireCurrentUser(); + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + LocalDate today = LocalDate.now(); + + List candidates = policyRepository + .findByIsActiveTrueOrderByPriorityDescViewCountDesc(PageRequest.of(0, CANDIDATE_SIZE)) + .getContent(); + + List scored = new ArrayList<>(); + for (Policy policy : candidates) { + if (isExpired(policy, today)) { + continue; + } + List reasons = new ArrayList<>(); + int score = score(policy, user, children, today, reasons); + if (score <= 0) { + continue; + } + scored.add(PersonalizedPolicyResponse.builder() + .policy(policyMapper.toResponse(policy)) + .score(score) + .reasons(reasons) + .build()); + } + + scored.sort(Comparator.comparingInt(PersonalizedPolicyResponse::getScore).reversed()); + return scored.size() > limit ? scored.subList(0, limit) : scored; + } + + /** 연령 조건이 있는데 맞는 아이가 없으면 0점으로 제외한다. */ + private int score(Policy policy, User user, List children, LocalDate today, List reasons) { + int score = 1; // 조건 없는 범용 정책도 노출되도록 하는 기본 점수 + + boolean hasAgeCondition = policy.getTargetAgeMin() != null || policy.getTargetAgeMax() != null; + if (hasAgeCondition) { + Child matched = children.stream().filter(c -> matchesAge(policy, c, today)).findFirst().orElse(null); + if (matched == null) { + return 0; + } + score += SCORE_AGE_MATCH; + reasons.add(matched.getName() + " 연령 조건에 해당합니다."); + } + + if (matchesRegion(policy, user, reasons)) { + score += SCORE_REGION_MATCH; + } + + if (isDeadlineNear(policy, today)) { + score += SCORE_DEADLINE_SOON; + reasons.add("신청 마감이 " + DEADLINE_SOON_DAYS + "일 이내입니다."); + } + + Integer priority = policy.getPriority(); + if (priority != null && priority > 0) { + score += Math.min(priority, 3); + } + return score; + } + + /** 정책 대상 월령과 아이의 월령을 비교한다. 시드 데이터 기준 targetAge 단위는 개월이다. */ + private boolean matchesAge(Policy policy, Child child, LocalDate today) { + if (child.getBirthDate() == null) { + return false; + } + long months = ChronoUnit.MONTHS.between(child.getBirthDate(), today); + Integer min = policy.getTargetAgeMin(); + Integer max = policy.getTargetAgeMax(); + return (min == null || months >= min) && (max == null || months <= max); + } + + /** 정책 대상 지역이 사용자 주소와 겹치는지. 전국 정책은 항상 일치로 본다. */ + private boolean matchesRegion(Policy policy, User user, List reasons) { + String region = policy.getTargetRegion(); + if (region == null || region.isBlank() || region.contains("전국")) { + reasons.add("전국 어디서나 신청할 수 있습니다."); + return true; + } + String address = user.getAddress(); + if (address == null || address.isBlank()) { + return false; + } + // 주소는 "서울특별시 강남구...", 대상 지역은 "서울" 처럼 표기 단위가 달라 양방향으로 확인한다. + if (address.contains(region) || region.contains(address)) { + reasons.add(region + " 거주자 대상 정책입니다."); + return true; + } + return false; + } + + private boolean isExpired(Policy policy, LocalDate today) { + LocalDate end = policy.getApplicationEndDate(); + return end != null && end.isBefore(today); + } + + private boolean isDeadlineNear(Policy policy, LocalDate today) { + LocalDate end = policy.getApplicationEndDate(); + return end != null && !end.isBefore(today) && ChronoUnit.DAYS.between(today, end) <= DEADLINE_SOON_DAYS; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 37c72398..8cdb176d 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -7,6 +7,15 @@ spring: hibernate: ddl-auto: update show-sql: true + properties: + hibernate: + generate_statistics: true + +app: + monitoring: + query-count: + enabled: true + threshold: 20 springdoc: swagger-ui: @@ -29,5 +38,7 @@ logging: org.springframework.security: DEBUG org.hibernate.SQL: DEBUG org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.stat: DEBUG + com.carecode.core.monitoring: DEBUG pattern: console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e87113c1..f548d6ef 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -141,6 +141,12 @@ app: cron: ${BOOKING_REMINDER_CRON:0 0 18 * * *} cleanup: cron: ${CLEANUP_CRON:0 0 4 * * *} + public-data: + # 시설 정보는 변동이 잦지 않아 주 1회, 정책은 신청 기간이 있어 매일 확인한다. + facility-cron: ${PUBLIC_DATA_FACILITY_CRON:0 0 3 * * MON} + kindergarten-cron: ${PUBLIC_DATA_KINDERGARTEN_CRON:0 0 4 * * MON} + benefit-cron: ${PUBLIC_DATA_BENEFIT_CRON:0 30 3 * * *} + hospital-cron: ${PUBLIC_DATA_HOSPITAL_CRON:0 0 3 * * TUE} jwt: secret: ${JWT_SECRET} @@ -192,6 +198,37 @@ kakao: public: data: + # 레거시 키 (서울 열린데이터광장 전용 CareFacilityApiService 가 참조) api: key: ${PUBLIC_DATA_API_KEY} base-url: ${PUBLIC_DATA_API_BASE_URL:http://openapi.seoul.go.kr:8088} + + # 서울 열린데이터광장 — 경로 파라미터 방식 + seoul: + api-key: ${SEOUL_OPEN_DATA_KEY:${PUBLIC_DATA_API_KEY:}} + base-url: ${SEOUL_OPEN_DATA_BASE_URL:http://openapi.seoul.go.kr:8088} + + # 공공데이터포털(data.go.kr) — serviceKey 쿼리 파라미터 방식. + # 비워두면 해당 공급자를 쓰는 동기화가 건너뛰어진다(기동에는 영향 없음). + datagokr: + service-key: ${DATA_GO_KR_SERVICE_KEY:} + base-url: ${DATA_GO_KR_BASE_URL:https://apis.data.go.kr} + + # 심평원 병원정보서비스 + hospital: + # 진료과목 코드. 심평원 코드표 기준 소아청소년과는 "10". + # 코드가 개정될 수 있어 설정으로 뺀다. + pediatric-subject-code: ${HOSPITAL_PEDIATRIC_SUBJECT_CODE:10} + subject-name: ${HOSPITAL_SUBJECT_NAME:소아청소년과} + + # 데이터셋 경로. 공공데이터 오퍼레이션은 개편되므로 재배포 없이 바꿀 수 있게 뺀다. + # 절대 URL 을 넣으면 base-url 대신 그대로 호출한다(표준데이터는 호스트가 다르다). + resource: + childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:B551014/CCEF/childcare} + kindergarten: ${PUBLIC_DATA_RESOURCE_KINDERGARTEN:http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api} + benefit: ${PUBLIC_DATA_RESOURCE_BENEFIT:1741000/publicServiceInformations/publicServiceInformation} + hospital: ${PUBLIC_DATA_RESOURCE_HOSPITAL:B551182/hospInfoServicev2/getHospBasisList} + + sync: + # 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다. + max-pages: ${PUBLIC_DATA_SYNC_MAX_PAGES:200} diff --git a/src/main/resources/db/migration/V3__hospital_external_code.sql b/src/main/resources/db/migration/V3__hospital_external_code.sql new file mode 100644 index 00000000..9f5afdee --- /dev/null +++ b/src/main/resources/db/migration/V3__hospital_external_code.sql @@ -0,0 +1,13 @@ +-- ================================================================================ +-- V3: 병원 외부 식별자(심평원 암호화 요양기호) 추가 +-- +-- 공공데이터 동기화 시 같은 병원을 중복 적재하지 않기 위한 키다. +-- 심평원은 ykiho 를 암호화해 제공하며 복호화 수단이 없으므로 값 자체를 식별자로 쓴다. +-- 기존 수기 등록 병원은 NULL 로 남고, UNIQUE 제약은 NULL 을 중복으로 보지 않는다. +-- ================================================================================ + +ALTER TABLE TBL_HOSPITAL + ADD COLUMN external_code VARCHAR(100) NULL COMMENT '심평원 암호화 요양기호(ykiho)'; + +ALTER TABLE TBL_HOSPITAL + ADD CONSTRAINT uk_hospital_external_code UNIQUE (external_code); diff --git a/src/main/resources/db/migration/V4__search_indexes.sql b/src/main/resources/db/migration/V4__search_indexes.sql new file mode 100644 index 00000000..48068efb --- /dev/null +++ b/src/main/resources/db/migration/V4__search_indexes.sql @@ -0,0 +1,18 @@ +-- V4: 위치 검색 및 전문 검색 인덱스 +-- +-- 반경 검색은 그동안 모든 행에 삼각함수를 계산한 뒤 HAVING 으로 걸러 풀 스캔이었다. +-- 바운딩 박스로 후보를 좁히도록 바꾸면서 BETWEEN 조건이 인덱스를 타도록 추가한다. +-- 검색은 LIKE '%키워드%' 라 인덱스를 쓸 수 없어 FULLTEXT 를 추가한다(ngram: 한글 대응). + +-- 위치 검색: 위도로 범위를 좁히고 경도로 다시 좁힌다. +CREATE INDEX idx_facility_location ON TBL_CARE_FACILITIES (LATITUDE, LONGITUDE); +CREATE INDEX idx_hospital_location ON TBL_HOSPITAL (LATITUDE, LONGITUDE); + +-- 전문 검색 (ngram 파서는 MySQL 5.7+ / MariaDB 10.0+ 에서 한글 토큰화에 필요) +CREATE FULLTEXT INDEX ft_facility_search ON TBL_CARE_FACILITIES (NAME, ADDRESS) WITH PARSER ngram; +CREATE FULLTEXT INDEX ft_policy_search ON TBL_POLICIES (TITLE, DESCRIPTION) WITH PARSER ngram; +CREATE FULLTEXT INDEX ft_post_search ON TBL_POST (TITLE, CONTENT) WITH PARSER ngram; + +-- 목록 정렬에 쓰이는 컬럼 +CREATE INDEX idx_facility_active_rating ON TBL_CARE_FACILITIES (IS_ACTIVE, RATING); +CREATE INDEX idx_policy_active_created ON TBL_POLICIES (IS_ACTIVE, CREATED_AT); diff --git a/src/test/java/com/carecode/core/client/XmlResponseParserTest.java b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java new file mode 100644 index 00000000..abcb86b0 --- /dev/null +++ b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java @@ -0,0 +1,81 @@ +package com.carecode.core.client; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** XML 응답 파서 검증. */ +@DisplayName("XmlResponseParser") +class XmlResponseParserTest { + + private final XmlResponseParser parser = new XmlResponseParser(); + + @Test + @DisplayName("반복되는 엘리먼트를 배열로 모은다") + void collectsRepeatedElementsAsArray() { + String xml = """ + + A1행복소아과 + A2사랑소아과 + + """; + + JsonNode root = parser.parse(xml); + JsonNode items = root.path("body").path("items").path("item"); + + assertThat(items.isArray()).isTrue(); + assertThat(items).hasSize(2); + assertThat(items.get(0).path("yadmNm").asText()).isEqualTo("행복소아과"); + assertThat(items.get(1).path("ykiho").asText()).isEqualTo("A2"); + } + + @Test + @DisplayName("항목이 하나면 객체로 반환된다") + void singleItemBecomesObject() { + String xml = """ + + A1행복소아과 + + """; + + JsonNode item = parser.parse(xml).path("body").path("items").path("item"); + + assertThat(item.isObject()).isTrue(); + assertThat(item.path("yadmNm").asText()).isEqualTo("행복소아과"); + } + + @Test + @DisplayName("좌표와 전화번호 등 값 노드를 문자열로 읽는다") + void readsLeafValues() { + String xml = "127.0537.5102-123-4567"; + + JsonNode node = parser.parse(xml); + + assertThat(node.path("XPos").asText()).isEqualTo("127.05"); + assertThat(node.path("YPos").asText()).isEqualTo("37.51"); + assertThat(node.path("telno").asText()).isEqualTo("02-123-4567"); + } + + @Test + @DisplayName("XXE 공격 페이로드는 파싱하지 않는다") + void rejectsXxePayload() { + String malicious = """ + + ]> + &xxe; + """; + + // DOCTYPE 선언 자체를 막으므로 파싱이 실패하고 null 이 된다. + assertThat(parser.parse(malicious)).isNull(); + } + + @Test + @DisplayName("빈 입력과 잘못된 XML은 null을 반환한다") + void returnsNullForInvalidInput() { + assertThat(parser.parse(null)).isNull(); + assertThat(parser.parse("")).isNull(); + assertThat(parser.parse("")).isNull(); + } +} diff --git a/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java b/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java new file mode 100644 index 00000000..2ae02e7c --- /dev/null +++ b/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java @@ -0,0 +1,38 @@ +package com.carecode.core.client.constants; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("공공데이터 시도 코드") +class PublicDataApiConstantsTest { + + @Test + @DisplayName("정식 명칭으로 조회한다") + void findsByFullName() { + assertThat(PublicDataApiConstants.findSidoCode("서울특별시")).isEqualTo("11"); + assertThat(PublicDataApiConstants.findSidoCode("경기도")).isEqualTo("31"); + } + + @Test + @DisplayName("축약 표기도 매칭한다") + void findsByShortName() { + assertThat(PublicDataApiConstants.findSidoCode("서울")).isEqualTo("11"); + assertThat(PublicDataApiConstants.findSidoCode("제주")).isEqualTo("39"); + } + + @Test + @DisplayName("알 수 없는 지역은 null을 반환한다") + void returnsNullForUnknown() { + assertThat(PublicDataApiConstants.findSidoCode("존재하지않는시")).isNull(); + assertThat(PublicDataApiConstants.findSidoCode("")).isNull(); + assertThat(PublicDataApiConstants.findSidoCode(null)).isNull(); + } + + @Test + @DisplayName("전국 17개 시도가 정의돼 있다") + void coversAllProvinces() { + assertThat(PublicDataApiConstants.SIDO_CODES).hasSize(17); + } +} diff --git a/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java new file mode 100644 index 00000000..937b5cdc --- /dev/null +++ b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java @@ -0,0 +1,115 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** 공공데이터포털 공급자 검증. */ +@DisplayName("DataGoKrProvider") +class DataGoKrProviderTest { + + private static final String ENCODED_KEY = "abc%2Bdef%3D%3D"; + + @Test + @DisplayName("서비스 키가 없으면 비활성 상태다") + void inactiveWithoutServiceKey() { + DataGoKrProvider provider = new DataGoKrProvider(new RestTemplate(), "", "https://apis.data.go.kr"); + + assertThat(provider.isAvailable()).isFalse(); + assertThatThrownBy(() -> provider.fetch("some/resource", 1, 10, Map.of())) + .isInstanceOf(PublicDataApiException.class) + .hasMessageContaining("서비스 키"); + } + + @Test + @DisplayName("serviceKey를 이중 인코딩하지 않는다") + void doesNotDoubleEncodeServiceKey() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> { + URI uri = request.getURI(); + String query = uri.getRawQuery(); + assertThat(query).contains("serviceKey=" + ENCODED_KEY); + // 이중 인코딩되면 %252B 가 된다 + assertThat(query).doesNotContain("%252B"); + }) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"response\":{}}", MediaType.APPLICATION_JSON)); + + String body = provider.fetch("B551014/CCEF/childcare", 1, 100, Map.of()); + + assertThat(body).isEqualTo("{\"response\":{}}"); + server.verify(); + } + + @Test + @DisplayName("페이지·건수를 쿼리 파라미터로 전달한다") + void sendsPagingAsQueryParams() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> { + String query = request.getURI().getRawQuery(); + assertThat(query).contains("pageNo=3"); + assertThat(query).contains("numOfRows=50"); + assertThat(query).contains("sidoCd=11"); + }) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + provider.fetch("some/resource", 3, 50, Map.of("sidoCd", "11")); + + server.verify(); + } + + @Test + @DisplayName("절대 URL 리소스는 기본 호스트를 붙이지 않는다") + void usesAbsoluteResourceUrlAsIs() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> { + URI uri = request.getURI(); + // 표준데이터는 별도 호스트에 있다 + assertThat(uri.getHost()).isEqualTo("api.data.go.kr"); + assertThat(uri.getPath()).isEqualTo("/openapi/tn_pubr_public_kindergarten_api"); + }) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + provider.fetch("http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api", 1, 10, Map.of()); + + server.verify(); + } + + @Test + @DisplayName("빈 파라미터 값은 쿼리에서 제외한다") + void skipsBlankParams() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> assertThat(request.getURI().getRawQuery()).doesNotContain("sidoCd")) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + java.util.Map params = new java.util.HashMap<>(); + params.put("sidoCd", ""); + provider.fetch("some/resource", 1, 10, params); + + server.verify(); + } +} diff --git a/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java new file mode 100644 index 00000000..7e3affd3 --- /dev/null +++ b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java @@ -0,0 +1,125 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("유치원 적재") +class KindergartenUpsertServiceTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private CareFacilityRepository repository; + private KindergartenUpsertService service; + + @BeforeEach + void setUp() { + repository = mock(CareFacilityRepository.class); + when(repository.findByFacilityCode(anyString())).thenReturn(Optional.empty()); + service = new KindergartenUpsertService(repository); + } + + @Test + @DisplayName("표준데이터 한글 필드명을 읽는다") + void mapsStandardDataFields() { + boolean isNew = service.upsert(row(""" + {"유치원명":"행복유치원","소재지도로명주소":"서울특별시 강남구 테헤란로 1", + "전화번호":"02-123-4567","설립유형":"공립","정원":"100","현원":"80", + "위도":"37.5","경도":"127.0","시도명":"서울특별시","시군구명":"강남구"} + """)); + + assertThat(isNew).isTrue(); + CareFacility saved = captureSaved(); + assertThat(saved.getName()).isEqualTo("행복유치원"); + assertThat(saved.getFacilityType()).isEqualTo(FacilityType.KINDERGARTEN); + assertThat(saved.getIsPublic()).isTrue(); + assertThat(saved.getAvailableSpots()).isEqualTo(20); + assertThat(saved.getLatitude()).isEqualTo(37.5); + assertThat(saved.getCity()).isEqualTo("서울특별시"); + } + + @Test + @DisplayName("영문 필드명으로 와도 동일하게 읽는다") + void mapsEnglishFieldNames() { + service.upsert(row(""" + {"kindrgrtnNm":"한빛유치원","rdnmadr":"부산광역시 해운대구 1","telno":"051-1234-5678"} + """)); + + CareFacility saved = captureSaved(); + assertThat(saved.getName()).isEqualTo("한빛유치원"); + assertThat(saved.getPhone()).isEqualTo("051-1234-5678"); + } + + @Test + @DisplayName("고유 코드가 없어도 같은 유치원은 같은 코드가 나온다") + void generatesStableCodeFromNaturalKey() { + String json = """ + {"유치원명":"행복유치원","소재지도로명주소":"서울특별시 강남구 테헤란로 1"} + """; + + service.upsert(row(json)); + String first = captureSaved().getFacilityCode(); + + service.upsert(row(json)); + String second = captureSaved().getFacilityCode(); + + assertThat(first).isEqualTo(second).startsWith(KindergartenUpsertService.CODE_PREFIX); + } + + @Test + @DisplayName("이름이 같아도 주소가 다르면 다른 시설로 본다") + void distinguishesSameNameDifferentAddress() { + service.upsert(row("{\"유치원명\":\"행복유치원\",\"소재지도로명주소\":\"서울특별시 강남구 1\"}")); + String seoul = captureSaved().getFacilityCode(); + + service.upsert(row("{\"유치원명\":\"행복유치원\",\"소재지도로명주소\":\"부산광역시 해운대구 1\"}")); + String busan = captureSaved().getFacilityCode(); + + assertThat(seoul).isNotEqualTo(busan); + } + + @Test + @DisplayName("고유 코드가 있으면 그것을 쓴다") + void prefersExternalCode() { + service.upsert(row("{\"유치원명\":\"행복유치원\",\"유치원코드\":\"K12345\"}")); + + assertThat(captureSaved().getFacilityCode()) + .isEqualTo(KindergartenUpsertService.CODE_PREFIX + "K12345"); + } + + @Test + @DisplayName("유치원명이 없으면 저장하지 않는다") + void rejectsRowWithoutName() { + assertThatThrownBy(() -> service.upsert(row("{\"소재지도로명주소\":\"서울특별시\"}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유치원명"); + } + + private CareFacility captureSaved() { + ArgumentCaptor captor = ArgumentCaptor.forClass(CareFacility.class); + verify(repository, org.mockito.Mockito.atLeastOnce()).save(captor.capture()); + return captor.getValue(); + } + + private JsonNode row(String json) { + try { + return objectMapper.readTree(json); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java b/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java new file mode 100644 index 00000000..f7c47e5e --- /dev/null +++ b/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java @@ -0,0 +1,246 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.XmlResponseParser; +import com.carecode.core.client.provider.PublicDataProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("공공데이터 페이징 동기화") +class PagedSyncTemplateTest { + + private PagedSyncTemplate template; + + @BeforeEach + void setUp() { + template = new PagedSyncTemplate(new ObjectMapper(), new XmlResponseParser()); + ReflectionTestUtils.setField(template, "maxPages", 5); + } + + @Test + @DisplayName("서비스 키가 없으면 호출하지 않고 사유를 남긴다") + void skipsWhenProviderUnavailable() { + StubProvider provider = new StubProvider(false, List.of()); + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("서비스 키"); + assertThat(provider.callCount).isZero(); + } + + @Test + @DisplayName("요청 건수보다 적게 오면 마지막 페이지로 보고 더 호출하지 않는다") + void stopsOnShortPage() { + StubProvider provider = new StubProvider(true, List.of(page(1))); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(1); + assertThat(result.getPagesProcessed()).isEqualTo(1); + assertThat(provider.callCount).isEqualTo(1); + assertThat(result.isCompleted()).isTrue(); + } + + @Test + @DisplayName("빈 응답이 오면 정상 종료한다") + void stopsOnEmptyResponse() { + StubProvider provider = new StubProvider(true, List.of(page(2))); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.isCompleted()).isTrue(); + assertThat(provider.callCount).isEqualTo(2); // 두 번째 호출에서 데이터 없음을 확인 + } + + @Test + @DisplayName("첫 페이지가 전건 실패면 매핑 불일치로 보고 즉시 중단한다") + void stopsWhenFirstPageEntirelyFails() { + StubProvider provider = new StubProvider(true, List.of(page(3), page(3))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .upsert(row -> { + throw new IllegalArgumentException("필수 코드 없음"); + }) + .build()); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("매핑 불일치"); + assertThat(result.getFailed()).isEqualTo(3); + assertThat(provider.callCount).isEqualTo(1); // 나머지 페이지를 헛돌지 않는다 + } + + @Test + @DisplayName("일부만 실패하면 계속 진행한다") + void continuesOnPartialFailure() { + StubProvider provider = new StubProvider(true, List.of(page(3))); + List seen = new ArrayList<>(); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .upsert(row -> { + int id = row.get("id").asInt(); + seen.add(id); + if (id == 1) { + throw new IllegalStateException("한 건 실패"); + } + return true; + }) + .build()); + + assertThat(seen).hasSize(3); + assertThat(result.getFailed()).isEqualTo(1); + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.isCompleted()).isTrue(); + } + + @Test + @DisplayName("필터에 걸린 행은 실패가 아니라 제외로 센다") + void countsFilteredRowsAsSkipped() { + StubProvider provider = new StubProvider(true, List.of(page(4))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(4) + .filter(row -> row.get("id").asInt() % 2 == 0) + .upsert(row -> true) + .build()); + + assertThat(result.getSkipped()).isEqualTo(2); + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.getFailed()).isZero(); + } + + @Test + @DisplayName("전건 제외는 매핑 불일치로 보지 않는다") + void doesNotStopWhenAllRowsFiltered() { + StubProvider provider = new StubProvider(true, List.of(page(3))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .filter(row -> false) + .upsert(row -> true) + .build()); + + assertThat(result.isCompleted()).isTrue(); + assertThat(result.getSkipped()).isEqualTo(3); + } + + @Test + @DisplayName("XML 로 응답해도 항목을 읽는다") + void parsesXmlResponse() { + String xml = """ + + 1 + 2 + + """; + StubProvider provider = new StubProvider(true, List.of(xml)); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(2); + } + + @Test + @DisplayName("페이지 상한에 걸리면 조용히 끝내지 않고 사유를 남긴다") + void reportsPageLimit() { + List pages = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + pages.add(page(2)); + } + StubProvider provider = new StubProvider(true, pages); + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("최대 페이지"); + assertThat(result.getPagesProcessed()).isEqualTo(5); + } + + @Test + @DisplayName("조회 실패는 사유와 함께 중단한다") + void stopsOnFetchFailure() { + StubProvider provider = new StubProvider(true, List.of()) { + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + throw new IllegalStateException("연결 시간 초과"); + } + }; + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("연결 시간 초과"); + } + + private SyncResult run(PublicDataProvider provider, Function upsert) { + return template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(2) + .upsert(upsert) + .build()); + } + + /** id 만 담은 행 n 건짜리 페이지 JSON. */ + private String page(int rowCount) { + StringBuilder sb = new StringBuilder("{\"response\":{\"body\":{\"items\":["); + for (int i = 0; i < rowCount; i++) { + sb.append(i > 0 ? "," : "").append("{\"id\":").append(i).append("}"); + } + return sb.append("]}}}").toString(); + } + + /** 페이지를 순서대로 돌려주고, 다 떨어지면 빈 응답을 준다. */ + private static class StubProvider implements PublicDataProvider { + private final boolean available; + private final List pages; + private int callCount; + + StubProvider(boolean available, List pages) { + this.available = available; + this.pages = pages; + } + + @Override + public String getProviderName() { + return "STUB"; + } + + @Override + public boolean isAvailable() { + return available; + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + callCount++; + return pageNo <= pages.size() ? pages.get(pageNo - 1) : null; + } + } +} diff --git a/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java b/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java new file mode 100644 index 00000000..305c2aa5 --- /dev/null +++ b/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java @@ -0,0 +1,39 @@ +package com.carecode.core.search; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("전문 검색 키워드 정규화") +class FullTextSearchSupportTest { + + private final FullTextSearchSupport support = new FullTextSearchSupport(true); + + @Test + @DisplayName("불리언 모드 연산자를 제거한다") + void stripsBooleanOperators() { + assertThat(support.normalize("강남 +어린이집 -사립")).isEqualTo("강남 어린이집 사립"); + } + + @Test + @DisplayName("한 글자 키워드는 전문 검색으로 처리하지 않는다") + void rejectsTooShortKeyword() { + assertThat(support.normalize("가")).isNull(); + assertThat(support.canUseFullText("가")).isFalse(); + } + + @Test + @DisplayName("연산자만 있는 키워드는 무효로 본다") + void rejectsOperatorOnlyKeyword() { + assertThat(support.normalize("+++")).isNull(); + } + + @Test + @DisplayName("비활성화하면 항상 LIKE 로 폴백한다") + void fallsBackWhenDisabled() { + FullTextSearchSupport disabled = new FullTextSearchSupport(false); + + assertThat(disabled.canUseFullText("어린이집")).isFalse(); + } +} diff --git a/src/test/java/com/carecode/core/util/AgeRangeParserTest.java b/src/test/java/com/carecode/core/util/AgeRangeParserTest.java new file mode 100644 index 00000000..eec064d6 --- /dev/null +++ b/src/test/java/com/carecode/core/util/AgeRangeParserTest.java @@ -0,0 +1,92 @@ +package com.carecode.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("지원대상 연령 파싱") +class AgeRangeParserTest { + + @Test + @DisplayName("개월 범위를 그대로 읽는다") + void parsesMonthRange() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("생후 12~23개월 아동"); + + assertThat(range.minMonths()).isEqualTo(12); + assertThat(range.maxMonths()).isEqualTo(23); + } + + @Test + @DisplayName("세 범위는 개월로 환산하고 상한은 해당 연도의 마지막 달로 잡는다") + void convertsYearRangeToMonths() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 3~5세 유아"); + + assertThat(range.minMonths()).isEqualTo(36); + assertThat(range.maxMonths()).isEqualTo(71); + } + + @Test + @DisplayName("만 N세 사이에 만이 반복돼도 인식한다") + void parsesRepeatedManPrefix() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 0세 ~ 만 2세"); + + assertThat(range.minMonths()).isZero(); + assertThat(range.maxMonths()).isEqualTo(35); + } + + @Test + @DisplayName("미만은 상한을 한 단위 낮춘다") + void parsesExclusiveUpperBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 7세 미만 아동"); + + assertThat(range.minMonths()).isZero(); + assertThat(range.maxMonths()).isEqualTo(83); + } + + @Test + @DisplayName("이하는 해당 연도 끝까지 포함한다") + void parsesInclusiveUpperBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 8세 이하"); + + assertThat(range.maxMonths()).isEqualTo(107); + } + + @Test + @DisplayName("이상은 상한을 두지 않는다") + void parsesLowerBoundOnly() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 6세 이상 아동"); + + assertThat(range.minMonths()).isEqualTo(72); + assertThat(range.maxMonths()).isNull(); + } + + @Test + @DisplayName("개월 미만 표기도 처리한다") + void parsesMonthBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("24개월 미만 영아"); + + assertThat(range.maxMonths()).isEqualTo(23); + } + + @Test + @DisplayName("연령 조건이 없으면 추정하지 않는다") + void returnsNullWhenNoAgeCondition() { + assertThat(AgeRangeParser.parse("임신부 및 산모")).isNull(); + assertThat(AgeRangeParser.parse("")).isNull(); + assertThat(AgeRangeParser.parse(null)).isNull(); + } + + @Test + @DisplayName("연령으로 보기 어려운 큰 숫자는 무시한다") + void ignoresImplausibleNumbers() { + assertThat(AgeRangeParser.parse("2024~2025세")).isNull(); + assertThat(AgeRangeParser.parse("500개월 미만")).isNull(); + } + + @Test + @DisplayName("역순 범위는 잘못된 표기로 보고 버린다") + void rejectsInvertedRange() { + assertThat(AgeRangeParser.parse("5~3세")).isNull(); + } +} diff --git a/src/test/java/com/carecode/core/util/BoundingBoxTest.java b/src/test/java/com/carecode/core/util/BoundingBoxTest.java new file mode 100644 index 00000000..5217d20e --- /dev/null +++ b/src/test/java/com/carecode/core/util/BoundingBoxTest.java @@ -0,0 +1,41 @@ +package com.carecode.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("바운딩 박스 계산") +class BoundingBoxTest { + + @Test + @DisplayName("반경만큼 위도 범위를 넓힌다") + void expandsLatitudeByRadius() { + BoundingBox box = BoundingBox.around(37.5665, 126.9780, 5); + + // 위도 1도 ≈ 111km 이므로 5km 는 약 0.045도 + assertThat(box.maxLat() - box.minLat()).isCloseTo(0.09, org.assertj.core.data.Offset.offset(0.005)); + assertThat(box.minLat()).isLessThan(37.5665); + assertThat(box.maxLat()).isGreaterThan(37.5665); + } + + @Test + @DisplayName("고위도일수록 경도 범위가 넓어진다") + void widensLongitudeAtHighLatitude() { + BoundingBox seoul = BoundingBox.around(37.5, 127.0, 5); + BoundingBox equator = BoundingBox.around(0.0, 127.0, 5); + + double seoulWidth = seoul.maxLng() - seoul.minLng(); + double equatorWidth = equator.maxLng() - equator.minLng(); + assertThat(seoulWidth).isGreaterThan(equatorWidth); + } + + @Test + @DisplayName("극지방에서도 위도가 범위를 벗어나지 않는다") + void clampsLatitudeAtPoles() { + BoundingBox box = BoundingBox.around(89.9, 0.0, 100); + + assertThat(box.maxLat()).isLessThanOrEqualTo(90.0); + assertThat(box.minLat()).isGreaterThanOrEqualTo(-90.0); + } +} diff --git a/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java b/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java new file mode 100644 index 00000000..95d94533 --- /dev/null +++ b/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java @@ -0,0 +1,54 @@ +package com.carecode.domain.chatbot.rag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("챗봇 키워드 추출") +class CareKnowledgeRetrieverTest { + + private final CareKnowledgeRetriever retriever = new CareKnowledgeRetriever(null, null); + + @Test + @DisplayName("조사를 떼어내고 원형을 남긴다") + void stripsJosa() { + List keywords = retriever.extractKeywords("강남구에서 어린이집은 어디에 있나요"); + + assertThat(keywords).contains("어린이집"); + assertThat(keywords).noneMatch(k -> k.endsWith("에서")); + } + + @Test + @DisplayName("의문사·범용어는 검색어에서 제외한다") + void dropsStopWords() { + List keywords = retriever.extractKeywords("부모급여 정보 알려줘"); + + assertThat(keywords).containsExactly("부모급여"); + } + + @Test + @DisplayName("긴 토큰을 먼저 시도하도록 정렬한다") + void ordersByLengthDescending() { + List keywords = retriever.extractKeywords("아동수당 신청"); + + assertThat(keywords).containsExactly("아동수당", "신청"); + } + + @Test + @DisplayName("키워드는 최대 3개까지만 뽑는다") + void limitsKeywordCount() { + List keywords = retriever.extractKeywords("서울 강남 어린이집 보육료 지원 신청 방법"); + + assertThat(keywords).hasSize(3); + } + + @Test + @DisplayName("빈 질문이면 검색을 건너뛴다") + void returnsEmptyForBlankQuestion() { + assertThat(retriever.extractKeywords(" ")).isEmpty(); + assertThat(retriever.extractKeywords(null)).isEmpty(); + } +}